Files
soroban-abacus-flashcards/apps/web/docs/MEMORY_QUIZ_MIGRATION_PLAN.md
Thomas Hallock 7c33d0246f fix: prevent undefined displayRules error in worksheet generator
Fixes production error "Cannot read properties of undefined (reading 'carryBoxes')"
that occurred when users tried to adjust difficulty settings.

Root cause: displayRules was undefined for new users or users with old V1 config
in database. Difficulty adjustment buttons accessed displayRules.carryBoxes without
checking if displayRules existed first.

Changes:
- AdditionWorksheetClient: Initialize displayRules with defaults when missing
- ConfigPanel: Use null-coalescing operators instead of non-null assertions
- ConfigPanel: Add error logging when required fields are missing
- NEW: WorksheetErrorBoundary component to catch all errors in worksheet page
- page.tsx: Wrap client component with error boundary

This ensures users see helpful error messages instead of blank pages,
and never need to open the browser console to understand what went wrong.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 13:01:54 -06:00

20 KiB

Memory Quiz Migration Plan

Game: Memory Lightning (memory-quiz) Date: 2025-01-16 Target: Migrate to Modular Game Platform (Game SDK)


Executive Summary

Migrate the Memory Lightning game from the legacy architecture to the new modular game platform. This game is unique because:

  • Already has a validator (MemoryQuizGameValidator)
  • Already uses useArcadeSession in room mode
  • Located in /app/arcade/memory-quiz/ instead of /arcade-games/
  • Uses reducer pattern instead of server-driven state
  • Not using Game SDK types and structure

Complexity: Medium-High (4-6 hours) Risk: Low (validator already exists, well-tested game)


Current Architecture

File Structure

src/app/arcade/memory-quiz/
├── page.tsx                          # Main page (local mode)
├── types.ts                          # State and move types
├── reducer.ts                        # State reducer (local only)
├── context/
│   ├── MemoryQuizContext.tsx         # Context interface
│   ├── LocalMemoryQuizProvider.tsx   # Local (solo) provider
│   └── RoomMemoryQuizProvider.tsx    # Multiplayer provider
└── components/
    ├── MemoryQuizGame.tsx            # Game wrapper component
    ├── SetupPhase.tsx                # Setup/lobby UI
    ├── DisplayPhase.tsx              # Card display phase
    ├── InputPhase.tsx                # Input/guessing phase
    ├── ResultsPhase.tsx              # End game results
    ├── CardGrid.tsx                  # Card display component
    └── ResultsCardGrid.tsx           # Results card display

src/lib/arcade/validation/
└── MemoryQuizGameValidator.ts        # Server validator (✅ exists!)

Important Notes

⚠️ Local Mode Deprecated: This migration only supports room mode. All games must be played in a room (even solo play is a single-player room). No local/offline mode code should be included.

Current State Type (SorobanQuizState)

interface SorobanQuizState {
  // Core game data
  cards: QuizCard[]
  quizCards: QuizCard[]
  correctAnswers: number[]

  // Game progression
  currentCardIndex: number
  displayTime: number
  selectedCount: 2 | 5 | 8 | 12 | 15
  selectedDifficulty: DifficultyLevel

  // Input system state
  foundNumbers: number[]
  guessesRemaining: number
  currentInput: string
  incorrectGuesses: number

  // Multiplayer state
  activePlayers: string[]
  playerMetadata: Record<string, PlayerMetadata>
  playerScores: Record<string, PlayerScore>
  playMode: 'cooperative' | 'competitive'
  numberFoundBy: Record<number, string>

  // UI state
  gamePhase: 'setup' | 'display' | 'input' | 'results'
  prefixAcceptanceTimeout: NodeJS.Timeout | null
  finishButtonsBound: boolean
  wrongGuessAnimations: Array<{...}>

  // Keyboard state
  hasPhysicalKeyboard: boolean | null
  testingMode: boolean
  showOnScreenKeyboard: boolean
}

Current Move Types

type MemoryQuizGameMove =
  | {
      type: "START_QUIZ";
      data: { numbers: number[]; activePlayers; playerMetadata };
    }
  | { type: "NEXT_CARD" }
  | { type: "SHOW_INPUT_PHASE" }
  | { type: "ACCEPT_NUMBER"; data: { number: number } }
  | { type: "REJECT_NUMBER" }
  | { type: "SET_INPUT"; data: { input: string } }
  | { type: "SHOW_RESULTS" }
  | { type: "RESET_QUIZ" }
  | { type: "SET_CONFIG"; data: { field; value } };

Current Config

interface MemoryQuizGameConfig {
  selectedCount: 2 | 5 | 8 | 12 | 15;
  displayTime: number;
  selectedDifficulty: "beginner" | "easy" | "medium" | "hard" | "expert";
  playMode: "cooperative" | "competitive";
}

Target Architecture

New File Structure

src/arcade-games/memory-quiz/           # NEW location
├── index.ts                            # Game definition (defineGame)
├── Validator.ts                        # Move from /lib/arcade/validation/
├── Provider.tsx                        # Single unified provider
├── types.ts                            # State, config, move types
├── game.yaml                           # Manifest (optional)
└── components/
    ├── GameComponent.tsx               # Main game wrapper
    ├── SetupPhase.tsx                  # Setup UI (updated)
    ├── DisplayPhase.tsx                # Display phase (minimal changes)
    ├── InputPhase.tsx                  # Input phase (minimal changes)
    ├── ResultsPhase.tsx                # Results (minimal changes)
    ├── CardGrid.tsx                    # Unchanged
    └── ResultsCardGrid.tsx             # Unchanged

New Provider Pattern

  • Single provider (room mode only)
  • Uses useArcadeSession with roomId (always provided)
  • Uses Game SDK hooks (useViewerId, useRoomData, useGameMode)
  • All state driven by server validator (no client reducer)
  • All settings persist to room config automatically

Migration Steps

Phase 1: Preparation (1 hour)

Goal: Set up new structure without breaking existing game

  1. Create /src/arcade-games/memory-quiz/ directory
  2. Copy Validator from /lib/arcade/validation/ to new location
  3. Update Validator to use Game SDK types if needed
  4. Create index.ts stub for game definition
  5. Copy types.ts to new location (will be updated)
  6. Document what needs to change in each file

Verification: Existing game still works, new directory has scaffold


Phase 2: Create Game Definition (1 hour)

Goal: Define the game using defineGame() helper

Steps:

  1. Create game.yaml manifest (optional but recommended)

    name: memory-quiz
    displayName: Memory Lightning
    icon: 🧠
    description: Memorize soroban numbers and recall them
    longDescription: |
      Flash cards with soroban numbers. Memorize them during the display
      phase, then recall and type them during the input phase.
    maxPlayers: 8
    difficulty: Intermediate
    chips:
      - 👥 Multiplayer
      - ⚡ Fast-Paced
      - 🧠 Memory Challenge
    color: blue
    gradient: linear-gradient(135deg, #dbeafe, #bfdbfe)
    borderColor: blue.200
    available: true
    
  2. Create index.ts game definition:

    import { defineGame } from "@/lib/arcade/game-sdk";
    import type { GameManifest } from "@/lib/arcade/game-sdk";
    import { GameComponent } from "./components/GameComponent";
    import { MemoryQuizProvider } from "./Provider";
    import type {
      MemoryQuizConfig,
      MemoryQuizMove,
      MemoryQuizState,
    } from "./types";
    import { memoryQuizValidator } from "./Validator";
    
    const manifest: GameManifest = {
      name: "memory-quiz",
      displayName: "Memory Lightning",
      icon: "🧠",
      // ... (copy from game.yaml or define inline)
    };
    
    const defaultConfig: MemoryQuizConfig = {
      selectedCount: 5,
      displayTime: 2.0,
      selectedDifficulty: "easy",
      playMode: "cooperative",
    };
    
    function validateMemoryQuizConfig(
      config: unknown,
    ): config is MemoryQuizConfig {
      return (
        typeof config === "object" &&
        config !== null &&
        "selectedCount" in config &&
        "displayTime" in config &&
        "selectedDifficulty" in config &&
        "playMode" in config &&
        [2, 5, 8, 12, 15].includes((config as any).selectedCount) &&
        typeof (config as any).displayTime === "number" &&
        (config as any).displayTime > 0 &&
        ["beginner", "easy", "medium", "hard", "expert"].includes(
          (config as any).selectedDifficulty,
        ) &&
        ["cooperative", "competitive"].includes((config as any).playMode)
      );
    }
    
    export const memoryQuizGame = defineGame<
      MemoryQuizConfig,
      MemoryQuizState,
      MemoryQuizMove
    >({
      manifest,
      Provider: MemoryQuizProvider,
      GameComponent,
      validator: memoryQuizValidator,
      defaultConfig,
      validateConfig: validateMemoryQuizConfig,
    });
    
  3. Register game in game-registry.ts:

    import { memoryQuizGame } from "@/arcade-games/memory-quiz";
    registerGame(memoryQuizGame);
    
  4. Update validators.ts to import from new location:

    import { memoryQuizValidator } from "@/arcade-games/memory-quiz/Validator";
    
  5. Add type inference to game-configs.ts:

    import type { memoryQuizGame } from "@/arcade-games/memory-quiz";
    export type MemoryQuizGameConfig = InferGameConfig<typeof memoryQuizGame>;
    

Verification: Game definition compiles, validator registered


Phase 3: Update Types (30 minutes)

Goal: Ensure types match Game SDK expectations

Changes to types.ts:

  1. Rename SorobanQuizStateMemoryQuizState
  2. Ensure MemoryQuizState extends GameState from SDK
  3. Rename move types to match SDK patterns
  4. Export proper config type

Example:

import type { GameConfig, GameState, GameMove } from '@/lib/arcade/game-sdk'

export interface MemoryQuizConfig extends GameConfig {
  selectedCount: 2 | 5 | 8 | 12 | 15
  displayTime: number
  selectedDifficulty: DifficultyLevel
  playMode: 'cooperative' | 'competitive'
}

export interface MemoryQuizState extends GameState {
  // Core game data
  cards: QuizCard[]
  quizCards: QuizCard[]
  correctAnswers: number[]

  // Game progression
  currentCardIndex: number
  displayTime: number
  selectedCount: number
  selectedDifficulty: DifficultyLevel

  // Input system state
  foundNumbers: number[]
  guessesRemaining: number
  currentInput: string
  incorrectGuesses: number

  // Multiplayer state (from GameState)
  activePlayers: string[]
  playerMetadata: Record<string, PlayerMetadata>

  // Game-specific multiplayer
  playerScores: Record<string, PlayerScore>
  playMode: 'cooperative' | 'competitive'
  numberFoundBy: Record<number, string>

  // UI state
  gamePhase: 'setup' | 'display' | 'input' | 'results'
  prefixAcceptanceTimeout: NodeJS.Timeout | null
  finishButtonsBound: boolean
  wrongGuessAnimations: Array<{...}>

  // Keyboard state
  hasPhysicalKeyboard: boolean | null
  testingMode: boolean
  showOnScreenKeyboard: boolean
}

export type MemoryQuizMove =
  | { type: 'START_QUIZ'; playerId: string; userId: string; timestamp: number; data: {...} }
  | { type: 'NEXT_CARD'; playerId: string; userId: string; timestamp: number; data: {} }
  // ... (ensure all moves have playerId, userId, timestamp)

Key Changes:

  • All moves must have playerId, userId, timestamp (SDK requirement)
  • State should include activePlayers and playerMetadata (SDK standard)
  • Use TEAM_MOVE for moves where specific player doesn't matter

Verification: Types compile, validator accepts move types


Phase 4: Create Provider (2 hours)

Goal: Single provider for room mode (only mode supported)

Key Pattern:

'use client'

import { useCallback, useMemo } from 'react'
import {
  useArcadeSession,
  useGameMode,
  useRoomData,
  useViewerId,
  useUpdateGameConfig,
  buildPlayerMetadata,
} from '@/lib/arcade/game-sdk'
import type { MemoryQuizState, MemoryQuizMove } from './types'

export function MemoryQuizProvider({ children }: { children: ReactNode }) {
  const { data: viewerId } = useViewerId()
  const { roomData } = useRoomData()
  const { activePlayers: activePlayerIds, players } = useGameMode()
  const { mutate: updateGameConfig } = useUpdateGameConfig()

  const activePlayers = Array.from(activePlayerIds)

  // Merge saved config from room
  const initialState = useMemo(() => {
    const gameConfig = roomData?.gameConfig?.['memory-quiz']
    return {
      // ... default state
      displayTime: gameConfig?.displayTime ?? 2.0,
      selectedCount: gameConfig?.selectedCount ?? 5,
      selectedDifficulty: gameConfig?.selectedDifficulty ?? 'easy',
      playMode: gameConfig?.playMode ?? 'cooperative',
      // ... rest of state
    }
  }, [roomData])

  const { state, sendMove, exitSession, lastError, clearError } =
    useArcadeSession<MemoryQuizState>({
      userId: viewerId || '',
      roomId: roomData?.id, // Always provided (room mode only)
      initialState,
      applyMove: (state) => state, // Server handles all updates
    })

  // Action creators
  const startQuiz = useCallback((quizCards: QuizCard[]) => {
    const numbers = quizCards.map(c => c.number)
    const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId)

    sendMove({
      type: 'START_QUIZ',
      playerId: TEAM_MOVE,
      userId: viewerId || '',
      data: { numbers, quizCards, activePlayers, playerMetadata },
    })
  }, [viewerId, sendMove, activePlayers, players])

  // ... more action creators

  return (
    <MemoryQuizContext.Provider value={{
      state,
      startQuiz,
      // ... all other actions
      lastError,
      clearError,
      exitSession,
    }}>
      {children}
    </MemoryQuizContext.Provider>
  )
}

Key Changes from Current RoomProvider:

  1. No reducer - server handles all state
  2. Uses SDK hooks exclusively
  3. Simpler action creators (server does the work)
  4. Config persistence via useUpdateGameConfig
  5. Always uses roomId (no conditional logic)

Files to Delete:

  • reducer.ts (no longer needed)
  • LocalMemoryQuizProvider.tsx (local mode deprecated)
  • Client-side applyMoveOptimistically() (server authoritative)

Verification: Provider compiles, context works


Phase 5: Update Components (1 hour)

Goal: Update components to use new provider API

Changes Needed:

  1. GameComponent.tsx (new file):

    'use client'
    
    import { useRouter } from 'next/navigation'
    import { PageWithNav } from '@/components/PageWithNav'
    import { useMemoryQuiz } from '../Provider'
    import { SetupPhase } from './SetupPhase'
    import { DisplayPhase } from './DisplayPhase'
    import { InputPhase } from './InputPhase'
    import { ResultsPhase } from './ResultsPhase'
    
    export function GameComponent() {
      const router = useRouter()
      const { state, exitSession } = useMemoryQuiz()
    
      return (
        <PageWithNav
          navTitle="Memory Lightning"
          navEmoji="🧠"
          emphasizePlayerSelection={state.gamePhase === 'setup'}
          onExitSession={() => {
            exitSession()
            router.push('/arcade')
          }}
        >
          <style dangerouslySetInnerHTML={{ __html: globalAnimations }} />
    
          {state.gamePhase === 'setup' && <SetupPhase />}
          {state.gamePhase === 'display' && <DisplayPhase />}
          {state.gamePhase === 'input' && <InputPhase key="input-phase" />}
          {state.gamePhase === 'results' && <ResultsPhase />}
        </PageWithNav>
      )
    }
    
  2. SetupPhase.tsx: Update to use action creators instead of dispatch

    - dispatch({ type: 'SET_DIFFICULTY', difficulty: value })
    + setConfig('selectedDifficulty', value)
    
  3. DisplayPhase.tsx: Update to use nextCard action

    - dispatch({ type: 'NEXT_CARD' })
    + nextCard()
    
  4. InputPhase.tsx: Update to use acceptNumber, rejectNumber actions

    - dispatch({ type: 'ACCEPT_NUMBER', number })
    + acceptNumber(number)
    
  5. ResultsPhase.tsx: Update to use resetGame, showResults actions

    - dispatch({ type: 'RESET_QUIZ' })
    + resetGame()
    

Minimal Changes:

  • Components mostly stay the same
  • Replace dispatch() calls with action creators
  • No other UI changes needed

Verification: All phases render, actions work


Phase 6: Update Page Route (15 minutes)

Goal: Update page to use new game definition

New /app/arcade/memory-quiz/page.tsx:

'use client'

import { memoryQuizGame } from '@/arcade-games/memory-quiz'

const { Provider, GameComponent } = memoryQuizGame

export default function MemoryQuizPage() {
  return (
    <Provider>
      <GameComponent />
    </Provider>
  )
}

That's it! The game now uses the modular system.

Verification: Game loads and plays end-to-end


Phase 7: Testing (30 minutes)

Goal: Verify all functionality works

Test Cases:

  1. Solo Play (single player in room):

    • Setup phase renders
    • Can change all settings (count, difficulty, display time, play mode)
    • Can start quiz
    • Cards display with timing
    • Input phase works
    • Can type and submit answers
    • Correct/incorrect feedback works
    • Results phase shows scores
    • Can play again
    • Settings persist across page reloads
  2. Multiplayer (multiple players):

    • Settings persist across page reloads
    • All players see same cards
    • Timing synchronized (room creator controls)
    • Input from any player works
    • Scores track correctly per player
    • Cooperative mode: team score works
    • Competitive mode: individual scores work
    • Results show all player scores
  3. Edge Cases:

    • Switching games preserves settings
    • Leaving mid-game doesn't crash
    • Keyboard detection works
    • On-screen keyboard toggle works
    • Wrong guess animations work
    • Timeout handling works

Verification: All tests pass


Breaking Changes

For Users

  • None - Game should work identically

For Developers

  • Can't use dispatch() anymore (use action creators)
  • Can't access reducer (server-driven state only)
  • No local mode support (room mode only)

Rollback Plan

If migration fails:

  1. Revert page to use old providers
  2. Keep old files in place
  3. Remove new /arcade-games/memory-quiz/ directory
  4. Unregister from game registry

Time to rollback: 5 minutes


Post-Migration Tasks

  1. Delete old files:

    • /app/arcade/memory-quiz/reducer.ts (no longer needed)
    • /app/arcade/memory-quiz/context/LocalMemoryQuizProvider.tsx (local mode deprecated)
    • /app/arcade/memory-quiz/page.tsx (old local mode page, replaced by arcade page)
    • /lib/arcade/validation/MemoryQuizGameValidator.ts (moved to new location)
  2. Update imports across codebase

  3. Add to ARCHITECTURAL_IMPROVEMENTS.md:

    • Memory Quiz migrated successfully
    • Now 3 games on modular platform
  4. Run full test suite


Complexity Analysis

What Makes This Easier

  • Validator already exists and works
  • Already uses useArcadeSession
  • Move types mostly match SDK requirements
  • Well-tested, stable game

What Makes This Harder

  • Complex UI state (keyboard detection, animations)
  • Two-phase gameplay (display, then input)
  • Timing synchronization requirements
  • Local input optimization (doesn't sync every keystroke)

Estimated Time

  • Fast path (no issues): 3-4 hours
  • Normal path (minor fixes): 4-6 hours
  • Slow path (major issues): 6-8 hours

Success Criteria

  1. Game registered in game registry
  2. Config types inferred from game definition
  3. Single provider for local and room modes
  4. All phases work in both modes
  5. Settings persist in room mode
  6. Multiplayer synchronization works
  7. No TypeScript errors
  8. No lint errors
  9. Pre-commit checks pass
  10. Manual testing confirms all features work

Notes

UI State Challenges

Memory Quiz has significant UI-only state:

  • wrongGuessAnimations - visual feedback
  • hasPhysicalKeyboard - device detection
  • showOnScreenKeyboard - toggle state
  • prefixAcceptanceTimeout - timeout handling

Solution: These can remain client-only (not synced). They don't affect game logic.

Input Optimization

Current implementation doesn't sync currentInput over network (only final submission).

Solution: Keep this pattern. Use local state for input, only sync ACCEPT_NUMBER/REJECT_NUMBER.

Timing Synchronization

Room creator controls card timing (NEXT_CARD moves).

Solution: Check isRoomCreator flag, only creator can advance cards.


References

  • Game SDK Documentation: /src/arcade-games/README.md
  • Example Migration: Number Guesser, Math Sprint
  • Architecture Docs: /docs/ARCHITECTURAL_IMPROVEMENTS.md
  • Validator Registry: /src/lib/arcade/validators.ts
  • Game Registry: /src/lib/arcade/game-registry.ts