soroban-abacus-flashcards/apps/web/.claude/GAME_SETTINGS_PERSISTENCE.md

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_configs table
  • 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_configs table

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 initialState defaults
  • 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:

  1. API route not writing to room_game_configs table
  2. Helper function not being used correctly
  3. 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:

  1. Check database:

    • Query room_game_configs table
    • Verify row exists for room + game
    • Verify JSON config has correct structure
  2. Check API write path:

    • /api/arcade/rooms/[roomId]/settings logs
    • Verify setGameConfig() is called
    • Check for errors in console
  3. Check API read path:

    • /api/arcade/rooms/current logs
    • Verify getAllGameConfigs() returns data
    • Check room.gameConfig in response
  4. Check socket server:

    • socket-server.ts logs for getGameConfig()
    • Verify config passed to validator
    • Check initialState has correct values
  5. 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:

  1. 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
};
  1. 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
  2. Update the validator (*Validator.ts):

getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
  return {
    // ...
    newSetting: config.newSetting,  // TypeScript enforces this
  }
}
  1. 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:

  1. Join a room and select a game
  2. Change each setting to a non-default value
  3. Go back to game selection (gameName becomes null)
  4. Select the same game again
  5. 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_config JSON column
  • Config stored directly for currently selected game only
  • Config lost when switching games

New Schema:

  • Settings stored in room_game_configs table
  • 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_configs table 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_config column still exists in arcade_rooms table
  • 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