33 KiB
Arcade Game System
A modular, plugin-based architecture for building multiplayer arcade games with real-time synchronization.
Table of Contents
- Overview
- Architecture
- Game SDK
- Creating a New Game
- File Structure
- Examples
- Best Practices
- Troubleshooting
Available Games
Know Your World
Location: know-your-world/
Documentation: know-your-world/README.md
A geography quiz game where players identify countries, states, and territories on unlabeled maps.
Key Features:
- Precision Controls - 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
- Modularity: Each game is self-contained and independently deployable
- Type Safety: Full TypeScript support with compile-time validation
- Real-time Sync: Built-in multiplayer support via WebSocket
- Optimistic Updates: Instant client feedback with server validation
- 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!
// 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<typeof numberGuesserGame>;
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
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:
interface GameDefinition<TConfig, TState, TMove> {
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:
interface GameState {
gamePhase: string; // Current phase (setup, playing, results)
activePlayers: string[]; // Array of player IDs
playerMetadata: Record<string, PlayerMeta>; // Player info (name, emoji, etc.)
// ... game-specific state
}
GameMove
Actions that players take, validated server-side:
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<string, unknown>; // Move-specific payload
}
GameValidator
Server-side validation logic:
interface GameValidator<TState, TMove> {
validateMove(state: TState, move: TMove): ValidationResult;
isGameComplete(state: TState): boolean;
getInitialState(config: unknown): TState;
}
Creating a New Game
Step 1: Create Game Directory
mkdir -p src/arcade-games/my-game/components
Step 2: Define Types (types.ts)
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<string, PlayerMetadata>;
currentPlayer: string;
score: Record<string, number>;
// ... 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)
import type { GameValidator, ValidationResult } from "@/lib/arcade/game-sdk";
import type { MyGameState, MyGameMove } from "./types";
export class MyGameValidator implements GameValidator<MyGameState, MyGameMove> {
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)
'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<MyGameContextValue | null>(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<MyGameState>({
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 (
<MyGameContext.Provider value={{
state,
lastError,
startGame,
makeMove,
clearError,
exitSession,
}}>
{children}
</MyGameContext.Provider>
)
}
Step 5: Create Game Component (GameComponent.tsx)
'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 (
<PageWithNav
navTitle="My Game"
navEmoji="🎮"
emphasizePlayerSelection={state.gamePhase === 'setup'}
currentPlayerId={currentPlayerId}
playerScores={state.score}
onExitSession={() => {
exitSession()
router.push('/arcade')
}}
>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'playing' && <PlayingPhase />}
{state.gamePhase === 'results' && <ResultsPhase />}
</PageWithNav>
)
}
Step 6: Define Game (index.ts)
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<MyGameConfig, MyGameState, MyGameMove>({
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<typeof myGame>.
Step 7: Register Game
7a. Register Validator (Server-Side)
Add your validator to the unified registry in src/lib/arcade/validators.ts:
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
GameNametype (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:
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:
// Add type-only import (won't load React components)
import type { myGame } from "@/arcade-games/my-game";
// Utility type (already defined)
type InferGameConfig<T> = T extends { defaultConfig: infer Config }
? Config
: never;
// Infer your config type
export type MyGameConfig = InferGameConfig<typeof myGame>;
// 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
currentPlayerIdfor 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.
// 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:
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:
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 && (
<div className="error-banner">
<div>⚠️ Move Rejected</div>
<div>{lastError}</div>
<button onClick={clearError}>Dismiss</button>
</div>
)}
4. Turn Indicators
Problem: Players don't know whose turn it is.
Solution: Pass currentPlayerId to PageWithNav:
<PageWithNav
currentPlayerId={state.currentPlayer}
playerScores={state.scores}
>
5. Server-Only Logic
Problem: Client can cheat by modifying local state.
Solution: All game logic in validator, client uses applyMove: (state) => state:
// ❌ 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:
type GamePhase = 'setup' | 'playing' | 'results'
interface MyGameState {
gamePhase: GamePhase
// ...
}
// In GameComponent
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'playing' && <PlayingPhase />}
{state.gamePhase === 'results' && <ResultsPhase />}
Troubleshooting
"Player not found" errors
Cause: Player IDs from useGameMode() don't match server state.
Fix: Always use buildPlayerMetadata() helper:
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:
// 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:
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:
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:
- Add to
game-registry.ts:registerGame(myGame) - Set
available: truein manifest - 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:
- Client updates config in database
- Config change hasn't propagated to server state yet
- Moves are validated against old state
Fix: Ensure config changes trigger state reset or are applied atomically:
// 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:
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:
- Browser console: Look for
[ArcadeSession] Move rejected by server:messages - Server logs: Check validator console.log output for types and values
- Type mismatches: Numbers becoming strings is the #1 issue
- State sync: Is the validator using the state you expect?
Common debugging workflow:
- Move rejected → Check browser console for error message
- Error unclear → Add console.log to validator
- Restart server → See debug output when move is made
- Compare expected vs. actual values/types
- 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:
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.
Practice System Integration
Arcade games can be used during Game Breaks in the practice system. This section documents how the integration works and what game developers need to know.
Overview
The practice system offers students periodic game breaks as a reward for focused work. During a game break:
- A temporary arcade room is created for the student
- The student selects a game to play
- The break ends when either:
- The break timer expires (timeout)
- The student finishes a game round (game completion)
- The student clicks "Back to Practice" (skip)
Game Completion Detection
The practice system automatically detects when a game finishes by listening for the gamePhase transition to 'results'.
How it works:
┌─────────────────────────────────────────────────────────────┐
│ PracticeGameModeProvider │
│ │
│ Listens for 'session-state' socket events │
│ Tracks gamePhase transitions │
│ When gamePhase → 'results', calls onGameComplete() │
│ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ GameBreakScreen │
│ │
│ Receives onGameComplete callback │
│ Ends game break with reason: 'gameFinished' │
│ Cleans up room and returns to practice │
│ │
└─────────────────────────────────────────────────────────────┘
Requirements for Practice Integration
For games with definite endings:
Games that have a natural completion point (matching all pairs, finishing all rounds, etc.) should:
- Use
gamePhase: 'results'when the game ends - Transition to this phase in the validator when
isGameComplete()returns true
// In Validator.ts
isGameComplete(state: MyGameState): boolean {
return state.gamePhase === 'results'
}
validateMove(state: MyGameState, move: MyGameMove): ValidationResult {
// When game ends, transition to 'results' phase
if (allPairsMatched) {
return {
valid: true,
newState: {
...state,
gamePhase: 'results', // ← This triggers practice integration!
}
}
}
}
For endless/infinite games:
Some games don't have natural endings (e.g., complement-race, infinite runners). These games:
- Will only end via timeout or manual skip during game breaks
- No changes needed - the practice system handles this gracefully
- Game breaks have a maximum duration that acts as a safety net
Testing Practice Integration
To verify your game integrates correctly with the practice system:
- Start a practice session with a student
- Wait for (or trigger) a game break
- Select your game
- Complete a game round
- Verify the break ends automatically with log:
[PracticeGameModeProvider] Game completed - phase transitioned to results
Debug checklist:
- Does your game have a
'results'phase? - Does
isGameComplete()returntruewhengamePhase === 'results'? - Does your validator set
gamePhase: 'results'when the game ends? - Check browser console for socket events containing
gamePhase
Architecture Notes
Key files:
| File | Purpose |
|---|---|
src/components/practice/GameBreakScreen.tsx |
Game break UI, timer, game selection |
src/components/practice/PracticeGameModeProvider.tsx |
Wraps games, listens for completion |
src/hooks/useGameBreakRoom.ts |
Creates temporary room for game break |
src/hooks/useGameBreakTimer.ts |
Break duration timer |
See also: .claude/ARCADE_ROOM_ARCHITECTURE.md for complete integration documentation.