12 KiB
Game Settings Persistence Architecture
Overview
Game settings in room mode persist across game switches using a normalized database schema. Settings for each game are stored in a dedicated room_game_configs table with one row per game per room.
Database Schema
Settings are stored in the room_game_configs 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,
UNIQUE(room_id, game_name)
);
Benefits:
- ✅ Type-safe config access with shared types
- ✅ Smaller rows (only configs for games that have been used)
- ✅ Easier updates (single row vs entire JSON blob)
- ✅ Better concurrency (no lock contention between games)
- ✅ Foundation for per-game audit trail
- ✅ Can query/index individual game settings
Example Row:
{
"id": "clxyz123",
"room_id": "room_abc",
"game_name": "memory-quiz",
"config": {
"selectedCount": 8,
"displayTime": 3.0,
"selectedDifficulty": "medium",
"playMode": "competitive"
},
"created_at": 1234567890,
"updated_at": 1234567890
}
Shared Type System
All game configs are defined in src/lib/arcade/game-configs.ts:
// Shared config types (single source of truth)
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";
}
// 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",
};
Why This Matters:
- TypeScript enforces that validators, helpers, and API routes all use the same types
- Adding a new setting requires changes in only ONE place (the type definition)
- Impossible to forget a setting or use wrong type
Critical Components
Settings persistence requires coordination between FOUR systems:
1. Helper Functions
Location: src/lib/arcade/game-config-helpers.ts
Responsibilities:
- Read/write game configs from
room_game_configstable - Provide type-safe access with automatic defaults
- Validate configs at runtime
Key Functions:
// Get config with defaults (type-safe)
const config = await getGameConfig(roomId, "memory-quiz");
// Returns: MemoryQuizGameConfig
// Set/update config (upsert)
await setGameConfig(roomId, "memory-quiz", {
playMode: "competitive",
selectedCount: 8,
});
// Get all game configs for a room
const allConfigs = await getAllGameConfigs(roomId);
// Returns: { matching?: MatchingGameConfig, 'memory-quiz'?: MemoryQuizGameConfig }
2. API Routes
Location:
src/app/api/arcade/rooms/current/route.ts(read)src/app/api/arcade/rooms/[roomId]/settings/route.ts(write)
Responsibilities:
- Aggregate game configs from database
- Return them to client in
room.gameConfig - Write config updates to
room_game_configstable
Read Example: GET /api/arcade/rooms/current
const gameConfig = await getAllGameConfigs(roomId);
return NextResponse.json({
room: {
...room,
gameConfig, // Aggregated from room_game_configs table
},
members,
memberPlayers,
});
Write Example: PATCH /api/arcade/rooms/[roomId]/settings
if (body.gameConfig !== undefined) {
// body.gameConfig: { matching: {...}, memory-quiz: {...} }
for (const [gameName, config] of Object.entries(body.gameConfig)) {
await setGameConfig(roomId, gameName, config);
}
}
3. Socket Server (Session Creation)
Location: src/socket-server.ts:70-90
Responsibilities:
- Create initial arcade session when user joins room
- Read saved settings using
getGameConfig()helper - Pass settings to validator's
getInitialState()
Example:
const room = await getRoomById(roomId);
const validator = getValidator(room.gameName as GameName);
// Get config from database (type-safe, includes defaults)
const gameConfig = await getGameConfig(roomId, room.gameName as GameName);
// Pass to validator (types match automatically)
const initialState = validator.getInitialState(gameConfig);
await createArcadeSession({ userId, gameName, initialState, roomId });
Key Point: No more manual config extraction or default fallbacks!
4. Game Validators
Location: src/lib/arcade/validation/*Validator.ts
Responsibilities:
- Define
getInitialState()method with shared config type - Create initial game state from config
- TypeScript enforces all settings are handled
Example: MemoryQuizGameValidator.ts
import type { MemoryQuizGameConfig } from "@/lib/arcade/game-configs";
class MemoryQuizGameValidator {
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
return {
selectedCount: config.selectedCount,
displayTime: config.displayTime,
selectedDifficulty: config.selectedDifficulty,
playMode: config.playMode, // TypeScript ensures this field exists!
// ...other state
};
}
}
5. Client Providers (Unchanged)
Location: src/app/arcade/{game}/context/Room{Game}Provider.tsx
Responsibilities:
- Read settings from
roomData.gameConfig[gameName] - Merge with
initialStatedefaults - Works transparently with new backend structure
Example: RoomMemoryQuizProvider.tsx:211-233
const mergedInitialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, any>;
const savedConfig = gameConfig?.["memory-quiz"];
if (!savedConfig) {
return initialState;
}
return {
...initialState,
selectedCount: savedConfig.selectedCount ?? initialState.selectedCount,
displayTime: savedConfig.displayTime ?? initialState.displayTime,
selectedDifficulty:
savedConfig.selectedDifficulty ?? initialState.selectedDifficulty,
playMode: savedConfig.playMode ?? initialState.playMode,
};
}, [roomData?.gameConfig]);
Common Bugs and Solutions
Bug #1: Settings Not Persisting
Symptom: Settings reset to defaults after game switch
Root Cause: One of the following:
- API route not writing to
room_game_configstable - Helper function not being used correctly
- Validator not using shared config type
Solution: Verify the data flow:
# 1. Check database write
SELECT * FROM room_game_configs WHERE room_id = '...';
# 2. Check API logs for setGameConfig() calls
# Look for: [GameConfig] Updated {game} config for room {roomId}
# 3. Check socket server logs for getGameConfig() calls
# Look for: [join-arcade-session] Got validator for: {game}
# 4. Check validator signature matches shared type
# MemoryQuizGameValidator.getInitialState(config: MemoryQuizGameConfig)
Bug #2: TypeScript Errors About Missing Fields
Symptom: Property '{field}' is missing in type ...
Root Cause: Validator's getInitialState() signature doesn't match shared config type
Solution: Import and use the shared config type:
// ❌ WRONG
getInitialState(config: {
selectedCount: number
displayTime: number
// Missing playMode!
}): SorobanQuizState
// ✅ CORRECT
import type { MemoryQuizGameConfig } from '@/lib/arcade/game-configs'
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState
Bug #3: Settings Wiped When Returning to Game Selection
Symptom: Settings reset when going back to game selection
Root Cause: Sending gameConfig: null in PATCH request
Solution: Only send gameName: null, don't touch gameConfig:
// ❌ WRONG
body: JSON.stringify({ gameName: null, gameConfig: null });
// ✅ CORRECT
body: JSON.stringify({ gameName: null });
Debugging Checklist
When a setting doesn't persist:
-
Check database:
- Query
room_game_configstable - Verify row exists for room + game
- Verify JSON config has correct structure
- Query
-
Check API write path:
/api/arcade/rooms/[roomId]/settingslogs- Verify
setGameConfig()is called - Check for errors in console
-
Check API read path:
/api/arcade/rooms/currentlogs- Verify
getAllGameConfigs()returns data - Check
room.gameConfigin response
-
Check socket server:
socket-server.tslogs forgetGameConfig()- Verify config passed to validator
- Check
initialStatehas correct values
-
Check validator:
- Signature uses shared config type
- All config fields used (not hardcoded)
- Add logging to see received config
Adding a New Setting
To add a new setting to an existing game:
- Update the shared config type (
game-configs.ts):
export interface MemoryQuizGameConfig {
selectedCount: 2 | 5 | 8 | 12 | 15;
displayTime: number;
selectedDifficulty: DifficultyLevel;
playMode: "cooperative" | "competitive";
newSetting: string; // ← Add here
}
export const DEFAULT_MEMORY_QUIZ_CONFIG: MemoryQuizGameConfig = {
selectedCount: 5,
displayTime: 2.0,
selectedDifficulty: "easy",
playMode: "cooperative",
newSetting: "default", // ← Add default
};
-
TypeScript will now enforce:
- ✅ Validator must accept
newSetting(compile error if missing) - ✅ Helper functions will include it automatically
- ✅ Client providers will need to handle it
- ✅ Validator must accept
-
Update the validator (
*Validator.ts):
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
return {
// ...
newSetting: config.newSetting, // TypeScript enforces this
}
}
- Update the UI to expose the new setting
- No changes needed to API routes or helper functions!
- They automatically handle any field in the config type
Testing Settings Persistence
Manual test procedure:
- Join a room and select a game
- Change each setting to a non-default value
- Go back to game selection (gameName becomes null)
- Select the same game again
- Verify ALL settings retained their values
Expected behavior: All settings should be exactly as you left them.
Migration Notes
Old Schema:
- Settings stored in
arcade_rooms.game_configJSON column - Config stored directly for currently selected game only
- Config lost when switching games
New Schema:
- Settings stored in
room_game_configstable - One row per game per room
- Unique constraint on (room_id, game_name)
- Configs persist when switching between games
Migration: See .claude/MANUAL_MIGRATION_0011.md for complete details
Summary:
- Manual migration applied on 2025-10-15
- Created
room_game_configstable via sqlite3 CLI - Migrated 6000 existing configs (5991 matching, 9 memory-quiz)
- Table created directly instead of through drizzle migration system
Rollback Plan:
- Old
game_configcolumn still exists inarcade_roomstable - Old data preserved (was only read, not deleted)
- Can revert to reading from old column if needed
- New table can be dropped:
DROP TABLE room_game_configs
Architecture Benefits
Type Safety:
- Single source of truth for config types
- TypeScript enforces consistency everywhere
- Impossible to forget a setting
DRY (Don't Repeat Yourself):
- No duplicated default values
- No manual config extraction
- No manual merging with defaults
Maintainability:
- Adding a setting touches fewer places
- Clear separation of concerns
- Easier to trace data flow
Performance:
- Smaller database rows
- Better query performance
- Less network payload
Correctness:
- Runtime validation available
- Database constraints (unique index)
- Impossible to create duplicate configs