# Arcade Game System A modular, plugin-based architecture for building multiplayer arcade games with real-time synchronization. ## Table of Contents - [Overview](#overview) - [Architecture](#architecture) - [Game SDK](#game-sdk) - [Creating a New Game](#creating-a-new-game) - [File Structure](#file-structure) - [Examples](#examples) - [Best Practices](#best-practices) - [Troubleshooting](#troubleshooting) --- ## Available Games ### Know Your World **Location**: [`know-your-world/`](./know-your-world/) **Documentation**: [`know-your-world/README.md`](./know-your-world/README.md) A geography quiz game where players identify countries, states, and territories on unlabeled maps. **Key Features**: - **[Precision Controls](./know-your-world/PRECISION_CONTROLS.md)** - Automatic cursor dampening and super zoom for tiny regions (Gibraltar 0.08px) - **3 Game Modes**: Cooperative, Race, Turn-Based - **Multiple Maps**: World (256 countries), USA States (51 states) - **Adaptive Difficulty**: Per-map difficulty tiers with smart filtering - **Study Mode**: Optional memorization period before gameplay - **Visual Features**: Excluded region visualization, adaptive zoom magnifier, smart label positioning --- ## Overview ### Goals 1. **Modularity**: Each game is self-contained and independently deployable 2. **Type Safety**: Full TypeScript support with compile-time validation 3. **Real-time Sync**: Built-in multiplayer support via WebSocket 4. **Optimistic Updates**: Instant client feedback with server validation 5. **Consistent UX**: Shared navigation, player management, and room features ### Key Features - **Plugin Architecture**: Games register themselves with a central registry - **Stable SDK API**: Games only import from `@/lib/arcade/game-sdk` - **Server-side Validation**: All moves validated server-side with client rollback - **Automatic State Sync**: Multi-client synchronization handled automatically - **Turn Indicators**: Built-in UI for showing active player - **Error Handling**: Standardized error feedback to users --- ## Architecture ### Key Improvements **✨ Phase 3: Type Inference (January 2025)** Config types are now **automatically inferred** from game definitions for modern games. No more manual type definitions! ```typescript // Before Phase 3: Manual type definition export interface NumberGuesserGameConfig { minNumber: number; maxNumber: number; roundsToWin: number; } // After Phase 3: Inferred from game definition export type NumberGuesserGameConfig = InferGameConfig; ``` **Benefits**: - Add a game → Config types automatically available system-wide - Single source of truth (the game definition) - Eliminates 10-15 lines of boilerplate per game ### System Components ``` ┌─────────────────────────────────────────────────────────────┐ │ Validator Registry │ │ - Server-side validators (isomorphic) │ │ - Single source of truth for game names │ │ - Auto-derived GameName type │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Game Registry │ │ - Client-side game definitions │ │ - React components (Provider, GameComponent) │ │ - Provides game discovery │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Game SDK │ │ - Stable API surface for games │ │ - React hooks (useArcadeSession, useRoomData, etc.) │ │ - Type definitions and utilities │ │ - defineGame() helper │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Individual Games │ │ number-guesser/ │ │ ├── index.ts (Game definition + validation) │ │ ├── Validator.ts (Server validation logic) │ │ ├── Provider.tsx (Client state management) │ │ ├── GameComponent.tsx (Main UI) │ │ ├── types.ts (TypeScript types) │ │ └── components/ (Phase UIs) │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Type System (NEW) │ │ - Config types inferred from game definitions │ │ - GameConfigByName auto-derived │ │ - RoomGameConfig auto-derived │ └─────────────────────────────────────────────────────────────┘ ``` ### Data Flow ``` User Action → Provider (sendMove) ↓ useArcadeSession ↓ Optimistic Update (instant UI feedback) ↓ WebSocket → Server ↓ Validator.validateMove() ↓ ✓ Valid: State Update → Broadcast ✗ Invalid: Reject → Client Rollback ↓ Client receives validated state ``` --- ## Game SDK ### Core API Surface ```typescript import { // Types type GameDefinition, type GameValidator, type GameState, type GameMove, type GameConfig, type ValidationResult, // React Hooks useArcadeSession, useRoomData, useGameMode, useViewerId, useUpdateGameConfig, // Utilities defineGame, buildPlayerMetadata, } from "@/lib/arcade/game-sdk"; ``` ### Key Concepts #### GameDefinition Complete description of a game: ```typescript interface GameDefinition { manifest: GameManifest; // Display info, max players, etc. Provider: GameProviderComponent; // React context provider GameComponent: GameComponent; // Main UI component validator: GameValidator; // Server-side validation defaultConfig: TConfig; // Default game settings validateConfig?: (config: unknown) => config is TConfig; // Runtime config validation } ``` **Key Concept**: The `defaultConfig` property serves as the source of truth for config types. TypeScript can infer the config type from `typeof game.defaultConfig`, eliminating the need for manual type definitions in `game-configs.ts`. #### GameState The complete game state that's synchronized across all clients: ```typescript interface GameState { gamePhase: string; // Current phase (setup, playing, results) activePlayers: string[]; // Array of player IDs playerMetadata: Record; // Player info (name, emoji, etc.) // ... game-specific state } ``` #### GameMove Actions that players take, validated server-side: ```typescript interface GameMove { type: string; // Move type (e.g., 'FLIP_CARD', 'MAKE_GUESS') playerId: string; // Player making the move userId: string; // User ID (for authentication) timestamp: number; // Client timestamp data: Record; // Move-specific payload } ``` #### GameValidator Server-side validation logic: ```typescript interface GameValidator { validateMove(state: TState, move: TMove): ValidationResult; isGameComplete(state: TState): boolean; getInitialState(config: unknown): TState; } ``` --- ## Creating a New Game ### Step 1: Create Game Directory ```bash mkdir -p src/arcade-games/my-game/components ``` ### Step 2: Define Types (`types.ts`) ```typescript import type { GameConfig, GameMove, GameState } from "@/lib/arcade/game-sdk"; // Game configuration (persisted to database) export interface MyGameConfig extends GameConfig { difficulty: number; timer: number; } // Game state (synchronized across clients) export interface MyGameState extends GameState { gamePhase: "setup" | "playing" | "results"; activePlayers: string[]; playerMetadata: Record; currentPlayer: string; score: Record; // ... your game-specific state } // Move types export type MyGameMove = | { type: "START_GAME"; playerId: string; userId: string; timestamp: number; data: { activePlayers: string[] }; } | { type: "MAKE_MOVE"; playerId: string; userId: string; timestamp: number; data: { /* move data */ }; } | { type: "END_GAME"; playerId: string; userId: string; timestamp: number; data: {}; }; ``` ### Step 3: Create Validator (`Validator.ts`) ```typescript import type { GameValidator, ValidationResult } from "@/lib/arcade/game-sdk"; import type { MyGameState, MyGameMove } from "./types"; export class MyGameValidator implements GameValidator { validateMove(state: MyGameState, move: MyGameMove): ValidationResult { switch (move.type) { case "START_GAME": return this.validateStartGame(state, move.data.activePlayers); case "MAKE_MOVE": return this.validateMakeMove(state, move.playerId, move.data); default: return { valid: false, error: "Unknown move type" }; } } private validateStartGame( state: MyGameState, activePlayers: string[], ): ValidationResult { if (activePlayers.length < 2) { return { valid: false, error: "Need at least 2 players" }; } const newState: MyGameState = { ...state, gamePhase: "playing", activePlayers, currentPlayer: activePlayers[0], score: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}), }; return { valid: true, newState }; } // ... more validation methods isGameComplete(state: MyGameState): boolean { return state.gamePhase === "results"; } getInitialState(config: unknown): MyGameState { const { difficulty, timer } = config as MyGameConfig; return { difficulty, timer, gamePhase: "setup", activePlayers: [], playerMetadata: {}, currentPlayer: "", score: {}, }; } } export const myGameValidator = new MyGameValidator(); ``` ### Step 4: Create Provider (`Provider.tsx`) ```typescript 'use client' import { createContext, useCallback, useContext, useMemo } from 'react' import { type GameMove, buildPlayerMetadata, useArcadeSession, useGameMode, useRoomData, useViewerId, } from '@/lib/arcade/game-sdk' import type { MyGameState } from './types' interface MyGameContextValue { state: MyGameState lastError: string | null startGame: () => void makeMove: (data: any) => void clearError: () => void exitSession: () => void } const MyGameContext = createContext(null) export function useMyGame() { const context = useContext(MyGameContext) if (!context) throw new Error('useMyGame must be used within MyGameProvider') return context } export function MyGameProvider({ children }: { children: React.ReactNode }) { const { data: viewerId } = useViewerId() const { roomData } = useRoomData() const { activePlayers: activePlayerIds, players } = useGameMode() // Get active players as array (keep Set iteration order to match UI display) const activePlayers = Array.from(activePlayerIds) const initialState = useMemo(() => ({ difficulty: 1, timer: 30, gamePhase: 'setup' as const, activePlayers: [], playerMetadata: {}, currentPlayer: '', score: {}, }), []) const { state, sendMove, exitSession, lastError, clearError } = useArcadeSession({ userId: viewerId || '', roomId: roomData?.id, initialState, applyMove: (state, move) => state, // Server handles all updates }) const startGame = useCallback(() => { const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId) sendMove({ type: 'START_GAME', playerId: activePlayers[0], userId: viewerId || '', data: { activePlayers, playerMetadata }, }) }, [activePlayers, players, viewerId, sendMove]) const makeMove = useCallback((data: any) => { sendMove({ type: 'MAKE_MOVE', playerId: state.currentPlayer, userId: viewerId || '', data, }) }, [state.currentPlayer, viewerId, sendMove]) return ( {children} ) } ``` ### Step 5: Create Game Component (`GameComponent.tsx`) ```typescript 'use client' import { useRouter } from 'next/navigation' import { PageWithNav } from '@/components/PageWithNav' import { useMyGame } from '../Provider' import { SetupPhase } from './SetupPhase' import { PlayingPhase } from './PlayingPhase' import { ResultsPhase } from './ResultsPhase' export function GameComponent() { const router = useRouter() const { state, exitSession } = useMyGame() // Determine whose turn it is for the turn indicator const currentPlayerId = state.gamePhase === 'playing' ? state.currentPlayer : undefined return ( { exitSession() router.push('/arcade') }} > {state.gamePhase === 'setup' && } {state.gamePhase === 'playing' && } {state.gamePhase === 'results' && } ) } ``` ### Step 6: Define Game (`index.ts`) ```typescript import { defineGame } from "@/lib/arcade/game-sdk"; import type { GameManifest } from "@/lib/arcade/game-sdk"; import { GameComponent } from "./components/GameComponent"; import { MyGameProvider } from "./Provider"; import type { MyGameConfig, MyGameMove, MyGameState } from "./types"; import { myGameValidator } from "./Validator"; const manifest: GameManifest = { name: "my-game", displayName: "My Awesome Game", icon: "🎮", description: "A fun multiplayer game", longDescription: "Detailed description of gameplay...", maxPlayers: 4, difficulty: "Beginner", chips: ["👥 Multiplayer", "🎲 Turn-Based"], color: "blue", gradient: "linear-gradient(135deg, #bfdbfe, #93c5fd)", borderColor: "blue.200", available: true, }; const defaultConfig: MyGameConfig = { difficulty: 1, timer: 30, }; // Runtime config validation (optional but recommended) function validateMyGameConfig(config: unknown): config is MyGameConfig { return ( typeof config === "object" && config !== null && "difficulty" in config && "timer" in config && typeof config.difficulty === "number" && typeof config.timer === "number" && config.difficulty >= 1 && config.timer >= 10 ); } export const myGame = defineGame({ manifest, Provider: MyGameProvider, GameComponent, validator: myGameValidator, defaultConfig, validateConfig: validateMyGameConfig, // Self-contained validation }); ``` **Phase 3 Benefit**: After defining your game, the config type will be automatically inferred in `game-configs.ts`. You don't need to manually add type definitions - just add a type-only import and use `InferGameConfig`. ### Step 7: Register Game #### 7a. Register Validator (Server-Side) Add your validator to the unified registry in `src/lib/arcade/validators.ts`: ```typescript import { myGameValidator } from "@/arcade-games/my-game/Validator"; export const validatorRegistry = { matching: matchingGameValidator, "memory-quiz": memoryQuizGameValidator, "number-guesser": numberGuesserValidator, "my-game": myGameValidator, // Add your game here! // GameName type will auto-update from these keys } as const; ``` **Why**: The validator registry is isomorphic (runs on both client and server) and serves as the single source of truth for all game validators. Adding your validator here automatically: - Makes it available for server-side move validation - Updates the `GameName` type (no manual type updates needed!) - Enables your game for multiplayer rooms #### 7b. Register Game Definition (Client-Side) Add to `src/lib/arcade/game-registry.ts`: ```typescript import { myGame } from "@/arcade-games/my-game"; registerGame(myGame); ``` **Why**: The game registry is client-only and connects your game's UI components (Provider, GameComponent) with the arcade system. Registration happens on client init and verifies that your validator is also registered server-side. **Verification**: When you register a game, the registry will warn you if: - ⚠️ The validator is missing from `validators.ts` - ⚠️ The validator instance doesn't match (different imports) **Important**: Both steps are required for a working game. The validator registry handles server logic, while the game registry handles client UI. #### 7c. Add Config Type Inference (Optional but Recommended) Update `src/lib/arcade/game-configs.ts` to infer your game's config type: ```typescript // Add type-only import (won't load React components) import type { myGame } from "@/arcade-games/my-game"; // Utility type (already defined) type InferGameConfig = T extends { defaultConfig: infer Config } ? Config : never; // Infer your config type export type MyGameConfig = InferGameConfig; // Add to GameConfigByName export type GameConfigByName = { // ... other games "my-game": MyGameConfig; // TypeScript infers the type automatically! }; // RoomGameConfig is auto-derived from GameConfigByName export type RoomGameConfig = { [K in keyof GameConfigByName]?: GameConfigByName[K]; }; // Add default config constant export const DEFAULT_MY_GAME_CONFIG: MyGameConfig = { difficulty: 1, timer: 30, }; ``` **Benefits**: - Config type automatically matches your game definition - No manual type definition needed - Single source of truth (your game's `defaultConfig`) - TypeScript will error if you reference undefined properties **Note**: You still need to manually add the default config constant. This is a small amount of duplication but necessary for server-side code that can't import the full game definition. --- ## File Structure ``` src/arcade-games/my-game/ ├── index.ts # Game definition and export ├── Validator.ts # Server-side move validation ├── Provider.tsx # Client state management ├── GameComponent.tsx # Main UI wrapper ├── types.ts # TypeScript type definitions └── components/ ├── SetupPhase.tsx # Setup/lobby UI ├── PlayingPhase.tsx # Main gameplay UI └── ResultsPhase.tsx # End game/scores UI ``` ### File Responsibilities | File | Purpose | Runs On | | ------------------- | --------------------------- | --------------- | | `index.ts` | Game registration | Both | | `Validator.ts` | Move validation, game logic | **Server only** | | `Provider.tsx` | State management, API calls | Client only | | `GameComponent.tsx` | Navigation, phase routing | Client only | | `types.ts` | Shared type definitions | Both | | `components/*` | UI for each game phase | Client only | --- ## Examples ### Number Guesser (Turn-Based) See `src/arcade-games/number-guesser/` for a complete example of: - Turn-based gameplay (chooser → guessers) - Player rotation logic - Round management - Score tracking - Hot/cold feedback system - Error handling and user feedback **Key Patterns:** - Setting `currentPlayerId` for turn indicators - Rotating turns in validator - Handling round vs. game completion - Type coercion for JSON-serialized numbers --- ## Best Practices ### 1. Player Ordering Consistency **Problem**: Sets don't guarantee order, causing mismatch between UI and game logic. **Solution**: Use `Array.from(activePlayerIds)` without sorting in both UI and game logic. ```typescript // In Provider const activePlayers = Array.from(activePlayerIds); // NO .sort() // In Validator const newState = { ...state, currentPlayer: activePlayers[0], // First in Set order = first in UI }; ``` ### 2. Type Coercion for Numbers **Problem**: WebSocket JSON serialization converts numbers to strings. **Solution**: Explicitly coerce in validator: ```typescript validateMove(state: MyGameState, move: MyGameMove): ValidationResult { switch (move.type) { case 'MAKE_GUESS': return this.validateGuess(state, Number(move.data.guess)) // Coerce! } } ``` ### 3. Error Feedback **Problem**: Users don't see why their moves were rejected. **Solution**: Use `lastError` from `useArcadeSession`: ```typescript const { state, lastError, clearError } = useArcadeSession(...) // Auto-dismiss after 5 seconds useEffect(() => { if (lastError) { const timeout = setTimeout(() => clearError(), 5000) return () => clearTimeout(timeout) } }, [lastError, clearError]) // Show in UI {lastError && (
⚠️ Move Rejected
{lastError}
)} ``` ### 4. Turn Indicators **Problem**: Players don't know whose turn it is. **Solution**: Pass `currentPlayerId` to `PageWithNav`: ```typescript ``` ### 5. Server-Only Logic **Problem**: Client can cheat by modifying local state. **Solution**: All game logic in validator, client uses `applyMove: (state) => state`: ```typescript // ❌ BAD: Client calculates winner const { state, sendMove } = useArcadeSession({ applyMove: (state, move) => { if (move.type === "SCORE") { return { ...state, winner: calculateWinner(state) }; // Cheatable! } }, }); // ✅ GOOD: Server calculates everything const { state, sendMove } = useArcadeSession({ applyMove: (state, move) => state, // Client just waits for server }); ``` ### 6. Phase Management Use discriminated union for type-safe phase rendering: ```typescript type GamePhase = 'setup' | 'playing' | 'results' interface MyGameState { gamePhase: GamePhase // ... } // In GameComponent {state.gamePhase === 'setup' && } {state.gamePhase === 'playing' && } {state.gamePhase === 'results' && } ``` --- ## Troubleshooting ### "Player not found" errors **Cause**: Player IDs from `useGameMode()` don't match server state. **Fix**: Always use `buildPlayerMetadata()` helper: ```typescript const playerMetadata = buildPlayerMetadata( activePlayers, {}, players, viewerId, ); ``` ### Turn indicator not showing **Cause**: `currentPlayerId` not passed or doesn't match player IDs in UI. **Fix**: Verify player order matches between game state and `activePlayerIds`: ```typescript // Both should use same source without sorting const activePlayers = Array.from(activePlayerIds); // Provider const activePlayerList = Array.from(activePlayers); // PageWithNav ``` ### Moves rejected with type errors **Cause**: JSON serialization converts numbers to strings. **Fix**: Add `Number()` coercion in validator: ```typescript case 'SET_VALUE': return this.validateValue(state, Number(move.data.value)) ``` ### State not syncing across clients **Cause**: Not using `useArcadeSession` correctly. **Fix**: Ensure `roomId` is passed: ```typescript const { state, sendMove } = useArcadeSession({ userId: viewerId || "", roomId: roomData?.id, // Required for room sync! initialState, applyMove: (state) => state, }); ``` ### Game not appearing in selector **Cause**: Not registered or `available: false`. **Fix**: 1. Add to `game-registry.ts`: `registerGame(myGame)` 2. Set `available: true` in manifest 3. Verify no console errors on import ### Config changes not taking effect **Cause**: State sync timing - validator uses old state while config is being updated. **Context**: When you change game config (e.g., min/max numbers), there's a brief window where: 1. Client updates config in database 2. Config change hasn't propagated to server state yet 3. Moves are validated against old state **Fix**: Ensure config changes trigger state reset or are applied atomically: ```typescript // When changing config, also update initialState const setConfig = useCallback( (field, value) => { sendMove({ type: "SET_CONFIG", data: { field, value } }); // Persist to database for next session if (roomData?.id) { updateGameConfig({ roomId: roomData.id, gameConfig: { ...roomData.gameConfig, "my-game": { ...currentConfig, [field]: value }, }, }); } }, [sendMove, updateGameConfig, roomData], ); ``` **Best Practice**: Make config changes only during setup phase, before game starts. ### Debugging validation errors **Problem**: Moves rejected but unclear why (especially type-related issues). **Solution**: Add debug logging in validator: ```typescript private validateGuess(state: State, guess: number): ValidationResult { // Debug logging console.log('[MyGame] Validating guess:', { guess, guessType: typeof guess, // Check if it's a string! min: state.minNumber, minType: typeof state.minNumber, max: state.maxNumber, maxType: typeof state.maxNumber, }) if (guess < state.minNumber || guess > state.maxNumber) { return { valid: false, error: `Guess must be between ${state.minNumber} and ${state.maxNumber}` } } // ... rest of validation } ``` **What to check:** 1. **Browser console**: Look for `[ArcadeSession] Move rejected by server:` messages 2. **Server logs**: Check validator console.log output for types and values 3. **Type mismatches**: Numbers becoming strings is the #1 issue 4. **State sync**: Is the validator using the state you expect? **Common debugging workflow:** 1. Move rejected → Check browser console for error message 2. Error unclear → Add console.log to validator 3. Restart server → See debug output when move is made 4. Compare expected vs. actual values/types 5. Add `Number()` coercion if types don't match --- ## Resources - **Game SDK**: `src/lib/arcade/game-sdk/` - **Registry**: `src/lib/arcade/game-registry.ts` - **Example Game**: `src/arcade-games/number-guesser/` - **Validation Types**: `src/lib/arcade/validation/types.ts` --- ## FAQ **Q: Can I use external libraries in my game?** A: Yes, but install them in the workspace package.json. Games should be self-contained. **Q: How do I add game configuration that persists?** A: Use `useUpdateGameConfig()` to save to room: ```typescript const { mutate: updateGameConfig } = useUpdateGameConfig(); updateGameConfig({ roomId: roomData.id, gameConfig: { ...roomData.gameConfig, "my-game": { difficulty: 5 }, }, }); ``` **Q: Can I have asymmetric player roles?** A: Yes! See Number Guesser's chooser/guesser pattern. **Q: How do I handle real-time timers?** A: Store `startTime` in state, use client-side countdown, server validates elapsed time. **Q: What's the difference between `playerId` and `userId`?** A: `userId` is the user account, `playerId` is the avatar/character in the game. One user can control multiple players.