14 KiB
Game Settings Persistence - Refactoring Recommendations
Current Pain Points
- Type safety is weak - Easy to forget to add a setting in one place
- Duplication - Config reading logic duplicated in socket-server.ts for each game
- Manual synchronization - Have to manually keep validator signature, provider, and socket server in sync
- Error-prone - Easy to hardcode values or forget to read from config
Recommended Refactorings
1. Create Shared Config Types (HIGHEST PRIORITY)
Problem: Each game's settings are defined in multiple places with no type enforcement
Solution: Define a single source of truth for each game's config
// src/lib/arcade/game-configs.ts
export interface MatchingGameConfig {
gameType: "abacus-numeral" | "complement-pairs";
difficulty: number;
turnTimer: number;
}
export interface MemoryQuizGameConfig {
selectedCount: 2 | 5 | 8 | 12 | 15;
displayTime: number;
selectedDifficulty: DifficultyLevel;
playMode: "cooperative" | "competitive";
}
export interface ComplementRaceGameConfig {
// ... future settings
}
export interface RoomGameConfig {
matching?: MatchingGameConfig;
"memory-quiz"?: MemoryQuizGameConfig;
"complement-race"?: ComplementRaceGameConfig;
}
// Default configs
export const DEFAULT_MATCHING_CONFIG: MatchingGameConfig = {
gameType: "abacus-numeral",
difficulty: 6,
turnTimer: 30,
};
export const DEFAULT_MEMORY_QUIZ_CONFIG: MemoryQuizGameConfig = {
selectedCount: 5,
displayTime: 2.0,
selectedDifficulty: "easy",
playMode: "cooperative",
};
Benefits:
- Single source of truth for each game's settings
- TypeScript enforces consistency across codebase
- Easy to see what settings each game has
2. Create Config Helper Functions
Problem: Config reading logic is duplicated and error-prone
Solution: Centralized helper functions with type safety
// src/lib/arcade/game-config-helpers.ts
import type { GameName } from "./validation";
import type {
RoomGameConfig,
MatchingGameConfig,
MemoryQuizGameConfig,
} from "./game-configs";
import {
DEFAULT_MATCHING_CONFIG,
DEFAULT_MEMORY_QUIZ_CONFIG,
} from "./game-configs";
/**
* Get game-specific config from room's gameConfig with defaults
*/
export function getGameConfig<T extends GameName>(
roomGameConfig: RoomGameConfig | null | undefined,
gameName: T,
): T extends "matching"
? MatchingGameConfig
: T extends "memory-quiz"
? MemoryQuizGameConfig
: never {
if (!roomGameConfig) {
return getDefaultGameConfig(gameName) as any;
}
const savedConfig = roomGameConfig[gameName];
if (!savedConfig) {
return getDefaultGameConfig(gameName) as any;
}
// Merge saved config with defaults to handle missing fields
const defaults = getDefaultGameConfig(gameName);
return { ...defaults, ...savedConfig } as any;
}
function getDefaultGameConfig(gameName: GameName) {
switch (gameName) {
case "matching":
return DEFAULT_MATCHING_CONFIG;
case "memory-quiz":
return DEFAULT_MEMORY_QUIZ_CONFIG;
case "complement-race":
// return DEFAULT_COMPLEMENT_RACE_CONFIG
throw new Error("complement-race config not implemented");
default:
throw new Error(`Unknown game: ${gameName}`);
}
}
/**
* Update a specific game's config in the room's gameConfig
*/
export function updateGameConfig<T extends GameName>(
currentRoomConfig: RoomGameConfig | null | undefined,
gameName: T,
updates: Partial<
T extends "matching"
? MatchingGameConfig
: T extends "memory-quiz"
? MemoryQuizGameConfig
: never
>,
): RoomGameConfig {
const current = currentRoomConfig || {};
const gameConfig = current[gameName] || getDefaultGameConfig(gameName);
return {
...current,
[gameName]: {
...gameConfig,
...updates,
},
};
}
Usage in socket-server.ts:
// BEFORE (error-prone, duplicated)
const memoryQuizConfig = (room.gameConfig as any)?.["memory-quiz"] || {};
initialState = validator.getInitialState({
selectedCount: memoryQuizConfig.selectedCount || 5,
displayTime: memoryQuizConfig.displayTime || 2.0,
selectedDifficulty: memoryQuizConfig.selectedDifficulty || "easy",
playMode: memoryQuizConfig.playMode || "cooperative",
});
// AFTER (type-safe, concise)
const config = getGameConfig(room.gameConfig, "memory-quiz");
initialState = validator.getInitialState(config);
Usage in RoomMemoryQuizProvider.tsx:
// BEFORE (verbose, error-prone)
const mergedInitialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, any>;
const savedConfig = gameConfig?.["memory-quiz"];
return {
...initialState,
selectedCount: savedConfig?.selectedCount ?? initialState.selectedCount,
displayTime: savedConfig?.displayTime ?? initialState.displayTime,
selectedDifficulty:
savedConfig?.selectedDifficulty ?? initialState.selectedDifficulty,
playMode: savedConfig?.playMode ?? initialState.playMode,
};
}, [roomData?.gameConfig]);
// AFTER (type-safe, concise)
const mergedInitialState = useMemo(() => {
const config = getGameConfig(roomData?.gameConfig, "memory-quiz");
return {
...initialState,
...config, // Spread config directly - all settings included
};
}, [roomData?.gameConfig]);
Benefits:
- No more manual property-by-property merging
- Type-safe
- Defaults handled automatically
- Reusable across codebase
3. Enforce Validator Config Type from Game Config
Problem: Easy to forget to add a new setting to validator's getInitialState() signature
Solution: Make validator use the shared config type
// src/lib/arcade/validation/MemoryQuizGameValidator.ts
import type { MemoryQuizGameConfig } from "@/lib/arcade/game-configs";
export class MemoryQuizGameValidator {
// BEFORE: Manual type definition
// getInitialState(config: {
// selectedCount: number
// displayTime: number
// selectedDifficulty: DifficultyLevel
// playMode?: 'cooperative' | 'competitive'
// }): SorobanQuizState
// AFTER: Use shared type
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
return {
// ...
selectedCount: config.selectedCount,
displayTime: config.displayTime,
selectedDifficulty: config.selectedDifficulty,
playMode: config.playMode, // TypeScript ensures all fields are handled
// ...
};
}
}
Benefits:
- If you add a setting to
MemoryQuizGameConfig, TypeScript forces you to handle it - Impossible to forget a setting
- Impossible to use wrong type
4. Add Exhaustiveness Checking
Problem: Easy to miss handling a setting field
Solution: Use TypeScript's exhaustiveness checking
// src/lib/arcade/validation/MemoryQuizGameValidator.ts
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
// Exhaustiveness check - ensures all config fields are used
const _exhaustivenessCheck: Record<keyof MemoryQuizGameConfig, boolean> = {
selectedCount: true,
displayTime: true,
selectedDifficulty: true,
playMode: true,
}
return {
// ... use all config fields
selectedCount: config.selectedCount,
displayTime: config.displayTime,
selectedDifficulty: config.selectedDifficulty,
playMode: config.playMode,
}
}
If you add a new field to MemoryQuizGameConfig, TypeScript will error on _exhaustivenessCheck until you add it.
5. Validate Config on Save
Problem: Invalid config can be saved to database
Solution: Add runtime validation
// src/lib/arcade/game-config-helpers.ts
export function validateGameConfig(
gameName: GameName,
config: any,
): config is MatchingGameConfig | MemoryQuizGameConfig {
switch (gameName) {
case "matching":
return (
typeof config.gameType === "string" &&
["abacus-numeral", "complement-pairs"].includes(config.gameType) &&
typeof config.difficulty === "number" &&
config.difficulty > 0 &&
typeof config.turnTimer === "number" &&
config.turnTimer > 0
);
case "memory-quiz":
return (
[2, 5, 8, 12, 15].includes(config.selectedCount) &&
typeof config.displayTime === "number" &&
config.displayTime > 0 &&
["beginner", "easy", "medium", "hard", "expert"].includes(
config.selectedDifficulty,
) &&
["cooperative", "competitive"].includes(config.playMode)
);
default:
return false;
}
}
Use in settings API:
// src/app/api/arcade/rooms/[roomId]/settings/route.ts
if (body.gameConfig !== undefined) {
if (!validateGameConfig(room.gameName, body.gameConfig[room.gameName])) {
return NextResponse.json({ error: "Invalid game config" }, { status: 400 });
}
updateData.gameConfig = body.gameConfig;
}
Schema Refactoring: Separate Table for Game Configs
Current Problem
All game configs are stored in a single JSON column in arcade_rooms.gameConfig:
{
"matching": { "gameType": "...", "difficulty": 15 },
"memory-quiz": { "selectedCount": 8, "playMode": "competitive" }
}
Issues:
- No schema validation
- Inefficient updates (read/parse/modify/serialize entire blob)
- Grows without bounds as more games added
- Can't query or index individual game settings
- No audit trail
- Potential concurrent update race conditions
Recommended: Separate Table
Create room_game_configs table with one row per game per room:
// src/db/schema/room-game-configs.ts
export const roomGameConfigs = sqliteTable(
"room_game_configs",
{
id: text("id")
.primaryKey()
.$defaultFn(() => createId()),
roomId: text("room_id")
.notNull()
.references(() => arcadeRooms.id, { onDelete: "cascade" }),
gameName: text("game_name", {
enum: ["matching", "memory-quiz", "complement-race"],
}).notNull(),
config: text("config", { mode: "json" }).notNull(), // Game-specific JSON
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
},
(table) => ({
uniqueRoomGame: uniqueIndex("room_game_idx").on(
table.roomId,
table.gameName,
),
}),
);
Benefits:
- ✅ Smaller rows (only configs for games that have been used)
- ✅ Easier updates (single row, not entire JSON blob)
- ✅ Can track updatedAt per game
- ✅ Better concurrency (no lock contention between games)
- ✅ Foundation for future audit trail
Migration Strategy:
- Create new table
- Migrate existing data from
arcade_rooms.gameConfig - Update all config read/write code
- Deploy and test
- Drop old
gameConfigcolumn fromarcade_rooms
See migration SQL below.
Implementation Priority
Phase 1: Schema Migration (HIGHEST PRIORITY)
- Create new table - Add
room_game_configsschema - Create migration - SQL to migrate existing data
- Update helper functions - Adapt to new table structure
- Update all read/write code - Use new table
- Test thoroughly - Verify all settings persist correctly
- Drop old column - Remove
gameConfigfromarcade_rooms
Phase 2: Type Safety (HIGH)
- Create shared config types (
game-configs.ts) - Prevents type mismatches - Create helper functions (
game-config-helpers.ts) - Now queries new table - Update validators to use shared types - Enforces consistency
Phase 3: Compile-Time Safety (MEDIUM)
- Add exhaustiveness checking - Catches missing fields at compile time
- Enforce validator config types - Use shared types
Phase 4: Runtime Safety (LOW)
- Add runtime validation - Prevents invalid data from being saved
Detailed Migration SQL
-- drizzle/migrations/XXXX_split_game_configs.sql
-- Create new table
CREATE TABLE room_game_configs (
id TEXT PRIMARY KEY,
room_id TEXT NOT NULL REFERENCES arcade_rooms(id) ON DELETE CASCADE,
game_name TEXT NOT NULL CHECK(game_name IN ('matching', 'memory-quiz', 'complement-race')),
config TEXT NOT NULL, -- JSON
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX room_game_idx ON room_game_configs(room_id, game_name);
-- Migrate existing 'matching' configs
INSERT INTO room_game_configs (id, room_id, game_name, config, created_at, updated_at)
SELECT
lower(hex(randomblob(16))),
id,
'matching',
json_extract(game_config, '$.matching'),
created_at,
last_activity
FROM arcade_rooms
WHERE json_extract(game_config, '$.matching') IS NOT NULL;
-- Migrate existing 'memory-quiz' configs
INSERT INTO room_game_configs (id, room_id, game_name, config, created_at, updated_at)
SELECT
lower(hex(randomblob(16))),
id,
'memory-quiz',
json_extract(game_config, '$."memory-quiz"'),
created_at,
last_activity
FROM arcade_rooms
WHERE json_extract(game_config, '$."memory-quiz"') IS NOT NULL;
-- After testing and verifying all works:
-- ALTER TABLE arcade_rooms DROP COLUMN game_config;
Migration Strategy
Step-by-Step with Checkpoints
Checkpoint 1: Schema & Migration
- Create
src/db/schema/room-game-configs.ts - Export from
src/db/schema/index.ts - Generate and apply migration
- Verify data migrated correctly
Checkpoint 2: Helper Functions
- Create shared config types in
src/lib/arcade/game-configs.ts - Create helper functions in
src/lib/arcade/game-config-helpers.ts - Add unit tests for helpers
Checkpoint 3: Update Config Reads
- Update socket-server.ts to read from new table
- Update RoomMemoryQuizProvider to read from new table
- Update RoomMemoryPairsProvider to read from new table
- Test: Load room and verify settings appear
Checkpoint 4: Update Config Writes
- Update useRoomData.ts updateGameConfig to write to new table
- Update settings API to write to new table
- Test: Change settings and verify they persist
Checkpoint 5: Update Validators
- Update validators to use shared config types
- Test: All games work correctly
Checkpoint 6: Cleanup
- Remove old gameConfig column references
- Drop gameConfig column from arcade_rooms table
- Final testing of all games
Benefits Summary
- Type Safety: TypeScript enforces consistency across all systems
- DRY: Config reading logic not duplicated
- Maintainability: Adding a setting requires changes in fewer places
- Correctness: Impossible to forget a setting or use wrong type
- Debugging: Centralized config logic easier to trace
- Testing: Can test config helpers in isolation