Compare commits

...

77 Commits

Author SHA1 Message Date
semantic-release-bot
d1c40f1733 chore(release): 4.1.0 [skip ci]
## [4.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.0.3...v4.1.0) (2025-10-16)

### Features

* **arcade:** migrate memory-quiz to modular game system ([f48c37a](f48c37accc))

### Code Refactoring

* **arcade:** remove memory-quiz from legacy GAMES_CONFIG ([9952e11](9952e11c27))

### Documentation

* add matching pairs battle migration plan ([3948582](39485826fc))
* add memory-quiz migration plan documentation ([7e2df10](7e2df106e6))
* **arcade:** document Phase 3 completion in ARCHITECTURAL_IMPROVEMENTS.md ([704f34f](704f34f83e))
* update playbook with memory-quiz completion ([99eee69](99eee69f28))
2025-10-16 03:34:36 +00:00
Thomas Hallock
39485826fc docs: add matching pairs battle migration plan
Create comprehensive migration plan for Matching Pairs Battle game.
Documents dual-location complexity, 8-phase migration approach, and
key differences from Memory Quiz migration.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 22:33:39 -05:00
Thomas Hallock
7e2df106e6 docs: add memory-quiz migration plan documentation
Add detailed migration plan document for the Memory Quiz game migration
to the modular game system. This serves as a reference for future game
migrations.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 22:31:35 -05:00
Thomas Hallock
99eee69f28 docs: update playbook with memory-quiz completion
Mark memory-quiz migration as completed in the Game Migration Playbook.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 22:31:35 -05:00
Thomas Hallock
9952e11c27 refactor(arcade): remove memory-quiz from legacy GAMES_CONFIG
Memory-quiz now only exists in the game registry, eliminating duplicate:
- Removed from GAMES_CONFIG in GameSelector.tsx
- Removed from GAME_TYPE_TO_NAME mapping in room/page.tsx
- Updated /arcade/memory-quiz route to redirect to arcade
- Removed legacy switch case (now handled by registry)

Fixes issue where Memory Lightning appeared twice in game selector.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 22:28:45 -05:00
Thomas Hallock
f48c37accc feat(arcade): migrate memory-quiz to modular game system
Create new modular structure for Memory Lightning game:
- New location: /src/arcade-games/memory-quiz/
- Game definition with manifest and config validation
- Unified Provider using useArcadeSession (room-mode only)
- Server-side Validator for move validation
- SDK-compatible types (Config, State, Moves)
- Registered in game-registry.ts

Key changes:
- Room-mode only (local mode deprecated)
- Type-safe config with InferGameConfig<>
- Action creators replace reducer pattern
- Optimistic client updates + server validation
- Config persistence to room_game_configs table

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 22:28:28 -05:00
Thomas Hallock
704f34f83e docs(arcade): document Phase 3 completion in ARCHITECTURAL_IMPROVEMENTS.md
**Updates**:
- Added Phase 3 section with implementation details
- Updated "Before vs After" comparison: 12 files → 3 files (75% reduction)
- Updated Executive Summary: Grade A (up from B- originally, A- after Phase 2)
- Updated Conclusion with all three phases completed
- Updated Quick Reference with Phase 3 type inference steps
- Renamed "Future Work" to include optional Phase 4

**Key Metrics**:
- Files to update: 12 → 3 (75% reduction)
- Lines of boilerplate: ~60 → ~20 (67% reduction)
- All critical architectural issues resolved

**Status**: Production-ready modular game system.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 21:40:39 -05:00
semantic-release-bot
9e393b42aa chore(release): 4.0.3 [skip ci]
## [4.0.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.0.2...v4.0.3) (2025-10-16)

### Bug Fixes

* **math-sprint:** remove unused import and autoFocus attribute ([51593eb](51593eb44f))

### Code Refactoring

* **arcade:** implement Phase 3 - infer config types from game definitions ([eed468c](eed468c6c4))

### Documentation

* **arcade:** update README with Phase 3 type inference architecture ([b47b1cc](b47b1cc03f))

### Styles

* **math-sprint:** apply Biome formatting ([d7d8d8b](d7d8d8b1e3))
2025-10-16 02:39:45 +00:00
Thomas Hallock
d7d8d8b1e3 style(math-sprint): apply Biome formatting
**Changes**: Auto-formatting from Biome formatter.
- Provider: Multi-line formatting for useArcadeSession call
- SetupPhase: Multi-line formatting for long className

No logic changes, purely stylistic.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 21:38:44 -05:00
Thomas Hallock
51593eb44f fix(math-sprint): remove unused import and autoFocus attribute
**Lint fixes**:
- Removed unused TEAM_MOVE import from Validator.ts
- Removed autoFocus attribute from PlayingPhase input (a11y best practice)

**Reason**: These were flagged by Biome linter as issues.
The unused import was left over from development, and autoFocus
can cause accessibility problems.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 21:38:44 -05:00
Thomas Hallock
b47b1cc03f docs(arcade): update README with Phase 3 type inference architecture
**Updates**:
- Added "Key Improvements" section highlighting Phase 3
- Updated architecture diagram to show type system layer
- Added validateConfig to GameDefinition interface docs
- Updated Step 6 to include validateConfig example
- Added Step 7c: Config Type Inference guide
- Documented benefits of type inference (10-15 lines saved per game)

**Example shown**:
```typescript
// Before: Manual definition
export interface NumberGuesserGameConfig { ... }

// After: Inferred
export type NumberGuesserGameConfig = InferGameConfig<typeof numberGuesserGame>
```

**Key concept**: defaultConfig serves as source of truth for types.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 21:38:44 -05:00
Thomas Hallock
eed468c6c4 refactor(arcade): implement Phase 3 - infer config types from game definitions
**Problem**: Config types were manually defined in game-configs.ts,
requiring 10-15 lines of boilerplate per game.

**Solution**: Use TypeScript's type inference to extract config types
from game definitions' defaultConfig property.

**Changes**:
- Added InferGameConfig<T> utility type
- NumberGuesserGameConfig now inferred from numberGuesserGame
- MathSprintGameConfig now inferred from mathSprintGame
- RoomGameConfig auto-derived from GameConfigByName using mapped type
- Changed RoomGameConfig from interface to type for auto-derivation

**Benefits**:
- Single source of truth (game definition)
- Add game → types automatically available
- No manual type definitions needed
- TypeScript ensures type consistency

**Architecture**: Phase 3 of modular game system improvements.
Legacy games (matching, memory-quiz, complement-race) still use
manual types until migrated to new system.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 21:38:44 -05:00
semantic-release-bot
d17ebb3f42 chore(release): 4.0.2 [skip ci]
## [4.0.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.0.1...v4.0.2) (2025-10-16)

### Bug Fixes

* **arcade:** prevent server-side loading of React components ([784793b](784793ba24))
2025-10-16 02:28:55 +00:00
Thomas Hallock
784793ba24 fix(arcade): prevent server-side loading of React components
Issue: game-config-helpers.ts was importing game-registry.ts which loads
game definitions including React components. This caused server startup to
fail with MODULE_NOT_FOUND for GameModeContext.

Solution: Lazy-load game registry only in browser environment.
On server, getGame() returns undefined and validation falls back to
switch statement for legacy games.

Changes:
- game-config-helpers.ts: Add conditional getGame() that checks typeof window
- Only requires game-registry in browser environment
- Server uses switch statement fallback for validation
- Browser uses game.validateConfig() when available

This maintains the architectural improvement (games own validation)
while keeping server-side code working.

Test: Dev server starts successfully, no MODULE_NOT_FOUND errors
2025-10-15 21:27:59 -05:00
semantic-release-bot
aa868e3f7f chore(release): 4.0.1 [skip ci]
## [4.0.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.0.0...v4.0.1) (2025-10-16)

### Code Refactoring

* **arcade:** move config validation to game definitions ([b19437b](b19437b7dc)), closes [#3](https://github.com/antialias/soroban-abacus-flashcards/issues/3)
2025-10-16 02:20:55 +00:00
Thomas Hallock
b19437b7dc refactor(arcade): move config validation to game definitions
This implements Critical Fix #3 from AUDIT_2_ARCHITECTURE_QUALITY.md

Changes:
1. Add validateConfig to GameDefinition type
2. Update defineGame() to accept validateConfig function
3. Add validation functions to Number Guesser and Math Sprint
4. Update game-config-helpers.ts to use registry validation

Before (switch statement in helpers):
  - validateGameConfig() had 50+ line switch statement
  - Must update helper for every new game
  - Validation logic separated from game

After (validation in game definition):
  - Games own their validation logic
  - validateGameConfig() calls game.validateConfig()
  - Switch only for legacy games (matching, memory-quiz, complement-race)
  - New games: just add validateConfig to defineGame()

Example (Number Guesser):
  function validateNumberGuesserConfig(config: unknown): config is NumberGuesserConfig {
    return (
      typeof config === 'object' &&
      config !== null &&
      typeof config.minNumber === 'number' &&
      typeof config.maxNumber === 'number' &&
      typeof config.roundsToWin === 'number' &&
      config.minNumber >= 1 &&
      config.maxNumber > config.minNumber &&
      config.roundsToWin >= 1
    )
  }

Benefits:
 Eliminates switch statement boilerplate
 Single source of truth for validation
 Games are self-contained
 No helper updates needed for new games

To add a new game now:
1. Define validation function in game index.ts
2. Pass to defineGame({ validateConfig })
That's it! No helper file changes needed.
2025-10-15 21:20:11 -05:00
semantic-release-bot
eef636f644 chore(release): 4.0.0 [skip ci]
## [4.0.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.24.0...v4.0.0) (2025-10-16)

### ⚠ BREAKING CHANGES

* **db:** Database schemas now accept any string for game names

### Code Refactoring

* **db:** remove database schema coupling for game names ([e135d92](e135d92abb)), closes [#1](https://github.com/antialias/soroban-abacus-flashcards/issues/1)
2025-10-16 02:17:57 +00:00
Thomas Hallock
e135d92abb refactor(db): remove database schema coupling for game names
BREAKING CHANGE: Database schemas now accept any string for game names

This implements Critical Fix #1 from AUDIT_2_ARCHITECTURE_QUALITY.md

Changes:
- Remove hardcoded enums from all database schemas
- arcade-rooms.ts: gameName now accepts any string
- arcade-sessions.ts: currentGame now accepts any string
- room-game-configs.ts: gameName now accepts any string

Runtime Validation:
- Add isValidGameName() helper to validate against registry
- Add assertValidGameName() helper for fail-fast validation
- Update settings API to use runtime validation instead of hardcoded array

Benefits:
 No schema migration needed when adding new games
 No TypeScript compilation errors for new games
 Single source of truth: validator registry
 "Just register and go" - no database changes required

Migration Impact:
- Existing data is compatible (strings remain strings)
- No data migration needed
- TypeScript will now allow any string, but runtime validation enforces correctness

This eliminates the most critical architectural issue identified in the audit.
Future games can be added by:
1. Register validator in validators.ts
2. Register game in game-registry.ts
That's it! No database schema changes needed.
2025-10-15 21:17:00 -05:00
semantic-release-bot
b3cbec85bd chore(release): 3.24.0 [skip ci]
## [3.24.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.23.0...v3.24.0) (2025-10-16)

### Features

* **math-sprint:** add game manifest ([1eefcc8](1eefcc89a5))
2025-10-16 02:14:22 +00:00
Thomas Hallock
1eefcc89a5 feat(math-sprint): add game manifest
Add game.yaml with metadata for Math Sprint:
- Display name, icon, description
- Max 6 players
- Difficulty: Beginner
- Tags: Multiplayer, Free-for-All, Math Skills, Speed
- Purple color theme to match UI
- Set available: true

This manifest enables Math Sprint to appear in GameSelector
automatically via the registry system.
2025-10-15 21:13:24 -05:00
semantic-release-bot
1ec8cc7640 chore(release): 3.23.0 [skip ci]
## [3.23.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.22.3...v3.23.0) (2025-10-16)

### Features

* **arcade:** add Math Sprint game implementation ([e5be09e](e5be09ef5f))
* **arcade:** register Math Sprint in game system ([0c05a7c](0c05a7c6bb)), closes [#2](https://github.com/antialias/soroban-abacus-flashcards/issues/2) [#3](https://github.com/antialias/soroban-abacus-flashcards/issues/3)

### Bug Fixes

* **api:** add 'math-sprint' to settings endpoint validation ([d790e5e](d790e5e278)), closes [#1](https://github.com/antialias/soroban-abacus-flashcards/issues/1)
* **db:** add 'math-sprint' to database schema enums ([7b112a9](7b112a98ba)), closes [#1](https://github.com/antialias/soroban-abacus-flashcards/issues/1)

### Documentation

* add architecture quality audit [#2](https://github.com/antialias/soroban-abacus-flashcards/issues/2) ([5b91b71](5b91b71078))
2025-10-16 02:13:09 +00:00
Thomas Hallock
5b91b71078 docs: add architecture quality audit #2
Comprehensive audit of modular game system after implementing Math Sprint.

Key Findings:
- Grade: B- (down from B+ after implementation testing)
- SDK design is solid (useArcadeSession, Provider pattern)
- Unified validator registry works well
- BUT: Significant boilerplate and coupling issues

Critical Issues Identified:
1. 🚨 Database Schema Coupling - Must update schema for each game
2. ⚠️ game-config-helpers.ts - Switch statements for defaults/validation
3. ⚠️ game-configs.ts - 5 places to update per game
4. 📊 High Boilerplate Ratio - 12 files touched per game, ~44 lines boilerplate

Files That Required Updates for Math Sprint:
- 3 database schemas (arcade-rooms, arcade-sessions, room-game-configs)
- 1 API endpoint (settings/route.ts)
- 2 config files (game-configs.ts, game-config-helpers.ts)
- 2 registry files (validators.ts, game-registry.ts)
- 8 game implementation files (types, validator, provider, components, etc.)

Recommendations:
- Critical: Fix database schema to accept any string, validate at runtime
- Infer config types from game definitions (single source of truth)
- Move config validation to game definitions (eliminate switch statements)

Developer Experience:
- Time to add a game: 3-5 hours (including boilerplate)
- Pain point: Database schema updates require migration
- Pain point: Easy to forget one of the 12 files

See audit for detailed analysis and architectural recommendations.
2025-10-15 21:12:18 -05:00
Thomas Hallock
d790e5e278 fix(api): add 'math-sprint' to settings endpoint validation
Add 'math-sprint' to validGames array in PATCH /api/arcade/rooms/:roomId/settings

Without this change, selecting math-sprint from room page returns:
  400 Bad Request - "Invalid game name"

This is another instance of the coupling issue - hardcoded validation
array must be manually updated for each new game.

The TODO comment on line 97 acknowledges this:
  "TODO: make this dynamic when we refactor to lazy-load registry"

Addresses Issue #1 from AUDIT_2_ARCHITECTURE_QUALITY.md
2025-10-15 21:12:18 -05:00
Thomas Hallock
7b112a98ba fix(db): add 'math-sprint' to database schema enums
Update all database schemas to include 'math-sprint':
- arcade-rooms.ts: Add to gameName enum
- arcade-sessions.ts: Add to currentGame enum
- room-game-configs.ts: Add to gameName enum and documentation

CRITICAL ISSUE DEMONSTRATED:
This is the schema coupling problem (Issue #1 from AUDIT_2).
Must manually update database schemas for every new game.
Breaks modularity - cannot "just register and go".

Without this change, TypeScript compilation fails with:
  Type '"math-sprint"' is not assignable to type '...'

Recommendation from audit: Change schemas to accept any string,
validate against registry at runtime instead of compile-time enums.
2025-10-15 21:12:18 -05:00
Thomas Hallock
0c05a7c6bb feat(arcade): register Math Sprint in game system
Register math-sprint in all required places:
- validators.ts: Add mathSprintValidator to registry
- game-registry.ts: Register mathSprintGame
- game-configs.ts: Add MathSprintGameConfig type and defaults
- game-config-helpers.ts: Add config getters and validation

This demonstrates the boilerplate issue documented in AUDIT_2:
Had to update 4 files with switch/case statements and type definitions.

Addresses issue #2 and #3 from architecture audit.
2025-10-15 21:12:18 -05:00
Thomas Hallock
e5be09ef5f feat(arcade): add Math Sprint game implementation
- Implement free-for-all math racing game
- Demonstrates TEAM_MOVE pattern (no specific turn owner)
- Server-generated math questions (addition, subtraction, multiplication)
- Real-time competitive gameplay with scoring
- Three difficulty levels: easy, medium, hard
- Configurable questions per round and time limits

Components:
- SetupPhase: Configure difficulty, questions, time
- PlayingPhase: Answer questions competitively
- ResultsPhase: Display final scores and winner
- Validator: Server-side question generation and validation

Game follows SDK patterns established by Number Guesser.
2025-10-15 21:12:18 -05:00
semantic-release-bot
693fe6bb9f chore(release): 3.22.3 [skip ci]
## [3.22.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.22.2...v3.22.3) (2025-10-16)

### Bug Fixes

* **number-guesser:** add turn indicators, error feedback, and fix player ordering ([9f62623](9f62623684))

### Documentation

* **arcade:** update docs for unified validator registry ([6f6cb14](6f6cb14650))
2025-10-16 01:46:31 +00:00
Thomas Hallock
9f62623684 fix(number-guesser): add turn indicators, error feedback, and fix player ordering
## Bug Fixes

### 1. Turn Indicators
- Added `currentPlayerId` prop to PageWithNav
- Shows whose turn it is during choosing and guessing phases
- Visual highlighting of active player avatar
- Displays "Your turn" label for current user

**Files**:
- `GameComponent.tsx`: Calculate currentPlayerId based on game phase
- `Provider.tsx`: Expose lastError and clearError to context

### 2. Error Feedback
- Added error banner in GuessingPhase
- Shows server rejection messages (out of bounds, not your turn, etc.)
- Auto-dismisses after 5 seconds
- Clear dismiss button for manual dismissal

**Impact**: Users now see why their moves were rejected instead of
silent failures.

### 3. Player Ordering Consistency
- Fixed player ordering mismatch between UI and game logic
- Removed `.sort()` to keep Set iteration order consistent
- Both UI (PageWithNav) and game logic now use same player order

**Issue**: UI showed players in Set order, but game logic used
alphabetical order, causing "skipped leftmost player" bug.

**Fix**: Use `Array.from(activePlayerIds)` without sorting everywhere.

### 4. Score Display
- Added `playerScores` prop to PageWithNav
- Shows scores for all players in the navigation

## Testing Notes

These fixes address all issues found during manual testing:
-  Turn indicator now shows correctly
-  Error messages display to users
-  Player order matches between UI and game logic
-  Scores visible in navigation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 20:45:39 -05:00
Thomas Hallock
6f6cb14650 docs(arcade): update docs for unified validator registry
## Documentation Updates

### src/arcade-games/README.md
- **Step 7**: Expanded to explain both registration steps
  - 7a: Register validator in validators.ts (server-side)
  - 7b: Register game in game-registry.ts (client-side)
- Added explanation of why both steps are needed
- Added verification warnings that appear during registration
- Clarified the difference between isomorphic and client-only code

### docs/AUDIT_MODULAR_GAME_SYSTEM.md
- **Status**: Updated from "CRITICAL ISSUES" to "ISSUE RESOLVED"
- **Executive Summary**: Marked system as Production Ready
- **Issue #1**: Marked as RESOLVED with implementation details
- **Issue #2**: Marked as RESOLVED (validators now accessible)
- **Issue #5**: Marked as RESOLVED (GameName auto-derived)
- **Compliance Table**: Updated grade from D to B+
- **Action Items**: Marked critical items 1-3 as completed

## Summary

Documentation now accurately reflects the unified validator registry
implementation, providing clear guidance for developers adding new games.

Related: 9459f37b (implementation commit)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 20:41:06 -05:00
semantic-release-bot
61196ccbff chore(release): 3.22.2 [skip ci]
## [3.22.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.22.1...v3.22.2) (2025-10-16)

### Code Refactoring

* **arcade:** create unified validator registry to fix dual registration ([f775fc5](f775fc55e5)), closes [#1](https://github.com/antialias/soroban-abacus-flashcards/issues/1)
2025-10-16 01:39:05 +00:00
Thomas Hallock
f775fc55e5 refactor(arcade): create unified validator registry to fix dual registration
**Problem**: Games required dual registration - once in client registry
(game-registry.ts) and again in server validator map (validation/index.ts).
This broke the modular architecture goal.

**Solution**: Created unified isomorphic validator registry that serves
both client and server needs.

## Changes

### New File
- `src/lib/arcade/validators.ts` - Unified validator registry
  - Single source of truth for all game validators
  - Auto-derives GameName type from registry keys
  - Isomorphic (runs on both client and server)

### Updated Files
- `validation/index.ts` - Converted to re-export from unified registry
  - Maintains backwards compatibility
  - Marked as deprecated
- `validation/types.ts` - GameName now re-exported from validators
  - No longer hard-coded union type
  - Auto-updates when new games added
- `game-registry.ts` - Added runtime validation
  - Checks if validator registered server-side
  - Warns on registration mismatch
- `session-manager.ts` - Import from unified registry
- `socket-server.ts` - Import from unified registry
- `route.ts` (rooms API) - Use hasValidator() instead of hard-coded array
- `game-config-helpers.ts` - Handle ExtendedGameName for legacy games
  - Supports both registered validators and legacy 'complement-race'
  - TODO comment for migration

## Benefits
 Single registration point for new games
 Auto-derived GameName type (no manual updates)
 Type-safe validator access
 Backwards compatible with existing code
 Clear migration path for old games

## Migration Status
-  number-guesser: Uses new system
- 🔄 matching, memory-quiz: Use new validator registry, not migrated to arcade-games/ yet
-  complement-race: Legacy game, handled via ExtendedGameName

Addresses critical Issue #1 from AUDIT_MODULAR_GAME_SYSTEM.md

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 20:38:15 -05:00
semantic-release-bot
3cef4fcbac chore(release): 3.22.1 [skip ci]
## [3.22.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.22.0...v3.22.1) (2025-10-16)

### Bug Fixes

* **arcade:** add Number Guesser to game config helpers ([7d1a351](7d1a351ed6))
* **nav:** update types for registry games with nullable gameName ([a51e539](a51e539d02))
2025-10-16 00:16:26 +00:00
Thomas Hallock
a51e539d02 fix(nav): update types for registry games with nullable gameName
Fixes TypeScript errors introduced by registry game system:
- Allow gameName to be string | null in nav component types
- Update RecentRoom, AddPlayerButton, GameContextNav, ArcadeRoomInfo interfaces
- Convert null to undefined where needed for legacy function calls
- Fix GameTitleMenu showMenu prop
- Update JoinRoomModal to use separate useGetRoomByCode and useJoinRoom hooks

These changes allow rooms to exist without a selected game (gameName = null).

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 19:15:32 -05:00
Thomas Hallock
7d1a351ed6 fix(arcade): add Number Guesser to game config helpers
Fixes server-side session creation for Number Guesser:
- Import DEFAULT_NUMBER_GUESSER_CONFIG
- Add case for 'number-guesser' in getDefaultGameConfig()
- Add validation for number-guesser config
- Include arcade-games validators in server TypeScript build

This resolves the "Unknown game: number-guesser" error when creating sessions.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 19:15:32 -05:00
semantic-release-bot
3e81c1f480 chore(release): 3.22.0 [skip ci]
## [3.22.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.21.0...v3.22.0) (2025-10-16)

### Features

* **arcade:** add Number Guesser demo game with plugin architecture ([0e3c058](0e3c058707))
2025-10-16 00:08:18 +00:00
Thomas Hallock
0e3c058707 feat(arcade): add Number Guesser demo game with plugin architecture
Implements the first registry-based game to demonstrate the modular plugin system:

Game Features:
- Turn-based number guessing with hot/cold feedback
- 2-4 players with configurable settings
- Round-based scoring system
- Complete game phases: Setup → Choosing → Guessing → Results

Technical Implementation:
- Created Number Guesser game in src/arcade-games/number-guesser/
- Registered NumberGuesserValidator in validation system
- Added 'number-guesser' to database schema enums (arcade_rooms, arcade_sessions, room_game_configs)
- Created NumberGuesserGameConfig type with default configuration
- Integrated game into GameSelector and room page
- Added PageWithNav wrapper for consistent navigation
- Fixed controlled input warnings with fallback values

Registry Integration:
- Game automatically appears in /arcade/room selection UI
- Settings persist to room_game_configs table
- Validator creates and manages server-side game state
- Provider syncs client state via arcade session

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 19:07:20 -05:00
semantic-release-bot
0e76bcd79a chore(release): 3.21.0 [skip ci]
## [3.21.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.20.0...v3.21.0) (2025-10-15)

### Features

* **arcade:** add modular game SDK and registry system ([de30bec](de30bec479))
2025-10-15 23:10:06 +00:00
Thomas Hallock
de30bec479 feat(arcade): add modular game SDK and registry system
Create foundation for modular arcade game architecture:

**Game SDK** (`/src/lib/arcade/game-sdk/`):
- Stable API surface that games can safely import
- Type-safe game definition with `defineGame()` helper
- Controlled hook exports (useArcadeSession, useRoomData, etc.)
- Player ownership and metadata utilities
- Error boundary component for game crashes

**Manifest System**:
- YAML-based game manifests with Zod validation
- Game metadata (name, icon, description, difficulty, etc.)
- Type-safe manifest loading with `loadManifest()`

**Game Registry**:
- Central registry for all arcade games
- Explicit registration pattern via `registerGame()`
- Helper functions to query available games

**Type Safety**:
- Full TypeScript contracts for games
- GameValidator, GameState, GameMove, GameConfig types
- Compile-time validation of game implementations

This establishes the plugin system for drop-in arcade games.
Next: Create demo games to exercise the system.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 18:09:17 -05:00
semantic-release-bot
0eed26966c chore(release): 3.20.0 [skip ci]
## [3.20.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.19.0...v3.20.0) (2025-10-15)

### Features

* adjust tier probabilities for more abacus flavor ([49219e3](49219e34cd))

### Code Refactoring

* use per-word-type tier selection for name generation ([499ee52](499ee525a8))
2025-10-15 19:10:15 +00:00
Thomas Hallock
49219e34cd feat: adjust tier probabilities for more abacus flavor
Change weighted selection from 70/20/10 to 50/25/25:
- Emoji-specific: 70% → 50%
- Category-specific: 20% → 25%
- Global abacus: 10% → 25%

This increases abacus-themed words by 2.5x, ensuring stronger
presence of core abacus vocabulary (Calculator, Abacist, Counter)
while still maintaining emoji personality theming.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 14:09:25 -05:00
Thomas Hallock
499ee525a8 refactor: use per-word-type tier selection for name generation
Changed from tier-then-mix approach to per-word-type selection:
- Before: Pick one tier, then optionally mix with abacus words
- After: Pick tier independently for adjective and noun

Benefits:
- Simpler, cleaner code
- More natural variety in name combinations
- Adjective and noun can come from different tiers naturally
- Examples: "Grinning Calculator" (emoji + global), "Ancient Smiler" (global + emoji)

Each word still uses weighted selection:
- 70% emoji-specific
- 20% category-specific
- 10% global abacus

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 14:03:21 -05:00
semantic-release-bot
843b45b14e chore(release): 3.19.0 [skip ci]
## [3.19.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.18.1...v3.19.0) (2025-10-15)

### Features

* implement avatar-themed name generation with probabilistic mixing ([76a8472](76a8472f12))
2025-10-15 19:01:15 +00:00
Thomas Hallock
76a8472f12 feat: implement avatar-themed name generation with probabilistic mixing
Add comprehensive emoji-themed player name generation system:
- 150+ emoji-specific word lists (10 adjectives + 10 nouns each)
- 45+ category-themed word lists as fallback
- Generic abacus-themed words as ultimate fallback

Probabilistic tier selection for variety:
- 70% emoji-specific (e.g., "Grinning Grinner" for 😀)
- 20% category-specific (e.g., "Cheerful Optimist" for happy faces)
- 10% global abacus theme (e.g., "Lightning Calculator")

Cross-tier mixing for abacus flavor infusion:
- 60% pure themed words
- 30% themed adjective + abacus noun
- 10% abacus adjective + themed noun

Updated all name generation call sites to pass player emoji:
- PlayerConfigDialog.tsx: Pass emoji when generating name
- GameModeContext.tsx: Theme names for default players and new players

This creates ultra-personalized, variety-rich names while maintaining
abacus theme presence across all generated names.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 14:00:18 -05:00
semantic-release-bot
bf02bc14fd chore(release): 3.18.1 [skip ci]
## [3.18.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.18.0...v3.18.1) (2025-10-15)

### Bug Fixes

* **arcade:** prevent empty update in settings API when only gameConfig changes ([ffb626f](ffb626f403))
2025-10-15 18:55:55 +00:00
Thomas Hallock
ffb626f403 fix(arcade): prevent empty update in settings API when only gameConfig changes
When only gameConfig is updated (without accessMode, password, or gameName),
the updateData object remained empty, causing Drizzle to throw "No values to set"
error when attempting to update arcade_rooms table.

Now only updates arcade_rooms if there are actual fields to update, preventing
the 500 error while still allowing gameConfig-only updates to room_game_configs table.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 13:54:47 -05:00
semantic-release-bot
860fd607be chore(release): 3.18.0 [skip ci]
## [3.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.14...v3.18.0) (2025-10-15)

### Features

* add drizzle migration for room_game_configs table ([3bae00b](3bae00b9a9))

### Documentation

* document manual migration of room_game_configs table ([ff79140](ff791409cf))
2025-10-15 18:41:45 +00:00
Thomas Hallock
3bae00b9a9 feat: add drizzle migration for room_game_configs table
Creates migration 0011 to:
- Create room_game_configs table with proper schema
- Add unique index on (room_id, game_name)
- Migrate existing game_config data from arcade_rooms table

Migration is idempotent and safe to run on any database state:
- Uses IF NOT EXISTS for table and index creation
- Uses INSERT OR IGNORE to avoid duplicate data
- Will work on both fresh databases and existing production

This ensures production will automatically get the new table structure
when the migration runs on deployment.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 13:40:40 -05:00
Thomas Hallock
ff791409cf docs: document manual migration of room_game_configs table
Manual migration applied on 2025-10-15:
- Created room_game_configs table via sqlite3 CLI
- Migrated 6000 existing configs from arcade_rooms.game_config
- 5991 matching configs + 9 memory-quiz configs
- Table created directly instead of through drizzle migration system

The manually created drizzle migration SQL file has been removed since
the migration was applied directly to the database. See
.claude/MANUAL_MIGRATION_0011.md for complete details on the migration
process, verification steps, and rollback plan.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 13:31:55 -05:00
semantic-release-bot
c1be0277c1 chore(release): 3.17.14 [skip ci]
## [3.17.14](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.13...v3.17.14) (2025-10-15)

### Bug Fixes

* **arcade:** resolve TypeScript errors in game config helpers ([04c9944](04c9944f2e))

### Documentation

* **arcade:** update GAME_SETTINGS_PERSISTENCE.md for new schema ([260bdc2](260bdc2e9d))
2025-10-15 18:21:03 +00:00
Thomas Hallock
04c9944f2e fix(arcade): resolve TypeScript errors in game config helpers
Fixed three TypeScript compilation errors:

1. game-config-helpers.ts:82 - Cast existing.config to object for spread
2. game-config-helpers.ts:116 - Fixed dynamic field assignment in updateGameConfigField
3. MatchingGameValidator.ts:540 - Use proper Difficulty type in MatchingGameConfig

Changes:
- Import Difficulty and GameType from matching types
- Update MatchingGameConfig to use proper union types
- Cast existing.config as object before spreading
- Rewrite updateGameConfigField to avoid type assertion issue

All TypeScript errors resolved. Compilation passes cleanly.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 13:19:58 -05:00
Thomas Hallock
260bdc2e9d docs(arcade): update GAME_SETTINGS_PERSISTENCE.md for new schema
Updated documentation to reflect the refactored implementation:

- Documented new room_game_configs table structure
- Explained shared type system and benefits
- Updated all code examples to use new helpers
- Revised debugging checklist for new architecture
- Added migration notes and rollback plan
- Clarified the four critical systems (was three, now includes helpers)

The documentation now accurately describes the normalized database
schema approach instead of the old monolithic JSON column.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 13:17:34 -05:00
semantic-release-bot
8dbdc837cc chore(release): 3.17.13 [skip ci]
## [3.17.13](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.12...v3.17.13) (2025-10-15)

### Code Refactoring

* **arcade:** migrate game settings to normalized database schema ([1bd7354](1bd73544df))
2025-10-15 18:17:00 +00:00
Thomas Hallock
1bd73544df refactor(arcade): migrate game settings to normalized database schema
### Schema Changes
- Create `room_game_configs` table with one row per game per room
- Migrate existing gameConfig data from arcade_rooms.game_config JSON column
- Add unique index on (roomId, gameName) for efficient queries

### Benefits
-  Type-safe config access with shared types
-  Smaller rows (only configs for used games)
-  Easier updates (single row vs entire JSON blob)
-  Better concurrency (no lock contention between games)
-  Foundation for per-game audit trail

### Core Changes
1. **Shared Config Types** (`game-configs.ts`)
   - `MatchingGameConfig`, `MemoryQuizGameConfig` interfaces
   - Default configs for each game
   - Single source of truth for all settings

2. **Helper Functions** (`game-config-helpers.ts`)
   - `getGameConfig<T>()` - type-safe config retrieval with defaults
   - `setGameConfig()` - upsert game config
   - `getAllGameConfigs()` - aggregate all game configs for a room
   - `validateGameConfig()` - runtime validation

3. **API Routes**
   - `/api/arcade/rooms/current`: Aggregates configs from new table
   - `/api/arcade/rooms/[roomId]/settings`: Writes to new table

4. **Socket Server** (`socket-server.ts`)
   - Uses `getGameConfig()` helper for session creation
   - Eliminates manual config extraction and defaults

5. **Validators**
   - `MemoryQuizGameValidator.getInitialState(config: MemoryQuizGameConfig)`
   - `MatchingGameValidator.getInitialState(config: MatchingGameConfig)`
   - Type signatures enforce consistency

### Migration Path
- Existing data migrated automatically (SQL in migration file)
- Old `gameConfig` column preserved temporarily
- Client-side providers unchanged (read from aggregated response)

Next steps:
- Test settings persistence thoroughly
- Drop old `gameConfig` column after validation
- Update documentation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 13:16:01 -05:00
semantic-release-bot
506bfeccf2 chore(release): 3.17.12 [skip ci]
## [3.17.12](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.11...v3.17.12) (2025-10-15)

### Code Refactoring

* **arcade:** remove non-productive debug logging from memory-quiz ([38e554e](38e554e6ea))

### Documentation

* **arcade:** document game settings persistence architecture ([8f8f112](8f8f112de2))
2025-10-15 18:05:53 +00:00
Thomas Hallock
38e554e6ea refactor(arcade): remove non-productive debug logging from memory-quiz
Removed verbose console.log statements added during settings persistence debugging:
- socket-server.ts: Removed JSON.stringify logging of gameConfig flow
- RoomMemoryQuizProvider.tsx: Removed logging from mergedInitialState useMemo and setConfig
- MemoryQuizGameValidator.ts: Removed logging from validateAcceptNumber

The actual fix (playMode parameter addition) is preserved. Debug logging was only needed to identify the root cause and is no longer necessary.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 13:04:49 -05:00
Thomas Hallock
8f8f112de2 docs(arcade): document game settings persistence architecture
Added comprehensive documentation for game settings persistence system
after fixing multiple settings bugs (gameType, playMode not persisting).

New documentation:
- .claude/GAME_SETTINGS_PERSISTENCE.md: Complete architecture guide
  - How settings are structured (nested by game name)
  - Three critical systems that must stay in sync
  - Common bugs with detailed solutions
  - Debugging checklist
  - Step-by-step guide for adding new settings

- .claude/GAME_SETTINGS_REFACTORING.md: Recommended improvements
  - Shared config types to prevent type mismatches
  - Helper functions to reduce duplication (getGameConfig, updateGameConfig)
  - Validator config type enforcement
  - Exhaustiveness checking
  - Runtime validation
  - Migration strategy with priority order

Updated .claude/CLAUDE.md to reference these docs with quick reference guide.

This documentation will prevent similar bugs in the future by making the
architecture explicit and providing clear patterns to follow.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 12:54:15 -05:00
semantic-release-bot
f3080b50d9 chore(release): 3.17.11 [skip ci]
## [3.17.11](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.10...v3.17.11) (2025-10-15)

### Bug Fixes

* **memory-quiz:** fix playMode persistence by updating validator ([de0efd5](de0efd5932))
2025-10-15 17:51:21 +00:00
Thomas Hallock
de0efd5932 fix(memory-quiz): fix playMode persistence by updating validator
ROOT CAUSE FOUND:
The MemoryQuizGameValidator.getInitialState() method was hardcoding
playMode to 'cooperative' and not accepting it as a config parameter.

Even though socket-server.ts was passing playMode from the saved config,
the validator's TypeScript signature didn't include it:

BEFORE:
```typescript
getInitialState(config: {
  selectedCount: number
  displayTime: number
  selectedDifficulty: DifficultyLevel
}): SorobanQuizState {
  return {
    // ...
    playMode: 'cooperative',  // ← ALWAYS HARDCODED!
  }
}
```

AFTER:
```typescript
getInitialState(config: {
  selectedCount: number
  displayTime: number
  selectedDifficulty: DifficultyLevel
  playMode?: 'cooperative' | 'competitive'  // ← NEW!
}): SorobanQuizState {
  return {
    // ...
    playMode: config.playMode || 'cooperative',  // ← USES CONFIG VALUE!
  }
}
```

Also added comprehensive debug logging throughout the flow:
- socket-server.ts: logs room.gameConfig, extracted config, and resulting playMode
- RoomMemoryQuizProvider.tsx: logs roomData.gameConfig and merged state
- MemoryQuizGameValidator.ts: logs config received and playMode returned

This will help identify any remaining persistence issues.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 12:50:25 -05:00
semantic-release-bot
c9e5c473e6 chore(release): 3.17.10 [skip ci]
## [3.17.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.9...v3.17.10) (2025-10-15)

### Bug Fixes

* **memory-quiz:** persist playMode setting across game switches ([487ca7f](487ca7fba6))
2025-10-15 17:47:48 +00:00
Thomas Hallock
487ca7fba6 fix(memory-quiz): persist playMode setting across game switches
The socket-server was missing playMode when creating the initial session
for memory-quiz games. It was only loading selectedCount, displayTime, and
selectedDifficulty from the saved config, causing playMode to always reset
to the default 'cooperative' even when 'competitive' was saved.

Now includes playMode in the initial state config:
- selectedCount
- displayTime
- selectedDifficulty
- playMode (NEW)

This ensures the playMode setting persists when users:
1. Set playMode to 'competitive'
2. Go back to game selection
3. Select memory-quiz again
4. PlayMode is still 'competitive' (not reset to 'cooperative')

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 12:46:48 -05:00
semantic-release-bot
8f7eebce4b chore(release): 3.17.9 [skip ci]
## [3.17.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.8...v3.17.9) (2025-10-15)

### Bug Fixes

* **arcade:** read nested gameConfig correctly when creating sessions ([94ef392](94ef39234d))
2025-10-15 17:45:11 +00:00
Thomas Hallock
94ef39234d fix(arcade): read nested gameConfig correctly when creating sessions
The session initialization was looking for settings at the wrong level:
- Was reading: room.gameConfig.gameType (undefined, falls back to default)
- Should read: room.gameConfig.matching.gameType (saved value)

gameConfig is structured as:
{
  "matching": { "gameType": "...", "difficulty": ..., "turnTimer": ... },
  "memory-quiz": { "selectedCount": ..., "displayTime": ..., ... }
}

This caused the session to be created with default settings even though
the settings were saved in the database. The client would load the correct
settings from roomData.gameConfig, but then the socket would immediately
overwrite them with the session's default state.

Now properly accesses the nested config for each game type.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 12:44:10 -05:00
semantic-release-bot
6d14dd8b47 chore(release): 3.17.8 [skip ci]
## [3.17.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.7...v3.17.8) (2025-10-15)

### Bug Fixes

* **arcade:** preserve game settings when returning to game selection ([0ee7739](0ee7739091))
2025-10-15 17:42:27 +00:00
Thomas Hallock
0ee7739091 fix(arcade): preserve game settings when returning to game selection
When users clicked "back to game selection", the clearRoomGameApi function
was sending both gameName: null AND gameConfig: null to the server. This
destroyed all saved game settings (like gameType, difficulty, etc.).

Now clearRoomGameApi only sends gameName: null and preserves gameConfig,
so settings persist when users select a game again.

Root cause discovered via comprehensive database-level logging that traced
the exact data flow through the system.

Fixes settings persistence bug in room mode.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 12:41:36 -05:00
Thomas Hallock
5c135358fc debug(arcade): add comprehensive database-level logging for gameConfig
Add detailed logging at every layer to trace gameConfig through the system:

Server-side (Settings API):
- Log incoming PATCH request body
- Log database state BEFORE update
- Log what will be written to database
- Log database state AFTER update

Server-side (Current Room API):
- Log what's READ from database when fetching room

Client-side:
- Track roomData.gameConfig changes with useEffect

This will show us exactly when and where gameConfig is being overwritten.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 12:36:36 -05:00
semantic-release-bot
74554c3669 chore(release): 3.17.7 [skip ci]
## [3.17.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.6...v3.17.7) (2025-10-15)

### Bug Fixes

* **arcade:** prevent gameConfig from being overwritten when switching games ([a89d3a9](a89d3a9701))
2025-10-15 17:33:21 +00:00
Thomas Hallock
a89d3a9701 fix(arcade): prevent gameConfig from being overwritten when switching games
Root cause: setRoomGameApi was sending `gameConfig: {}` when gameConfig
was undefined, which overwrote all saved settings in the database.

Changes:
- Client: Only include gameConfig in request body if explicitly provided
- Server: Only include gameConfig in socket broadcast if provided
- Client handler: Update gameConfig from broadcast if present

This preserves all game settings (difficulty, card count, etc.) when
switching between games in a room.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 12:32:31 -05:00
semantic-release-bot
180e213d00 chore(release): 3.17.6 [skip ci]
## [3.17.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.5...v3.17.6) (2025-10-15)

### Code Refactoring

* **logging:** use JSON.stringify for all object logging ([c33698c](c33698ce52))
2025-10-15 17:30:09 +00:00
Thomas Hallock
c33698ce52 refactor(logging): use JSON.stringify for all object logging
Replace collapsed object logging with JSON.stringify to ensure full
object details are visible when console logs are copied/pasted.

This affects all settings persistence logging:
- Loading settings from database
- Saving settings to database
- API calls to server
- Cache updates

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 12:29:08 -05:00
semantic-release-bot
5b4cb7d35a chore(release): 3.17.5 [skip ci]
## [3.17.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.4...v3.17.5) (2025-10-15)

### Bug Fixes

* **arcade:** implement settings persistence for matching game ([08fe432](08fe4326a6))
2025-10-15 16:04:05 +00:00
Thomas Hallock
eacbafb1ea debug(arcade): add detailed logging for settings persistence
Add comprehensive console logging to trace the settings persistence flow:
- Load settings from database on initialization
- Save settings to database when changed (gameType, difficulty, turnTimer)
- API calls to server with full request/response logging

This will help diagnose if settings are being persisted correctly.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 11:03:07 -05:00
Thomas Hallock
08fe4326a6 fix(arcade): implement settings persistence for matching game
- Add useUpdateGameConfig hook and database saves to RoomMemoryPairsProvider
- Load saved settings from gameConfig['matching'] on init
- Save gameType, difficulty, and turnTimer changes to database
- Apply lint fixes: use dot notation instead of bracket notation

Matching game now persists settings when switching between games,
matching the behavior of memory-quiz.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 11:03:07 -05:00
semantic-release-bot
fabb33252c chore(release): 3.17.4 [skip ci]
## [3.17.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.3...v3.17.4) (2025-10-15)

### Bug Fixes

* **matching:** add settings persistence to matching game ([00dcb87](00dcb872b7))
2025-10-15 15:16:36 +00:00
Thomas Hallock
00dcb872b7 fix(matching): add settings persistence to matching game
The matching game was not saving settings to the database at all.
When you changed gameType, difficulty, or turnTimer, it only sent
a move to the arcade session but never saved to the database.

This adds the same persistence logic that memory-quiz uses:

**On Load:**
- Reads settings from gameConfig['matching'] in the database
- Merges with initialState
- Passes to useArcadeSession

**On Change:**
- Sends SET_CONFIG move (for real-time sync)
- Saves to gameConfig['matching'] via updateGameConfig
- Updates TanStack Query cache

Changes:
- Import useUpdateGameConfig hook
- Add mergedInitialState with settings from database
- Save settings in setGameType, setDifficulty, setTurnTimer

Now settings persist when switching between games!

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 10:15:42 -05:00
semantic-release-bot
ea23651cb6 chore(release): 3.17.3 [skip ci]
## [3.17.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.2...v3.17.3) (2025-10-15)

### Bug Fixes

* **arcade:** preserve gameConfig when switching games ([2273c71](2273c71a87))

### Code Refactoring

* remove verbose console logging for cleaner debugging ([9cb5fdd](9cb5fdd2fa))
2025-10-15 15:13:35 +00:00
Thomas Hallock
2273c71a87 fix(arcade): preserve gameConfig when switching games
**ROOT CAUSE:**
When switching games, setRoomGame was called with gameConfig: {},
which OVERWROTE the entire gameConfig in the database, destroying
all saved settings for ALL games.

**THE FIX:**
Remove gameConfig parameter from setRoomGame call - only change the
game name, preserve all existing settings.

**ADDED DEBUG LOGGING:**
Added detailed logging in RoomMemoryQuizProvider to help diagnose
settings persistence issues:
- Log gameConfig on component init
- Log what settings are being loaded
- Log what settings are being saved
- Log the full updated gameConfig

Changes:
- src/app/arcade/room/page.tsx: Don't pass gameConfig when switching games
- src/app/arcade/memory-quiz/context/RoomMemoryQuizProvider.tsx: Added debug logs

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 10:12:33 -05:00
Thomas Hallock
9cb5fdd2fa refactor: remove verbose console logging for cleaner debugging
Removed excessive console.log statements from:
- RoomMemoryQuizProvider.tsx: Removed ~14 verbose logs related to player
  metadata, scores, and move processing
- useRoomData.ts: Removed logs for moderation events and player updates

Kept critical logs for debugging settings persistence:
- Loading saved game config
- Saving game config
- Room game changed
- Cache updates

This cleanup makes console output much more manageable when debugging
settings persistence issues.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 10:04:24 -05:00
111 changed files with 19354 additions and 1371 deletions

View File

@@ -1,3 +1,274 @@
## [4.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.0.3...v4.1.0) (2025-10-16)
### Features
* **arcade:** migrate memory-quiz to modular game system ([f48c37a](https://github.com/antialias/soroban-abacus-flashcards/commit/f48c37accccb88e790c7a1b438fd0566e7120e11))
### Code Refactoring
* **arcade:** remove memory-quiz from legacy GAMES_CONFIG ([9952e11](https://github.com/antialias/soroban-abacus-flashcards/commit/9952e11c27f6cacb8eef1c5494b8cfea29dac907))
### Documentation
* add matching pairs battle migration plan ([3948582](https://github.com/antialias/soroban-abacus-flashcards/commit/39485826fc6c87f54c07795211909da0278a2ad0))
* add memory-quiz migration plan documentation ([7e2df10](https://github.com/antialias/soroban-abacus-flashcards/commit/7e2df106e68a1a0be414852a3e603b89029635b7))
* **arcade:** document Phase 3 completion in ARCHITECTURAL_IMPROVEMENTS.md ([704f34f](https://github.com/antialias/soroban-abacus-flashcards/commit/704f34f83e76332cb3610bda75289cbd0036e7eb))
* update playbook with memory-quiz completion ([99eee69](https://github.com/antialias/soroban-abacus-flashcards/commit/99eee69f28d17d0f9a3c806a1b84d90ee1fad683))
## [4.0.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.0.2...v4.0.3) (2025-10-16)
### Bug Fixes
* **math-sprint:** remove unused import and autoFocus attribute ([51593eb](https://github.com/antialias/soroban-abacus-flashcards/commit/51593eb44f93e369d6a773ee80e5f5cf50f3be67))
### Code Refactoring
* **arcade:** implement Phase 3 - infer config types from game definitions ([eed468c](https://github.com/antialias/soroban-abacus-flashcards/commit/eed468c6c4057e3c09a1e8df88551a9336c490c5))
### Documentation
* **arcade:** update README with Phase 3 type inference architecture ([b47b1cc](https://github.com/antialias/soroban-abacus-flashcards/commit/b47b1cc03f4b5fcfe8340653ca8a5dd903833481))
### Styles
* **math-sprint:** apply Biome formatting ([d7d8d8b](https://github.com/antialias/soroban-abacus-flashcards/commit/d7d8d8b1e32f9c9bb73d076f5d611210f809eca8))
## [4.0.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.0.1...v4.0.2) (2025-10-16)
### Bug Fixes
* **arcade:** prevent server-side loading of React components ([784793b](https://github.com/antialias/soroban-abacus-flashcards/commit/784793ba244731edf45391da44588a978b137abe))
## [4.0.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.0.0...v4.0.1) (2025-10-16)
### Code Refactoring
* **arcade:** move config validation to game definitions ([b19437b](https://github.com/antialias/soroban-abacus-flashcards/commit/b19437b7dc418f194fb60e12f1c17034024eca2a)), closes [#3](https://github.com/antialias/soroban-abacus-flashcards/issues/3)
## [4.0.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.24.0...v4.0.0) (2025-10-16)
### ⚠ BREAKING CHANGES
* **db:** Database schemas now accept any string for game names
### Code Refactoring
* **db:** remove database schema coupling for game names ([e135d92](https://github.com/antialias/soroban-abacus-flashcards/commit/e135d92abb4d27f646c1fbeff6524a729d107426)), closes [#1](https://github.com/antialias/soroban-abacus-flashcards/issues/1)
## [3.24.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.23.0...v3.24.0) (2025-10-16)
### Features
* **math-sprint:** add game manifest ([1eefcc8](https://github.com/antialias/soroban-abacus-flashcards/commit/1eefcc89a58b79f928932a7425d6b88fb45a5526))
## [3.23.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.22.3...v3.23.0) (2025-10-16)
### Features
* **arcade:** add Math Sprint game implementation ([e5be09e](https://github.com/antialias/soroban-abacus-flashcards/commit/e5be09ef5f170c7544557f75b9eca17bb2069246))
* **arcade:** register Math Sprint in game system ([0c05a7c](https://github.com/antialias/soroban-abacus-flashcards/commit/0c05a7c6bbc8d6f6e1f92e15e691d7e1aba0d8f7)), closes [#2](https://github.com/antialias/soroban-abacus-flashcards/issues/2) [#3](https://github.com/antialias/soroban-abacus-flashcards/issues/3)
### Bug Fixes
* **api:** add 'math-sprint' to settings endpoint validation ([d790e5e](https://github.com/antialias/soroban-abacus-flashcards/commit/d790e5e278f81686077dbe3ef4adca49574ae434)), closes [#1](https://github.com/antialias/soroban-abacus-flashcards/issues/1)
* **db:** add 'math-sprint' to database schema enums ([7b112a9](https://github.com/antialias/soroban-abacus-flashcards/commit/7b112a98babe782d4c254ef18a0295e7cbf8fefa)), closes [#1](https://github.com/antialias/soroban-abacus-flashcards/issues/1)
### Documentation
* add architecture quality audit [#2](https://github.com/antialias/soroban-abacus-flashcards/issues/2) ([5b91b71](https://github.com/antialias/soroban-abacus-flashcards/commit/5b91b710782dc450405583bc196e5156a296d0df))
## [3.22.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.22.2...v3.22.3) (2025-10-16)
### Bug Fixes
* **number-guesser:** add turn indicators, error feedback, and fix player ordering ([9f62623](https://github.com/antialias/soroban-abacus-flashcards/commit/9f626236845493ef68e1b3626e80efa35637b449))
### Documentation
* **arcade:** update docs for unified validator registry ([6f6cb14](https://github.com/antialias/soroban-abacus-flashcards/commit/6f6cb14650ba3636a7e2b036e2a2a9410492e7c3))
## [3.22.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.22.1...v3.22.2) (2025-10-16)
### Code Refactoring
* **arcade:** create unified validator registry to fix dual registration ([f775fc5](https://github.com/antialias/soroban-abacus-flashcards/commit/f775fc55e50af0c3a29b3e00fc722e7d7ce90212)), closes [#1](https://github.com/antialias/soroban-abacus-flashcards/issues/1)
## [3.22.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.22.0...v3.22.1) (2025-10-16)
### Bug Fixes
* **arcade:** add Number Guesser to game config helpers ([7d1a351](https://github.com/antialias/soroban-abacus-flashcards/commit/7d1a351ed6a1442ae34f6b75d46039bfa77a921b))
* **nav:** update types for registry games with nullable gameName ([a51e539](https://github.com/antialias/soroban-abacus-flashcards/commit/a51e539d023681daf639ec104e79079c8ceec98e))
## [3.22.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.21.0...v3.22.0) (2025-10-16)
### Features
* **arcade:** add Number Guesser demo game with plugin architecture ([0e3c058](https://github.com/antialias/soroban-abacus-flashcards/commit/0e3c0587073a69574a50f05c467f2499296012bf))
## [3.21.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.20.0...v3.21.0) (2025-10-15)
### Features
* **arcade:** add modular game SDK and registry system ([de30bec](https://github.com/antialias/soroban-abacus-flashcards/commit/de30bec47923565fe5d1d5a6f719f3fc4e9d1509))
## [3.20.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.19.0...v3.20.0) (2025-10-15)
### Features
* adjust tier probabilities for more abacus flavor ([49219e3](https://github.com/antialias/soroban-abacus-flashcards/commit/49219e34cde32736155a11929d10581e783cba69))
### Code Refactoring
* use per-word-type tier selection for name generation ([499ee52](https://github.com/antialias/soroban-abacus-flashcards/commit/499ee525a835249b439044cf602bf9f0ff322cec))
## [3.19.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.18.1...v3.19.0) (2025-10-15)
### Features
* implement avatar-themed name generation with probabilistic mixing ([76a8472](https://github.com/antialias/soroban-abacus-flashcards/commit/76a8472f12d251071b97f2288f62f0b358576232))
## [3.18.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.18.0...v3.18.1) (2025-10-15)
### Bug Fixes
* **arcade:** prevent empty update in settings API when only gameConfig changes ([ffb626f](https://github.com/antialias/soroban-abacus-flashcards/commit/ffb626f4038fd32d0f40dba8d83ae4d881d698d0))
## [3.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.14...v3.18.0) (2025-10-15)
### Features
* add drizzle migration for room_game_configs table ([3bae00b](https://github.com/antialias/soroban-abacus-flashcards/commit/3bae00b9a9dc925039a02fe07d036a2fc5e0fb79))
### Documentation
* document manual migration of room_game_configs table ([ff79140](https://github.com/antialias/soroban-abacus-flashcards/commit/ff791409cf4bae1a5df43eb974eacbc7612d8eec))
## [3.17.14](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.13...v3.17.14) (2025-10-15)
### Bug Fixes
* **arcade:** resolve TypeScript errors in game config helpers ([04c9944](https://github.com/antialias/soroban-abacus-flashcards/commit/04c9944f2ed1025f5a4ece61761889edd08cc60d))
### Documentation
* **arcade:** update GAME_SETTINGS_PERSISTENCE.md for new schema ([260bdc2](https://github.com/antialias/soroban-abacus-flashcards/commit/260bdc2e9d458cb42a96d3ed36a18134260b4520))
## [3.17.13](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.12...v3.17.13) (2025-10-15)
### Code Refactoring
* **arcade:** migrate game settings to normalized database schema ([1bd7354](https://github.com/antialias/soroban-abacus-flashcards/commit/1bd73544df6d62416961eea0b358955aaf82b79d))
## [3.17.12](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.11...v3.17.12) (2025-10-15)
### Code Refactoring
* **arcade:** remove non-productive debug logging from memory-quiz ([38e554e](https://github.com/antialias/soroban-abacus-flashcards/commit/38e554e6ea0386e48798338dd938e50ba73d5576))
### Documentation
* **arcade:** document game settings persistence architecture ([8f8f112](https://github.com/antialias/soroban-abacus-flashcards/commit/8f8f112de222e40901d4b3168fa751d233337e4b))
## [3.17.11](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.10...v3.17.11) (2025-10-15)
### Bug Fixes
* **memory-quiz:** fix playMode persistence by updating validator ([de0efd5](https://github.com/antialias/soroban-abacus-flashcards/commit/de0efd59321ec779cddb900724035884290419b7))
## [3.17.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.9...v3.17.10) (2025-10-15)
### Bug Fixes
* **memory-quiz:** persist playMode setting across game switches ([487ca7f](https://github.com/antialias/soroban-abacus-flashcards/commit/487ca7fba62e370c85bc3779ca8a96eb2c2cc3e3))
## [3.17.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.8...v3.17.9) (2025-10-15)
### Bug Fixes
* **arcade:** read nested gameConfig correctly when creating sessions ([94ef392](https://github.com/antialias/soroban-abacus-flashcards/commit/94ef39234d362b82e032cb69d3561b9fcb436eaf))
## [3.17.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.7...v3.17.8) (2025-10-15)
### Bug Fixes
* **arcade:** preserve game settings when returning to game selection ([0ee7739](https://github.com/antialias/soroban-abacus-flashcards/commit/0ee7739091d60580d2f98cfe288b8586b03348f3))
## [3.17.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.6...v3.17.7) (2025-10-15)
### Bug Fixes
* **arcade:** prevent gameConfig from being overwritten when switching games ([a89d3a9](https://github.com/antialias/soroban-abacus-flashcards/commit/a89d3a970137471e2652de992c45370dbb97416d))
## [3.17.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.5...v3.17.6) (2025-10-15)
### Code Refactoring
* **logging:** use JSON.stringify for all object logging ([c33698c](https://github.com/antialias/soroban-abacus-flashcards/commit/c33698ce52ebdc18ce3a0d856f9241c7389ed651))
## [3.17.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.4...v3.17.5) (2025-10-15)
### Bug Fixes
* **arcade:** implement settings persistence for matching game ([08fe432](https://github.com/antialias/soroban-abacus-flashcards/commit/08fe4326a6a7c484b9058a241f4ff79b3fb5125f))
## [3.17.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.3...v3.17.4) (2025-10-15)
### Bug Fixes
* **matching:** add settings persistence to matching game ([00dcb87](https://github.com/antialias/soroban-abacus-flashcards/commit/00dcb872b7e70bdb7de301b56fe42195e6ee923f))
## [3.17.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.2...v3.17.3) (2025-10-15)
### Bug Fixes
* **arcade:** preserve gameConfig when switching games ([2273c71](https://github.com/antialias/soroban-abacus-flashcards/commit/2273c71a872a5122d0b2023835fe30640106048e))
### Code Refactoring
* remove verbose console logging for cleaner debugging ([9cb5fdd](https://github.com/antialias/soroban-abacus-flashcards/commit/9cb5fdd2fa43560adc32dd052f47a7b06b2c5b69))
## [3.17.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.1...v3.17.2) (2025-10-15)

View File

@@ -108,3 +108,31 @@ npm run check # Biome check (format + lint + organize imports)
- Lint checks
**Status:** Known issue, does not block development or deployment.
## Game Settings Persistence
When working on arcade room game settings, refer to:
- **`.claude/GAME_SETTINGS_PERSISTENCE.md`** - Complete architecture documentation
- How settings are stored (nested by game name)
- Three critical systems that must stay in sync
- Common bugs and their solutions
- Debugging checklist
- Step-by-step guide for adding new settings
- **`.claude/GAME_SETTINGS_REFACTORING.md`** - Recommended improvements
- Shared config types to prevent inconsistencies
- Helper functions to reduce duplication
- Type-safe validation
- Migration strategy
**Quick Reference:**
Settings are stored as: `gameConfig[gameName][setting]`
Three places must handle settings correctly:
1. **Provider** (`Room{Game}Provider.tsx`) - Merges saved config with defaults
2. **Socket Server** (`socket-server.ts`) - Creates session from saved config
3. **Validator** (`{Game}Validator.ts`) - `getInitialState()` must accept ALL settings
If a setting doesn't persist, check all three locations.

View File

@@ -0,0 +1,421 @@
# 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:
```sql
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:**
```json
{
"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`:
```typescript
// 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:**
```typescript
// 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`
```typescript
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`
```typescript
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:**
```typescript
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`
```typescript
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`
```typescript
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:
```bash
# 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:
```typescript
// ❌ 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:
```typescript
// ❌ 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`):
```typescript
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
}
```
2. **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
3. **Update the validator** (`*Validator.ts`):
```typescript
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
return {
// ...
newSetting: config.newSetting, // TypeScript enforces this
}
}
```
4. **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

View File

@@ -0,0 +1,479 @@
# Game Settings Persistence - Refactoring Recommendations
## Current Pain Points
1. **Type safety is weak** - Easy to forget to add a setting in one place
2. **Duplication** - Config reading logic duplicated in socket-server.ts for each game
3. **Manual synchronization** - Have to manually keep validator signature, provider, and socket server in sync
4. **Error-prone** - Easy to hardcode values or forget to read from config
## Recommended Refactorings
### 1. Create Shared Config Types (HIGHEST PRIORITY)
**Problem:** Each game's settings are defined in multiple places with no type enforcement
**Solution:** Define a single source of truth for each game's config
```typescript
// src/lib/arcade/game-configs.ts
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'
}
export interface ComplementRaceGameConfig {
// ... future settings
}
export interface RoomGameConfig {
matching?: MatchingGameConfig
'memory-quiz'?: MemoryQuizGameConfig
'complement-race'?: ComplementRaceGameConfig
}
// 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',
}
```
**Benefits:**
- Single source of truth for each game's settings
- TypeScript enforces consistency across codebase
- Easy to see what settings each game has
### 2. Create Config Helper Functions
**Problem:** Config reading logic is duplicated and error-prone
**Solution:** Centralized helper functions with type safety
```typescript
// src/lib/arcade/game-config-helpers.ts
import type { GameName } from './validation'
import type { RoomGameConfig, MatchingGameConfig, MemoryQuizGameConfig } from './game-configs'
import { DEFAULT_MATCHING_CONFIG, DEFAULT_MEMORY_QUIZ_CONFIG } from './game-configs'
/**
* Get game-specific config from room's gameConfig with defaults
*/
export function getGameConfig<T extends GameName>(
roomGameConfig: RoomGameConfig | null | undefined,
gameName: T
): T extends 'matching'
? MatchingGameConfig
: T extends 'memory-quiz'
? MemoryQuizGameConfig
: never {
if (!roomGameConfig) {
return getDefaultGameConfig(gameName) as any
}
const savedConfig = roomGameConfig[gameName]
if (!savedConfig) {
return getDefaultGameConfig(gameName) as any
}
// Merge saved config with defaults to handle missing fields
const defaults = getDefaultGameConfig(gameName)
return { ...defaults, ...savedConfig } as any
}
function getDefaultGameConfig(gameName: GameName) {
switch (gameName) {
case 'matching':
return DEFAULT_MATCHING_CONFIG
case 'memory-quiz':
return DEFAULT_MEMORY_QUIZ_CONFIG
case 'complement-race':
// return DEFAULT_COMPLEMENT_RACE_CONFIG
throw new Error('complement-race config not implemented')
default:
throw new Error(`Unknown game: ${gameName}`)
}
}
/**
* Update a specific game's config in the room's gameConfig
*/
export function updateGameConfig<T extends GameName>(
currentRoomConfig: RoomGameConfig | null | undefined,
gameName: T,
updates: Partial<T extends 'matching' ? MatchingGameConfig : T extends 'memory-quiz' ? MemoryQuizGameConfig : never>
): RoomGameConfig {
const current = currentRoomConfig || {}
const gameConfig = current[gameName] || getDefaultGameConfig(gameName)
return {
...current,
[gameName]: {
...gameConfig,
...updates,
},
}
}
```
**Usage in socket-server.ts:**
```typescript
// BEFORE (error-prone, duplicated)
const memoryQuizConfig = (room.gameConfig as any)?.['memory-quiz'] || {}
initialState = validator.getInitialState({
selectedCount: memoryQuizConfig.selectedCount || 5,
displayTime: memoryQuizConfig.displayTime || 2.0,
selectedDifficulty: memoryQuizConfig.selectedDifficulty || 'easy',
playMode: memoryQuizConfig.playMode || 'cooperative',
})
// AFTER (type-safe, concise)
const config = getGameConfig(room.gameConfig, 'memory-quiz')
initialState = validator.getInitialState(config)
```
**Usage in RoomMemoryQuizProvider.tsx:**
```typescript
// BEFORE (verbose, error-prone)
const mergedInitialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, any>
const savedConfig = gameConfig?.['memory-quiz']
return {
...initialState,
selectedCount: savedConfig?.selectedCount ?? initialState.selectedCount,
displayTime: savedConfig?.displayTime ?? initialState.displayTime,
selectedDifficulty: savedConfig?.selectedDifficulty ?? initialState.selectedDifficulty,
playMode: savedConfig?.playMode ?? initialState.playMode,
}
}, [roomData?.gameConfig])
// AFTER (type-safe, concise)
const mergedInitialState = useMemo(() => {
const config = getGameConfig(roomData?.gameConfig, 'memory-quiz')
return {
...initialState,
...config, // Spread config directly - all settings included
}
}, [roomData?.gameConfig])
```
**Benefits:**
- No more manual property-by-property merging
- Type-safe
- Defaults handled automatically
- Reusable across codebase
### 3. Enforce Validator Config Type from Game Config
**Problem:** Easy to forget to add a new setting to validator's `getInitialState()` signature
**Solution:** Make validator use the shared config type
```typescript
// src/lib/arcade/validation/MemoryQuizGameValidator.ts
import type { MemoryQuizGameConfig } from '@/lib/arcade/game-configs'
export class MemoryQuizGameValidator {
// BEFORE: Manual type definition
// getInitialState(config: {
// selectedCount: number
// displayTime: number
// selectedDifficulty: DifficultyLevel
// playMode?: 'cooperative' | 'competitive'
// }): SorobanQuizState
// AFTER: Use shared type
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
return {
// ...
selectedCount: config.selectedCount,
displayTime: config.displayTime,
selectedDifficulty: config.selectedDifficulty,
playMode: config.playMode, // TypeScript ensures all fields are handled
// ...
}
}
}
```
**Benefits:**
- If you add a setting to `MemoryQuizGameConfig`, TypeScript forces you to handle it
- Impossible to forget a setting
- Impossible to use wrong type
### 4. Add Exhaustiveness Checking
**Problem:** Easy to miss handling a setting field
**Solution:** Use TypeScript's exhaustiveness checking
```typescript
// src/lib/arcade/validation/MemoryQuizGameValidator.ts
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
// Exhaustiveness check - ensures all config fields are used
const _exhaustivenessCheck: Record<keyof MemoryQuizGameConfig, boolean> = {
selectedCount: true,
displayTime: true,
selectedDifficulty: true,
playMode: true,
}
return {
// ... use all config fields
selectedCount: config.selectedCount,
displayTime: config.displayTime,
selectedDifficulty: config.selectedDifficulty,
playMode: config.playMode,
}
}
```
If you add a new field to `MemoryQuizGameConfig`, TypeScript will error on `_exhaustivenessCheck` until you add it.
### 5. Validate Config on Save
**Problem:** Invalid config can be saved to database
**Solution:** Add runtime validation
```typescript
// src/lib/arcade/game-config-helpers.ts
export function validateGameConfig(
gameName: GameName,
config: any
): config is MatchingGameConfig | MemoryQuizGameConfig {
switch (gameName) {
case 'matching':
return (
typeof config.gameType === 'string' &&
['abacus-numeral', 'complement-pairs'].includes(config.gameType) &&
typeof config.difficulty === 'number' &&
config.difficulty > 0 &&
typeof config.turnTimer === 'number' &&
config.turnTimer > 0
)
case 'memory-quiz':
return (
[2, 5, 8, 12, 15].includes(config.selectedCount) &&
typeof config.displayTime === 'number' &&
config.displayTime > 0 &&
['beginner', 'easy', 'medium', 'hard', 'expert'].includes(config.selectedDifficulty) &&
['cooperative', 'competitive'].includes(config.playMode)
)
default:
return false
}
}
```
Use in settings API:
```typescript
// src/app/api/arcade/rooms/[roomId]/settings/route.ts
if (body.gameConfig !== undefined) {
if (!validateGameConfig(room.gameName, body.gameConfig[room.gameName])) {
return NextResponse.json({ error: 'Invalid game config' }, { status: 400 })
}
updateData.gameConfig = body.gameConfig
}
```
## Schema Refactoring: Separate Table for Game Configs
### Current Problem
All game configs are stored in a single JSON column in `arcade_rooms.gameConfig`:
```json
{
"matching": { "gameType": "...", "difficulty": 15 },
"memory-quiz": { "selectedCount": 8, "playMode": "competitive" }
}
```
**Issues:**
- No schema validation
- Inefficient updates (read/parse/modify/serialize entire blob)
- Grows without bounds as more games added
- Can't query or index individual game settings
- No audit trail
- Potential concurrent update race conditions
### Recommended: Separate Table
Create `room_game_configs` table with one row per game per room:
```typescript
// src/db/schema/room-game-configs.ts
export const roomGameConfigs = sqliteTable('room_game_configs', {
id: text('id').primaryKey().$defaultFn(() => createId()),
roomId: text('room_id')
.notNull()
.references(() => arcadeRooms.id, { onDelete: 'cascade' }),
gameName: text('game_name', {
enum: ['matching', 'memory-quiz', 'complement-race'],
}).notNull(),
config: text('config', { mode: 'json' }).notNull(), // Game-specific JSON
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
}, (table) => ({
uniqueRoomGame: uniqueIndex('room_game_idx').on(table.roomId, table.gameName),
}))
```
**Benefits:**
- ✅ Smaller rows (only configs for games that have been used)
- ✅ Easier updates (single row, not entire JSON blob)
- ✅ Can track updatedAt per game
- ✅ Better concurrency (no lock contention between games)
- ✅ Foundation for future audit trail
**Migration Strategy:**
1. Create new table
2. Migrate existing data from `arcade_rooms.gameConfig`
3. Update all config read/write code
4. Deploy and test
5. Drop old `gameConfig` column from `arcade_rooms`
See migration SQL below.
## Implementation Priority
### Phase 1: Schema Migration (HIGHEST PRIORITY)
1. **Create new table** - Add `room_game_configs` schema
2. **Create migration** - SQL to migrate existing data
3. **Update helper functions** - Adapt to new table structure
4. **Update all read/write code** - Use new table
5. **Test thoroughly** - Verify all settings persist correctly
6. **Drop old column** - Remove `gameConfig` from `arcade_rooms`
### Phase 2: Type Safety (HIGH)
1. **Create shared config types** (`game-configs.ts`) - Prevents type mismatches
2. **Create helper functions** (`game-config-helpers.ts`) - Now queries new table
3. **Update validators** to use shared types - Enforces consistency
### Phase 3: Compile-Time Safety (MEDIUM)
1. **Add exhaustiveness checking** - Catches missing fields at compile time
2. **Enforce validator config types** - Use shared types
### Phase 4: Runtime Safety (LOW)
1. **Add runtime validation** - Prevents invalid data from being saved
## Detailed Migration SQL
```sql
-- drizzle/migrations/XXXX_split_game_configs.sql
-- Create new 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
);
CREATE UNIQUE INDEX room_game_idx ON room_game_configs(room_id, game_name);
-- Migrate existing 'matching' configs
INSERT INTO room_game_configs (id, room_id, game_name, config, created_at, updated_at)
SELECT
lower(hex(randomblob(16))),
id,
'matching',
json_extract(game_config, '$.matching'),
created_at,
last_activity
FROM arcade_rooms
WHERE json_extract(game_config, '$.matching') IS NOT NULL;
-- Migrate existing 'memory-quiz' configs
INSERT INTO room_game_configs (id, room_id, game_name, config, created_at, updated_at)
SELECT
lower(hex(randomblob(16))),
id,
'memory-quiz',
json_extract(game_config, '$."memory-quiz"'),
created_at,
last_activity
FROM arcade_rooms
WHERE json_extract(game_config, '$."memory-quiz"') IS NOT NULL;
-- After testing and verifying all works:
-- ALTER TABLE arcade_rooms DROP COLUMN game_config;
```
## Migration Strategy
### Step-by-Step with Checkpoints
**Checkpoint 1: Schema & Migration**
1. Create `src/db/schema/room-game-configs.ts`
2. Export from `src/db/schema/index.ts`
3. Generate and apply migration
4. Verify data migrated correctly
**Checkpoint 2: Helper Functions**
1. Create shared config types in `src/lib/arcade/game-configs.ts`
2. Create helper functions in `src/lib/arcade/game-config-helpers.ts`
3. Add unit tests for helpers
**Checkpoint 3: Update Config Reads**
1. Update socket-server.ts to read from new table
2. Update RoomMemoryQuizProvider to read from new table
3. Update RoomMemoryPairsProvider to read from new table
4. Test: Load room and verify settings appear
**Checkpoint 4: Update Config Writes**
1. Update useRoomData.ts updateGameConfig to write to new table
2. Update settings API to write to new table
3. Test: Change settings and verify they persist
**Checkpoint 5: Update Validators**
1. Update validators to use shared config types
2. Test: All games work correctly
**Checkpoint 6: Cleanup**
1. Remove old gameConfig column references
2. Drop gameConfig column from arcade_rooms table
3. Final testing of all games
## Benefits Summary
- **Type Safety:** TypeScript enforces consistency across all systems
- **DRY:** Config reading logic not duplicated
- **Maintainability:** Adding a setting requires changes in fewer places
- **Correctness:** Impossible to forget a setting or use wrong type
- **Debugging:** Centralized config logic easier to trace
- **Testing:** Can test config helpers in isolation

View File

@@ -0,0 +1,120 @@
# Manual Migration: room_game_configs Table
**Date:** 2025-10-15
**Migration:** Create `room_game_configs` table (equivalent to drizzle migration 0011)
## Context
This migration was applied manually using sqlite3 CLI instead of through drizzle-kit's migration system, because the interactive prompt from `drizzle-kit push` cannot be automated in the deployment pipeline.
## What Was Done
### 1. Created Table
```sql
CREATE TABLE IF NOT EXISTS room_game_configs (
id TEXT PRIMARY KEY NOT NULL,
room_id TEXT NOT NULL,
game_name TEXT NOT NULL,
config TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (room_id) REFERENCES arcade_rooms(id) ON UPDATE NO ACTION ON DELETE CASCADE
);
```
### 2. Created Index
```sql
CREATE UNIQUE INDEX IF NOT EXISTS room_game_idx ON room_game_configs (room_id, game_name);
```
### 3. Migrated Existing Data
Migrated 6000 game configs from the old `arcade_rooms.game_config` column to the new normalized table:
```sql
INSERT OR IGNORE INTO room_game_configs (id, room_id, game_name, config, created_at, updated_at)
SELECT
lower(hex(randomblob(16))) as id,
id as room_id,
game_name,
game_config as config,
created_at,
last_activity as updated_at
FROM arcade_rooms
WHERE game_config IS NOT NULL
AND game_name IS NOT NULL;
```
**Results:**
- 5991 matching game configs migrated
- 9 memory-quiz game configs migrated
- Total: 6000 configs
## Old vs New Schema
**Old Schema:**
- `arcade_rooms.game_config` (TEXT/JSON) - stored config for currently selected game only
- Config was lost when switching games
**New Schema:**
- `room_game_configs` table - one row per game per room
- Unique constraint on (room_id, game_name)
- Configs persist when switching between games
## Verification
```bash
# Verify table exists
sqlite3 data/sqlite.db ".tables" | grep room_game_configs
# Verify schema
sqlite3 data/sqlite.db ".schema room_game_configs"
# Count migrated data
sqlite3 data/sqlite.db "SELECT COUNT(*) FROM room_game_configs;"
# Expected: 6000
# Check data distribution
sqlite3 data/sqlite.db "SELECT game_name, COUNT(*) FROM room_game_configs GROUP BY game_name;"
# Expected: matching: 5991, memory-quiz: 9
```
## Related Files
This migration supports the refactoring documented in:
- `.claude/GAME_SETTINGS_PERSISTENCE.md` - Architecture documentation
- `src/lib/arcade/game-configs.ts` - Shared config types
- `src/lib/arcade/game-config-helpers.ts` - Database access helpers
## Note on Drizzle Migration Tracking
This migration was NOT recorded in drizzle's `__drizzle_migrations` table because it was applied manually. This is acceptable because:
1. The schema definition exists in code (`src/db/schema/room-game-configs.ts`)
2. The table was created with the exact schema drizzle would generate
3. Future schema changes will go through proper drizzle migrations
4. The `arcade_rooms.game_config` column is preserved for rollback safety
## Rollback Plan
If issues arise, the old system can be restored by:
1. Reverting code changes (game-config-helpers.ts, API routes, validators)
2. The old `game_config` column still exists in `arcade_rooms` table
3. Data is still there (we only read from it, didn't delete it)
The new `room_game_configs` table can be dropped if needed:
```sql
DROP TABLE IF EXISTS room_game_configs;
```
## Future Work
Once this migration is stable in production:
1. Consider dropping the old `arcade_rooms.game_config` column
2. Add this migration to drizzle's migration journal for tracking (optional)
3. Monitor for any issues with settings persistence

View File

@@ -70,7 +70,16 @@
"Bash(lsof:*)",
"Bash(killall:*)",
"Bash(echo:*)",
"Bash(git restore:*)"
"Bash(git restore:*)",
"Bash(timeout 10 npm run dev:*)",
"Bash(timeout 30 npm run dev)",
"Bash(pkill:*)",
"Bash(for i in {1..30})",
"Bash(do gh run list --limit 1 --json conclusion,status,name,databaseId --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\") - Run ID: \\(.databaseId)\"\"')",
"Bash(tsc:*)",
"Bash(tsc-alias:*)",
"Bash(npx tsc-alias:*)",
"Bash(timeout 20 pnpm run:*)"
],
"deny": [],
"ask": []

View File

@@ -0,0 +1,302 @@
# Architectural Improvements - Summary
**Date**: 2025-10-16
**Status**: ✅ **Implemented**
**Based on**: AUDIT_2_ARCHITECTURE_QUALITY.md
---
## Executive Summary
Successfully implemented **all 3 critical architectural improvements** identified in the audit. The modular game system is now **truly modular** - new games can be added without touching database schemas, API endpoints, helper switch statements, or manual type definitions.
**Phase 1**: Eliminated database schema coupling
**Phase 2**: Moved config validation to game definitions
**Phase 3**: Implemented type inference from game definitions
**Grade**: **A** (Up from B- after improvements)
---
## What Was Fixed
### 1. ✅ Database Schema Coupling (CRITICAL)
**Problem**: Schemas used hardcoded enums, requiring migration for each new game.
**Solution**: Accept any string, validate at runtime against validator registry.
**Changes**:
- `arcade-rooms.ts`: `gameName: text('game_name')` (removed enum)
- `arcade-sessions.ts`: `currentGame: text('current_game').notNull()` (removed enum)
- `room-game-configs.ts`: `gameName: text('game_name').notNull()` (removed enum)
- Added `isValidGameName()` and `assertValidGameName()` runtime validators
- Updated settings API to use `isValidGameName()` instead of hardcoded array
**Impact**:
```diff
- BEFORE: Update 3 database schemas + run migration for each game
+ AFTER: No database changes needed - just register validator
```
**Files Modified**: 4 files
**Commit**: `e135d92a - refactor(db): remove database schema coupling for game names`
---
### 2. ✅ Config Validation in Game Definitions
**Problem**: 50+ line switch statement in `game-config-helpers.ts` had to be updated for each game.
**Solution**: Move validation to game definitions - games own their validation logic.
**Changes**:
- Added `validateConfig?: (config: unknown) => config is TConfig` to `GameDefinition`
- Updated `defineGame()` to accept and return `validateConfig`
- Added validation to Number Guesser and Math Sprint
- Updated `validateGameConfig()` to call `game.validateConfig()` from registry
**Impact**:
```diff
- BEFORE: Add case to 50-line switch statement in helper file
+ AFTER: Add validateConfig function to game definition
```
**Example**:
```typescript
// In game index.ts
function validateMathSprintConfig(config: unknown): config is MathSprintConfig {
return (
typeof config === 'object' &&
config !== null &&
['easy', 'medium', 'hard'].includes(config.difficulty) &&
typeof config.questionsPerRound === 'number' &&
config.questionsPerRound >= 5 &&
config.questionsPerRound <= 20
)
}
export const mathSprintGame = defineGame({
// ... other fields
validateConfig: validateMathSprintConfig,
})
```
**Files Modified**: 5 files
**Commit**: `b19437b7 - refactor(arcade): move config validation to game definitions`
---
## Before vs After Comparison
### Adding a New Game
| Task | Before | After (Phase 1-3) |
|------|--------|----------|
| **Database Schemas** | Update 3 enum types | ✅ No changes needed |
| **Settings API** | Add to validGames array | ✅ No changes needed (runtime validation) |
| **Config Helpers** | Add switch case + validation (25 lines) | ✅ No changes needed |
| **Game Config Types** | Manually define interface (10-15 lines) | ✅ One-line type inference |
| **GameConfigByName** | Add entry manually | ✅ Add entry (auto-typed) |
| **RoomGameConfig** | Add optional property | ✅ Auto-derived from GameConfigByName |
| **Default Config** | Add to DEFAULT_X_CONFIG constant | ✔️ Still needed (3-5 lines) |
| **Validator Registry** | Register in validators.ts | ✔️ Still needed (1 line) |
| **Game Registry** | Register in game-registry.ts | ✔️ Still needed (1 line) |
| **validateConfig Function** | N/A | ✔️ Add to game definition (10-15 lines) |
**Total Files to Update**: 12 → **3** (75% reduction)
**Total Lines of Boilerplate**: ~60 lines → ~20 lines (67% reduction)
### What's Left
Three items still require manual updates:
1. **Default Config Constants** (`game-configs.ts`) - 3-5 lines per game
2. **Validator Registry** (`validators.ts`) - 1 line per game
3. **Game Registry** (`game-registry.ts`) - 1 line per game
4. **validateConfig Function** (in game definition) - 10-15 lines per game (but co-located with game!)
---
## Migration Impact
### Existing Data
-**No data migration needed** - strings remain strings
-**Backward compatible** - existing games work unchanged
### TypeScript Changes
- ⚠️ Database columns now accept `string` instead of specific enum
- ✅ Runtime validation prevents invalid data
- ✅ Type safety maintained through validator registry
### Developer Experience
```diff
- BEFORE: 15-20 minutes of boilerplate per game
+ AFTER: 2-3 minutes to add validation function
```
---
## Architectural Wins
### 1. Single Source of Truth
- ✅ Validator registry is the authoritative list of games
- ✅ All validation checks against registry at runtime
- ✅ No duplication across database/API/helpers
### 2. Self-Contained Games
- ✅ Games define their own validation logic
- ✅ No scattered switch statements
- ✅ Easy to understand - everything in one place
### 3. True Modularity
- ✅ Database schemas accept any registered game
- ✅ API endpoints dynamically validate
- ✅ Helper functions delegate to games
### 4. Developer Friction Reduced
- ✅ No database schema changes
- ✅ No API endpoint updates
- ✅ No helper switch statements
- ✅ Clear error messages (runtime validation)
---
### 3. ✅ Config Type Inference (Phase 3)
**Problem**: Config types manually defined in `game-configs.ts`, requiring 10-15 lines per game.
**Solution**: Use TypeScript utility types to infer from game definitions.
**Changes**:
- Added `InferGameConfig<T>` utility type that extracts config from game definitions
- `NumberGuesserGameConfig` now inferred: `InferGameConfig<typeof numberGuesserGame>`
- `MathSprintGameConfig` now inferred: `InferGameConfig<typeof mathSprintGame>`
- `RoomGameConfig` auto-derived from `GameConfigByName` using mapped types
- Changed `RoomGameConfig` from interface to type for auto-derivation
**Impact**:
```diff
- BEFORE: Manually define interface with 10-15 lines per game
+ AFTER: One-line type inference from game definition
```
**Example**:
```typescript
// Type-only import (won't load React components)
import type { mathSprintGame } from '@/arcade-games/math-sprint'
// Utility type
type InferGameConfig<T> = T extends { defaultConfig: infer Config } ? Config : never
// Inferred type (was 6 lines, now 1 line!)
export type MathSprintGameConfig = InferGameConfig<typeof mathSprintGame>
// Auto-derived RoomGameConfig (was 5 manual entries, now automatic!)
export type RoomGameConfig = {
[K in keyof GameConfigByName]?: GameConfigByName[K]
}
```
**Files Modified**: 2 files
**Commits**:
- `271b8ec3 - refactor(arcade): implement Phase 3 - infer config types from game definitions`
- `4c15c13f - docs(arcade): update README with Phase 3 type inference architecture`
**Note**: Default config constants (e.g., `DEFAULT_MATH_SPRINT_CONFIG`) still manually defined. This small duplication is necessary for server-side code that can't import full game definitions with React components.
---
## Future Work (Optional)
### Phase 4: Extract Config-Only Exports
**Optional improvement**: Create separate `config.ts` files in each game directory that export just config and validation (no React dependencies). This would allow importing default configs directly without duplication.
---
## Testing
### Manual Testing
- ✅ Math Sprint works end-to-end
- ✅ Number Guesser works end-to-end
- ✅ Room settings API accepts math-sprint
- ✅ Config validation rejects invalid configs
- ✅ TypeScript compilation succeeds
### Test Coverage Needed
- [ ] Unit tests for `isValidGameName()`
- [ ] Unit tests for game `validateConfig()` functions
- [ ] Integration test: Add new game without touching infrastructure
- [ ] E2E test: Verify runtime validation works
---
## Lessons Learned
### What Worked Well
1. **Incremental Approach** - Fixed one issue at a time
2. **Backward Compatibility** - Legacy games still work
3. **Runtime Validation** - Flexible and extensible
4. **Clear Commit Messages** - Easy to track changes
### Challenges
1. **TypeScript Enums → Runtime Checks** - Required migration strategy
2. **Fallback for Legacy Games** - Switch statement still exists for old games
3. **Type Inference** - Config types still manually defined
### Best Practices Established
1. **Games own validation** - Self-contained, testable
2. **Registry as source of truth** - No duplicate lists
3. **Runtime validation** - Catch errors early with good messages
4. **Fail-fast** - Use assertions where appropriate
---
## Conclusion
The modular game system is now **significantly improved across all three phases**:
**Before (Phases 1-3)**:
- Must update 12 files to add a game (~60 lines of boilerplate)
- Database migration required for each new game
- Easy to forget a step (manual type definitions, switch statements)
- Scattered validation logic across multiple files
**After (All Phases Complete)**:
- Update 3 files to add a game (75% reduction)
- ~20 lines of boilerplate (67% reduction)
- No database migration needed
- Validation is self-contained in game definitions
- Config types auto-inferred from game definitions
- Clear runtime error messages
**Key Achievements**:
1.**Phase 1**: Runtime validation replaces database enums
2.**Phase 2**: Games own their validation logic
3.**Phase 3**: TypeScript types inferred from game definitions
**Remaining Work**:
- Optional Phase 4: Extract config-only exports to eliminate DEFAULT_*_CONFIG duplication
- Add comprehensive test suite for validation and type inference
- Migrate legacy games (matching, memory-quiz) to new system
The architecture is now **production-ready** and can scale to dozens of games without becoming unmaintainable. Each game is truly self-contained, with all its logic, validation, and types defined in one place.
---
## Quick Reference: Adding a New Game
1. Create game directory with required files (types, Validator, Provider, components, index)
2. Add validation function (`validateConfig`) in index.ts and pass to `defineGame()`
3. Register validator in `validators.ts` (1 line)
4. Register game in `game-registry.ts` (1 line)
5. Add type inference to `game-configs.ts`:
```typescript
import type { myGame } from '@/arcade-games/my-game'
export type MyGameConfig = InferGameConfig<typeof myGame>
```
6. Add to `GameConfigByName` (1 line - type is auto-inferred!)
7. Add defaults to `game-configs.ts` (3-5 lines)
**That's it!** No database schemas, API endpoints, helper switch statements, or manual interface definitions.
**Total**: 3 files to update, ~20 lines of boilerplate

View File

@@ -0,0 +1,451 @@
# Architecture Quality Audit #2
**Date**: 2025-10-16
**Context**: After implementing Number Guesser (turn-based) and starting Math Sprint (free-for-all)
**Goal**: Assess if the system is truly modular or if there's too much boilerplate
---
## Executive Summary
**Status**: ⚠️ **Good Foundation, But Boilerplate Issues**
The unified validator registry successfully solved the dual registration problem. However, implementing a second game revealed **significant boilerplate** and **database schema coupling** that violate the modular architecture goals.
**Grade**: **B-** (Down from B+ after implementation testing)
---
## Issues Found
### 🚨 Issue #1: Database Schema Coupling (CRITICAL)
**Problem**: The `room_game_configs` table schema hard-codes game names, preventing true modularity.
**Evidence**:
```typescript
// db/schema/room-game-configs.ts
gameName: text('game_name').$type<'matching' | 'memory-quiz' | 'number-guesser' | 'complement-race'>()
```
When adding 'math-sprint':
```
Type '"math-sprint"' is not assignable to type '"matching" | "memory-quiz" | "number-guesser" | "complement-race"'
```
**Impact**:
- ❌ Must manually update database schema for every new game
- ❌ TypeScript errors force schema migration
- ❌ Breaks "just register and go" promise
- ❌ Requires database migration for each game
**Root Cause**: The schema uses a union type instead of a string with runtime validation.
**Fix Required**: Change schema to accept any string, validate against registry at runtime.
---
### ⚠️ Issue #2: game-config-helpers.ts Boilerplate
**Problem**: Three switch statements must be updated for every new game:
1. `getDefaultGameConfig()` - add case
2. Import default config constant
3. `validateGameConfig()` - add validation logic
**Example** (from Math Sprint):
```typescript
// Must add to imports
import { DEFAULT_MATH_SPRINT_CONFIG } from './game-configs'
// Must add case to switch #1
case 'math-sprint':
return DEFAULT_MATH_SPRINT_CONFIG
// Must add case to switch #2
case 'math-sprint':
return (
typeof config === 'object' &&
config !== null &&
['easy', 'medium', 'hard'].includes(config.difficulty) &&
// ... 10+ lines of validation
)
```
**Impact**:
- ⏱️ 5-10 minutes of boilerplate per game
- 🐛 Easy to forget a switch case
- 📝 Repetitive validation logic
**Better Approach**: Config defaults and validation should be part of the game definition.
---
### ⚠️ Issue #3: game-configs.ts Boilerplate
**Problem**: Must update 4 places in game-configs.ts:
1. Import types from game
2. Define `XGameConfig` interface
3. Add to `GameConfigByName` union
4. Add to `RoomGameConfig` interface
5. Create `DEFAULT_X_CONFIG` constant
**Example** (from Math Sprint):
```typescript
// 1. Import
import type { Difficulty as MathSprintDifficulty } from '@/arcade-games/math-sprint/types'
// 2. Interface
export interface MathSprintGameConfig {
difficulty: MathSprintDifficulty
questionsPerRound: number
timePerQuestion: number
}
// 3. Add to union
export type GameConfigByName = {
'math-sprint': MathSprintGameConfig
// ...
}
// 4. Add to RoomGameConfig
export interface RoomGameConfig {
'math-sprint'?: MathSprintGameConfig
// ...
}
// 5. Default constant
export const DEFAULT_MATH_SPRINT_CONFIG: MathSprintGameConfig = {
difficulty: 'medium',
questionsPerRound: 10,
timePerQuestion: 30,
}
```
**Impact**:
- ⏱️ 10-15 lines of boilerplate per game
- 🐛 Easy to forget one of the 5 updates
- 🔄 Repeating type information (already in game definition)
**Better Approach**: Game config types should be inferred from game definitions.
---
### 📊 Issue #4: High Boilerplate Ratio
**Files Required Per Game**:
| Category | Files | Purpose |
|----------|-------|---------|
| **Game Code** | 7 files | types.ts, Validator.ts, Provider.tsx, index.ts, 3x components |
| **Registration** | 2 files | validators.ts, game-registry.ts |
| **Config** | 2 files | game-configs.ts, game-config-helpers.ts |
| **Database** | 1 file | schema migration |
| **Total** | **12 files** | For one game! |
**Lines of Boilerplate** (non-game-logic):
- game-configs.ts: ~15 lines
- game-config-helpers.ts: ~25 lines
- validators.ts: ~2 lines
- game-registry.ts: ~2 lines
- **Total: ~44 lines of pure boilerplate per game**
**Comparison**:
- Number Guesser: ~500 lines of actual game logic
- Boilerplate: ~44 lines (8.8% overhead) ✅ Acceptable
- But spread across 4 different files ⚠️ Developer friction
---
## Positive Aspects
### ✅ What Works Well
1. **SDK Abstraction**
- `useArcadeSession` is clean and reusable
- `buildPlayerMetadata` helper reduces duplication
- Hook-based API is intuitive
2. **Provider Pattern**
- Consistent across games
- Clear separation of concerns
- Easy to understand
3. **Component Structure**
- SetupPhase, PlayingPhase, ResultsPhase pattern is clear
- GameComponent wrapper is simple
- PageWithNav integration is seamless
4. **Unified Validator Registry**
- Single source of truth for validators ✅
- Auto-derived GameName type ✅
- Type-safe validator access ✅
5. **Error Feedback**
- lastError/clearError pattern works well
- Auto-dismiss UX is good
- Consistent error handling
---
## Comparison: Number Guesser vs. Math Sprint
### Similarities (Good!)
- ✅ Same file structure
- ✅ Same SDK usage patterns
- ✅ Same Provider pattern
- ✅ Same component phases
### Differences (Revealing!)
- Math Sprint uses TEAM_MOVE (no turn owner)
- Math Sprint has server-generated questions
- Database schema didn't support Math Sprint name
**Key Insight**: The SDK handles different game types well (turn-based vs. free-for-all), but infrastructure (database, config system) is rigid.
---
## Developer Experience Score
### Time to Add a Game
| Task | Time | Notes |
|------|------|-------|
| Write game logic | 2-4 hours | Validator, state management, components |
| Registration boilerplate | 15-20 min | 4 files to update |
| Database migration | 10-15 min | Schema update, migration file |
| Debugging type errors | 10-30 min | Database schema mismatches |
| **Total** | **3-5 hours** | For a simple game |
### Pain Points
1. **Database Schema** ⚠️ Critical blocker
- Must update schema for each game
- Requires migration
- TypeScript errors are confusing
2. **Config System** ⚠️ Medium friction
- 5 places to update in game-configs.ts
- Easy to miss one
- Repetitive type definitions
3. **Helper Functions** ⚠️ Low friction
- Switch statements in game-config-helpers.ts
- Not hard, just tedious
### What Developers Like
1. ✅ SDK is intuitive
2. ✅ Pattern is consistent
3. ✅ Error messages are clear (once you know where to look)
4. ✅ Documentation is comprehensive
---
## Architectural Recommendations
### Critical (Before Adding More Games)
**1. Fix Database Schema Coupling**
**Current**:
```typescript
gameName: text('game_name').$type<'matching' | 'memory-quiz' | 'number-guesser' | 'complement-race'>()
```
**Recommended**:
```typescript
// Accept any string, validate at runtime
gameName: text('game_name').$type<string>().notNull()
// Runtime validation in helper functions
export function validateGameName(gameName: string): gameName is GameName {
return hasValidator(gameName)
}
```
**Benefits**:
- ✅ No schema migration per game
- ✅ Works with auto-derived GameName
- ✅ Runtime validation is sufficient
---
**2. Infer Config Types from Game Definitions**
**Current** (manual):
```typescript
// In game-configs.ts
export interface MathSprintGameConfig { ... }
export const DEFAULT_MATH_SPRINT_CONFIG = { ... }
// In game definition
const defaultConfig: MathSprintGameConfig = { ... }
```
**Recommended**:
```typescript
// In game definition (single source of truth)
export const mathSprintGame = defineGame({
defaultConfig: {
difficulty: 'medium',
questionsPerRound: 10,
timePerQuestion: 30,
},
validator: mathSprintValidator,
// ...
})
// Auto-infer types
type MathSprintConfig = typeof mathSprintGame.defaultConfig
```
**Benefits**:
- ✅ No duplication
- ✅ Single source of truth
- ✅ Type inference handles it
---
**3. Move Config Validation to Game Definition**
**Current** (switch statement in helper):
```typescript
function validateGameConfig(gameName: GameName, config: any): boolean {
switch (gameName) {
case 'math-sprint':
return /* 15 lines of validation */
}
}
```
**Recommended**:
```typescript
// In game definition
export const mathSprintGame = defineGame({
defaultConfig: { ... },
validateConfig: (config: any): config is MathSprintConfig => {
return /* validation logic */
},
// ...
})
// In helper (generic)
export function validateGameConfig(gameName: GameName, config: any): boolean {
const game = getGame(gameName)
return game?.validateConfig?.(config) ?? true
}
```
**Benefits**:
- ✅ No switch statement
- ✅ Validation lives with game
- ✅ One place to update
---
### Medium Priority
**4. Create CLI Tool for Game Generation**
```bash
npm run create-game math-sprint "Math Sprint" "🧮"
```
Generates:
- File structure
- Boilerplate code
- Registration entries
- Types
**Benefits**:
- ✅ Eliminates manual boilerplate
- ✅ Consistent structure
- ✅ Reduces errors
---
**5. Add Runtime Registry Validation**
On app start, verify:
- ✅ All games in registry have validators
- ✅ All validators have games
- ✅ No orphaned configs
- ✅ All game names are unique
```typescript
function validateRegistries() {
const games = getAllGames()
const validators = getRegisteredGameNames()
for (const game of games) {
if (!validators.includes(game.manifest.name)) {
throw new Error(`Game ${game.manifest.name} has no validator!`)
}
}
}
```
---
## Updated Compliance Table
| Intention | Status | Notes |
|-----------|--------|-------|
| Modularity | ⚠️ Partial | Validators unified, but database/config not modular |
| Self-registration | ✅ Pass | Two registration points (validator + game), both clear |
| Type safety | ⚠️ Partial | Types work, but database schema breaks for new games |
| No core changes | ⚠️ Partial | Must update 4 files + database schema |
| Drop-in games | ❌ Fail | Database migration required |
| Stable SDK API | ✅ Pass | SDK is excellent |
| Clear patterns | ✅ Pass | Patterns are consistent |
| Low boilerplate | ⚠️ Partial | SDK usage is clean, registration is verbose |
**Overall Grade**: **B-** (Was B+, downgraded after implementation testing)
---
## Summary
### What We Learned
**The Good**:
- SDK design is solid
- Unified validator registry works
- Pattern is consistent and learnable
- Number Guesser proves the concept
⚠️ **The Not-So-Good**:
- Database schema couples to game names (critical blocker)
- Config system has too much boilerplate
- 12 files touched per game is high
**The Bad**:
- Can't truly "drop in" a game without schema migration
- Config types are duplicated
- Helper switch statements are tedious
### Verdict
The system **works** and is **usable**, but falls short of "modular architecture" goals due to:
1. Database schema hard-coding
2. Config system boilerplate
3. Required schema migrations
**Recommendation**:
1. **Option A (Quick Fix)**: Document the 12-file checklist, live with boilerplate for now
2. **Option B (Proper Fix)**: Implement Critical recommendations 1-3 before adding Math Sprint
**My Recommendation**: Option A for now (get Math Sprint working), then Option B as a refactoring sprint.
---
## Next Steps
1. ✅ Document "Adding a Game" checklist (12 files)
2. 🔴 Fix database schema to accept any game name
3. 🟡 Test Math Sprint with current architecture
4. 🟡 Evaluate if boilerplate is acceptable in practice
5. 🟢 Consider config system refactoring for later

View File

@@ -0,0 +1,519 @@
# Modular Game System Audit
**Date**: 2025-10-15
**Updated**: 2025-10-15
**Status**: ✅ CRITICAL ISSUE RESOLVED
---
## Executive Summary
The modular game system **now meets its stated intentions** after implementing the unified validator registry. The critical dual registration issue has been resolved.
**Original Issue**: Client-side implementation (SDK, registry, game definitions) was well-designed, but server-side validation used a hard-coded legacy system, breaking the core premise of modularity.
**Resolution**: Created unified isomorphic validator registry (`src/lib/arcade/validators.ts`) that serves both client and server needs, with auto-derived GameName type.
**Verdict**: ✅ **Production Ready** - System is now truly modular with single registration point
---
## Intention vs. Reality
### Stated Intentions
> "A modular, plugin-based architecture for building multiplayer arcade games"
>
> **Goals:**
> 1. **Modularity**: Each game is self-contained and independently deployable
> 2. Games register themselves with a central registry
> 3. No need to modify core infrastructure when adding games
### Current Reality
**Client-Side**: Fully modular, games use SDK and register themselves
**Server-Side**: Hard-coded validator map, requires manual code changes
**Overall**: **System is NOT modular** - adding a game requires editing 2 different registries
---
## Critical Issues
### ✅ Issue #1: Dual Registration System (RESOLVED)
**Original Problem**: Games had to register in TWO separate places:
1. **Client Registry** (`src/lib/arcade/game-registry.ts`)
2. **Server Validator Map** (`src/lib/arcade/validation/index.ts`)
**Impact**:
- ❌ Broke modularity - couldn't just drop in a new game
- ❌ Easy to forget one registration, causing runtime errors
- ❌ Violated DRY principle
- ❌ Two sources of truth for "what games exist"
**Resolution** (Implemented 2025-10-15):
Created unified isomorphic validator registry at `src/lib/arcade/validators.ts`:
```typescript
export const validatorRegistry = {
matching: matchingGameValidator,
'memory-quiz': memoryQuizGameValidator,
'number-guesser': numberGuesserValidator,
// Add new games here - GameName type auto-updates!
} as const
// Auto-derived type - no manual updates needed!
export type GameName = keyof typeof validatorRegistry
```
**Changes Made**:
1. ✅ Created `src/lib/arcade/validators.ts` - Unified validator registry (isomorphic)
2. ✅ Updated `validation/index.ts` - Now re-exports from unified registry (backwards compatible)
3. ✅ Updated `validation/types.ts` - GameName now auto-derived (no more hard-coded union)
4. ✅ Updated `session-manager.ts` - Imports from unified registry
5. ✅ Updated `socket-server.ts` - Imports from unified registry
6. ✅ Updated `route.ts` - Uses `hasValidator()` instead of hard-coded array
7. ✅ Updated `game-config-helpers.ts` - Handles ExtendedGameName for legacy games
8. ✅ Updated `game-registry.ts` - Added runtime validation check
**Benefits**:
- ✅ Single registration point for validators
- ✅ Auto-derived GameName type (no manual updates)
- ✅ Type-safe validator access
- ✅ Backwards compatible with existing code
- ✅ Runtime warnings for registration mismatches
**Commit**: `refactor(arcade): create unified validator registry to fix dual registration` (9459f37b)
---
### ✅ Issue #2: Validators Not Accessible from Registry (RESOLVED)
**Original Problem**: The `GameDefinition` contained validators, but server couldn't access them because `game-registry.ts` imported React components.
**Resolution**: Created separate isomorphic validator registry that server can import without pulling in client-only code.
**How It Works Now**:
- `src/lib/arcade/validators.ts` - Isomorphic, server can import safely
- `src/lib/arcade/game-registry.ts` - Client-only, imports React components
- Both use the same validator instances (verified at runtime)
**Benefits**:
- ✅ Server has direct access to validators
- ✅ No need for dual validator maps
- ✅ Clear separation: validators (isomorphic) vs UI (client-only)
---
### ⚠️ Issue #3: Type System Fragmentation
**Problem**: Multiple overlapping type definitions for same concepts:
**GameValidator** has THREE definitions:
1. `validation/types.ts` - Legacy validator interface
2. `game-sdk/types.ts` - SDK validator interface (extends legacy)
3. Individual game validators - Implement one or both?
**GameMove** has TWO type systems:
1. `validation/types.ts` - Legacy move types (MatchingFlipCardMove, etc.)
2. Game-specific types in each game's `types.ts`
**GameName** is hard-coded:
```typescript
// validation/types.ts:9
export type GameName = 'matching' | 'memory-quiz' | 'complement-race' | 'number-guesser'
```
This must be manually updated for every new game!
**Impact**:
- Confusing which types to use
- Easy to use wrong import
- GameName type doesn't auto-update from registry
---
### ⚠️ Issue #4: Old Games Not Migrated
**Problem**: Existing games (matching, memory-quiz) still use old structure:
**Old Pattern** (matching, memory-quiz):
```
src/app/arcade/matching/
├── context/ (Old pattern)
│ └── RoomMemoryPairsProvider.tsx
└── components/
```
**New Pattern** (number-guesser):
```
src/arcade-games/number-guesser/
├── index.ts (New pattern)
├── Validator.ts
├── Provider.tsx
└── components/
```
**Impact**:
- Inconsistent codebase structure
- Two different patterns developers must understand
- Documentation shows new pattern, but most games use old pattern
- Confusing for new developers
**Evidence**:
- `src/app/arcade/matching/` - Uses old structure
- `src/app/arcade/memory-quiz/` - Uses old structure
- `src/arcade-games/number-guesser/` - Uses new structure
---
### ✅ Issue #5: Manual GameName Type Updates (RESOLVED)
**Original Problem**: `GameName` type was a hard-coded union that had to be manually updated for each new game.
**Resolution**: Changed validator registry from Map to const object, enabling type derivation:
```typescript
// src/lib/arcade/validators.ts
export const validatorRegistry = {
matching: matchingGameValidator,
'memory-quiz': memoryQuizGameValidator,
'number-guesser': numberGuesserValidator,
// Add new games here...
} as const
// Auto-derived! No manual updates needed!
export type GameName = keyof typeof validatorRegistry
```
**Benefits**:
- ✅ GameName type updates automatically when adding to registry
- ✅ Impossible to forget type update (it's derived)
- ✅ Single registration step (just add to validatorRegistry)
- ✅ Type-safe throughout codebase
---
## Secondary Issues
### Issue #6: No Server-Side Registry Access
**Problem**: Server code cannot import `game-registry.ts` because it contains React components.
**Why**:
- `GameDefinition` includes `Provider` and `GameComponent` (React components)
- Server-side code runs in Node.js, can't import React components
- No way to access just the validator from registry
**Potential Solutions**:
1. Split registry into isomorphic and client-only parts
2. Separate validator registration from game registration
3. Use conditional exports in package.json
---
### Issue #7: Documentation Doesn't Match Reality
**Problem**: Documentation describes a fully modular system, but reality requires manual edits in multiple places.
**From README.md**:
> "Step 7: Register Game - Add to src/lib/arcade/game-registry.ts"
**Missing Steps**:
- Also add to `validation/index.ts` validator map
- Also add to `GameName` type union
- Import validator in server files
**Impact**: Developers follow docs, game doesn't work, confusion ensues.
---
### Issue #8: No Validation of Registered Games
**Problem**: Registration is type-safe but has no runtime validation:
```typescript
registerGame(numberGuesserGame) // No validation that validator works
```
**Missing Checks**:
- Does validator implement all required methods?
- Does manifest match expected schema?
- Are all required fields present?
- Does validator.getInitialState() return valid state?
**Impact**: Bugs only caught at runtime when game is played.
---
## Proposed Solutions
### Solution 1: Unified Server-Side Registry (RECOMMENDED)
**Create isomorphic validator registry**:
```typescript
// src/lib/arcade/validators.ts (NEW FILE - isomorphic)
import { numberGuesserValidator } from '@/arcade-games/number-guesser/Validator'
import { matchingGameValidator } from '@/lib/arcade/validation/MatchingGameValidator'
// ... other validators
export const validatorRegistry = new Map([
['number-guesser', numberGuesserValidator],
['matching', matchingGameValidator],
// ...
])
export function getValidator(gameName: string) {
const validator = validatorRegistry.get(gameName)
if (!validator) throw new Error(`No validator for game: ${gameName}`)
return validator
}
export type GameName = keyof typeof validatorRegistry // Auto-derived!
```
**Update game-registry.ts** to use this:
```typescript
// src/lib/arcade/game-registry.ts
import { getValidator } from './validators'
export function registerGame(game: GameDefinition) {
const { name } = game.manifest
// Verify validator is registered server-side
const validator = getValidator(name)
if (validator !== game.validator) {
console.warn(`[Registry] Validator mismatch for ${name}`)
}
registry.set(name, game)
}
```
**Pros**:
- Single source of truth for validators
- Auto-derived GameName type
- Client and server use same validator
- Only one registration needed
**Cons**:
- Still requires manual import in validators.ts
- Doesn't solve "drop in a game" fully
---
### Solution 2: Code Generation
**Auto-generate validator registry from file system**:
```typescript
// scripts/generate-registry.ts
// Scans src/arcade-games/**/Validator.ts
// Generates validators.ts and game-registry imports
```
**Pros**:
- Truly modular - just add folder, run build
- No manual registration
- Auto-derived types
**Cons**:
- Build-time complexity
- Magic (harder to understand)
- May not work with all bundlers
---
### Solution 3: Split GameDefinition
**Separate client and server concerns**:
```typescript
// Isomorphic (client + server)
export interface GameValidatorDefinition {
name: string
validator: GameValidator
defaultConfig: GameConfig
}
// Client-only
export interface GameUIDefinition {
name: string
manifest: GameManifest
Provider: GameProviderComponent
GameComponent: GameComponent
}
// Combined (client-only)
export interface GameDefinition extends GameValidatorDefinition, GameUIDefinition {}
```
**Pros**:
- Clear separation of concerns
- Server can import just validator definition
- Type-safe
**Cons**:
- More complexity
- Still requires two registries
---
## Immediate Action Items
### Critical (Do Before Next Game)
1. **✅ Document the dual registration requirement** (COMPLETED)
- ✅ Update README with both registration steps
- ✅ Add troubleshooting section for "game not found" errors
- ✅ Document unified validator registry in Step 7
2. **✅ Unify validator registration** (COMPLETED 2025-10-15)
- ✅ Chose Solution 1 (Unified Server-Side Registry)
- ✅ Implemented unified registry (src/lib/arcade/validators.ts)
- ✅ Updated session-manager.ts and socket-server.ts
- ✅ Tested with number-guesser (no TypeScript errors)
3. **✅ Auto-derive GameName type** (COMPLETED 2025-10-15)
- ✅ Removed hard-coded union
- ✅ Derive from validator registry using `keyof typeof`
- ✅ Updated all usages (backwards compatible via re-exports)
### High Priority
4. **🟡 Migrate old games to new pattern**
- Move matching to `arcade-games/matching/`
- Move memory-quiz to `arcade-games/memory-quiz/`
- Update imports and tests
- OR document that old games use old pattern (transitional)
5. **🟡 Add validator registration validation**
- Runtime check in registerGame()
- Warn if validator missing
- Validate manifest schema
### Medium Priority
6. **🟢 Clean up type definitions**
- Consolidate GameValidator types
- Single source of truth for GameMove
- Clear documentation on which to use
7. **🟢 Update documentation**
- Add "dual registry" warning
- Update step-by-step guide
- Add troubleshooting for common mistakes
---
## Architectural Debt
### Technical Debt Accumulated
1. **Old validation system** (`validation/types.ts`, `validation/index.ts`)
- Used by server-side code
- Hard-coded game list
- No migration path documented
2. **Mixed game structures** (old in `app/arcade/`, new in `arcade-games/`)
- Confusing for developers
- Inconsistent imports
- Harder to maintain
3. **Type fragmentation** (3 GameValidator definitions)
- Unclear which to use
- Potential for bugs
- Harder to refactor
### Migration Path
**Option A: Big Bang** (Risky)
- Migrate all games to new structure in one PR
- Update server to use unified registry
- High risk of breakage
**Option B: Incremental** (Safer)
- Document dual registration as "current reality"
- Create unified validator registry (doesn't break old games)
- Slowly migrate old games one by one
- Eventually deprecate old validation system
**Recommendation**: Option B (Incremental)
---
## Compliance with Intentions
| Intention | Status | Notes |
|-----------|--------|-------|
| Modularity | ✅ Pass | Single registration in validators.ts + game-registry.ts |
| Self-registration | ✅ Pass | Both client and server use unified registry |
| Type safety | ✅ Pass | Good TypeScript coverage + auto-derived GameName |
| No core changes | ⚠️ Improved | Must edit validators.ts, but one central file |
| Drop-in games | ⚠️ Improved | Two registration points (validator + game def) |
| Stable SDK API | ✅ Pass | SDK is well-designed and consistent |
| Clear patterns | ⚠️ Partial | New pattern is clear, but old games don't follow it |
**Original Grade**: **D** (Failed core modularity requirement)
**Current Grade**: **B+** (Modularity achieved, some legacy migration pending)
---
## Positive Aspects (What Works Well)
1. **✅ SDK Design** - Clean, well-documented, type-safe
2. **✅ Client-Side Registry** - Simple, effective pattern
3. **✅ GameDefinition Structure** - Good separation of concerns
4. **✅ Documentation** - Comprehensive (though doesn't match reality)
5. **✅ defineGame() Helper** - Makes game creation easy
6. **✅ Type Safety** - Excellent TypeScript coverage
7. **✅ Number Guesser Example** - Good reference implementation
---
## Recommendations
### Immediate (This Sprint)
1.**Document current reality** - Update docs to show both registrations required
2. 🔴 **Create unified validator registry** - Implement Solution 1
3. 🔴 **Update server to use unified registry** - Modify session-manager.ts and socket-server.ts
### Next Sprint
4. 🟡 **Migrate one old game** - Move matching to new structure as proof of concept
5. 🟡 **Add registration validation** - Runtime checks for validator consistency
6. 🟡 **Auto-derive GameName** - Remove hard-coded type union
### Future
7. 🟢 **Code generation** - Explore automated registry generation
8. 🟢 **Plugin system** - True drop-in games with discovery
9. 🟢 **Deprecate old validation system** - Once all games migrated
---
## Conclusion
The modular game system has a **solid foundation** but is **not truly modular** due to server-side technical debt. The client-side implementation is excellent, but the server still uses a legacy hard-coded validation system.
**Status**: Needs significant refactoring before claiming "modular architecture"
**Path Forward**: Implement unified validator registry (Solution 1), then incrementally migrate old games.
**Risk**: If we add more games before fixing this, technical debt will compound.
---
*This audit was conducted by reviewing:*
- `src/lib/arcade/game-registry.ts`
- `src/lib/arcade/validation/index.ts`
- `src/lib/arcade/session-manager.ts`
- `src/socket-server.ts`
- `src/lib/arcade/game-sdk/`
- `src/arcade-games/number-guesser/`
- Documentation in `docs/` and `src/arcade-games/README.md`

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,502 @@
# Matching Pairs Battle - Migration to Modular Game System
**Status**: Planning Phase
**Target Version**: v4.2.0
**Created**: 2025-01-16
**Game Name**: `matching`
---
## Executive Summary
This document outlines the migration plan for **Matching Pairs Battle** (aka Memory Pairs Challenge) from the legacy dual-location architecture to the modern modular game system using the Game SDK.
**Key Complexity Factors**:
- **Dual Location**: Game exists in BOTH `/src/app/arcade/matching/` AND `/src/app/games/matching/`
- **Partial Migration**: RoomMemoryPairsProvider already uses `useArcadeSession` but not in modular format
- **Turn-Based Multiplayer**: More complex than memory-quiz (requires turn validation, player ownership)
- **Rich UI State**: Hover state, animations, mismatch feedback, pause/resume
- **Existing Tests**: Has playerMetadata test that must continue to pass
---
## Current File Structure Analysis
### Location 1: `/src/app/arcade/matching/`
**Components** (4 files):
- `components/GameCard.tsx`
- `components/PlayerStatusBar.tsx`
- `components/ResultsPhase.tsx`
- `components/SetupPhase.tsx`
- `components/EmojiPicker.tsx`
- `components/GamePhase.tsx`
- `components/MemoryPairsGame.tsx`
- `components/__tests__/EmojiPicker.test.tsx`
**Context** (4 files):
- `context/MemoryPairsContext.tsx` - Context definition and hook
- `context/LocalMemoryPairsProvider.tsx` - Local mode provider (DEPRECATED)
- `context/RoomMemoryPairsProvider.tsx` - Room mode provider (PARTIALLY MIGRATED)
- `context/types.ts` - Type definitions
- `context/index.ts` - Re-exports
- `context/__tests__/playerMetadata-userId.test.ts` - Test for player ownership
**Utils** (3 files):
- `utils/cardGeneration.ts` - Card generation logic
- `utils/gameScoring.ts` - Scoring calculations
- `utils/matchValidation.ts` - Match validation logic
**Page**:
- `page.tsx` - Route handler for `/arcade/matching`
### Location 2: `/src/app/games/matching/`
**Components** (6 files - DUPLICATES):
- `components/GameCard.tsx`
- `components/PlayerStatusBar.tsx`
- `components/ResultsPhase.tsx`
- `components/SetupPhase.tsx`
- `components/EmojiPicker.tsx`
- `components/GamePhase.tsx`
- `components/MemoryPairsGame.tsx`
- `components/__tests__/EmojiPicker.test.tsx`
- `components/PlayerStatusBar.stories.tsx` - Storybook story
**Context** (2 files):
- `context/MemoryPairsContext.tsx`
- `context/types.ts`
**Utils** (3 files - DUPLICATES):
- `utils/cardGeneration.ts`
- `utils/gameScoring.ts`
- `utils/matchValidation.ts`
**Page**:
- `page.tsx` - Route handler for `/games/matching` (legacy?)
### Shared Components
- `/src/components/matching/HoverAvatar.tsx` - Networked presence component
- `/src/components/matching/MemoryGrid.tsx` - Grid layout component
### Validator
- `/src/lib/arcade/validation/MatchingGameValidator.ts` - ✅ Already exists and comprehensive (570 lines)
### Configuration
- Already in `GAMES_CONFIG` as `'battle-arena'` (maps to internal name `'matching'`)
- Config type: `MatchingGameConfig` in `/src/lib/arcade/game-configs.ts`
---
## Migration Complexity Assessment
### Complexity: **HIGH** (8/10)
**Reasons**:
1. **Dual Locations**: Must consolidate two separate implementations
2. **Partial Migration**: RoomMemoryPairsProvider uses useArcadeSession but not in modular format
3. **Turn-Based Logic**: Player ownership validation, turn switching
4. **Rich State**: Hover state, animations, pause/resume, mismatch feedback
5. **Large Validator**: 570 lines (vs 350 for memory-quiz)
6. **More Components**: 7 components + 2 shared (vs 7 for memory-quiz)
7. **Tests**: Must maintain playerMetadata test coverage
**Similar To**: Memory Quiz migration (same pattern)
**Unique Challenges**:
- Consolidating duplicate files from two locations
- Deciding which version of duplicates is canonical
- Handling `/games/matching/` route (deprecate or redirect?)
- More complex multiplayer state (turn order, player ownership)
---
## Recommended Migration Approach
### Phase 1: Pre-Migration Audit ✅
**Goal**: Understand current state and identify discrepancies
**Tasks**:
- [x] Map all files in both locations
- [ ] Compare duplicate files to identify differences (e.g., `diff /src/app/arcade/matching/components/GameCard.tsx /src/app/games/matching/components/GameCard.tsx`)
- [ ] Identify which location is canonical (likely `/src/app/arcade/matching/` based on RoomProvider)
- [ ] Verify validator completeness (already done - looks comprehensive)
- [ ] Check for references to `/games/matching/` route
**Deliverables**:
- File comparison report
- Decision: Which duplicate files to keep
- List of files to delete
---
### Phase 2: Create Modular Game Definition
**Goal**: Define game in registry following SDK pattern
**Tasks**:
1. Create `/src/arcade-games/matching/index.ts` with `defineGame()`
2. Register in `/src/lib/arcade/game-registry.ts`
3. Update `/src/lib/arcade/validators.ts` to import from new location
4. Add type inference to `/src/lib/arcade/game-configs.ts`
**Template**:
```typescript
// /src/arcade-games/matching/index.ts
import type { GameManifest, GameConfig } from '@/lib/arcade/game-sdk/types'
import { defineGame } from '@/lib/arcade/game-sdk'
import { MatchingProvider } from './Provider'
import { MemoryPairsGame } from './components/MemoryPairsGame'
import { matchingGameValidator } from './Validator'
import { validateMatchingConfig } from './config-validation'
import type { MatchingConfig, MatchingState, MatchingMove } from './types'
const manifest: GameManifest = {
name: 'matching',
displayName: 'Matching Pairs Battle',
icon: '⚔️',
description: 'Multiplayer memory battle with friends',
longDescription: 'Battle friends in epic memory challenges. Match pairs faster than your opponents in this exciting multiplayer experience.',
maxPlayers: 4,
difficulty: 'Intermediate',
chips: ['👥 Multiplayer', '🎯 Strategic', '🏆 Competitive'],
color: 'purple',
gradient: 'linear-gradient(135deg, #e9d5ff, #ddd6fe)',
borderColor: 'purple.200',
available: true,
}
const defaultConfig: MatchingConfig = {
gameType: 'abacus-numeral',
difficulty: 6,
turnTimer: 30,
}
export const matchingGame = defineGame<MatchingConfig, MatchingState, MatchingMove>({
manifest,
Provider: MatchingProvider,
GameComponent: MemoryPairsGame,
validator: matchingGameValidator,
defaultConfig,
validateConfig: validateMatchingConfig,
})
```
**Files Modified**:
- `/src/arcade-games/matching/index.ts` (new)
- `/src/lib/arcade/game-registry.ts` (add import + register)
- `/src/lib/arcade/validators.ts` (update import path)
- `/src/lib/arcade/game-configs.ts` (add type inference)
---
### Phase 3: Move and Update Validator
**Goal**: Move validator to modular game directory
**Tasks**:
1. Move `/src/lib/arcade/validation/MatchingGameValidator.ts``/src/arcade-games/matching/Validator.ts`
2. Update imports to use local types from `./types` instead of importing from game-configs (avoid circular deps)
3. Verify all move types are handled
4. Check `getInitialState()` accepts all config fields
**Note**: Validator looks comprehensive already - likely minimal changes needed
**Files Modified**:
- `/src/arcade-games/matching/Validator.ts` (moved)
- Update imports in validator
---
### Phase 4: Consolidate and Move Types
**Goal**: Create SDK-compatible type definitions in modular location
**Tasks**:
1. Compare types from both locations:
- `/src/app/arcade/matching/context/types.ts`
- `/src/app/games/matching/context/types.ts`
2. Create `/src/arcade-games/matching/types.ts` with:
- `MatchingConfig extends GameConfig`
- `MatchingState` (from MemoryPairsState)
- `MatchingMove` union type (7 move types: FLIP_CARD, START_GAME, CLEAR_MISMATCH, GO_TO_SETUP, SET_CONFIG, RESUME_GAME, HOVER_CARD)
3. Ensure compatibility with validator expectations
4. Fix any `{}``Record<string, never>` warnings
**Move Types**:
```typescript
export interface MatchingConfig extends GameConfig {
gameType: 'abacus-numeral' | 'complement-pairs'
difficulty: 6 | 8 | 12 | 15
turnTimer: number
}
export interface MatchingState {
// Core game data
cards: GameCard[]
gameCards: GameCard[]
flippedCards: GameCard[]
// Config
gameType: 'abacus-numeral' | 'complement-pairs'
difficulty: 6 | 8 | 12 | 15
turnTimer: number
// Progression
gamePhase: 'setup' | 'playing' | 'results'
currentPlayer: string
matchedPairs: number
totalPairs: number
moves: number
scores: Record<string, number>
activePlayers: string[]
playerMetadata: Record<string, PlayerMetadata>
consecutiveMatches: Record<string, number>
// Timing
gameStartTime: number | null
gameEndTime: number | null
currentMoveStartTime: number | null
timerInterval: NodeJS.Timeout | null
// UI state
celebrationAnimations: CelebrationAnimation[]
isProcessingMove: boolean
showMismatchFeedback: boolean
lastMatchedPair: [string, string] | null
// Pause/Resume
originalConfig?: {
gameType: 'abacus-numeral' | 'complement-pairs'
difficulty: 6 | 8 | 12 | 15
turnTimer: number
}
pausedGamePhase?: 'setup' | 'playing' | 'results'
pausedGameState?: PausedGameState
// Hover state
playerHovers: Record<string, string | null>
}
export type MatchingMove =
| { type: 'FLIP_CARD'; playerId: string; userId: string; data: { cardId: string } }
| { type: 'START_GAME'; playerId: string; userId: string; data: { cards: GameCard[]; activePlayers: string[]; playerMetadata: Record<string, PlayerMetadata> } }
| { type: 'CLEAR_MISMATCH'; playerId: string; userId: string; data: Record<string, never> }
| { type: 'GO_TO_SETUP'; playerId: string; userId: string; data: Record<string, never> }
| { type: 'SET_CONFIG'; playerId: string; userId: string; data: { field: 'gameType' | 'difficulty' | 'turnTimer'; value: any } }
| { type: 'RESUME_GAME'; playerId: string; userId: string; data: Record<string, never> }
| { type: 'HOVER_CARD'; playerId: string; userId: string; data: { cardId: string | null } }
```
**Files Created**:
- `/src/arcade-games/matching/types.ts`
---
### Phase 5: Create Unified Provider
**Goal**: Convert RoomMemoryPairsProvider to modular Provider using SDK
**Tasks**:
1. Copy RoomMemoryPairsProvider as starting point (already uses useArcadeSession)
2. Create `/src/arcade-games/matching/Provider.tsx`
3. Remove dependency on MemoryPairsContext (will export its own hook)
4. Update imports to use local types
5. Ensure all action creators are present:
- `startGame`
- `flipCard`
- `resetGame`
- `setGameType`
- `setDifficulty`
- `setTurnTimer`
- `goToSetup`
- `resumeGame`
- `hoverCard`
6. Verify config persistence (nested under `gameConfig.matching`)
7. Export `useMatching` hook
**Key Changes**:
- Import types from `./types` not from context
- Export hook: `export function useMatching() { return useContext(MatchingContext) }`
- Ensure hooks called before early returns (React rules)
**Files Created**:
- `/src/arcade-games/matching/Provider.tsx`
---
### Phase 6: Consolidate and Move Components
**Goal**: Move components to modular location, choosing canonical versions
**Decision Process** (for each component):
1. If files are identical → pick either (prefer `/src/app/arcade/matching/`)
2. If files differ → manually merge, keeping best of both
3. Update imports to use new Provider: `from '@/arcade-games/matching/Provider'`
4. Fix styled-system import paths (4 levels: `../../../../styled-system/css`)
**Components to Move**:
- GameCard.tsx
- PlayerStatusBar.tsx
- ResultsPhase.tsx
- SetupPhase.tsx
- EmojiPicker.tsx
- GamePhase.tsx
- MemoryPairsGame.tsx
**Shared Components** (leave in place):
- `/src/components/matching/HoverAvatar.tsx`
- `/src/components/matching/MemoryGrid.tsx`
**Tests**:
- Move test to `/src/arcade-games/matching/components/__tests__/EmojiPicker.test.tsx`
**Files Created**:
- `/src/arcade-games/matching/components/*.tsx` (7 files)
- `/src/arcade-games/matching/components/__tests__/EmojiPicker.test.tsx`
---
### Phase 7: Move Utility Functions
**Goal**: Consolidate utils in modular location
**Tasks**:
1. Compare utils from both locations (likely identical)
2. Move to `/src/arcade-games/matching/utils/`
- `cardGeneration.ts`
- `gameScoring.ts`
- `matchValidation.ts`
3. Update imports in components and validator
**Files Created**:
- `/src/arcade-games/matching/utils/*.ts` (3 files)
---
### Phase 8: Update Routes and Clean Up
**Goal**: Update page routes and delete legacy files
**Tasks**:
**Route Updates**:
1. `/src/app/arcade/matching/page.tsx` - Replace with redirect to `/arcade` (local mode deprecated)
2. `/src/app/games/matching/page.tsx` - Replace with redirect to `/arcade` (legacy route)
3. Remove from `GAMES_CONFIG` in `/src/components/GameSelector.tsx`
4. Remove from `GAME_TYPE_TO_NAME` in `/src/app/arcade/room/page.tsx`
5. Update `/src/lib/arcade/validation/types.ts` imports (if referencing old types)
**Delete Legacy Files** (~30 files):
- `/src/app/arcade/matching/components/` (7 files + 1 test)
- `/src/app/arcade/matching/context/` (5 files + 1 test)
- `/src/app/arcade/matching/utils/` (3 files)
- `/src/app/games/matching/components/` (7 files + 1 test + 1 story)
- `/src/app/games/matching/context/` (2 files)
- `/src/app/games/matching/utils/` (3 files)
- `/src/lib/arcade/validation/MatchingGameValidator.ts` (moved)
**Files Modified**:
- `/src/app/arcade/matching/page.tsx` (redirect)
- `/src/app/games/matching/page.tsx` (redirect)
- `/src/components/GameSelector.tsx` (remove from GAMES_CONFIG)
- `/src/app/arcade/room/page.tsx` (remove from GAME_TYPE_TO_NAME)
---
## Testing Checklist
After migration, verify:
- [ ] Type checking passes (`npm run type-check`)
- [ ] Format/lint passes (`npm run pre-commit`)
- [ ] EmojiPicker test passes
- [ ] PlayerMetadata test passes
- [ ] Game loads in room mode
- [ ] Game selector shows one "Matching Pairs Battle" button
- [ ] Settings persist when changed in setup
- [ ] Turn-based gameplay works (only current player can flip)
- [ ] Card matching works (both abacus-numeral and complement-pairs)
- [ ] Pause/Resume works
- [ ] Hover state shows for other players
- [ ] Mismatch feedback displays correctly
- [ ] Results phase calculates scores correctly
---
## Migration Steps Summary
**8 Phases**:
1. ✅ Pre-Migration Audit - Compare duplicate files
2. ⏳ Create Modular Game Definition - Registry + types
3. ⏳ Move and Update Validator - Move to new location
4. ⏳ Consolidate and Move Types - SDK-compatible types
5. ⏳ Create Unified Provider - Room-only provider
6. ⏳ Consolidate and Move Components - Choose canonical versions
7. ⏳ Move Utility Functions - Consolidate utils
8. ⏳ Update Routes and Clean Up - Delete legacy files
**Estimated Effort**: 4-6 hours (larger than memory-quiz due to dual locations and more complexity)
---
## Key Differences from Memory Quiz Migration
1. **Dual Locations**: Must consolidate two separate implementations
2. **More Complex**: Turn-based multiplayer vs cooperative team play
3. **Partial Migration**: RoomProvider already uses useArcadeSession
4. **More Components**: 7 game components + 2 shared
5. **Existing Tests**: Must maintain test coverage
6. **Two Routes**: Both `/arcade/matching` and `/games/matching` exist
---
## Risks and Mitigation
### Risk 1: File Divergence
**Risk**: Duplicate files may have different features/fixes
**Mitigation**: Manually diff each duplicate pair, merge best of both
### Risk 2: Test Breakage
**Risk**: PlayerMetadata test may break during migration
**Mitigation**: Run tests frequently, update test if needed
### Risk 3: Turn Logic Complexity
**Risk**: Player ownership and turn validation is complex
**Mitigation**: Validator already handles this - trust existing logic
### Risk 4: Unknown Dependencies
**Risk**: Other parts of codebase may depend on `/games/matching/`
**Mitigation**: Search for imports before deletion: `grep -r "from.*games/matching" src/`
---
## Post-Migration Verification
After completing all phases:
1. Run full test suite
2. Manual testing:
- Create room
- Select "Matching Pairs Battle"
- Configure settings (verify persistence)
- Start game with multiple players
- Play several turns (verify turn order)
- Pause and resume
- Complete game (verify results)
3. Verify no duplicate game buttons
4. Check browser console for errors
5. Verify settings load correctly on page refresh
---
## References
- Memory Quiz Migration Plan: `docs/MEMORY_QUIZ_MIGRATION_PLAN.md`
- Game Migration Playbook: `docs/GAME_MIGRATION_PLAYBOOK.md`
- Game SDK Documentation: `.claude/GAME_SDK_DOCUMENTATION.md`
- Settings Persistence: `.claude/GAME_SETTINGS_PERSISTENCE.md`

View File

@@ -0,0 +1,676 @@
# 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`)
```typescript
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
```typescript
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
```typescript
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)
```yaml
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:
```typescript
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`:
```typescript
import { memoryQuizGame } from '@/arcade-games/memory-quiz'
registerGame(memoryQuizGame)
```
4. Update `validators.ts` to import from new location:
```typescript
import { memoryQuizValidator } from '@/arcade-games/memory-quiz/Validator'
```
5. Add type inference to `game-configs.ts`:
```typescript
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 `SorobanQuizState` → `MemoryQuizState`
2. Ensure `MemoryQuizState` extends `GameState` from SDK
3. Rename move types to match SDK patterns
4. Export proper config type
**Example**:
```typescript
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**:
```typescript
'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):
```typescript
'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
```diff
- dispatch({ type: 'SET_DIFFICULTY', difficulty: value })
+ setConfig('selectedDifficulty', value)
```
3. **DisplayPhase.tsx**: Update to use `nextCard` action
```diff
- dispatch({ type: 'NEXT_CARD' })
+ nextCard()
```
4. **InputPhase.tsx**: Update to use `acceptNumber`, `rejectNumber` actions
```diff
- dispatch({ type: 'ACCEPT_NUMBER', number })
+ acceptNumber(number)
```
5. **ResultsPhase.tsx**: Update to use `resetGame`, `showResults` actions
```diff
- 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`**:
```typescript
'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`

View File

@@ -0,0 +1,792 @@
# Arcade Game Architecture
> **Design Philosophy**: Modular, type-safe, multiplayer-first game development with real-time synchronization
---
## Table of Contents
- [Design Goals](#design-goals)
- [Architecture Overview](#architecture-overview)
- [Core Concepts](#core-concepts)
- [Implementation Details](#implementation-details)
- [Design Decisions](#design-decisions)
- [Lessons Learned](#lessons-learned)
- [Future Improvements](#future-improvements)
---
## Design Goals
### Primary Goals
1. **Modularity**
- Each game is a self-contained module
- Games can be added/removed without affecting the core system
- No tight coupling between games and infrastructure
2. **Type Safety**
- Full TypeScript support throughout the stack
- Compile-time validation of game definitions
- Type-safe move validation and state management
3. **Multiplayer-First**
- Real-time state synchronization via WebSocket
- Optimistic updates for instant feedback
- Server-authoritative validation to prevent cheating
4. **Developer Experience**
- Simple, intuitive API for game creators
- Minimal boilerplate
- Clear separation of concerns
- Comprehensive error messages
5. **Consistency**
- Shared navigation and UI components
- Standardized player management
- Common error handling patterns
- Unified room/lobby experience
### Non-Goals
- Supporting non-multiplayer games (use existing game routes for that)
- Backwards compatibility with old game implementations
- Supporting games outside the monorepo
---
## Architecture Overview
### System Layers
```
┌─────────────────────────────────────────────────────────────┐
│ Application Layer │
│ - GameSelector (game discovery) │
│ - Room management │
│ - Player management │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Registry Layer │
│ - Game registration │
│ - Game discovery (getGame, getAllGames) │
│ - Manifest validation │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ SDK Layer │
│ - Stable API surface │
│ - React hooks (useArcadeSession, etc.) │
│ - Type definitions │
│ - Utilities (buildPlayerMetadata, etc.) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Game Layer │
│ Individual games (number-guesser, math-sprint, etc.) │
│ Each game: Validator + Provider + Components + Types │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Infrastructure Layer │
│ - WebSocket (useArcadeSocket) │
│ - Optimistic state (useOptimisticGameState) │
│ - Database (room data, player data) │
└─────────────────────────────────────────────────────────────┘
```
### Data Flow: Move Execution
```
1. User clicks button
2. Provider calls sendMove()
3. useArcadeSession
├─→ Apply optimistically (instant UI update)
└─→ Send via WebSocket to server
4. Server validates move
├─→ VALID:
│ ├─→ Apply to server state
│ ├─→ Increment version
│ ├─→ Broadcast to all clients
│ └─→ Client: Remove from pending, confirm state
└─→ INVALID:
├─→ Send rejection message
└─→ Client: Rollback optimistic state, show error
```
---
## Core Concepts
### 1. Game Definition
A game is defined by five core pieces:
```typescript
interface GameDefinition<TConfig, TState, TMove> {
manifest: GameManifest // Display metadata
Provider: GameProviderComponent // React context provider
GameComponent: GameComponent // Main UI component
validator: GameValidator // Server validation logic
defaultConfig: TConfig // Default settings
}
```
**Why this structure?**
- `manifest`: Declarative metadata for discovery and UI
- `Provider`: Encapsulates all game logic and state management
- `GameComponent`: Pure UI component, no business logic
- `validator`: Server-authoritative validation prevents cheating
- `defaultConfig`: Sensible defaults, can be overridden per-room
### 2. Validator (Server-Side)
The validator is the **source of truth** for game logic.
```typescript
interface GameValidator<TState, TMove> {
validateMove(state: TState, move: TMove): ValidationResult
isGameComplete(state: TState): boolean
getInitialState(config: unknown): TState
}
```
**Key Principles:**
- **Pure functions**: No side effects, no I/O
- **Deterministic**: Same input → same output
- **Complete game logic**: All rules enforced here
- **Returns new state**: Immutable state updates
**Why server-side?**
- Prevents cheating (client can't fake moves)
- Single source of truth (no client/server divergence)
- Easier debugging (all logic in one place)
- Can add server-only features (analytics, anti-cheat)
### 3. Provider (Client-Side)
The provider manages client state and provides a clean API.
```typescript
interface GameContextValue {
state: GameState // Current game state
lastError: string | null // Last validation error
startGame: () => void // Action creators
makeMove: (data) => void // ...
clearError: () => void
exitSession: () => void
}
```
**Responsibilities:**
- Wrap `useArcadeSession` with game-specific actions
- Build player metadata from game mode context
- Provide clean, typed API to components
- Handle room config persistence
**Anti-Pattern:** Don't put game logic here. The provider is a **thin wrapper** around the SDK.
### 4. Optimistic Updates
The system uses **optimistic UI** for instant feedback:
1. User makes a move → UI updates immediately
2. Move sent to server for validation
3. Server validates:
- ✓ Valid → Confirm optimistic state
- ✗ Invalid → Rollback and show error
**Why optimistic updates?**
- Instant feedback (no perceived latency)
- Better UX for fast-paced games
- Handles network issues gracefully
**Tradeoff:**
- More complex state management
- Need rollback logic
- Potential for flashing/jumpy UI on rollback
**When NOT to use:**
- High-stakes actions (payments, permanent changes)
- Actions with irreversible side effects
- When server latency is acceptable
### 5. State Synchronization
State is synchronized across all clients in a room:
```
Client A makes move → Server validates → Broadcast to all clients
├─→ Client A: Confirm optimistic update
├─→ Client B: Apply server state
└─→ Client C: Apply server state
```
**Conflict Resolution:**
- Server state is **always authoritative**
- Version numbers prevent out-of-order updates
- Pending moves are reapplied after server sync
---
## Implementation Details
### SDK Design
The SDK provides a **stable API surface** that games import from:
```typescript
// ✅ GOOD: Import from SDK
import { useArcadeSession, type GameDefinition } from '@/lib/arcade/game-sdk'
// ❌ BAD: Import internal implementation
import { useArcadeSocket } from '@/hooks/useArcadeSocket'
```
**Why?**
- **Stability**: Internal APIs can change, SDK stays stable
- **Discoverability**: One place to find all game APIs
- **Encapsulation**: Hide implementation details
- **Documentation**: SDK is the "public API" to document
**SDK Exports:**
```typescript
// Types
export type { GameDefinition, GameValidator, GameState, GameMove, ... }
// React Hooks
export { useArcadeSession, useRoomData, useGameMode, useViewerId }
// Utilities
export { defineGame, buildPlayerMetadata, loadManifest }
```
### Registry Pattern
Games register themselves on module load:
```typescript
// game-registry.ts
const registry = new Map<string, GameDefinition>()
export function registerGame(game: GameDefinition) {
registry.set(game.manifest.name, game)
}
export function getGame(name: string) {
return registry.get(name)
}
// At bottom of file
import { numberGuesserGame } from '@/arcade-games/number-guesser'
registerGame(numberGuesserGame)
```
**Why self-registration?**
- No central "game list" to maintain
- Games are automatically discovered
- Import errors are caught at module load time
- Easy to enable/disable games (comment out registration)
**Alternative Considered:** Auto-discovery via file system
```typescript
// ❌ Rejected: Magic, fragile, breaks with bundlers
const games = import.meta.glob('../arcade-games/*/index.ts')
```
### Player Metadata
Player metadata is built from multiple sources:
```typescript
function buildPlayerMetadata(
playerIds: string[],
existingMetadata: Record<string, unknown>,
playerMap: Map<string, Player>,
viewerId?: string
): Record<string, PlayerMetadata>
```
**Sources:**
1. `playerIds`: Which players are active
2. `existingMetadata`: Carry over existing data (for reconnects)
3. `playerMap`: Player details (name, emoji, color, userId)
4. `viewerId`: Current user (for ownership checks)
**Why so complex?**
- Players can be local or remote (in rooms)
- Need to preserve data across state updates
- Must map player IDs to user IDs for permissions
- Support for guest players vs. authenticated users
### Move Validation Flow
```typescript
// 1. Client sends move
sendMove({
type: 'MAKE_GUESS',
playerId: 'player-123',
userId: 'user-456',
timestamp: Date.now(),
data: { guess: 42 }
})
// 2. Optimistic update (client-side)
const optimisticState = applyMove(currentState, move)
setOptimisticState(optimisticState)
// 3. Server validates
const result = validator.validateMove(serverState, move)
// 4a. Valid → Broadcast new state
if (result.valid) {
serverState = result.newState
version++
broadcastToAllClients({ gameState: serverState, version })
}
// 4b. Invalid → Send rejection
else {
sendToClient({ error: result.error, move })
}
// 5. Client handles response
// Valid: Confirm optimistic state, remove from pending
// Invalid: Rollback optimistic state, show error
```
**Key Points:**
- Optimistic update happens **before** server response
- Server is **authoritative** (client state can be overwritten)
- Version numbers prevent stale updates
- Rejected moves trigger error UI
---
## Design Decisions
### Decision: Server-Authoritative Validation
**Choice:** All game logic runs on server, client is "dumb"
**Rationale:**
- Prevents cheating (client can't manipulate state)
- Single source of truth (no client/server divergence)
- Easier testing (one codebase for game logic)
- Can add server-side features (analytics, matchmaking)
**Tradeoff:**
- Secure, consistent, easier to maintain
- Network latency affects UX (mitigated by optimistic updates)
- Can't play offline
**Alternative Considered:** Client-side validation + server verification
- Rejected: Duplicate logic, potential for divergence
### Decision: Optimistic Updates
**Choice:** Apply moves immediately, rollback on rejection
**Rationale:**
- Instant feedback (no perceived latency)
- Better UX for turn-based games
- Handles network issues gracefully
**Tradeoff:**
- Feels instant, smooth UX
- More complex state management
- Potential for jarring rollbacks
**When to disable:** High-stakes actions (payments, permanent bans)
### Decision: TypeScript Everywhere
**Choice:** Full TypeScript on client and server
**Rationale:**
- Compile-time validation catches bugs early
- Better IDE support (autocomplete, refactoring)
- Self-documenting code (types as documentation)
- Easier refactoring (compiler catches breakages)
**Tradeoff:**
- Fewer runtime errors, better DX
- Slower initial development (must define types)
- Learning curve for new developers
**Alternative Considered:** JavaScript with JSDoc
- Rejected: JSDoc is not type-safe, easy to drift
### Decision: React Context for State
**Choice:** Each game has a Provider that wraps game logic
**Rationale:**
- Natural React pattern
- Easy to compose (Provider wraps GameComponent)
- No prop drilling
- Easy to test (can provide mock context)
**Tradeoff:**
- Clean component APIs, easy to understand
- Can't use context outside React tree
- Re-renders if not memoized carefully
**Alternative Considered:** Zustand/Redux
- Rejected: Overkill for game-specific state, harder to isolate per-game
### Decision: Phase-Based UI
**Choice:** Each game has distinct phases (setup, playing, results)
**Rationale:**
- Clear separation of concerns
- Easy to understand game flow
- Each phase is independently testable
- Natural mapping to game states
**Tradeoff:**
- Organized, predictable
- Some duplication (multiple components)
- Can't have overlapping phases
**Pattern:**
```typescript
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'playing' && <PlayingPhase />}
{state.gamePhase === 'results' && <ResultsPhase />}
```
### Decision: Player Order from Set Iteration
**Choice:** Don't sort player arrays, use Set iteration order
**Rationale:**
- Set order is consistent within a session
- Matches UI display order (PageWithNav uses same Set)
- Avoids alphabetical bias (first player isn't always "AAA")
**Tradeoff:**
- UI and game logic always match
- Order is not predictable across sessions
- Different players see different orders (based on join time)
**Why not sort?**
- Creates mismatch: UI shows Set order, game uses sorted order
- Causes "skipping first player" bug (discovered in Number Guesser)
### Decision: No Optimistic Logic in Provider
**Choice:** Provider's `applyMove` just returns current state
```typescript
const { state, sendMove } = useArcadeSession({
applyMove: (state, move) => state // Don't apply, wait for server
})
```
**Rationale:**
- Keeps client logic minimal (less code to maintain)
- Prevents client/server logic divergence
- Server is authoritative (no client-side cheats)
**Tradeoff:**
- Simple, secure
- Slightly slower UX (wait for server)
**When to use client-side `applyMove`:**
- Very fast-paced games (60fps animations)
- Purely cosmetic updates (particles, sounds)
- Never for game logic (scoring, winning, etc.)
---
## Lessons Learned
### From Number Guesser Implementation
#### 1. Type Coercion is Critical
**Problem:** WebSocket/JSON serialization converts numbers to strings.
```typescript
// Client sends
sendMove({ data: { guess: 42 } })
// Server receives
move.data.guess === "42" // String! 😱
```
**Solution:** Explicit coercion in validator
```typescript
validateMove(state, move) {
case 'MAKE_GUESS':
return this.validateGuess(state, Number(move.data.guess))
}
```
**Lesson:** Always coerce types from `move.data` in validator.
**Symptom Observed:** User reported "first guess always rejected, second guess always correct" which was caused by:
- First guess: `"42" < 1` evaluates to `false` (string comparison)
- Validator thinks it's valid, calculates distance as `NaN`
- `NaN === 0` is false, so guess is "wrong"
- Second guess: `"50" < 1` also evaluates oddly, but `Math.abs("50" - 42)` coerces correctly
- The behavior was unpredictable due to mixed type coercion
**Root Cause:** String comparison operators (`<`, `>`) have weird behavior with string numbers.
#### 2. Player Ordering Must Be Consistent
**Problem:** Set iteration order differed from sorted order, causing "skipped player" bug.
**Root Cause:**
- UI used `Array.from(Set)` → Set iteration order
- Game used `Array.from(Set).sort()` → Alphabetical order
- Leftmost UI player ≠ First game player
**Solution:** Remove `.sort()` everywhere, use raw Set order.
**Lesson:** Player order must be identical in UI and game logic.
#### 3. Error Feedback is Essential
**Problem:** Moves rejected silently, users confused.
**Solution:** `lastError` state with auto-dismiss UI.
```typescript
const { lastError, clearError } = useArcadeSession()
{lastError && (
<ErrorBanner message={lastError} onDismiss={clearError} />
)}
```
**Lesson:** Always surface validation errors to users.
#### 4. Turn Indicators Improve UX
**Problem:** Players didn't know whose turn it was.
**Solution:** `currentPlayerId` prop to `PageWithNav`.
```typescript
<PageWithNav
currentPlayerId={state.currentPlayer}
playerScores={state.scores}
>
```
**Lesson:** Visual feedback for turn-based games is critical.
#### 5. Round vs. Game Completion
**Problem:** Validator checked `!state.winner` for next round, but winner is only set when game ends.
**Root Cause:** Confused "round complete" (someone guessed) with "game complete" (someone won).
**Solution:** Check if last guess was correct:
```typescript
const roundComplete = state.guesses.length > 0 &&
state.guesses[state.guesses.length - 1].distance === 0
```
**Lesson:** Be precise about what "complete" means (round vs. game).
#### 6. Debug Logging is Invaluable
**Problem:** Type issues caused subtle bugs (always correct guess).
**Solution:** Add logging in validator:
```typescript
console.log('[NumberGuesser] Validating guess:', {
guess,
guessType: typeof guess,
secretNumber: state.secretNumber,
secretNumberType: typeof state.secretNumber,
distance: Math.abs(guess - state.secretNumber)
})
```
**Lesson:** Log types and values during development.
---
## Future Improvements
### 1. Automated Testing
**Current State:** Manual testing only
**Proposal:**
- Unit tests for validators (pure functions, easy to test)
- Integration tests for Provider + useArcadeSession
- E2E tests for full game flows (Playwright)
**Example:**
```typescript
describe('NumberGuesserValidator', () => {
it('should reject out-of-bounds guess', () => {
const validator = new NumberGuesserValidator()
const state = { minNumber: 1, maxNumber: 100, ... }
const move = { type: 'MAKE_GUESS', data: { guess: 200 } }
const result = validator.validateMove(state, move)
expect(result.valid).toBe(false)
expect(result.error).toContain('must be between')
})
})
```
### 2. Move History / Replay
**Current State:** No move history
**Proposal:**
- Store all moves in database
- Allow "replay" of games
- Enable undo/redo (for certain games)
- Analytics on player behavior
**Schema:**
```typescript
interface GameSession {
id: string
roomId: string
gameType: string
moves: GameMove[]
finalState: GameState
startTime: number
endTime: number
}
```
### 3. Game Analytics
**Current State:** No analytics
**Proposal:**
- Track game completions, durations, winners
- Player skill ratings (Elo, TrueSkill)
- Popular games dashboard
- A/B testing for game variants
### 4. Spectator Mode
**Current State:** Only active players can view game
**Proposal:**
- Allow non-players to watch
- Spectators can't send moves (read-only)
- Show spectator count in room
**Implementation:**
```typescript
interface RoomMember {
userId: string
role: 'player' | 'spectator' | 'host'
}
```
### 5. Game Variants
**Current State:** One config per game
**Proposal:**
- Preset variants (Easy, Medium, Hard)
- Custom rules per room
- "House rules" feature
**Example:**
```typescript
const variants = {
beginner: { minNumber: 1, maxNumber: 20, roundsToWin: 1 },
standard: { minNumber: 1, maxNumber: 100, roundsToWin: 3 },
expert: { minNumber: 1, maxNumber: 1000, roundsToWin: 5 },
}
```
### 6. Tournaments / Brackets
**Current State:** Single-room games only
**Proposal:**
- Multi-round tournaments
- Bracket generation
- Leaderboards
### 7. Game Mod Support
**Current State:** Games are hard-coded
**Proposal:**
- Load games from external bundles
- Community-created games
- Sandboxed execution (Deno, WASM)
**Challenges:**
- Security (untrusted code)
- Type safety (dynamic loading)
- Versioning (breaking changes)
### 8. Voice/Video Chat
**Current State:** Text chat only (if implemented)
**Proposal:**
- WebRTC voice/video
- Per-room channels
- Mute/kick controls
---
## Appendix: Key Files Reference
| Path | Purpose |
|------|---------|
| `src/lib/arcade/game-sdk/index.ts` | SDK exports (public API) |
| `src/lib/arcade/game-registry.ts` | Game registration |
| `src/lib/arcade/manifest-schema.ts` | Manifest validation |
| `src/hooks/useArcadeSession.ts` | Session management hook |
| `src/hooks/useArcadeSocket.ts` | WebSocket connection |
| `src/hooks/useOptimisticGameState.ts` | Optimistic state management |
| `src/contexts/GameModeContext.tsx` | Player management |
| `src/components/PageWithNav.tsx` | Game navigation wrapper |
| `src/arcade-games/number-guesser/` | Example game implementation |
---
## Related Documentation
- [Game Development Guide](../arcade-games/README.md) - Step-by-step guide to creating games
- [API Reference](./arcade-game-api-reference.md) - Complete SDK API documentation (TODO)
- [Deployment Guide](./arcade-game-deployment.md) - How to deploy new games (TODO)
---
*Last Updated: 2025-10-15*

View File

@@ -0,0 +1,33 @@
-- Create room_game_configs table for normalized game settings storage
-- This migration is safe to run multiple times (uses IF NOT EXISTS)
-- Create the table
CREATE TABLE IF NOT EXISTS `room_game_configs` (
`id` text PRIMARY KEY NOT NULL,
`room_id` text NOT NULL,
`game_name` text NOT NULL,
`config` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`room_id`) REFERENCES `arcade_rooms`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
-- Create unique index
CREATE UNIQUE INDEX IF NOT EXISTS `room_game_idx` ON `room_game_configs` (`room_id`,`game_name`);
--> statement-breakpoint
-- Migrate existing game configs from arcade_rooms.game_config column
-- This INSERT will only run if data hasn't been migrated yet
INSERT OR IGNORE INTO room_game_configs (id, room_id, game_name, config, created_at, updated_at)
SELECT
lower(hex(randomblob(16))) as id,
id as room_id,
game_name,
game_config as config,
created_at,
last_activity as updated_at
FROM arcade_rooms
WHERE game_config IS NOT NULL
AND game_name IS NOT NULL
AND game_name IN ('matching', 'memory-quiz', 'complement-race');

View File

@@ -78,6 +78,13 @@
"when": 1760700000000,
"tag": "0010_make_game_name_nullable",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1760800000000,
"tag": "0011_add_room_game_configs",
"breakpoints": true
}
]
}

View File

@@ -59,6 +59,7 @@
"drizzle-orm": "^0.44.6",
"emojibase-data": "^16.0.3",
"jose": "^6.1.0",
"js-yaml": "^4.1.0",
"lucide-react": "^0.294.0",
"make-plural": "^7.4.0",
"nanoid": "^5.1.6",
@@ -69,7 +70,8 @@
"react-dom": "^18.2.0",
"react-resizable-layout": "^0.7.3",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1"
"socket.io-client": "^4.8.1",
"zod": "^4.1.12"
},
"devDependencies": {
"@playwright/test": "^1.55.1",
@@ -80,6 +82,7 @@
"@testing-library/react": "^16.3.0",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",

View File

@@ -7,6 +7,9 @@ import { recordRoomMemberHistory } from '@/lib/arcade/room-member-history'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getSocketIO } from '@/lib/socket-io'
import { getViewerId } from '@/lib/viewer'
import { getAllGameConfigs, setGameConfig } from '@/lib/arcade/game-config-helpers'
import { isValidGameName } from '@/lib/arcade/validators'
import type { GameName } from '@/lib/arcade/validators'
type RouteContext = {
params: Promise<{ roomId: string }>
@@ -18,8 +21,11 @@ type RouteContext = {
* Body:
* - accessMode?: 'open' | 'locked' | 'retired' | 'password' | 'restricted' | 'approval-only'
* - password?: string (plain text, will be hashed)
* - gameName?: 'matching' | 'memory-quiz' | 'complement-race' | null (select game for room)
* - gameName?: string | null (any game with a registered validator)
* - gameConfig?: object (game-specific settings)
*
* Note: gameName is validated at runtime against the validator registry.
* No need to update this file when adding new games!
*/
export async function PATCH(req: NextRequest, context: RouteContext) {
try {
@@ -27,6 +33,36 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
const viewerId = await getViewerId()
const body = await req.json()
console.log(
'[Settings API] PATCH request received:',
JSON.stringify(
{
roomId,
body,
},
null,
2
)
)
// Read current room state from database BEFORE any changes
const [currentRoom] = await db
.select()
.from(schema.arcadeRooms)
.where(eq(schema.arcadeRooms.id, roomId))
console.log(
'[Settings API] Current room state in database BEFORE update:',
JSON.stringify(
{
gameName: currentRoom?.gameName,
gameConfig: currentRoom?.gameConfig,
},
null,
2
)
)
// Check if user is the host
const members = await getRoomMembers(roomId)
const currentMember = members.find((m) => m.userId === viewerId)
@@ -60,11 +96,15 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
)
}
// Validate gameName if provided
// Validate gameName if provided - check against validator registry at runtime
if (body.gameName !== undefined && body.gameName !== null) {
const validGames = ['matching', 'memory-quiz', 'complement-race']
if (!validGames.includes(body.gameName)) {
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
if (!isValidGameName(body.gameName)) {
return NextResponse.json(
{
error: `Invalid game name: ${body.gameName}. Game must have a registered validator.`,
},
{ status: 400 }
)
}
}
@@ -92,11 +132,23 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
updateData.gameName = body.gameName
}
// Update game config if provided
if (body.gameConfig !== undefined) {
updateData.gameConfig = body.gameConfig
// Handle game config updates - write to new room_game_configs table
if (body.gameConfig !== undefined && body.gameConfig !== null) {
// body.gameConfig is expected to be nested by game name: { matching: {...}, memory-quiz: {...} }
// Extract each game's config and write to the new table
for (const [gameName, config] of Object.entries(body.gameConfig)) {
if (config && typeof config === 'object') {
await setGameConfig(roomId, gameName as GameName, config)
console.log(`[Settings API] Wrote ${gameName} config to room_game_configs table`)
}
}
}
console.log(
'[Settings API] Update data to be written to database:',
JSON.stringify(updateData, null, 2)
)
// If game is being changed (or cleared), delete the existing arcade session
// This ensures a fresh session will be created with the new game settings
if (body.gameName !== undefined) {
@@ -104,12 +156,30 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, roomId))
}
// Update room settings
const [updatedRoom] = await db
.update(schema.arcadeRooms)
.set(updateData)
.where(eq(schema.arcadeRooms.id, roomId))
.returning()
// Update room settings (only if there's something to update)
let updatedRoom = currentRoom
if (Object.keys(updateData).length > 0) {
;[updatedRoom] = await db
.update(schema.arcadeRooms)
.set(updateData)
.where(eq(schema.arcadeRooms.id, roomId))
.returning()
}
// Get aggregated game configs from new table
const gameConfig = await getAllGameConfigs(roomId)
console.log(
'[Settings API] Room state in database AFTER update:',
JSON.stringify(
{
gameName: updatedRoom.gameName,
gameConfig,
},
null,
2
)
)
// Broadcast game change to all room members
if (body.gameName !== undefined) {
@@ -117,11 +187,17 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
if (io) {
try {
console.log(`[Settings API] Broadcasting game change to room ${roomId}: ${body.gameName}`)
io.to(`room:${roomId}`).emit('room-game-changed', {
const broadcastData: {
roomId: string
gameName: string | null
gameConfig?: Record<string, unknown>
} = {
roomId,
gameName: body.gameName,
gameConfig: body.gameConfig || {},
})
gameConfig, // Include aggregated configs from new table
}
io.to(`room:${roomId}`).emit('room-game-changed', broadcastData)
} catch (socketError) {
console.error('[Settings API] Failed to broadcast game change:', socketError)
}
@@ -194,7 +270,15 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
}
}
return NextResponse.json({ room: updatedRoom }, { status: 200 })
return NextResponse.json(
{
room: {
...updatedRoom,
gameConfig, // Include aggregated configs from new table
},
},
{ status: 200 }
)
} catch (error: any) {
console.error('Failed to update room settings:', error)
return NextResponse.json({ error: 'Failed to update room settings' }, { status: 500 })

View File

@@ -4,6 +4,7 @@ import { getRoomById } from '@/lib/arcade/room-manager'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { getViewerId } from '@/lib/viewer'
import { getAllGameConfigs } from '@/lib/arcade/game-config-helpers'
/**
* GET /api/arcade/rooms/current
@@ -28,6 +29,22 @@ export async function GET() {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
// Get game configs from new room_game_configs table
const gameConfig = await getAllGameConfigs(roomId)
console.log(
'[Current Room API] Room data READ from database:',
JSON.stringify(
{
roomId,
gameName: room.gameName,
gameConfig,
},
null,
2
)
)
// Get members
const members = await getRoomMembers(roomId)
@@ -41,7 +58,10 @@ export async function GET() {
}
return NextResponse.json({
room,
room: {
...room,
gameConfig, // Override with configs from new table
},
members,
memberPlayers: memberPlayersObj,
})

View File

@@ -3,7 +3,7 @@ import { createRoom, listActiveRooms } from '@/lib/arcade/room-manager'
import { addRoomMember, getRoomMembers, isMember } from '@/lib/arcade/room-membership'
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { getViewerId } from '@/lib/viewer'
import type { GameName } from '@/lib/arcade/validation'
import { hasValidator, type GameName } from '@/lib/arcade/validators'
/**
* GET /api/arcade/rooms
@@ -72,8 +72,7 @@ export async function POST(req: NextRequest) {
// Validate game name if provided (gameName is now optional)
if (body.gameName) {
const validGames: GameName[] = ['matching', 'memory-quiz', 'complement-race']
if (!validGames.includes(body.gameName)) {
if (!hasValidator(body.gameName)) {
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
}
}

View File

@@ -2,7 +2,7 @@
import { type ReactNode, useCallback, useEffect, useMemo } from 'react'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useRoomData } from '@/hooks/useRoomData'
import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import {
buildPlayerMetadata as buildPlayerMetadataUtil,
@@ -240,6 +240,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData() // Fetch room data for room-based play
const { activePlayerCount, activePlayers: activePlayerIds, players } = useGameMode()
const { mutate: updateGameConfig } = useUpdateGameConfig()
// Get active player IDs directly as strings (UUIDs)
const activePlayers = Array.from(activePlayerIds)
@@ -247,8 +248,77 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
// Derive game mode from active player count
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
// NO LOCAL STATE - Configuration lives in session state
// Changes are sent as moves and synchronized across all room members
// Track roomData.gameConfig changes
useEffect(() => {
console.log(
'[RoomMemoryPairsProvider] roomData.gameConfig changed:',
JSON.stringify(
{
gameConfig: roomData?.gameConfig,
roomId: roomData?.id,
gameName: roomData?.gameName,
},
null,
2
)
)
}, [roomData?.gameConfig, roomData?.id, roomData?.gameName])
// Merge saved game config from room with initialState
// Settings are scoped by game name to preserve settings when switching games
const mergedInitialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, any> | null | undefined
console.log(
'[RoomMemoryPairsProvider] Loading settings from database:',
JSON.stringify(
{
gameConfig,
roomId: roomData?.id,
},
null,
2
)
)
if (!gameConfig) {
console.log('[RoomMemoryPairsProvider] No gameConfig, using initialState')
return initialState
}
// Get settings for this specific game (matching)
const savedConfig = gameConfig.matching as Record<string, any> | null | undefined
console.log(
'[RoomMemoryPairsProvider] Saved config for matching:',
JSON.stringify(savedConfig, null, 2)
)
if (!savedConfig) {
console.log('[RoomMemoryPairsProvider] No saved config for matching, using initialState')
return initialState
}
const merged = {
...initialState,
// Restore settings from saved config
gameType: savedConfig.gameType ?? initialState.gameType,
difficulty: savedConfig.difficulty ?? initialState.difficulty,
turnTimer: savedConfig.turnTimer ?? initialState.turnTimer,
}
console.log(
'[RoomMemoryPairsProvider] Merged state:',
JSON.stringify(
{
gameType: merged.gameType,
difficulty: merged.difficulty,
turnTimer: merged.turnTimer,
},
null,
2
)
)
return merged
}, [roomData?.gameConfig])
// Arcade session integration WITH room sync
const {
@@ -259,7 +329,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
} = useArcadeSession<MemoryPairsState>({
userId: viewerId || '',
roomId: roomData?.id, // CRITICAL: Pass roomId for network sync across room members
initialState,
initialState: mergedInitialState,
applyMove: applyMoveOptimistically,
})
@@ -498,6 +568,8 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
const setGameType = useCallback(
(gameType: typeof state.gameType) => {
console.log('[RoomMemoryPairsProvider] setGameType called:', gameType)
// Use first active player as playerId, or empty string if none
const playerId = activePlayers[0] || ''
sendMove({
@@ -506,12 +578,45 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
userId: viewerId || '',
data: { field: 'gameType', value: gameType },
})
// Save setting to room's gameConfig for persistence
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
const currentMatchingConfig = (currentGameConfig.matching as Record<string, any>) || {}
const updatedConfig = {
...currentGameConfig,
matching: {
...currentMatchingConfig,
gameType,
},
}
console.log(
'[RoomMemoryPairsProvider] Saving gameType to database:',
JSON.stringify(
{
roomId: roomData.id,
updatedConfig,
},
null,
2
)
)
updateGameConfig({
roomId: roomData.id,
gameConfig: updatedConfig,
})
} else {
console.warn('[RoomMemoryPairsProvider] Cannot save gameType - no roomData.id')
}
},
[activePlayers, sendMove, viewerId]
[activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig]
)
const setDifficulty = useCallback(
(difficulty: typeof state.difficulty) => {
console.log('[RoomMemoryPairsProvider] setDifficulty called:', difficulty)
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
@@ -519,12 +624,45 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
userId: viewerId || '',
data: { field: 'difficulty', value: difficulty },
})
// Save setting to room's gameConfig for persistence
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
const currentMatchingConfig = (currentGameConfig.matching as Record<string, any>) || {}
const updatedConfig = {
...currentGameConfig,
matching: {
...currentMatchingConfig,
difficulty,
},
}
console.log(
'[RoomMemoryPairsProvider] Saving difficulty to database:',
JSON.stringify(
{
roomId: roomData.id,
updatedConfig,
},
null,
2
)
)
updateGameConfig({
roomId: roomData.id,
gameConfig: updatedConfig,
})
} else {
console.warn('[RoomMemoryPairsProvider] Cannot save difficulty - no roomData.id')
}
},
[activePlayers, sendMove, viewerId]
[activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig]
)
const setTurnTimer = useCallback(
(turnTimer: typeof state.turnTimer) => {
console.log('[RoomMemoryPairsProvider] setTurnTimer called:', turnTimer)
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
@@ -532,8 +670,39 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
userId: viewerId || '',
data: { field: 'turnTimer', value: turnTimer },
})
// Save setting to room's gameConfig for persistence
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
const currentMatchingConfig = (currentGameConfig.matching as Record<string, any>) || {}
const updatedConfig = {
...currentGameConfig,
matching: {
...currentMatchingConfig,
turnTimer,
},
}
console.log(
'[RoomMemoryPairsProvider] Saving turnTimer to database:',
JSON.stringify(
{
roomId: roomData.id,
updatedConfig,
},
null,
2
)
)
updateGameConfig({
roomId: roomData.id,
gameConfig: updatedConfig,
})
} else {
console.warn('[RoomMemoryPairsProvider] Cannot save turnTimer - no roomData.id')
}
},
[activePlayers, sendMove, viewerId]
[activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig]
)
const goToSetup = useCallback(() => {

View File

@@ -1,113 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useReducer } from 'react'
import { useRouter } from 'next/navigation'
import { initialState, quizReducer } from '../reducer'
import type { QuizCard } from '../types'
import { MemoryQuizContext, type MemoryQuizContextValue } from './MemoryQuizContext'
interface LocalMemoryQuizProviderProps {
children: ReactNode
}
/**
* LocalMemoryQuizProvider - Provides context for single-player local mode
*
* This provider wraps the memory quiz reducer and provides action creators
* to child components. It's used for standalone local play (non-room mode).
*
* Action creators wrap dispatch calls to maintain same interface as RoomProvider.
*/
export function LocalMemoryQuizProvider({ children }: LocalMemoryQuizProviderProps) {
const router = useRouter()
const [state, dispatch] = useReducer(quizReducer, initialState)
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
if (state.prefixAcceptanceTimeout) {
clearTimeout(state.prefixAcceptanceTimeout)
}
}
}, [state.prefixAcceptanceTimeout])
// Computed values
const isGameActive = state.gamePhase === 'display' || state.gamePhase === 'input'
// Action creators - wrap dispatch calls to match RoomProvider interface
const startQuiz = useCallback((quizCards: QuizCard[]) => {
dispatch({ type: 'START_QUIZ', quizCards })
}, [])
const nextCard = useCallback(() => {
dispatch({ type: 'NEXT_CARD' })
}, [])
const showInputPhase = useCallback(() => {
dispatch({ type: 'SHOW_INPUT_PHASE' })
}, [])
const acceptNumber = useCallback((number: number) => {
dispatch({ type: 'ACCEPT_NUMBER', number })
}, [])
const rejectNumber = useCallback(() => {
dispatch({ type: 'REJECT_NUMBER' })
}, [])
const setInput = useCallback((input: string) => {
dispatch({ type: 'SET_INPUT', input })
}, [])
const showResults = useCallback(() => {
dispatch({ type: 'SHOW_RESULTS' })
}, [])
const resetGame = useCallback(() => {
dispatch({ type: 'RESET_QUIZ' })
}, [])
const setConfig = useCallback(
(field: 'selectedCount' | 'displayTime' | 'selectedDifficulty', value: any) => {
switch (field) {
case 'selectedCount':
dispatch({ type: 'SET_SELECTED_COUNT', count: value })
break
case 'displayTime':
dispatch({ type: 'SET_DISPLAY_TIME', time: value })
break
case 'selectedDifficulty':
dispatch({ type: 'SET_DIFFICULTY', difficulty: value })
break
}
},
[]
)
const exitSession = useCallback(() => {
router.push('/games')
}, [router])
const contextValue: MemoryQuizContextValue = {
state,
dispatch: () => {
// No-op - local provider uses action creators instead
console.warn('dispatch() is not available in local mode, use action creators instead')
},
isGameActive,
resetGame,
exitSession,
// Expose action creators for components to use
startQuiz,
nextCard,
showInputPhase,
acceptNumber,
rejectNumber,
setInput,
showResults,
setConfig,
}
return <MemoryQuizContext.Provider value={contextValue}>{children}</MemoryQuizContext.Provider>
}

View File

@@ -1,45 +0,0 @@
'use client'
import { createContext, useContext } from 'react'
import type { QuizAction, QuizCard, SorobanQuizState } from '../types'
// Context value interface
export interface MemoryQuizContextValue {
state: SorobanQuizState
dispatch: React.Dispatch<QuizAction>
// Computed values
isGameActive: boolean
isRoomCreator?: boolean // True if current user is room creator (controls timing in multiplayer)
// Action creators (to be implemented by providers)
// Local mode uses dispatch, room mode uses these action creators
startGame?: () => void
resetGame?: () => void
exitSession?: () => void
// Room mode action creators (optional for local mode)
startQuiz?: (quizCards: QuizCard[]) => void
nextCard?: () => void
showInputPhase?: () => void
acceptNumber?: (number: number) => void
rejectNumber?: () => void
setInput?: (input: string) => void
showResults?: () => void
setConfig?: (
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode',
value: any
) => void
}
// Create context
export const MemoryQuizContext = createContext<MemoryQuizContextValue | null>(null)
// Hook to use the context
export function useMemoryQuiz(): MemoryQuizContextValue {
const context = useContext(MemoryQuizContext)
if (!context) {
throw new Error('useMemoryQuiz must be used within a MemoryQuizProvider')
}
return context
}

View File

@@ -1,601 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useGameMode } from '@/contexts/GameModeContext'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import type { GameMove } from '@/lib/arcade/validation'
import { TEAM_MOVE } from '@/lib/arcade/validation/types'
import {
buildPlayerMetadata as buildPlayerMetadataUtil,
buildPlayerOwnershipFromRoomData,
} from '@/lib/arcade/player-ownership.client'
import { initialState } from '../reducer'
import type { QuizCard, SorobanQuizState } from '../types'
import { MemoryQuizContext, type MemoryQuizContextValue } from './MemoryQuizContext'
/**
* Optimistic move application (client-side prediction)
* The server will validate and send back the authoritative state
*/
function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): SorobanQuizState {
switch (move.type) {
case 'START_QUIZ': {
// Handle both client-generated moves (with quizCards) and server-generated moves (with numbers only)
// Server can't serialize React components, so it only sends numbers
const clientQuizCards = move.data.quizCards
const serverNumbers = move.data.numbers
let quizCards: QuizCard[]
let correctAnswers: number[]
if (clientQuizCards) {
// Client-side optimistic update: use the full quizCards with React components
quizCards = clientQuizCards
correctAnswers = clientQuizCards.map((card: QuizCard) => card.number)
} else if (serverNumbers) {
// Server update: create minimal quizCards from numbers (no React components needed for validation)
quizCards = serverNumbers.map((number: number) => ({
number,
svgComponent: null,
element: null,
}))
correctAnswers = serverNumbers
} else {
// Fallback: preserve existing state
quizCards = state.quizCards
correctAnswers = state.correctAnswers
}
const cardCount = quizCards.length
// Initialize player scores for all active players (by userId, not playerId)
const activePlayers = move.data.activePlayers || []
const playerMetadata = move.data.playerMetadata || {}
console.log('🎯 [START_QUIZ] Initializing player scores:', {
activePlayers,
playerMetadata,
})
// Extract unique userIds from playerMetadata
const uniqueUserIds = new Set<string>()
for (const playerId of activePlayers) {
const metadata = playerMetadata[playerId]
console.log('🎯 [START_QUIZ] Processing player:', {
playerId,
metadata,
hasUserId: !!metadata?.userId,
})
if (metadata?.userId) {
uniqueUserIds.add(metadata.userId)
}
}
console.log('🎯 [START_QUIZ] Unique userIds found:', Array.from(uniqueUserIds))
// Initialize scores for each userId
const playerScores = Array.from(uniqueUserIds).reduce((acc: any, userId: string) => {
acc[userId] = { correct: 0, incorrect: 0 }
return acc
}, {})
console.log('🎯 [START_QUIZ] Initialized playerScores:', playerScores)
return {
...state,
quizCards,
correctAnswers,
currentCardIndex: 0,
foundNumbers: [],
guessesRemaining: cardCount + Math.floor(cardCount / 2),
gamePhase: 'display',
incorrectGuesses: 0,
currentInput: '',
wrongGuessAnimations: [],
prefixAcceptanceTimeout: null,
// Multiplayer state
activePlayers,
playerMetadata,
playerScores,
}
}
case 'NEXT_CARD':
return {
...state,
currentCardIndex: state.currentCardIndex + 1,
}
case 'SHOW_INPUT_PHASE':
return {
...state,
gamePhase: 'input',
}
case 'ACCEPT_NUMBER': {
// Track scores by userId (not playerId) since we can't determine which player typed
// Defensive check: ensure state properties exist
const playerScores = state.playerScores || {}
const foundNumbers = state.foundNumbers || []
const numberFoundBy = state.numberFoundBy || {}
console.log('✅ [ACCEPT_NUMBER] Before update:', {
moveUserId: move.userId,
currentPlayerScores: playerScores,
number: move.data.number,
})
const newPlayerScores = { ...playerScores }
const newNumberFoundBy = { ...numberFoundBy }
if (move.userId) {
const currentScore = newPlayerScores[move.userId] || { correct: 0, incorrect: 0 }
newPlayerScores[move.userId] = {
...currentScore,
correct: currentScore.correct + 1,
}
// Track who found this number
newNumberFoundBy[move.data.number] = move.userId
console.log('✅ [ACCEPT_NUMBER] After update:', {
userId: move.userId,
newScore: newPlayerScores[move.userId],
allScores: newPlayerScores,
numberFoundBy: move.data.number,
})
} else {
console.warn('⚠️ [ACCEPT_NUMBER] No userId in move!')
}
return {
...state,
foundNumbers: [...foundNumbers, move.data.number],
playerScores: newPlayerScores,
numberFoundBy: newNumberFoundBy,
}
}
case 'REJECT_NUMBER': {
// Track scores by userId (not playerId) since we can't determine which player typed
// Defensive check: ensure state properties exist
const playerScores = state.playerScores || {}
console.log('❌ [REJECT_NUMBER] Before update:', {
moveUserId: move.userId,
currentPlayerScores: playerScores,
})
const newPlayerScores = { ...playerScores }
if (move.userId) {
const currentScore = newPlayerScores[move.userId] || { correct: 0, incorrect: 0 }
newPlayerScores[move.userId] = {
...currentScore,
incorrect: currentScore.incorrect + 1,
}
console.log('❌ [REJECT_NUMBER] After update:', {
userId: move.userId,
newScore: newPlayerScores[move.userId],
allScores: newPlayerScores,
})
} else {
console.warn('⚠️ [REJECT_NUMBER] No userId in move!')
}
return {
...state,
guessesRemaining: state.guessesRemaining - 1,
incorrectGuesses: state.incorrectGuesses + 1,
playerScores: newPlayerScores,
}
}
case 'SHOW_RESULTS':
return {
...state,
gamePhase: 'results',
}
case 'RESET_QUIZ':
return {
...state,
gamePhase: 'setup',
quizCards: [],
correctAnswers: [],
currentCardIndex: 0,
foundNumbers: [],
guessesRemaining: 0,
currentInput: '',
incorrectGuesses: 0,
wrongGuessAnimations: [],
prefixAcceptanceTimeout: null,
finishButtonsBound: false,
}
case 'SET_CONFIG': {
const { field, value } = move.data as {
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode'
value: any
}
return {
...state,
[field]: value,
}
}
default:
return state
}
}
/**
* RoomMemoryQuizProvider - Provides context for room-based multiplayer mode
*
* This provider uses useArcadeSession for network-synchronized gameplay.
* All state changes are sent as moves and validated on the server.
*/
export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData()
const { activePlayers: activePlayerIds, players } = useGameMode()
const { mutate: updateGameConfig } = useUpdateGameConfig()
// Get active player IDs as array
const activePlayers = Array.from(activePlayerIds)
// LOCAL-ONLY state for current input (not synced over network)
// This prevents sending a network request for every keystroke
const [localCurrentInput, setLocalCurrentInput] = useState('')
// Merge saved game config from room with initialState
// Settings are scoped by game name to preserve settings when switching games
const mergedInitialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, any> | null | undefined
if (!gameConfig) return initialState
// Get settings for this specific game (memory-quiz)
const savedConfig = gameConfig['memory-quiz'] as Record<string, any> | null | undefined
if (!savedConfig) return initialState
console.log('[RoomMemoryQuizProvider] Loading saved game config for memory-quiz:', savedConfig)
return {
...initialState,
// Restore settings from saved config
selectedCount: savedConfig.selectedCount ?? initialState.selectedCount,
displayTime: savedConfig.displayTime ?? initialState.displayTime,
selectedDifficulty: savedConfig.selectedDifficulty ?? initialState.selectedDifficulty,
playMode: savedConfig.playMode ?? initialState.playMode,
}
}, [roomData?.gameConfig])
// Arcade session integration WITH room sync
const {
state,
sendMove,
connected: _connected,
exitSession,
} = useArcadeSession<SorobanQuizState>({
userId: viewerId || '',
roomId: roomData?.id, // CRITICAL: Pass roomId for network sync across room members
initialState: mergedInitialState,
applyMove: applyMoveOptimistically,
})
// Clear local input when game phase changes or when game resets
useEffect(() => {
if (state.gamePhase !== 'input') {
setLocalCurrentInput('')
}
}, [state.gamePhase])
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
if (state.prefixAcceptanceTimeout) {
clearTimeout(state.prefixAcceptanceTimeout)
}
}
}, [state.prefixAcceptanceTimeout])
// Detect state corruption/mismatch (e.g., game type mismatch between sessions)
const hasStateCorruption =
!state.quizCards ||
!state.correctAnswers ||
!state.foundNumbers ||
!Array.isArray(state.quizCards)
// Computed values
const isGameActive = state.gamePhase === 'display' || state.gamePhase === 'input'
// Build player metadata from room data and player map
const buildPlayerMetadata = useCallback(() => {
console.log('🔍 [buildPlayerMetadata] Starting:', {
roomData: roomData?.id,
activePlayers,
viewerId,
playersMapSize: players.size,
})
const playerOwnership = buildPlayerOwnershipFromRoomData(roomData)
console.log('🔍 [buildPlayerMetadata] Player ownership:', playerOwnership)
const metadata = buildPlayerMetadataUtil(activePlayers, playerOwnership, players, viewerId)
console.log('🔍 [buildPlayerMetadata] Built metadata:', metadata)
return metadata
}, [activePlayers, players, roomData, viewerId])
// Action creators - send moves to arcade session
const startQuiz = useCallback(
(quizCards: QuizCard[]) => {
// Extract only serializable data (numbers) for server
// React components can't be sent over Socket.IO
const numbers = quizCards.map((card) => card.number)
// Build player metadata for multiplayer
const playerMetadata = buildPlayerMetadata()
console.log('🚀 [startQuiz] Sending START_QUIZ move:', {
viewerId,
activePlayers,
playerMetadata,
numbers,
})
sendMove({
type: 'START_QUIZ',
playerId: TEAM_MOVE, // Team move - all players act together
userId: viewerId || '', // User who initiated
data: {
numbers, // Send to server
quizCards, // Keep for optimistic local update
activePlayers, // Send active players list
playerMetadata, // Send player display info
},
})
},
[viewerId, sendMove, activePlayers, buildPlayerMetadata]
)
const nextCard = useCallback(() => {
sendMove({
type: 'NEXT_CARD',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const showInputPhase = useCallback(() => {
sendMove({
type: 'SHOW_INPUT_PHASE',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const acceptNumber = useCallback(
(number: number) => {
// Clear local input immediately
setLocalCurrentInput('')
console.log('🚀 [acceptNumber] Sending ACCEPT_NUMBER move:', {
viewerId,
number,
})
sendMove({
type: 'ACCEPT_NUMBER',
playerId: TEAM_MOVE, // Team move - can't identify specific player
userId: viewerId || '', // User who guessed correctly
data: { number },
})
},
[viewerId, sendMove]
)
const rejectNumber = useCallback(() => {
// Clear local input immediately
setLocalCurrentInput('')
console.log('🚀 [rejectNumber] Sending REJECT_NUMBER move:', {
viewerId,
})
sendMove({
type: 'REJECT_NUMBER',
playerId: TEAM_MOVE, // Team move - can't identify specific player
userId: viewerId || '', // User who guessed incorrectly
data: {},
})
}, [viewerId, sendMove])
const setInput = useCallback((input: string) => {
// LOCAL ONLY - no network sync!
// This makes typing instant with zero network lag
setLocalCurrentInput(input)
}, [])
const showResults = useCallback(() => {
sendMove({
type: 'SHOW_RESULTS',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const resetGame = useCallback(() => {
sendMove({
type: 'RESET_QUIZ',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const setConfig = useCallback(
(field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode', value: any) => {
sendMove({
type: 'SET_CONFIG',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: { field, value },
})
// Save setting to room's gameConfig for persistence
// Settings are scoped by game name to preserve settings when switching games
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
const currentMemoryQuizConfig =
(currentGameConfig['memory-quiz'] as Record<string, any>) || {}
const updatedConfig = {
...currentGameConfig,
'memory-quiz': {
...currentMemoryQuizConfig,
[field]: value,
},
}
console.log('[RoomMemoryQuizProvider] Saving game config for memory-quiz:', updatedConfig)
updateGameConfig({
roomId: roomData.id,
gameConfig: updatedConfig,
})
}
},
[viewerId, sendMove, roomData?.id, roomData?.gameConfig, updateGameConfig]
)
// Merge network state with local input state
const mergedState = {
...state,
currentInput: localCurrentInput, // Override network state with local input
}
// If state is corrupted, show error message instead of crashing
if (hasStateCorruption) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px',
textAlign: 'center',
minHeight: '400px',
}}
>
<div
style={{
fontSize: '48px',
marginBottom: '20px',
}}
>
</div>
<h2
style={{
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '12px',
color: '#dc2626',
}}
>
Game State Mismatch
</h2>
<p
style={{
fontSize: '16px',
color: '#6b7280',
marginBottom: '24px',
maxWidth: '500px',
}}
>
There's a mismatch between game types in this room. This usually happens when room members
are playing different games.
</p>
<div
style={{
background: '#f9fafb',
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '16px',
marginBottom: '24px',
maxWidth: '500px',
}}
>
<p
style={{
fontSize: '14px',
fontWeight: '600',
marginBottom: '8px',
}}
>
To fix this:
</p>
<ol
style={{
fontSize: '14px',
textAlign: 'left',
paddingLeft: '20px',
lineHeight: '1.6',
}}
>
<li>Make sure all room members are on the same game page</li>
<li>Try refreshing the page</li>
<li>If the issue persists, leave and rejoin the room</li>
</ol>
</div>
<button
onClick={() => window.location.reload()}
style={{
padding: '10px 20px',
background: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
}}
>
Refresh Page
</button>
</div>
)
}
// Determine if current user is the room creator (controls card timing)
const isRoomCreator =
roomData?.members.find((member) => member.userId === viewerId)?.isCreator || false
const contextValue: MemoryQuizContextValue = {
state: mergedState,
dispatch: () => {
// No-op - replaced with action creators
console.warn('dispatch() is deprecated in room mode, use action creators instead')
},
isGameActive,
resetGame,
exitSession,
isRoomCreator, // Pass room creator flag to components
// Expose action creators for components to use
startQuiz,
nextCard,
showInputPhase,
acceptNumber,
rejectNumber,
setInput,
showResults,
setConfig,
}
return <MemoryQuizContext.Provider value={contextValue}>{children}</MemoryQuizContext.Provider>
}
// Export the hook for this provider
export { useMemoryQuiz } from './MemoryQuizContext'

View File

@@ -1,157 +1,70 @@
'use client'
import Link from 'next/link'
import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../../../styled-system/css'
import { DisplayPhase } from './components/DisplayPhase'
import { InputPhase } from './components/InputPhase'
import { ResultsPhase } from './components/ResultsPhase'
import { SetupPhase } from './components/SetupPhase'
import { LocalMemoryQuizProvider } from './context/LocalMemoryQuizProvider'
import { useMemoryQuiz } from './context/MemoryQuizContext'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
// CSS animations that need to be global
const globalAnimations = `
@keyframes pulse {
0% { transform: scale(1); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); }
50% { transform: scale(1.05); box-shadow: 0 6px 20px rgba(59, 130, 246, 0.5); }
100% { transform: scale(1); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); }
}
/**
* Memory Quiz redirect page
*
* Local mode has been deprecated. Memory Quiz is now only available
* through the Champion Arena (arcade) in room mode.
*
* This page redirects users to the arcade where they can:
* 1. Create or join a room
* 2. Select Memory Lightning from the game selector
* 3. Play in multiplayer or solo (single-player room)
*/
export default function MemoryQuizRedirectPage() {
const router = useRouter()
@keyframes subtlePageFlash {
0% { background: linear-gradient(to bottom right, #f0fdf4, #ecfdf5); }
50% { background: linear-gradient(to bottom right, #dcfce7, #d1fae5); }
100% { background: linear-gradient(to bottom right, #f0fdf4, #ecfdf5); }
}
@keyframes fadeInScale {
from { opacity: 0; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
}
@keyframes explode {
0% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
50% {
opacity: 1;
transform: translate(-50%, -50%) scale(1.5);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(2) rotate(180deg);
}
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateX(-50%) translateY(20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
`
// Inner component that uses the context
function MemoryQuizGame() {
const { state } = useMemoryQuiz()
useEffect(() => {
// Redirect to arcade
router.replace('/arcade')
}, [router])
return (
<>
<style dangerouslySetInnerHTML={{ __html: globalAnimations }} />
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
padding: '20px',
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
}}
>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
padding: '20px 8px',
minHeight: '100vh',
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
fontSize: '48px',
marginBottom: '20px',
}}
>
<div
style={{
maxWidth: '100%',
margin: '0 auto',
flex: 1,
display: 'flex',
flexDirection: 'column',
}}
>
<div
className={css({
textAlign: 'center',
mb: '4',
flexShrink: 0,
})}
>
<Link
href="/games"
className={css({
display: 'inline-flex',
alignItems: 'center',
color: 'gray.600',
textDecoration: 'none',
mb: '4',
_hover: { color: 'gray.800' },
})}
>
Back to Games
</Link>
</div>
<div
className={css({
bg: 'white',
rounded: 'xl',
shadow: 'xl',
overflow: 'hidden',
border: '1px solid',
borderColor: 'gray.200',
flex: 1,
display: 'flex',
flexDirection: 'column',
maxHeight: '100%',
})}
>
<div
className={css({
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
})}
>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'display' && <DisplayPhase />}
{state.gamePhase === 'input' && <InputPhase key="input-phase" />}
{state.gamePhase === 'results' && <ResultsPhase />}
</div>
</div>
</div>
🧠
</div>
</>
)
}
// Main page component that provides the context
export default function MemoryQuizPage() {
return (
<PageWithNav navTitle="Memory Lightning" navEmoji="🧠">
<LocalMemoryQuizProvider>
<MemoryQuizGame />
</LocalMemoryQuizProvider>
</PageWithNav>
<h1
style={{
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '12px',
color: '#1f2937',
textAlign: 'center',
}}
>
Redirecting to Champion Arena...
</h1>
<p
style={{
fontSize: '16px',
color: '#6b7280',
textAlign: 'center',
maxWidth: '500px',
}}
>
Memory Lightning is now part of the Champion Arena.
<br />
You'll be able to play solo or with friends in multiplayer mode!
</p>
</div>
)
}

View File

@@ -1,138 +0,0 @@
import type { QuizAction, SorobanQuizState } from './types'
export const initialState: SorobanQuizState = {
cards: [],
quizCards: [],
correctAnswers: [],
currentCardIndex: 0,
displayTime: 2.0,
selectedCount: 5,
selectedDifficulty: 'easy', // Default to easy level
foundNumbers: [],
guessesRemaining: 0,
currentInput: '',
incorrectGuesses: 0,
// Multiplayer state
activePlayers: [],
playerMetadata: {},
playerScores: {},
playMode: 'cooperative', // Default to cooperative
numberFoundBy: {},
// UI state
gamePhase: 'setup',
prefixAcceptanceTimeout: null,
finishButtonsBound: false,
wrongGuessAnimations: [],
// Keyboard state (persistent across re-renders)
hasPhysicalKeyboard: null,
testingMode: false,
showOnScreenKeyboard: false,
}
export function quizReducer(state: SorobanQuizState, action: QuizAction): SorobanQuizState {
switch (action.type) {
case 'SET_CARDS':
return { ...state, cards: action.cards }
case 'SET_DISPLAY_TIME':
return { ...state, displayTime: action.time }
case 'SET_SELECTED_COUNT':
return { ...state, selectedCount: action.count }
case 'SET_DIFFICULTY':
return { ...state, selectedDifficulty: action.difficulty }
case 'SET_PLAY_MODE':
return { ...state, playMode: action.playMode }
case 'START_QUIZ':
return {
...state,
quizCards: action.quizCards,
correctAnswers: action.quizCards.map((card) => card.number),
currentCardIndex: 0,
foundNumbers: [],
guessesRemaining: action.quizCards.length + Math.floor(action.quizCards.length / 2),
gamePhase: 'display',
}
case 'NEXT_CARD':
return { ...state, currentCardIndex: state.currentCardIndex + 1 }
case 'SHOW_INPUT_PHASE':
return { ...state, gamePhase: 'input' }
case 'ACCEPT_NUMBER': {
// In competitive mode, track which player guessed correctly
const newPlayerScores = { ...state.playerScores }
if (state.playMode === 'competitive' && action.playerId) {
const currentScore = newPlayerScores[action.playerId] || { correct: 0, incorrect: 0 }
newPlayerScores[action.playerId] = {
...currentScore,
correct: currentScore.correct + 1,
}
}
return {
...state,
foundNumbers: [...state.foundNumbers, action.number],
currentInput: '',
playerScores: newPlayerScores,
}
}
case 'REJECT_NUMBER': {
// In competitive mode, track which player guessed incorrectly
const newPlayerScores = { ...state.playerScores }
if (state.playMode === 'competitive' && action.playerId) {
const currentScore = newPlayerScores[action.playerId] || { correct: 0, incorrect: 0 }
newPlayerScores[action.playerId] = {
...currentScore,
incorrect: currentScore.incorrect + 1,
}
}
return {
...state,
guessesRemaining: state.guessesRemaining - 1,
incorrectGuesses: state.incorrectGuesses + 1,
currentInput: '',
playerScores: newPlayerScores,
}
}
case 'SET_INPUT':
return { ...state, currentInput: action.input }
case 'SET_PREFIX_TIMEOUT':
return { ...state, prefixAcceptanceTimeout: action.timeout }
case 'ADD_WRONG_GUESS_ANIMATION':
return {
...state,
wrongGuessAnimations: [
...state.wrongGuessAnimations,
{
number: action.number,
id: `wrong-${action.number}-${Date.now()}`,
timestamp: Date.now(),
},
],
}
case 'CLEAR_WRONG_GUESS_ANIMATIONS':
return {
...state,
wrongGuessAnimations: [],
}
case 'SHOW_RESULTS':
return { ...state, gamePhase: 'results' }
case 'RESET_QUIZ':
return {
...initialState,
cards: state.cards, // Preserve generated cards
displayTime: state.displayTime,
selectedCount: state.selectedCount,
selectedDifficulty: state.selectedDifficulty,
playMode: state.playMode, // Preserve play mode
// Preserve keyboard state across resets
hasPhysicalKeyboard: state.hasPhysicalKeyboard,
testingMode: state.testingMode,
showOnScreenKeyboard: state.showOnScreenKeyboard,
}
case 'SET_PHYSICAL_KEYBOARD':
return { ...state, hasPhysicalKeyboard: action.hasKeyboard }
case 'SET_TESTING_MODE':
return { ...state, testingMode: action.enabled }
case 'TOGGLE_ONSCREEN_KEYBOARD':
return { ...state, showOnScreenKeyboard: !state.showOnScreenKeyboard }
default:
return state
}
}

View File

@@ -4,17 +4,15 @@ import { useRouter } from 'next/navigation'
import { useRoomData, useSetRoomGame } from '@/hooks/useRoomData'
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProvider'
import { MemoryQuizGame } from '../memory-quiz/components/MemoryQuizGame'
import { RoomMemoryQuizProvider } from '../memory-quiz/context/RoomMemoryQuizProvider'
import { GAMES_CONFIG } from '@/components/GameSelector'
import type { GameType } from '@/components/GameSelector'
import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../../../styled-system/css'
import { getAllGames, getGame, hasGame } from '@/lib/arcade/game-registry'
// Map GameType keys to internal game names
const GAME_TYPE_TO_NAME: Record<GameType, string> = {
'battle-arena': 'matching',
'memory-quiz': 'memory-quiz',
'complement-race': 'complement-race',
'master-organizer': 'master-organizer',
}
@@ -89,13 +87,35 @@ export default function RoomPage() {
const handleGameSelect = (gameType: GameType) => {
console.log('[RoomPage] handleGameSelect called with gameType:', gameType)
const gameConfig = GAMES_CONFIG[gameType]
// Check if it's a registry game first
if (hasGame(gameType)) {
const gameDef = getGame(gameType)
if (!gameDef?.manifest.available) {
console.log('[RoomPage] Registry game not available, blocking selection')
return
}
console.log('[RoomPage] Selecting registry game:', gameType)
setRoomGame({
roomId: roomData.id,
gameName: gameType, // Use the game name directly for registry games
})
return
}
// Legacy game handling
const gameConfig = GAMES_CONFIG[gameType as keyof typeof GAMES_CONFIG]
if (!gameConfig) {
console.log('[RoomPage] Unknown game type:', gameType)
return
}
console.log('[RoomPage] Game config:', {
name: gameConfig.name,
available: gameConfig.available,
available: 'available' in gameConfig ? gameConfig.available : true,
})
if (gameConfig.available === false) {
if ('available' in gameConfig && gameConfig.available === false) {
console.log('[RoomPage] Game not available, blocking selection')
return // Don't allow selecting unavailable games
}
@@ -111,13 +131,13 @@ export default function RoomPage() {
console.log('[RoomPage] Calling setRoomGame with:', {
roomId: roomData.id,
gameName: internalGameName,
gameConfig: {},
preservingGameConfig: true,
})
// Don't pass gameConfig - we want to preserve existing settings for all games
setRoomGame({
roomId: roomData.id,
gameName: internalGameName,
gameConfig: {},
})
}
@@ -160,64 +180,158 @@ export default function RoomPage() {
width: '100%',
})}
>
{Object.entries(GAMES_CONFIG).map(([gameType, config]) => (
<button
key={gameType}
onClick={() => handleGameSelect(gameType as GameType)}
disabled={config.available === false}
className={css({
background: config.gradient,
border: '2px solid',
borderColor: config.borderColor || 'blue.200',
borderRadius: '2xl',
padding: '6',
cursor: config.available === false ? 'not-allowed' : 'pointer',
opacity: config.available === false ? 0.5 : 1,
transition: 'all 0.3s ease',
_hover:
config.available === false
{/* Legacy games */}
{Object.entries(GAMES_CONFIG).map(([gameType, config]) => {
const isAvailable = !('available' in config) || config.available !== false
return (
<button
key={gameType}
onClick={() => handleGameSelect(gameType as GameType)}
disabled={!isAvailable}
className={css({
background: config.gradient,
border: '2px solid',
borderColor: config.borderColor || 'blue.200',
borderRadius: '2xl',
padding: '6',
cursor: !isAvailable ? 'not-allowed' : 'pointer',
opacity: !isAvailable ? 0.5 : 1,
transition: 'all 0.3s ease',
_hover: !isAvailable
? {}
: {
transform: 'translateY(-4px) scale(1.02)',
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
},
})}
>
<div
className={css({
fontSize: '4xl',
mb: '2',
})}
>
{config.icon}
</div>
<h3
<div
className={css({
fontSize: '4xl',
mb: '2',
})}
>
{config.icon}
</div>
<h3
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: 'gray.900',
mb: '2',
})}
>
{config.name}
</h3>
<p
className={css({
fontSize: 'sm',
color: 'gray.600',
})}
>
{config.description}
</p>
</button>
)
})}
{/* Registry games */}
{getAllGames().map((gameDef) => {
const isAvailable = gameDef.manifest.available
return (
<button
key={gameDef.manifest.name}
onClick={() => handleGameSelect(gameDef.manifest.name)}
disabled={!isAvailable}
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: 'gray.900',
mb: '2',
background: gameDef.manifest.gradient,
border: '2px solid',
borderColor: gameDef.manifest.borderColor,
borderRadius: '2xl',
padding: '6',
cursor: !isAvailable ? 'not-allowed' : 'pointer',
opacity: !isAvailable ? 0.5 : 1,
transition: 'all 0.3s ease',
_hover: !isAvailable
? {}
: {
transform: 'translateY(-4px) scale(1.02)',
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
},
})}
>
{config.name}
</h3>
<p
className={css({
fontSize: 'sm',
color: 'gray.600',
})}
>
{config.description}
</p>
</button>
))}
<div
className={css({
fontSize: '4xl',
mb: '2',
})}
>
{gameDef.manifest.icon}
</div>
<h3
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: 'gray.900',
mb: '2',
})}
>
{gameDef.manifest.displayName}
</h3>
<p
className={css({
fontSize: 'sm',
color: 'gray.600',
})}
>
{gameDef.manifest.description}
</p>
</button>
)
})}
</div>
</div>
</PageWithNav>
)
}
// Render the appropriate game based on room's gameName
// Check if this is a registry game first
if (hasGame(roomData.gameName)) {
const gameDef = getGame(roomData.gameName)
if (!gameDef) {
return (
<PageWithNav
navTitle="Game Not Found"
navEmoji="⚠️"
emphasizePlayerSelection={true}
onExitSession={() => router.push('/arcade')}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
}}
>
Game "{roomData.gameName}" not found in registry
</div>
</PageWithNav>
)
}
// Render registry game dynamically
const { Provider, GameComponent } = gameDef
return (
<Provider>
<GameComponent />
</Provider>
)
}
// Render legacy games based on room's gameName
switch (roomData.gameName) {
case 'matching':
return (
@@ -226,13 +340,6 @@ export default function RoomPage() {
</RoomMemoryPairsProvider>
)
case 'memory-quiz':
return (
<RoomMemoryQuizProvider>
<MemoryQuizGame />
</RoomMemoryQuizProvider>
)
// TODO: Add other games (complement-race, etc.)
default:
return (

View File

@@ -0,0 +1,917 @@
# 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)
---
## 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<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
```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<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:
```typescript
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:
```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<string, unknown> // Move-specific payload
}
```
#### GameValidator
Server-side validation logic:
```typescript
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
```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<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`)
```typescript
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`)
```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<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`)
```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 (
<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`)
```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<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`:
```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> = 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 `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 && (
<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`:
```typescript
<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`:
```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' && <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:
```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.

View File

@@ -0,0 +1,199 @@
/**
* Math Sprint Provider
*
* Context provider for Math Sprint game state management.
* Demonstrates free-for-all gameplay with TEAM_MOVE pattern.
*/
'use client'
import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'
import {
buildPlayerMetadata,
useArcadeSession,
useGameMode,
useRoomData,
useUpdateGameConfig,
useViewerId,
} from '@/lib/arcade/game-sdk'
import { TEAM_MOVE } from '@/lib/arcade/validation/types'
import type { Difficulty, MathSprintState } from './types'
/**
* Context value provided to child components
*/
interface MathSprintContextValue {
state: MathSprintState
lastError: string | null
startGame: () => void
submitAnswer: (answer: number) => void
nextQuestion: () => void
resetGame: () => void
setConfig: (field: 'difficulty' | 'questionsPerRound' | 'timePerQuestion', value: any) => void
clearError: () => void
exitSession: () => void
}
const MathSprintContext = createContext<MathSprintContextValue | null>(null)
/**
* Hook to access Math Sprint context
*/
export function useMathSprint() {
const context = useContext(MathSprintContext)
if (!context) {
throw new Error('useMathSprint must be used within MathSprintProvider')
}
return context
}
/**
* Math Sprint Provider Component
*/
export function MathSprintProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData()
const { activePlayers: activePlayerIds, players } = useGameMode()
const { mutate: updateGameConfig } = useUpdateGameConfig()
// Get active players as array (keep Set iteration order)
const activePlayers = Array.from(activePlayerIds)
// Merge saved config from room with defaults
const gameConfig = useMemo(() => {
const allGameConfigs = roomData?.gameConfig as Record<string, unknown> | null | undefined
const savedConfig = allGameConfigs?.['math-sprint'] as Record<string, unknown> | undefined
return {
difficulty: (savedConfig?.difficulty as Difficulty) || 'medium',
questionsPerRound: (savedConfig?.questionsPerRound as number) || 10,
timePerQuestion: (savedConfig?.timePerQuestion as number) || 30,
}
}, [roomData?.gameConfig])
// Initial state with merged config
const initialState = useMemo<MathSprintState>(
() => ({
gamePhase: 'setup',
activePlayers: [],
playerMetadata: {},
difficulty: gameConfig.difficulty,
questionsPerRound: gameConfig.questionsPerRound,
timePerQuestion: gameConfig.timePerQuestion,
currentQuestionIndex: 0,
questions: [],
scores: {},
correctAnswersCount: {},
answers: [],
questionStartTime: 0,
questionAnswered: false,
winnerId: null,
}),
[gameConfig]
)
// Arcade session integration
const { state, sendMove, exitSession, lastError, clearError } = useArcadeSession<MathSprintState>(
{
userId: viewerId || '',
roomId: roomData?.id,
initialState,
applyMove: (state) => state, // Server handles all state updates
}
)
// Action: Start game
const startGame = useCallback(() => {
const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId || undefined)
sendMove({
type: 'START_GAME',
playerId: TEAM_MOVE, // Free-for-all: no specific turn owner
userId: viewerId || '',
data: { activePlayers, playerMetadata },
})
}, [activePlayers, players, viewerId, sendMove])
// Action: Submit answer
const submitAnswer = useCallback(
(answer: number) => {
// Find this user's player ID from game state
const myPlayerId = state.activePlayers.find((pid) => {
return state.playerMetadata[pid]?.userId === viewerId
})
if (!myPlayerId) {
console.error('[MathSprint] No player found for current user')
return
}
sendMove({
type: 'SUBMIT_ANSWER',
playerId: myPlayerId, // Specific player answering
userId: viewerId || '',
data: { answer },
})
},
[state.activePlayers, state.playerMetadata, viewerId, sendMove]
)
// Action: Next question
const nextQuestion = useCallback(() => {
sendMove({
type: 'NEXT_QUESTION',
playerId: TEAM_MOVE, // Any player can advance
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
// Action: Reset game
const resetGame = useCallback(() => {
sendMove({
type: 'RESET_GAME',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
// Action: Set config
const setConfig = useCallback(
(field: 'difficulty' | 'questionsPerRound' | 'timePerQuestion', value: any) => {
sendMove({
type: 'SET_CONFIG',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: { field, value },
})
// Persist to database for next session
if (roomData?.id) {
updateGameConfig({
roomId: roomData.id,
gameConfig: {
...roomData.gameConfig,
'math-sprint': {
...(roomData.gameConfig?.['math-sprint'] || {}),
[field]: value,
},
},
})
}
},
[viewerId, sendMove, updateGameConfig, roomData]
)
const contextValue: MathSprintContextValue = {
state,
lastError,
startGame,
submitAnswer,
nextQuestion,
resetGame,
setConfig,
clearError,
exitSession,
}
return <MathSprintContext.Provider value={contextValue}>{children}</MathSprintContext.Provider>
}

View File

@@ -0,0 +1,329 @@
/**
* Math Sprint Validator
*
* Server-side validation for Math Sprint game.
* Generates questions, validates answers, awards points.
*/
import type { GameValidator, ValidationResult } from '@/lib/arcade/game-sdk'
import type {
Difficulty,
MathSprintConfig,
MathSprintMove,
MathSprintState,
Operation,
Question,
} from './types'
export class MathSprintValidator implements GameValidator<MathSprintState, MathSprintMove> {
/**
* Validate a game move
*/
validateMove(
state: MathSprintState,
move: MathSprintMove,
context?: { userId?: string }
): ValidationResult {
switch (move.type) {
case 'START_GAME':
return this.validateStartGame(state, move.data.activePlayers, move.data.playerMetadata)
case 'SUBMIT_ANSWER':
return this.validateSubmitAnswer(
state,
move.playerId,
Number(move.data.answer),
move.timestamp
)
case 'NEXT_QUESTION':
return this.validateNextQuestion(state)
case 'RESET_GAME':
return this.validateResetGame(state)
case 'SET_CONFIG':
return this.validateSetConfig(state, move.data.field, move.data.value)
default:
return { valid: false, error: 'Unknown move type' }
}
}
/**
* Check if game is complete
*/
isGameComplete(state: MathSprintState): boolean {
return state.gamePhase === 'results'
}
/**
* Get initial state for new game
*/
getInitialState(config: unknown): MathSprintState {
const { difficulty, questionsPerRound, timePerQuestion } = config as MathSprintConfig
return {
gamePhase: 'setup',
activePlayers: [],
playerMetadata: {},
difficulty: difficulty || 'medium',
questionsPerRound: questionsPerRound || 10,
timePerQuestion: timePerQuestion || 30,
currentQuestionIndex: 0,
questions: [],
scores: {},
correctAnswersCount: {},
answers: [],
questionStartTime: 0,
questionAnswered: false,
winnerId: null,
}
}
// ============================================================================
// Validation Methods
// ============================================================================
private validateStartGame(
state: MathSprintState,
activePlayers: string[],
playerMetadata: Record<string, any>
): ValidationResult {
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Game already started' }
}
if (activePlayers.length < 2) {
return { valid: false, error: 'Need at least 2 players' }
}
// Generate questions
const questions = this.generateQuestions(state.difficulty, state.questionsPerRound)
const newState: MathSprintState = {
...state,
gamePhase: 'playing',
activePlayers,
playerMetadata,
questions,
currentQuestionIndex: 0,
scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
correctAnswersCount: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
answers: [],
questionStartTime: Date.now(),
questionAnswered: false,
winnerId: null,
}
return { valid: true, newState }
}
private validateSubmitAnswer(
state: MathSprintState,
playerId: string,
answer: number,
timestamp: number
): ValidationResult {
if (state.gamePhase !== 'playing') {
return { valid: false, error: 'Game not in progress' }
}
if (!state.activePlayers.includes(playerId)) {
return { valid: false, error: 'Player not in game' }
}
if (state.questionAnswered) {
return { valid: false, error: 'Question already answered correctly' }
}
// Check if player already answered this question
const alreadyAnswered = state.answers.some((a) => a.playerId === playerId)
if (alreadyAnswered) {
return { valid: false, error: 'You already answered this question' }
}
const currentQuestion = state.questions[state.currentQuestionIndex]
const correct = answer === currentQuestion.correctAnswer
const answerRecord = {
playerId,
answer,
timestamp,
correct,
}
const newAnswers = [...state.answers, answerRecord]
let newState = { ...state, answers: newAnswers }
// If correct, award points and mark question as answered
if (correct) {
newState = {
...newState,
questionAnswered: true,
winnerId: playerId,
scores: {
...state.scores,
[playerId]: state.scores[playerId] + 10,
},
correctAnswersCount: {
...state.correctAnswersCount,
[playerId]: state.correctAnswersCount[playerId] + 1,
},
}
}
return { valid: true, newState }
}
private validateNextQuestion(state: MathSprintState): ValidationResult {
if (state.gamePhase !== 'playing') {
return { valid: false, error: 'Game not in progress' }
}
if (!state.questionAnswered) {
return { valid: false, error: 'Current question not answered yet' }
}
const isLastQuestion = state.currentQuestionIndex >= state.questions.length - 1
if (isLastQuestion) {
// Game complete, go to results
const newState: MathSprintState = {
...state,
gamePhase: 'results',
}
return { valid: true, newState }
}
// Move to next question
const newState: MathSprintState = {
...state,
currentQuestionIndex: state.currentQuestionIndex + 1,
answers: [],
questionStartTime: Date.now(),
questionAnswered: false,
winnerId: null,
}
return { valid: true, newState }
}
private validateResetGame(state: MathSprintState): ValidationResult {
const newState = this.getInitialState({
difficulty: state.difficulty,
questionsPerRound: state.questionsPerRound,
timePerQuestion: state.timePerQuestion,
})
return { valid: true, newState }
}
private validateSetConfig(state: MathSprintState, field: string, value: any): ValidationResult {
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Cannot change config during game' }
}
const newState = {
...state,
[field]: value,
}
return { valid: true, newState }
}
// ============================================================================
// Question Generation
// ============================================================================
private generateQuestions(difficulty: Difficulty, count: number): Question[] {
const questions: Question[] = []
for (let i = 0; i < count; i++) {
const operation = this.randomOperation()
const question = this.generateQuestion(difficulty, operation, `q-${i}`)
questions.push(question)
}
return questions
}
private generateQuestion(difficulty: Difficulty, operation: Operation, id: string): Question {
let operand1: number
let operand2: number
let correctAnswer: number
switch (difficulty) {
case 'easy':
operand1 = this.randomInt(1, 10)
operand2 = this.randomInt(1, 10)
break
case 'medium':
operand1 = this.randomInt(10, 50)
operand2 = this.randomInt(1, 20)
break
case 'hard':
operand1 = this.randomInt(10, 100)
operand2 = this.randomInt(10, 50)
break
}
switch (operation) {
case 'addition':
correctAnswer = operand1 + operand2
break
case 'subtraction':
// Ensure positive result
if (operand1 < operand2) {
;[operand1, operand2] = [operand2, operand1]
}
correctAnswer = operand1 - operand2
break
case 'multiplication':
// Smaller numbers for multiplication
if (difficulty === 'hard') {
operand1 = this.randomInt(2, 20)
operand2 = this.randomInt(2, 12)
} else {
operand1 = this.randomInt(2, 10)
operand2 = this.randomInt(2, 10)
}
correctAnswer = operand1 * operand2
break
}
const operationSymbol = this.getOperationSymbol(operation)
const displayText = `${operand1} ${operationSymbol} ${operand2} = ?`
return {
id,
operand1,
operand2,
operation,
correctAnswer,
displayText,
}
}
private randomOperation(): Operation {
const operations: Operation[] = ['addition', 'subtraction', 'multiplication']
return operations[Math.floor(Math.random() * operations.length)]
}
private randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min
}
private getOperationSymbol(operation: Operation): string {
switch (operation) {
case 'addition':
return '+'
case 'subtraction':
return ''
case 'multiplication':
return '×'
}
}
}
export const mathSprintValidator = new MathSprintValidator()

View File

@@ -0,0 +1,40 @@
/**
* Math Sprint - Game Component
*
* Main wrapper component with navigation and phase routing.
*/
'use client'
import { useRouter } from 'next/navigation'
import { PageWithNav } from '@/components/PageWithNav'
import { useMathSprint } from '../Provider'
import { PlayingPhase } from './PlayingPhase'
import { ResultsPhase } from './ResultsPhase'
import { SetupPhase } from './SetupPhase'
export function GameComponent() {
const router = useRouter()
const { state, exitSession, resetGame } = useMathSprint()
return (
<PageWithNav
navTitle="Math Sprint"
navEmoji="🧮"
emphasizePlayerSelection={state.gamePhase === 'setup'}
// No currentPlayerId - free-for-all game, everyone can act simultaneously
playerScores={state.scores}
onExitSession={() => {
exitSession?.()
router.push('/arcade')
}}
onNewGame={() => {
resetGame()
}}
>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'playing' && <PlayingPhase />}
{state.gamePhase === 'results' && <ResultsPhase />}
</PageWithNav>
)
}

View File

@@ -0,0 +1,350 @@
/**
* Math Sprint - Playing Phase
*
* Main gameplay: show question, accept answers, show feedback.
*/
'use client'
import { useEffect, useState } from 'react'
import { useViewerId } from '@/lib/arcade/game-sdk'
import { css } from '../../../../styled-system/css'
import { useMathSprint } from '../Provider'
export function PlayingPhase() {
const { state, submitAnswer, nextQuestion, lastError, clearError } = useMathSprint()
const { data: viewerId } = useViewerId()
const [inputValue, setInputValue] = useState('')
const currentQuestion = state.questions[state.currentQuestionIndex]
const progress = `${state.currentQuestionIndex + 1} / ${state.questions.length}`
// Find if current user answered
const myPlayerId = Object.keys(state.playerMetadata).find(
(pid) => state.playerMetadata[pid]?.userId === viewerId
)
const myAnswer = state.answers.find((a) => a.playerId === myPlayerId)
// Auto-clear error after 3 seconds
useEffect(() => {
if (lastError) {
const timeout = setTimeout(() => clearError(), 3000)
return () => clearTimeout(timeout)
}
}, [lastError, clearError])
// Clear input after question changes
useEffect(() => {
setInputValue('')
}, [state.currentQuestionIndex])
const handleSubmit = () => {
const answer = Number.parseInt(inputValue, 10)
if (Number.isNaN(answer)) return
submitAnswer(answer)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSubmit()
}
}
return (
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '24px',
maxWidth: '700px',
margin: '0 auto',
padding: '32px 20px',
})}
>
{/* Progress Bar */}
<div
className={css({
background: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '12px',
padding: '16px',
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8px',
})}
>
<span className={css({ fontSize: 'sm', fontWeight: 'semibold' })}>
Question {progress}
</span>
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
{state.difficulty.charAt(0).toUpperCase() + state.difficulty.slice(1)}
</span>
</div>
<div
className={css({
background: 'gray.200',
height: '8px',
borderRadius: '4px',
overflow: 'hidden',
})}
>
<div
className={css({
background: 'linear-gradient(90deg, #a78bfa, #8b5cf6)',
height: '100%',
borderRadius: '4px',
transition: 'width 0.3s',
})}
style={{
width: `${((state.currentQuestionIndex + 1) / state.questions.length) * 100}%`,
}}
/>
</div>
</div>
{/* Error Banner */}
{lastError && (
<div
className={css({
background: 'linear-gradient(135deg, #fef2f2, #fee2e2)',
border: '2px solid',
borderColor: 'red.300',
borderRadius: '12px',
padding: '12px 16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
<span></span>
<span className={css({ fontSize: 'sm', color: 'red.700' })}>{lastError}</span>
</div>
<button
type="button"
onClick={clearError}
className={css({
fontSize: 'xs',
padding: '4px 8px',
background: 'red.100',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
_hover: { background: 'red.200' },
})}
>
Dismiss
</button>
</div>
)}
{/* Question Display */}
<div
className={css({
background: 'linear-gradient(135deg, #ede9fe, #ddd6fe)',
border: '2px solid',
borderColor: 'purple.300',
borderRadius: '16px',
padding: '48px',
textAlign: 'center',
})}
>
<div
className={css({
fontSize: '4xl',
fontWeight: 'bold',
color: 'purple.700',
fontFamily: 'monospace',
})}
>
{currentQuestion.displayText}
</div>
</div>
{/* Answer Input */}
{!state.questionAnswered && (
<div
className={css({
background: 'white',
border: '2px solid',
borderColor: myAnswer ? 'gray.300' : 'purple.500',
borderRadius: '12px',
padding: '24px',
})}
>
{myAnswer ? (
<div className={css({ textAlign: 'center' })}>
<div
className={css({
fontSize: 'lg',
color: 'gray.600',
marginBottom: '8px',
})}
>
Your answer: <strong>{myAnswer.answer}</strong>
</div>
<div className={css({ fontSize: 'sm', color: 'gray.500' })}>
Waiting for others or correct answer...
</div>
</div>
) : (
<div>
<label
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'semibold',
marginBottom: '8px',
})}
>
Your Answer
</label>
<div className={css({ display: 'flex', gap: '12px' })}>
<input
type="number"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your answer..."
className={css({
flex: 1,
padding: '12px 16px',
fontSize: 'lg',
border: '2px solid',
borderColor: 'gray.300',
borderRadius: '8px',
_focus: {
outline: 'none',
borderColor: 'purple.500',
},
})}
/>
<button
type="button"
onClick={handleSubmit}
disabled={!inputValue}
className={css({
padding: '12px 24px',
fontSize: 'md',
fontWeight: 'semibold',
color: 'white',
background: inputValue ? 'purple.600' : 'gray.400',
border: 'none',
borderRadius: '8px',
cursor: inputValue ? 'pointer' : 'not-allowed',
_hover: {
background: inputValue ? 'purple.700' : 'gray.400',
},
})}
>
Submit
</button>
</div>
</div>
)}
</div>
)}
{/* Winner Display */}
{state.questionAnswered && state.winnerId && (
<div
className={css({
background: 'linear-gradient(135deg, #d1fae5, #a7f3d0)',
border: '2px solid',
borderColor: 'green.400',
borderRadius: '12px',
padding: '24px',
textAlign: 'center',
})}
>
<div className={css({ fontSize: '3xl', marginBottom: '8px' })}>🎉</div>
<div className={css({ fontSize: 'lg', fontWeight: 'bold', color: 'green.700' })}>
{state.playerMetadata[state.winnerId]?.name || 'Someone'} got it right!
</div>
<div className={css({ fontSize: 'md', color: 'green.600', marginTop: '4px' })}>
Answer: {currentQuestion.correctAnswer}
</div>
<button
type="button"
onClick={nextQuestion}
className={css({
marginTop: '16px',
padding: '12px 32px',
fontSize: 'md',
fontWeight: 'semibold',
color: 'white',
background: 'green.600',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
_hover: { background: 'green.700' },
})}
>
Next Question
</button>
</div>
)}
{/* Scoreboard */}
<div
className={css({
background: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '12px',
padding: '16px',
})}
>
<h3
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
marginBottom: '12px',
})}
>
Scores
</h3>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '8px' })}>
{Object.entries(state.scores)
.sort(([, a], [, b]) => b - a)
.map(([playerId, score]) => {
const player = state.playerMetadata[playerId]
return (
<div
key={playerId}
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
background: 'gray.50',
borderRadius: '8px',
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
<span className={css({ fontSize: 'xl' })}>{player?.emoji}</span>
<span className={css({ fontSize: 'sm', fontWeight: 'medium' })}>
{player?.name}
</span>
</div>
<span
className={css({ fontSize: 'sm', fontWeight: 'bold', color: 'purple.600' })}
>
{score} pts
</span>
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,194 @@
/**
* Math Sprint - Results Phase
*
* Show final scores and winner.
*/
'use client'
import { css } from '../../../../styled-system/css'
import { useMathSprint } from '../Provider'
export function ResultsPhase() {
const { state, resetGame } = useMathSprint()
// Sort players by score
const sortedPlayers = Object.entries(state.scores)
.map(([playerId, score]) => ({
playerId,
score,
correct: state.correctAnswersCount[playerId] || 0,
player: state.playerMetadata[playerId],
}))
.sort((a, b) => b.score - a.score)
const winner = sortedPlayers[0]
return (
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '24px',
maxWidth: '600px',
margin: '0 auto',
padding: '32px 20px',
})}
>
{/* Winner Announcement */}
<div
className={css({
background: 'linear-gradient(135deg, #fef3c7, #fde68a)',
border: '2px solid',
borderColor: 'yellow.400',
borderRadius: '16px',
padding: '32px',
textAlign: 'center',
})}
>
<div className={css({ fontSize: '4xl', marginBottom: '12px' })}>🏆</div>
<h2
className={css({
fontSize: '2xl',
fontWeight: 'bold',
color: 'yellow.800',
marginBottom: '8px',
})}
>
{winner.player?.name} Wins!
</h2>
<div className={css({ fontSize: 'lg', color: 'yellow.700' })}>
{winner.score} points {winner.correct} correct
</div>
</div>
{/* Final Scores */}
<div
className={css({
background: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '12px',
padding: '24px',
})}
>
<h3
className={css({
fontSize: 'lg',
fontWeight: 'semibold',
marginBottom: '16px',
})}
>
Final Scores
</h3>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '12px' })}>
{sortedPlayers.map((item, index) => (
<div
key={item.playerId}
className={css({
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '16px',
background: index === 0 ? 'linear-gradient(135deg, #fef3c7, #fde68a)' : 'gray.50',
border: '1px solid',
borderColor: index === 0 ? 'yellow.300' : 'gray.200',
borderRadius: '12px',
})}
>
{/* Rank */}
<div
className={css({
width: '32px',
height: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: index === 0 ? 'yellow.500' : 'gray.300',
color: index === 0 ? 'white' : 'gray.700',
borderRadius: '50%',
fontWeight: 'bold',
fontSize: 'sm',
})}
>
{index + 1}
</div>
{/* Player Info */}
<div className={css({ flex: 1 })}>
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
<span className={css({ fontSize: 'xl' })}>{item.player?.emoji}</span>
<span className={css({ fontSize: 'md', fontWeight: 'semibold' })}>
{item.player?.name}
</span>
</div>
<div className={css({ fontSize: 'xs', color: 'gray.600', marginTop: '2px' })}>
{item.correct} / {state.questions.length} correct
</div>
</div>
{/* Score */}
<div
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: index === 0 ? 'yellow.700' : 'purple.600',
})}
>
{item.score}
</div>
</div>
))}
</div>
</div>
{/* Stats */}
<div
className={css({
background: 'linear-gradient(135deg, #ede9fe, #ddd6fe)',
border: '1px solid',
borderColor: 'purple.300',
borderRadius: '12px',
padding: '20px',
})}
>
<div className={css({ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' })}>
<div className={css({ textAlign: 'center' })}>
<div className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'purple.700' })}>
{state.questions.length}
</div>
<div className={css({ fontSize: 'sm', color: 'purple.600' })}>Questions</div>
</div>
<div className={css({ textAlign: 'center' })}>
<div className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'purple.700' })}>
{state.difficulty.charAt(0).toUpperCase() + state.difficulty.slice(1)}
</div>
<div className={css({ fontSize: 'sm', color: 'purple.600' })}>Difficulty</div>
</div>
</div>
</div>
{/* Play Again Button */}
<button
type="button"
onClick={resetGame}
className={css({
padding: '14px 28px',
fontSize: 'lg',
fontWeight: 'semibold',
color: 'white',
background: 'purple.600',
border: 'none',
borderRadius: '12px',
cursor: 'pointer',
transition: 'background 0.2s',
_hover: {
background: 'purple.700',
},
})}
>
Play Again
</button>
</div>
)
}

View File

@@ -0,0 +1,198 @@
/**
* Math Sprint - Setup Phase
*
* Configure game settings before starting.
*/
'use client'
import { css } from '../../../../styled-system/css'
import { useMathSprint } from '../Provider'
import type { Difficulty } from '../types'
export function SetupPhase() {
const { state, startGame, setConfig } = useMathSprint()
const handleDifficultyChange = (difficulty: Difficulty) => {
setConfig('difficulty', difficulty)
}
const handleQuestionsChange = (questions: number) => {
setConfig('questionsPerRound', questions)
}
return (
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '24px',
maxWidth: '600px',
margin: '0 auto',
padding: '32px 20px',
})}
>
{/* Game Title */}
<div className={css({ textAlign: 'center' })}>
<h1
className={css({
fontSize: '2xl',
fontWeight: 'bold',
color: 'purple.700',
marginBottom: '8px',
})}
>
🧮 Math Sprint
</h1>
<p className={css({ color: 'gray.600' })}>
Race to solve math problems! First correct answer wins points.
</p>
</div>
{/* Settings Card */}
<div
className={css({
background: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '12px',
padding: '24px',
})}
>
<h2
className={css({
fontSize: 'lg',
fontWeight: 'semibold',
marginBottom: '16px',
})}
>
Game Settings
</h2>
{/* Difficulty */}
<div className={css({ marginBottom: '20px' })}>
<label
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'medium',
marginBottom: '8px',
})}
>
Difficulty
</label>
<div className={css({ display: 'flex', gap: '8px' })}>
{(['easy', 'medium', 'hard'] as Difficulty[]).map((diff) => (
<button
key={diff}
type="button"
onClick={() => handleDifficultyChange(diff)}
className={css({
flex: 1,
padding: '10px 16px',
borderRadius: '8px',
border: '2px solid',
borderColor: state.difficulty === diff ? 'purple.500' : 'gray.300',
background: state.difficulty === diff ? 'purple.50' : 'white',
color: state.difficulty === diff ? 'purple.700' : 'gray.700',
fontWeight: state.difficulty === diff ? 'semibold' : 'normal',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'purple.400',
},
})}
>
{diff.charAt(0).toUpperCase() + diff.slice(1)}
</button>
))}
</div>
<p className={css({ fontSize: 'xs', color: 'gray.500', marginTop: '4px' })}>
{state.difficulty === 'easy' && 'Numbers 1-10, simple operations'}
{state.difficulty === 'medium' && 'Numbers 1-50, varied operations'}
{state.difficulty === 'hard' && 'Numbers 1-100, harder calculations'}
</p>
</div>
{/* Questions Per Round */}
<div>
<label
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'medium',
marginBottom: '8px',
})}
>
Questions: {state.questionsPerRound}
</label>
<input
type="range"
min="5"
max="20"
step="5"
value={state.questionsPerRound}
onChange={(e) => handleQuestionsChange(Number(e.target.value))}
className={css({
width: '100%',
})}
/>
<div
className={css({ display: 'flex', justifyContent: 'space-between', fontSize: 'xs' })}
>
<span>5</span>
<span>10</span>
<span>15</span>
<span>20</span>
</div>
</div>
</div>
{/* Instructions */}
<div
className={css({
background: 'linear-gradient(135deg, #fef3c7, #fde68a)',
border: '1px solid',
borderColor: 'yellow.300',
borderRadius: '12px',
padding: '16px',
})}
>
<h3 className={css({ fontSize: 'sm', fontWeight: 'semibold', marginBottom: '8px' })}>
How to Play
</h3>
<ul className={css({ fontSize: 'sm', color: 'gray.700', paddingLeft: '20px' })}>
<li>Solve math problems as fast as you can</li>
<li>First correct answer earns 10 points</li>
<li>Everyone can answer at the same time</li>
<li>Most points wins!</li>
</ul>
</div>
{/* Start Button */}
<button
type="button"
onClick={startGame}
disabled={state.activePlayers.length < 2}
className={css({
padding: '14px 28px',
fontSize: 'lg',
fontWeight: 'semibold',
color: 'white',
background: state.activePlayers.length < 2 ? 'gray.400' : 'purple.600',
borderRadius: '12px',
border: 'none',
cursor: state.activePlayers.length < 2 ? 'not-allowed' : 'pointer',
transition: 'all 0.2s',
_hover: {
background: state.activePlayers.length < 2 ? 'gray.400' : 'purple.700',
},
})}
>
{state.activePlayers.length < 2
? `Need ${2 - state.activePlayers.length} more player(s)`
: 'Start Game'}
</button>
</div>
)
}

View File

@@ -0,0 +1,16 @@
name: math-sprint
displayName: Math Sprint
icon: 🧮
description: Fast-paced math racing game
longDescription: Race against other players to solve math problems! Answer questions quickly to earn points. First person to answer correctly wins the round. Features multiple difficulty levels and customizable question counts.
maxPlayers: 6
difficulty: Beginner
chips:
- 👥 Multiplayer
- ⚡ Free-for-All
- 🧮 Math Skills
- 🏃 Speed
color: purple
gradient: linear-gradient(135deg, #ddd6fe, #c4b5fd)
borderColor: purple.200
available: true

View File

@@ -0,0 +1,61 @@
/**
* Math Sprint Game Definition
*
* A free-for-all math game demonstrating the TEAM_MOVE pattern.
* Players race to solve math problems - first correct answer wins points.
*/
import { defineGame } from '@/lib/arcade/game-sdk'
import type { GameManifest } from '@/lib/arcade/game-sdk'
import { GameComponent } from './components/GameComponent'
import { MathSprintProvider } from './Provider'
import type { MathSprintConfig, MathSprintMove, MathSprintState } from './types'
import { mathSprintValidator } from './Validator'
const manifest: GameManifest = {
name: 'math-sprint',
displayName: 'Math Sprint',
icon: '🧮',
description: 'Race to solve math problems!',
longDescription:
'A fast-paced free-for-all game where players compete to solve math problems. First correct answer earns points. Choose your difficulty and test your mental math skills!',
maxPlayers: 8,
difficulty: 'Beginner',
chips: ['👥 Multiplayer', '⚡ Fast-Paced', '🧠 Mental Math'],
color: 'purple',
gradient: 'linear-gradient(135deg, #ddd6fe, #c4b5fd)',
borderColor: 'purple.200',
available: true,
}
const defaultConfig: MathSprintConfig = {
difficulty: 'medium',
questionsPerRound: 10,
timePerQuestion: 30,
}
// Config validation function
function validateMathSprintConfig(config: unknown): config is MathSprintConfig {
return (
typeof config === 'object' &&
config !== null &&
'difficulty' in config &&
'questionsPerRound' in config &&
'timePerQuestion' in config &&
['easy', 'medium', 'hard'].includes((config as any).difficulty) &&
typeof (config as any).questionsPerRound === 'number' &&
typeof (config as any).timePerQuestion === 'number' &&
(config as any).questionsPerRound >= 5 &&
(config as any).questionsPerRound <= 20 &&
(config as any).timePerQuestion >= 10
)
}
export const mathSprintGame = defineGame<MathSprintConfig, MathSprintState, MathSprintMove>({
manifest,
Provider: MathSprintProvider,
GameComponent,
validator: mathSprintValidator,
defaultConfig,
validateConfig: validateMathSprintConfig,
})

View File

@@ -0,0 +1,120 @@
/**
* Math Sprint Game Types
*
* A free-for-all game where players race to solve math problems.
* Demonstrates the TEAM_MOVE pattern (no specific turn owner).
*/
import type { GameConfig, GameMove, GameState } from '@/lib/arcade/game-sdk'
/**
* Difficulty levels for math problems
*/
export type Difficulty = 'easy' | 'medium' | 'hard'
/**
* Math operation types
*/
export type Operation = 'addition' | 'subtraction' | 'multiplication'
/**
* Game configuration (persisted to database)
*/
export interface MathSprintConfig extends GameConfig {
difficulty: Difficulty
questionsPerRound: number
timePerQuestion: number // seconds
}
/**
* A math question
*/
export interface Question {
id: string
operand1: number
operand2: number
operation: Operation
correctAnswer: number
displayText: string // e.g., "5 + 3 = ?"
}
/**
* Player answer submission
*/
export interface Answer {
playerId: string
answer: number
timestamp: number
correct: boolean
}
/**
* Game state (synchronized across all clients)
*/
export interface MathSprintState extends GameState {
gamePhase: 'setup' | 'playing' | 'results'
activePlayers: string[]
playerMetadata: Record<string, { name: string; emoji: string; color: string; userId: string }>
// Configuration
difficulty: Difficulty
questionsPerRound: number
timePerQuestion: number
// Game progress
currentQuestionIndex: number
questions: Question[]
// Scoring
scores: Record<string, number> // playerId -> score
correctAnswersCount: Record<string, number> // playerId -> count
// Current question state
answers: Answer[] // All answers for current question
questionStartTime: number // Timestamp when question was shown
questionAnswered: boolean // True if someone got it right
winnerId: string | null // Winner of current question (first correct)
}
/**
* Move types for Math Sprint
*/
export type MathSprintMove =
| StartGameMove
| SubmitAnswerMove
| NextQuestionMove
| ResetGameMove
| SetConfigMove
export interface StartGameMove extends GameMove {
type: 'START_GAME'
data: {
activePlayers: string[]
playerMetadata: Record<string, unknown>
}
}
export interface SubmitAnswerMove extends GameMove {
type: 'SUBMIT_ANSWER'
data: {
answer: number
}
}
export interface NextQuestionMove extends GameMove {
type: 'NEXT_QUESTION'
data: Record<string, never>
}
export interface ResetGameMove extends GameMove {
type: 'RESET_GAME'
data: Record<string, never>
}
export interface SetConfigMove extends GameMove {
type: 'SET_CONFIG'
data: {
field: 'difficulty' | 'questionsPerRound' | 'timePerQuestion'
value: Difficulty | number
}
}

View File

@@ -0,0 +1,537 @@
'use client'
import type { ReactNode } from 'react'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { useGameMode } from '@/contexts/GameModeContext'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import {
buildPlayerMetadata as buildPlayerMetadataUtil,
buildPlayerOwnershipFromRoomData,
} from '@/lib/arcade/player-ownership.client'
import { TEAM_MOVE } from '@/lib/arcade/validation/types'
import type { QuizCard, MemoryQuizState, MemoryQuizMove } from './types'
import type { GameMove } from '@/lib/arcade/validation'
/**
* Optimistic move application (client-side prediction)
* The server will validate and send back the authoritative state
*/
function applyMoveOptimistically(state: MemoryQuizState, move: GameMove): MemoryQuizState {
const typedMove = move as MemoryQuizMove
switch (typedMove.type) {
case 'START_QUIZ': {
// Handle both client-generated moves (with quizCards) and server-generated moves (with numbers only)
const clientQuizCards = typedMove.data.quizCards
const serverNumbers = typedMove.data.numbers
let quizCards: QuizCard[]
let correctAnswers: number[]
if (clientQuizCards) {
// Client-side optimistic update: use the full quizCards with React components
quizCards = clientQuizCards
correctAnswers = clientQuizCards.map((card: QuizCard) => card.number)
} else if (serverNumbers) {
// Server update: create minimal quizCards from numbers
quizCards = serverNumbers.map((number: number) => ({
number,
svgComponent: null,
element: null,
}))
correctAnswers = serverNumbers
} else {
quizCards = state.quizCards
correctAnswers = state.correctAnswers
}
const cardCount = quizCards.length
// Initialize player scores for all active players (by userId)
const activePlayers = typedMove.data.activePlayers || []
const playerMetadata = typedMove.data.playerMetadata || {}
const uniqueUserIds = new Set<string>()
for (const playerId of activePlayers) {
const metadata = playerMetadata[playerId]
if (metadata?.userId) {
uniqueUserIds.add(metadata.userId)
}
}
const playerScores = Array.from(uniqueUserIds).reduce(
(acc: Record<string, { correct: number; incorrect: number }>, userId: string) => {
acc[userId] = { correct: 0, incorrect: 0 }
return acc
},
{}
)
return {
...state,
quizCards,
correctAnswers,
currentCardIndex: 0,
foundNumbers: [],
guessesRemaining: cardCount + Math.floor(cardCount / 2),
gamePhase: 'display',
incorrectGuesses: 0,
currentInput: '',
wrongGuessAnimations: [],
prefixAcceptanceTimeout: null,
activePlayers,
playerMetadata,
playerScores,
numberFoundBy: {},
}
}
case 'NEXT_CARD':
return {
...state,
currentCardIndex: state.currentCardIndex + 1,
}
case 'SHOW_INPUT_PHASE':
return {
...state,
gamePhase: 'input',
}
case 'ACCEPT_NUMBER': {
const playerScores = state.playerScores || {}
const foundNumbers = state.foundNumbers || []
const numberFoundBy = state.numberFoundBy || {}
const newPlayerScores = { ...playerScores }
const newNumberFoundBy = { ...numberFoundBy }
if (typedMove.userId) {
const currentScore = newPlayerScores[typedMove.userId] || { correct: 0, incorrect: 0 }
newPlayerScores[typedMove.userId] = {
...currentScore,
correct: currentScore.correct + 1,
}
newNumberFoundBy[typedMove.data.number] = typedMove.userId
}
return {
...state,
foundNumbers: [...foundNumbers, typedMove.data.number],
currentInput: '',
playerScores: newPlayerScores,
numberFoundBy: newNumberFoundBy,
}
}
case 'REJECT_NUMBER': {
const playerScores = state.playerScores || {}
const newPlayerScores = { ...playerScores }
if (typedMove.userId) {
const currentScore = newPlayerScores[typedMove.userId] || { correct: 0, incorrect: 0 }
newPlayerScores[typedMove.userId] = {
...currentScore,
incorrect: currentScore.incorrect + 1,
}
}
return {
...state,
guessesRemaining: state.guessesRemaining - 1,
incorrectGuesses: state.incorrectGuesses + 1,
currentInput: '',
playerScores: newPlayerScores,
}
}
case 'SET_INPUT':
return {
...state,
currentInput: typedMove.data.input,
}
case 'SHOW_RESULTS':
return {
...state,
gamePhase: 'results',
}
case 'RESET_QUIZ':
return {
...state,
gamePhase: 'setup',
quizCards: [],
correctAnswers: [],
currentCardIndex: 0,
foundNumbers: [],
guessesRemaining: 0,
currentInput: '',
incorrectGuesses: 0,
wrongGuessAnimations: [],
prefixAcceptanceTimeout: null,
finishButtonsBound: false,
}
case 'SET_CONFIG': {
const { field, value } = typedMove.data
return {
...state,
[field]: value,
}
}
default:
return state
}
}
// Context interface
export interface MemoryQuizContextValue {
state: MemoryQuizState
isGameActive: boolean
isRoomCreator: boolean
resetGame: () => void
exitSession?: () => void
startQuiz: (quizCards: QuizCard[]) => void
nextCard: () => void
showInputPhase: () => void
acceptNumber: (number: number) => void
rejectNumber: () => void
setInput: (input: string) => void
showResults: () => void
setConfig: (
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode',
value: unknown
) => void
// Legacy dispatch for UI-only actions (to be migrated to local state)
dispatch: (action: unknown) => void
}
// Create context
const MemoryQuizContext = createContext<MemoryQuizContextValue | null>(null)
// Hook to use the context
export function useMemoryQuiz(): MemoryQuizContextValue {
const context = useContext(MemoryQuizContext)
if (!context) {
throw new Error('useMemoryQuiz must be used within MemoryQuizProvider')
}
return context
}
/**
* MemoryQuizProvider - Unified provider for room-based multiplayer
*
* This provider uses useArcadeSession for network-synchronized gameplay.
* All state changes are sent as moves and validated on the server.
*/
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)
// LOCAL-ONLY state for current input (not synced over network)
const [localCurrentInput, setLocalCurrentInput] = useState('')
// Merge saved game config from room with default initial state
const mergedInitialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, unknown> | null | undefined
const savedConfig = gameConfig?.['memory-quiz'] as Record<string, unknown> | null | undefined
// Default initial state
const defaultState: MemoryQuizState = {
cards: [],
quizCards: [],
correctAnswers: [],
currentCardIndex: 0,
displayTime: 2.0,
selectedCount: 5,
selectedDifficulty: 'easy',
foundNumbers: [],
guessesRemaining: 0,
currentInput: '',
incorrectGuesses: 0,
activePlayers: [],
playerMetadata: {},
playerScores: {},
playMode: 'cooperative',
numberFoundBy: {},
gamePhase: 'setup',
prefixAcceptanceTimeout: null,
finishButtonsBound: false,
wrongGuessAnimations: [],
hasPhysicalKeyboard: null,
testingMode: false,
showOnScreenKeyboard: false,
}
if (!savedConfig) {
return defaultState
}
return {
...defaultState,
selectedCount:
(savedConfig.selectedCount as 2 | 5 | 8 | 12 | 15) ?? defaultState.selectedCount,
displayTime: (savedConfig.displayTime as number) ?? defaultState.displayTime,
selectedDifficulty:
(savedConfig.selectedDifficulty as MemoryQuizState['selectedDifficulty']) ??
defaultState.selectedDifficulty,
playMode: (savedConfig.playMode as 'cooperative' | 'competitive') ?? defaultState.playMode,
}
}, [roomData?.gameConfig])
// Arcade session integration
const { state, sendMove, exitSession } = useArcadeSession<MemoryQuizState>({
userId: viewerId || '',
roomId: roomData?.id || undefined,
initialState: mergedInitialState,
applyMove: applyMoveOptimistically,
})
// Clear local input when game phase changes
useEffect(() => {
if (state.gamePhase !== 'input') {
setLocalCurrentInput('')
}
}, [state.gamePhase])
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
if (state.prefixAcceptanceTimeout) {
clearTimeout(state.prefixAcceptanceTimeout)
}
}
}, [state.prefixAcceptanceTimeout])
// Detect state corruption
const hasStateCorruption =
!state.quizCards ||
!state.correctAnswers ||
!state.foundNumbers ||
!Array.isArray(state.quizCards)
// Computed values
const isGameActive = state.gamePhase === 'display' || state.gamePhase === 'input'
// Build player metadata
const buildPlayerMetadata = useCallback(() => {
const playerOwnership = buildPlayerOwnershipFromRoomData(roomData)
const metadata = buildPlayerMetadataUtil(
activePlayers,
playerOwnership,
players,
viewerId || undefined
)
return metadata
}, [activePlayers, players, roomData, viewerId])
// Action creators
const startQuiz = useCallback(
(quizCards: QuizCard[]) => {
const numbers = quizCards.map((card) => card.number)
const playerMetadata = buildPlayerMetadata()
sendMove({
type: 'START_QUIZ',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {
numbers,
quizCards,
activePlayers,
playerMetadata,
},
})
},
[viewerId, sendMove, activePlayers, buildPlayerMetadata]
)
const nextCard = useCallback(() => {
sendMove({
type: 'NEXT_CARD',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const showInputPhase = useCallback(() => {
sendMove({
type: 'SHOW_INPUT_PHASE',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const acceptNumber = useCallback(
(number: number) => {
setLocalCurrentInput('')
sendMove({
type: 'ACCEPT_NUMBER',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: { number },
})
},
[viewerId, sendMove]
)
const rejectNumber = useCallback(() => {
setLocalCurrentInput('')
sendMove({
type: 'REJECT_NUMBER',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const setInput = useCallback((input: string) => {
// LOCAL ONLY - no network sync for instant typing
setLocalCurrentInput(input)
}, [])
const showResults = useCallback(() => {
sendMove({
type: 'SHOW_RESULTS',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const resetGame = useCallback(() => {
sendMove({
type: 'RESET_QUIZ',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const setConfig = useCallback(
(
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode',
value: unknown
) => {
sendMove({
type: 'SET_CONFIG',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: { field, value },
})
// Save to room config for persistence
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<string, unknown>) || {}
const currentMemoryQuizConfig =
(currentGameConfig['memory-quiz'] as Record<string, unknown>) || {}
updateGameConfig({
roomId: roomData.id,
gameConfig: {
...currentGameConfig,
'memory-quiz': {
...currentMemoryQuizConfig,
[field]: value,
},
},
})
}
},
[viewerId, sendMove, roomData?.id, roomData?.gameConfig, updateGameConfig]
)
// Legacy dispatch stub for UI-only actions
// TODO: Migrate these to local component state
const dispatch = useCallback((action: unknown) => {
console.warn(
'[MemoryQuizProvider] dispatch() is deprecated for UI-only actions. These should be migrated to local component state:',
action
)
// No-op - UI-only state changes should be handled locally
}, [])
// Merge network state with local input
const mergedState = {
...state,
currentInput: localCurrentInput,
}
// Determine if current user is room creator
const isRoomCreator =
roomData?.members.find((member) => member.userId === viewerId)?.isCreator || false
// Handle state corruption
if (hasStateCorruption) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px',
textAlign: 'center',
minHeight: '400px',
}}
>
<div style={{ fontSize: '48px', marginBottom: '20px' }}></div>
<h2
style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '12px', color: '#dc2626' }}
>
Game State Mismatch
</h2>
<p style={{ fontSize: '16px', color: '#6b7280', marginBottom: '24px', maxWidth: '500px' }}>
There's a mismatch between game types in this room. This usually happens when room members
are playing different games.
</p>
<button
type="button"
onClick={() => window.location.reload()}
style={{
padding: '10px 20px',
background: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
}}
>
Refresh Page
</button>
</div>
)
}
const contextValue: MemoryQuizContextValue = {
state: mergedState,
isGameActive,
resetGame,
exitSession,
isRoomCreator,
startQuiz,
nextCard,
showInputPhase,
acceptNumber,
rejectNumber,
setInput,
showResults,
setConfig,
dispatch,
}
return <MemoryQuizContext.Provider value={contextValue}>{children}</MemoryQuizContext.Provider>
}

View File

@@ -3,20 +3,18 @@
* Validates all game moves and state transitions
*/
import type { DifficultyLevel, SorobanQuizState } from '@/app/arcade/memory-quiz/types'
import type { GameValidator, ValidationResult } from '@/lib/arcade/game-sdk'
import type {
GameValidator,
MemoryQuizGameMove,
MemoryQuizConfig,
MemoryQuizState,
MemoryQuizMove,
MemoryQuizSetConfigMove,
ValidationResult,
} from './types'
export class MemoryQuizGameValidator
implements GameValidator<SorobanQuizState, MemoryQuizGameMove>
{
export class MemoryQuizGameValidator implements GameValidator<MemoryQuizState, MemoryQuizMove> {
validateMove(
state: SorobanQuizState,
move: MemoryQuizGameMove,
state: MemoryQuizState,
move: MemoryQuizMove,
context?: { userId?: string; playerOwnership?: Record<string, string> }
): ValidationResult {
switch (move.type) {
@@ -57,7 +55,7 @@ export class MemoryQuizGameValidator
}
}
private validateStartQuiz(state: SorobanQuizState, data: any): ValidationResult {
private validateStartQuiz(state: MemoryQuizState, data: any): ValidationResult {
// Can start quiz from setup or results phase
if (state.gamePhase !== 'setup' && state.gamePhase !== 'results') {
return {
@@ -101,7 +99,7 @@ export class MemoryQuizGameValidator
return acc
}, {})
const newState: SorobanQuizState = {
const newState: MemoryQuizState = {
...state,
quizCards,
correctAnswers: numbers,
@@ -126,7 +124,7 @@ export class MemoryQuizGameValidator
}
}
private validateNextCard(state: SorobanQuizState): ValidationResult {
private validateNextCard(state: MemoryQuizState): ValidationResult {
// Must be in display phase
if (state.gamePhase !== 'display') {
return {
@@ -135,7 +133,7 @@ export class MemoryQuizGameValidator
}
}
const newState: SorobanQuizState = {
const newState: MemoryQuizState = {
...state,
currentCardIndex: state.currentCardIndex + 1,
}
@@ -146,7 +144,7 @@ export class MemoryQuizGameValidator
}
}
private validateShowInputPhase(state: SorobanQuizState): ValidationResult {
private validateShowInputPhase(state: MemoryQuizState): ValidationResult {
// Must have shown all cards
if (state.currentCardIndex < state.quizCards.length) {
return {
@@ -155,7 +153,7 @@ export class MemoryQuizGameValidator
}
}
const newState: SorobanQuizState = {
const newState: MemoryQuizState = {
...state,
gamePhase: 'input',
}
@@ -167,7 +165,7 @@ export class MemoryQuizGameValidator
}
private validateAcceptNumber(
state: SorobanQuizState,
state: MemoryQuizState,
number: number,
userId?: string
): ValidationResult {
@@ -180,12 +178,6 @@ export class MemoryQuizGameValidator
}
// Number must be in correct answers
console.log('[MemoryQuizValidator] Checking number:', {
number,
correctAnswers: state.correctAnswers,
includes: state.correctAnswers.includes(number),
})
if (!state.correctAnswers.includes(number)) {
return {
valid: false,
@@ -217,7 +209,7 @@ export class MemoryQuizGameValidator
newNumberFoundBy[number] = userId
}
const newState: SorobanQuizState = {
const newState: MemoryQuizState = {
...state,
foundNumbers: [...state.foundNumbers, number],
currentInput: '',
@@ -231,7 +223,7 @@ export class MemoryQuizGameValidator
}
}
private validateRejectNumber(state: SorobanQuizState, userId?: string): ValidationResult {
private validateRejectNumber(state: MemoryQuizState, userId?: string): ValidationResult {
// Must be in input phase
if (state.gamePhase !== 'input') {
return {
@@ -259,7 +251,7 @@ export class MemoryQuizGameValidator
}
}
const newState: SorobanQuizState = {
const newState: MemoryQuizState = {
...state,
guessesRemaining: state.guessesRemaining - 1,
incorrectGuesses: state.incorrectGuesses + 1,
@@ -273,7 +265,7 @@ export class MemoryQuizGameValidator
}
}
private validateSetInput(state: SorobanQuizState, input: string): ValidationResult {
private validateSetInput(state: MemoryQuizState, input: string): ValidationResult {
// Must be in input phase
if (state.gamePhase !== 'input') {
return {
@@ -290,7 +282,7 @@ export class MemoryQuizGameValidator
}
}
const newState: SorobanQuizState = {
const newState: MemoryQuizState = {
...state,
currentInput: input,
}
@@ -301,7 +293,7 @@ export class MemoryQuizGameValidator
}
}
private validateShowResults(state: SorobanQuizState): ValidationResult {
private validateShowResults(state: MemoryQuizState): ValidationResult {
// Can show results from input phase
if (state.gamePhase !== 'input') {
return {
@@ -310,7 +302,7 @@ export class MemoryQuizGameValidator
}
}
const newState: SorobanQuizState = {
const newState: MemoryQuizState = {
...state,
gamePhase: 'results',
}
@@ -321,9 +313,9 @@ export class MemoryQuizGameValidator
}
}
private validateResetQuiz(state: SorobanQuizState): ValidationResult {
private validateResetQuiz(state: MemoryQuizState): ValidationResult {
// Can reset from any phase
const newState: SorobanQuizState = {
const newState: MemoryQuizState = {
...state,
gamePhase: 'setup',
quizCards: [],
@@ -345,7 +337,7 @@ export class MemoryQuizGameValidator
}
private validateSetConfig(
state: SorobanQuizState,
state: MemoryQuizState,
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode',
value: any
): ValidationResult {
@@ -397,15 +389,11 @@ export class MemoryQuizGameValidator
}
}
isGameComplete(state: SorobanQuizState): boolean {
isGameComplete(state: MemoryQuizState): boolean {
return state.gamePhase === 'results'
}
getInitialState(config: {
selectedCount: number
displayTime: number
selectedDifficulty: DifficultyLevel
}): SorobanQuizState {
getInitialState(config: MemoryQuizConfig): MemoryQuizState {
return {
cards: [],
quizCards: [],
@@ -422,7 +410,7 @@ export class MemoryQuizGameValidator
activePlayers: [],
playerMetadata: {},
playerScores: {},
playMode: 'cooperative',
playMode: config.playMode || 'cooperative',
numberFoundBy: {},
// UI state
gamePhase: 'setup',

View File

@@ -1,8 +1,8 @@
import { AbacusReact } from '@soroban/abacus-react'
import type { SorobanQuizState } from '../types'
import type { MemoryQuizState } from '../types'
interface CardGridProps {
state: SorobanQuizState
state: MemoryQuizState
}
export function CardGrid({ state }: CardGridProps) {

View File

@@ -1,6 +1,6 @@
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useMemoryQuiz } from '../context/MemoryQuizContext'
import { useMemoryQuiz } from '../Provider'
import type { QuizCard } from '../types'
// Calculate maximum columns needed for a set of numbers

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { isPrefix } from '@/lib/memory-quiz-utils'
import { useMemoryQuiz } from '../context/MemoryQuizContext'
import { useMemoryQuiz } from '../Provider'
import { CardGrid } from './CardGrid'
export function InputPhase() {

View File

@@ -3,8 +3,8 @@
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../../../../styled-system/css'
import { useMemoryQuiz } from '../context/MemoryQuizContext'
import { css } from '../../../../styled-system/css'
import { useMemoryQuiz } from '../Provider'
import { DisplayPhase } from './DisplayPhase'
import { InputPhase } from './InputPhase'
import { ResultsPhase } from './ResultsPhase'

View File

@@ -1,8 +1,8 @@
import { AbacusReact } from '@soroban/abacus-react'
import type { SorobanQuizState } from '../types'
import type { MemoryQuizState } from '../types'
interface ResultsCardGridProps {
state: SorobanQuizState
state: MemoryQuizState
}
export function ResultsCardGrid({ state }: ResultsCardGridProps) {

View File

@@ -1,5 +1,5 @@
import { useAbacusConfig } from '@soroban/abacus-react'
import { useMemoryQuiz } from '../context/MemoryQuizContext'
import { useMemoryQuiz } from '../Provider'
import { DIFFICULTY_LEVELS, type DifficultyLevel, type QuizCard } from '../types'
import { ResultsCardGrid } from './ResultsCardGrid'
@@ -384,7 +384,7 @@ export function ResultsPhase() {
// Group players by userId
const userTeams = new Map<
string,
{ userId: string; players: any[]; score: { correct: number; incorrect: 0 } }
{ userId: string; players: any[]; score: { correct: number; incorrect: number } }
>()
console.log('🤝 [ResultsPhase] Building team contributions:', {

View File

@@ -1,5 +1,5 @@
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
import { useMemoryQuiz } from '../context/MemoryQuizContext'
import { useMemoryQuiz } from '../Provider'
import { DIFFICULTY_LEVELS, type DifficultyLevel, type QuizCard } from '../types'
// Generate quiz cards with difficulty-based number ranges

View File

@@ -0,0 +1,85 @@
/**
* Memory Quiz (Memory Lightning) Game Definition
*
* A memory game where players memorize soroban numbers and recall them.
* Supports both cooperative and competitive multiplayer modes.
*/
import { defineGame } from '@/lib/arcade/game-sdk'
import type { GameManifest } from '@/lib/arcade/game-sdk'
import { MemoryQuizGame } from './components/MemoryQuizGame'
import { MemoryQuizProvider } from './Provider'
import type { MemoryQuizConfig, MemoryQuizMove, MemoryQuizState } from './types'
import { memoryQuizGameValidator } from './Validator'
const manifest: GameManifest = {
name: 'memory-quiz',
displayName: 'Memory Lightning',
icon: '🧠',
description: 'Memorize soroban numbers and recall them',
longDescription:
'Test your memory by studying soroban numbers for a brief time, then recall as many as you can. ' +
'Choose your difficulty level, number of cards, and display time. Play cooperatively with friends or compete for the highest score!',
maxPlayers: 8,
difficulty: 'Intermediate',
chips: ['👥 Multiplayer', '🧠 Memory', '🧮 Soroban'],
color: 'blue',
gradient: 'linear-gradient(135deg, #dbeafe, #bfdbfe)',
borderColor: 'blue.200',
available: true,
}
const defaultConfig: MemoryQuizConfig = {
selectedCount: 5,
displayTime: 2.0,
selectedDifficulty: 'easy',
playMode: 'cooperative',
}
// Config validation function
function validateMemoryQuizConfig(config: unknown): config is MemoryQuizConfig {
if (typeof config !== 'object' || config === null) {
return false
}
const c = config as any
// Validate selectedCount
if (!('selectedCount' in c) || ![2, 5, 8, 12, 15].includes(c.selectedCount)) {
return false
}
// Validate displayTime
if (
!('displayTime' in c) ||
typeof c.displayTime !== 'number' ||
c.displayTime < 0.5 ||
c.displayTime > 10
) {
return false
}
// Validate selectedDifficulty
if (
!('selectedDifficulty' in c) ||
!['beginner', 'easy', 'medium', 'hard', 'expert'].includes(c.selectedDifficulty)
) {
return false
}
// Validate playMode
if (!('playMode' in c) || !['cooperative', 'competitive'].includes(c.playMode)) {
return false
}
return true
}
export const memoryQuizGame = defineGame<MemoryQuizConfig, MemoryQuizState, MemoryQuizMove>({
manifest,
Provider: MemoryQuizProvider,
GameComponent: MemoryQuizGame,
validator: memoryQuizGameValidator,
defaultConfig,
validateConfig: validateMemoryQuizConfig,
})

View File

@@ -1,3 +1,4 @@
import type { GameConfig, GameState } from '@/lib/arcade/game-sdk'
import type { PlayerMetadata } from '@/lib/arcade/player-ownership.client'
export interface QuizCard {
@@ -11,7 +12,16 @@ export interface PlayerScore {
incorrect: number
}
export interface SorobanQuizState {
// Memory Quiz Configuration
export interface MemoryQuizConfig extends GameConfig {
selectedCount: 2 | 5 | 8 | 12 | 15
displayTime: number
selectedDifficulty: DifficultyLevel
playMode: 'cooperative' | 'competitive'
}
// Memory Quiz State
export interface MemoryQuizState extends GameState {
// Core game data
cards: QuizCard[]
quizCards: QuizCard[]
@@ -52,6 +62,7 @@ export interface SorobanQuizState {
showOnScreenKeyboard: boolean
}
// Legacy reducer actions (deprecated - will be removed)
export type QuizAction =
| { type: 'SET_CARDS'; cards: QuizCard[] }
| { type: 'SET_DISPLAY_TIME'; time: number }
@@ -103,3 +114,79 @@ export const DIFFICULTY_LEVELS = {
} as const
export type DifficultyLevel = keyof typeof DIFFICULTY_LEVELS
// Memory Quiz Move Types (SDK-compatible)
export type MemoryQuizMove =
| {
type: 'START_QUIZ'
playerId: string
userId: string
timestamp: number
data: {
numbers: number[]
quizCards?: QuizCard[]
activePlayers: string[]
playerMetadata: Record<string, PlayerMetadata>
}
}
| {
type: 'NEXT_CARD'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'SHOW_INPUT_PHASE'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'ACCEPT_NUMBER'
playerId: string
userId: string
timestamp: number
data: { number: number }
}
| {
type: 'REJECT_NUMBER'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'SET_INPUT'
playerId: string
userId: string
timestamp: number
data: { input: string }
}
| {
type: 'SHOW_RESULTS'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'RESET_QUIZ'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'SET_CONFIG'
playerId: string
userId: string
timestamp: number
data: {
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode'
value: any
}
}
export type MemoryQuizSetConfigMove = Extract<MemoryQuizMove, { type: 'SET_CONFIG' }>

View File

@@ -0,0 +1,215 @@
/**
* Number Guesser Provider
* Manages game state using the Arcade SDK
*/
'use client'
import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'
import {
type GameMove,
buildPlayerMetadata,
useArcadeSession,
useGameMode,
useRoomData,
useUpdateGameConfig,
useViewerId,
} from '@/lib/arcade/game-sdk'
import type { NumberGuesserState } from './types'
/**
* Context value interface
*/
interface NumberGuesserContextValue {
state: NumberGuesserState
lastError: string | null
startGame: () => void
chooseNumber: (number: number) => void
makeGuess: (guess: number) => void
nextRound: () => void
goToSetup: () => void
setConfig: (field: 'minNumber' | 'maxNumber' | 'roundsToWin', value: number) => void
clearError: () => void
exitSession: () => void
}
const NumberGuesserContext = createContext<NumberGuesserContextValue | null>(null)
/**
* Hook to access Number Guesser context
*/
export function useNumberGuesser() {
const context = useContext(NumberGuesserContext)
if (!context) {
throw new Error('useNumberGuesser must be used within NumberGuesserProvider')
}
return context
}
/**
* Optimistic move application
*/
function applyMoveOptimistically(state: NumberGuesserState, move: GameMove): NumberGuesserState {
// For simplicity, just return current state
// Server will send back the validated new state
return state
}
/**
* Number Guesser Provider Component
*/
export function NumberGuesserProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData()
const { activePlayers: activePlayerIds, players } = useGameMode()
const { mutate: updateGameConfig } = useUpdateGameConfig()
// Get active players as array (keep Set iteration order to match UI display)
const activePlayers = Array.from(activePlayerIds)
// Merge saved config from room
const initialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, unknown> | null | undefined
const savedConfig = gameConfig?.['number-guesser'] as Record<string, unknown> | undefined
return {
minNumber: (savedConfig?.minNumber as number) || 1,
maxNumber: (savedConfig?.maxNumber as number) || 100,
roundsToWin: (savedConfig?.roundsToWin as number) || 3,
gamePhase: 'setup' as const,
activePlayers: [],
playerMetadata: {},
secretNumber: null,
chooser: '',
currentGuesser: '',
guesses: [],
roundNumber: 0,
scores: {},
gameStartTime: null,
gameEndTime: null,
winner: null,
}
}, [roomData?.gameConfig])
// Arcade session integration
const { state, sendMove, exitSession, lastError, clearError } =
useArcadeSession<NumberGuesserState>({
userId: viewerId || '',
roomId: roomData?.id,
initialState,
applyMove: applyMoveOptimistically,
})
// Action creators
const startGame = useCallback(() => {
if (activePlayers.length < 2) {
console.error('Need at least 2 players to start')
return
}
const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId || undefined)
sendMove({
type: 'START_GAME',
playerId: activePlayers[0],
userId: viewerId || '',
data: {
activePlayers,
playerMetadata,
},
})
}, [activePlayers, players, viewerId, sendMove])
const chooseNumber = useCallback(
(secretNumber: number) => {
sendMove({
type: 'CHOOSE_NUMBER',
playerId: state.chooser,
userId: viewerId || '',
data: { secretNumber },
})
},
[state.chooser, viewerId, sendMove]
)
const makeGuess = useCallback(
(guess: number) => {
const playerName = state.playerMetadata[state.currentGuesser]?.name || 'Unknown'
sendMove({
type: 'MAKE_GUESS',
playerId: state.currentGuesser,
userId: viewerId || '',
data: { guess, playerName },
})
},
[state.currentGuesser, state.playerMetadata, viewerId, sendMove]
)
const nextRound = useCallback(() => {
sendMove({
type: 'NEXT_ROUND',
playerId: activePlayers[0] || '',
userId: viewerId || '',
data: {},
})
}, [activePlayers, viewerId, sendMove])
const goToSetup = useCallback(() => {
sendMove({
type: 'GO_TO_SETUP',
playerId: activePlayers[0] || state.chooser || '',
userId: viewerId || '',
data: {},
})
}, [activePlayers, state.chooser, viewerId, sendMove])
const setConfig = useCallback(
(field: 'minNumber' | 'maxNumber' | 'roundsToWin', value: number) => {
sendMove({
type: 'SET_CONFIG',
playerId: activePlayers[0] || '',
userId: viewerId || '',
data: { field, value },
})
// Persist to database
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<string, unknown>) || {}
const currentNumberGuesserConfig =
(currentGameConfig['number-guesser'] as Record<string, unknown>) || {}
const updatedConfig = {
...currentGameConfig,
'number-guesser': {
...currentNumberGuesserConfig,
[field]: value,
},
}
updateGameConfig({
roomId: roomData.id,
gameConfig: updatedConfig,
})
}
},
[activePlayers, viewerId, sendMove, roomData?.id, roomData?.gameConfig, updateGameConfig]
)
const contextValue: NumberGuesserContextValue = {
state,
lastError,
startGame,
chooseNumber,
makeGuess,
nextRound,
goToSetup,
setConfig,
clearError,
exitSession,
}
return (
<NumberGuesserContext.Provider value={contextValue}>{children}</NumberGuesserContext.Provider>
)
}

View File

@@ -0,0 +1,315 @@
/**
* Server-side validator for Number Guesser game
*/
import type { GameValidator, ValidationResult } from '@/lib/arcade/game-sdk'
import type { NumberGuesserConfig, NumberGuesserMove, NumberGuesserState } from './types'
export class NumberGuesserValidator
implements GameValidator<NumberGuesserState, NumberGuesserMove>
{
validateMove(state: NumberGuesserState, move: NumberGuesserMove): ValidationResult {
switch (move.type) {
case 'START_GAME':
return this.validateStartGame(state, move.data.activePlayers, move.data.playerMetadata)
case 'CHOOSE_NUMBER':
// Ensure secretNumber is a number (JSON deserialization can make it a string)
return this.validateChooseNumber(state, Number(move.data.secretNumber), move.playerId)
case 'MAKE_GUESS':
// Ensure guess is a number (JSON deserialization can make it a string)
return this.validateMakeGuess(
state,
Number(move.data.guess),
move.playerId,
move.data.playerName
)
case 'NEXT_ROUND':
return this.validateNextRound(state)
case 'GO_TO_SETUP':
return this.validateGoToSetup(state)
case 'SET_CONFIG':
// Ensure value is a number (JSON deserialization can make it a string)
return this.validateSetConfig(state, move.data.field, Number(move.data.value))
default:
return {
valid: false,
error: `Unknown move type: ${(move as { type: string }).type}`,
}
}
}
private validateStartGame(
state: NumberGuesserState,
activePlayers: string[],
playerMetadata: Record<string, unknown>
): ValidationResult {
if (!activePlayers || activePlayers.length < 2) {
return { valid: false, error: 'Need at least 2 players' }
}
const newState: NumberGuesserState = {
...state,
gamePhase: 'choosing',
activePlayers,
playerMetadata: playerMetadata as typeof state.playerMetadata,
chooser: activePlayers[0],
currentGuesser: '',
secretNumber: null,
guesses: [],
roundNumber: 1,
scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
gameStartTime: Date.now(),
gameEndTime: null,
winner: null,
}
return { valid: true, newState }
}
private validateChooseNumber(
state: NumberGuesserState,
secretNumber: number,
playerId: string
): ValidationResult {
if (state.gamePhase !== 'choosing') {
return { valid: false, error: 'Not in choosing phase' }
}
if (playerId !== state.chooser) {
return { valid: false, error: 'Not your turn to choose' }
}
if (
secretNumber < state.minNumber ||
secretNumber > state.maxNumber ||
!Number.isInteger(secretNumber)
) {
return {
valid: false,
error: `Number must be between ${state.minNumber} and ${state.maxNumber}`,
}
}
// Debug logging
console.log('[NumberGuesser] Setting secret number:', {
secretNumber,
secretNumberType: typeof secretNumber,
})
// First guesser is the next player after chooser
const chooserIndex = state.activePlayers.indexOf(state.chooser)
const firstGuesserIndex = (chooserIndex + 1) % state.activePlayers.length
const firstGuesser = state.activePlayers[firstGuesserIndex]
const newState: NumberGuesserState = {
...state,
gamePhase: 'guessing',
secretNumber,
currentGuesser: firstGuesser,
}
return { valid: true, newState }
}
private validateMakeGuess(
state: NumberGuesserState,
guess: number,
playerId: string,
playerName: string
): ValidationResult {
if (state.gamePhase !== 'guessing') {
return { valid: false, error: 'Not in guessing phase' }
}
if (playerId !== state.currentGuesser) {
return { valid: false, error: 'Not your turn to guess' }
}
if (guess < state.minNumber || guess > state.maxNumber || !Number.isInteger(guess)) {
return {
valid: false,
error: `Guess must be between ${state.minNumber} and ${state.maxNumber}`,
}
}
if (!state.secretNumber) {
return { valid: false, error: 'No secret number set' }
}
// Debug logging
console.log('[NumberGuesser] Validating guess:', {
guess,
guessType: typeof guess,
secretNumber: state.secretNumber,
secretNumberType: typeof state.secretNumber,
})
const distance = Math.abs(guess - state.secretNumber)
console.log('[NumberGuesser] Calculated distance:', distance)
const newGuess = {
playerId,
playerName,
guess,
distance,
timestamp: Date.now(),
}
const guesses = [...state.guesses, newGuess]
// Check if guess is correct
if (distance === 0) {
// Correct guess! Award point and end round
const newScores = {
...state.scores,
[playerId]: (state.scores[playerId] || 0) + 1,
}
// Check if player won
const winner = newScores[playerId] >= state.roundsToWin ? playerId : null
const newState: NumberGuesserState = {
...state,
guesses,
scores: newScores,
gamePhase: winner ? 'results' : 'guessing',
gameEndTime: winner ? Date.now() : null,
winner,
}
return { valid: true, newState }
}
// Incorrect guess, move to next guesser
const guesserIndex = state.activePlayers.indexOf(state.currentGuesser)
let nextGuesserIndex = (guesserIndex + 1) % state.activePlayers.length
// Skip the chooser
if (state.activePlayers[nextGuesserIndex] === state.chooser) {
nextGuesserIndex = (nextGuesserIndex + 1) % state.activePlayers.length
}
const newState: NumberGuesserState = {
...state,
guesses,
currentGuesser: state.activePlayers[nextGuesserIndex],
}
return { valid: true, newState }
}
private validateNextRound(state: NumberGuesserState): ValidationResult {
if (state.gamePhase !== 'guessing') {
return { valid: false, error: 'Not in guessing phase' }
}
// Check if the round is complete (someone guessed correctly)
const roundComplete =
state.guesses.length > 0 && state.guesses[state.guesses.length - 1].distance === 0
if (!roundComplete) {
return { valid: false, error: 'Round not complete yet - no one has guessed the number' }
}
// Rotate chooser to next player
const chooserIndex = state.activePlayers.indexOf(state.chooser)
const nextChooserIndex = (chooserIndex + 1) % state.activePlayers.length
const nextChooser = state.activePlayers[nextChooserIndex]
const newState: NumberGuesserState = {
...state,
gamePhase: 'choosing',
chooser: nextChooser,
currentGuesser: '',
secretNumber: null,
guesses: [],
roundNumber: state.roundNumber + 1,
winner: null,
}
return { valid: true, newState }
}
private validateGoToSetup(state: NumberGuesserState): ValidationResult {
const newState: NumberGuesserState = {
...state,
gamePhase: 'setup',
secretNumber: null,
chooser: '',
currentGuesser: '',
guesses: [],
roundNumber: 0,
scores: {},
activePlayers: [],
playerMetadata: {},
gameStartTime: null,
gameEndTime: null,
winner: null,
}
return { valid: true, newState }
}
private validateSetConfig(
state: NumberGuesserState,
field: 'minNumber' | 'maxNumber' | 'roundsToWin',
value: number
): ValidationResult {
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Can only change config in setup' }
}
if (!Number.isInteger(value) || value < 1) {
return { valid: false, error: 'Value must be a positive integer' }
}
if (field === 'minNumber' && value >= state.maxNumber) {
return { valid: false, error: 'Min must be less than max' }
}
if (field === 'maxNumber' && value <= state.minNumber) {
return { valid: false, error: 'Max must be greater than min' }
}
const newState: NumberGuesserState = {
...state,
[field]: value,
}
return { valid: true, newState }
}
isGameComplete(state: NumberGuesserState): boolean {
return state.gamePhase === 'results' && state.winner !== null
}
getInitialState(config: unknown): NumberGuesserState {
const { minNumber, maxNumber, roundsToWin } = config as NumberGuesserConfig
return {
minNumber: minNumber || 1,
maxNumber: maxNumber || 100,
roundsToWin: roundsToWin || 3,
gamePhase: 'setup',
activePlayers: [],
playerMetadata: {},
secretNumber: null,
chooser: '',
currentGuesser: '',
guesses: [],
roundNumber: 0,
scores: {},
gameStartTime: null,
gameEndTime: null,
winner: null,
}
}
}
export const numberGuesserValidator = new NumberGuesserValidator()

View File

@@ -0,0 +1,211 @@
/**
* Choosing Phase - Chooser picks a secret number
*/
'use client'
import { useState } from 'react'
import { useViewerId } from '@/lib/arcade/game-sdk'
import { css } from '../../../../styled-system/css'
import { useNumberGuesser } from '../Provider'
export function ChoosingPhase() {
const { state, chooseNumber } = useNumberGuesser()
const { data: viewerId } = useViewerId()
const [inputValue, setInputValue] = useState('')
const chooserMetadata = state.playerMetadata[state.chooser]
const isChooser = chooserMetadata?.userId === viewerId
const handleSubmit = () => {
const number = Number.parseInt(inputValue, 10)
if (Number.isNaN(number)) return
chooseNumber(number)
}
return (
<div
className={css({
padding: '32px',
maxWidth: '600px',
margin: '0 auto',
})}
>
<div
className={css({
textAlign: 'center',
marginBottom: '32px',
})}
>
<div
className={css({
fontSize: '64px',
marginBottom: '16px',
})}
>
{chooserMetadata?.emoji || '🤔'}
</div>
<h2
className={css({
fontSize: '2xl',
fontWeight: 'bold',
marginBottom: '8px',
})}
>
{isChooser ? "You're choosing!" : `${chooserMetadata?.name || 'Someone'} is choosing...`}
</h2>
<p
className={css({
color: 'gray.600',
})}
>
Round {state.roundNumber}
</p>
</div>
{isChooser ? (
<div
className={css({
background: 'white',
border: '2px solid',
borderColor: 'orange.200',
borderRadius: '12px',
padding: '24px',
})}
>
<label
className={css({
display: 'block',
fontSize: 'md',
fontWeight: '600',
marginBottom: '12px',
textAlign: 'center',
})}
>
Choose a secret number ({state.minNumber} - {state.maxNumber})
</label>
<input
type="number"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
min={state.minNumber}
max={state.maxNumber}
placeholder={`${state.minNumber} - ${state.maxNumber}`}
className={css({
width: '100%',
padding: '16px',
border: '2px solid',
borderColor: 'gray.300',
borderRadius: '8px',
fontSize: 'xl',
textAlign: 'center',
marginBottom: '16px',
})}
/>
<button
onClick={handleSubmit}
disabled={!inputValue}
className={css({
width: '100%',
padding: '16px',
background: 'linear-gradient(135deg, #fb923c, #f97316)',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: 'lg',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s',
_disabled: {
opacity: 0.5,
cursor: 'not-allowed',
},
_hover: {
transform: 'translateY(-2px)',
},
})}
>
Confirm Choice
</button>
</div>
) : (
<div
className={css({
background: 'white',
border: '2px solid',
borderColor: 'orange.200',
borderRadius: '12px',
padding: '32px',
textAlign: 'center',
})}
>
<div
className={css({
fontSize: '48px',
marginBottom: '16px',
})}
>
</div>
<p
className={css({
fontSize: 'lg',
color: 'gray.600',
})}
>
Waiting for {chooserMetadata?.name || 'player'} to choose a number...
</p>
</div>
)}
{/* Scoreboard */}
<div
className={css({
marginTop: '32px',
background: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '12px',
padding: '16px',
})}
>
<h3
className={css({
fontSize: 'md',
fontWeight: 'bold',
marginBottom: '12px',
textAlign: 'center',
})}
>
Scores
</h3>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
justifyContent: 'center',
})}
>
{state.activePlayers.map((playerId) => {
const player = state.playerMetadata[playerId]
return (
<div
key={playerId}
className={css({
padding: '8px 16px',
background: 'gray.100',
borderRadius: '8px',
fontSize: 'sm',
})}
>
{player?.emoji} {player?.name}: {state.scores[playerId] || 0}
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,60 @@
/**
* Number Guesser Game Component
* Main component that switches between game phases
*/
'use client'
import { useRouter } from 'next/navigation'
import { PageWithNav } from '@/components/PageWithNav'
import { useNumberGuesser } from '../Provider'
import { ChoosingPhase } from './ChoosingPhase'
import { GuessingPhase } from './GuessingPhase'
import { ResultsPhase } from './ResultsPhase'
import { SetupPhase } from './SetupPhase'
export function GameComponent() {
const router = useRouter()
const { state, exitSession, goToSetup } = useNumberGuesser()
// Determine whose turn it is based on game phase
const currentPlayerId =
state.gamePhase === 'choosing'
? state.chooser
: state.gamePhase === 'guessing'
? state.currentGuesser
: undefined
return (
<PageWithNav
navTitle="Number Guesser"
navEmoji="🎯"
emphasizePlayerSelection={state.gamePhase === 'setup'}
currentPlayerId={currentPlayerId}
playerScores={state.scores}
onExitSession={() => {
exitSession?.()
router.push('/arcade')
}}
onNewGame={() => {
goToSetup?.()
}}
>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
minHeight: '100vh',
background: 'linear-gradient(135deg, #fff7ed, #ffedd5)',
}}
>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'choosing' && <ChoosingPhase />}
{state.gamePhase === 'guessing' && <GuessingPhase />}
{state.gamePhase === 'results' && <ResultsPhase />}
</div>
</PageWithNav>
)
}

View File

@@ -0,0 +1,445 @@
/**
* Guessing Phase - Players take turns guessing the secret number
*/
'use client'
import { useEffect, useState } from 'react'
import { useViewerId } from '@/lib/arcade/game-sdk'
import { css } from '../../../../styled-system/css'
import { useNumberGuesser } from '../Provider'
export function GuessingPhase() {
const { state, makeGuess, nextRound, lastError, clearError } = useNumberGuesser()
const { data: viewerId } = useViewerId()
const [inputValue, setInputValue] = useState('')
const currentGuesserMetadata = state.playerMetadata[state.currentGuesser]
const isCurrentGuesser = currentGuesserMetadata?.userId === viewerId
// Check if someone just won the round
const lastGuess = state.guesses[state.guesses.length - 1]
const roundJustEnded = lastGuess?.distance === 0
// Auto-clear error after 5 seconds
useEffect(() => {
if (lastError) {
const timeout = setTimeout(() => clearError(), 5000)
return () => clearTimeout(timeout)
}
}, [lastError, clearError])
const handleSubmit = () => {
const guess = Number.parseInt(inputValue, 10)
if (Number.isNaN(guess)) return
makeGuess(guess)
setInputValue('')
}
const getHotColdMessage = (distance: number) => {
if (distance === 0) return '🎯 Correct!'
if (distance <= 5) return '🔥 Very Hot!'
if (distance <= 10) return '🌡️ Hot'
if (distance <= 20) return '😊 Warm'
if (distance <= 30) return '😐 Cool'
if (distance <= 50) return '❄️ Cold'
return '🧊 Very Cold'
}
return (
<div
className={css({
padding: '32px',
maxWidth: '800px',
margin: '0 auto',
})}
>
{/* Header */}
<div
className={css({
textAlign: 'center',
marginBottom: '32px',
})}
>
<div
className={css({
fontSize: '64px',
marginBottom: '16px',
})}
>
{roundJustEnded ? '🎉' : currentGuesserMetadata?.emoji || '🤔'}
</div>
<h2
className={css({
fontSize: '2xl',
fontWeight: 'bold',
marginBottom: '8px',
})}
>
{roundJustEnded
? `${lastGuess.playerName} guessed it!`
: isCurrentGuesser
? 'Your turn to guess!'
: `${currentGuesserMetadata?.name || 'Someone'} is guessing...`}
</h2>
<p
className={css({
color: 'gray.600',
})}
>
Round {state.roundNumber} Range: {state.minNumber} - {state.maxNumber}
</p>
</div>
{/* Error Banner */}
{lastError && (
<div
className={css({
background: 'linear-gradient(135deg, #fef2f2, #fee2e2)',
border: '2px solid',
borderColor: 'red.300',
borderRadius: '12px',
padding: '16px 20px',
marginBottom: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
animation: 'slideIn 0.3s ease',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '12px',
})}
>
<div
className={css({
fontSize: '24px',
})}
>
</div>
<div>
<div
className={css({
fontSize: 'md',
fontWeight: 'bold',
color: 'red.700',
marginBottom: '4px',
})}
>
Move Rejected
</div>
<div
className={css({
fontSize: 'sm',
color: 'red.600',
})}
>
{lastError}
</div>
</div>
</div>
<button
type="button"
onClick={clearError}
className={css({
padding: '8px 12px',
background: 'white',
border: '1px solid',
borderColor: 'red.300',
borderRadius: '6px',
fontSize: 'sm',
fontWeight: '600',
color: 'red.700',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
background: 'red.50',
},
})}
>
Dismiss
</button>
</div>
)}
{/* Round ended - show next round button */}
{roundJustEnded && (
<div
className={css({
background: 'white',
border: '2px solid',
borderColor: 'green.200',
borderRadius: '12px',
padding: '24px',
marginBottom: '24px',
textAlign: 'center',
})}
>
<div
className={css({
fontSize: '48px',
marginBottom: '16px',
})}
>
🎯
</div>
<p
className={css({
fontSize: 'lg',
marginBottom: '16px',
})}
>
The secret number was <strong>{state.secretNumber}</strong>!
</p>
<button
type="button"
onClick={nextRound}
className={css({
padding: '12px 24px',
background: 'linear-gradient(135deg, #fb923c, #f97316)',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: 'md',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
transform: 'translateY(-2px)',
},
})}
>
Next Round
</button>
</div>
)}
{/* Guessing input (only if round not ended) */}
{!roundJustEnded && (
<div
className={css({
background: 'white',
border: '2px solid',
borderColor: 'orange.200',
borderRadius: '12px',
padding: '24px',
marginBottom: '24px',
})}
>
{isCurrentGuesser ? (
<>
<label
className={css({
display: 'block',
fontSize: 'md',
fontWeight: '600',
marginBottom: '12px',
textAlign: 'center',
})}
>
Make your guess ({state.minNumber} - {state.maxNumber})
</label>
<input
type="number"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && inputValue) {
handleSubmit()
}
}}
min={state.minNumber}
max={state.maxNumber}
placeholder={`${state.minNumber} - ${state.maxNumber}`}
className={css({
width: '100%',
padding: '16px',
border: '2px solid',
borderColor: 'gray.300',
borderRadius: '8px',
fontSize: 'xl',
textAlign: 'center',
marginBottom: '16px',
})}
/>
<button
type="button"
onClick={handleSubmit}
disabled={!inputValue}
className={css({
width: '100%',
padding: '16px',
background: 'linear-gradient(135deg, #fb923c, #f97316)',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: 'lg',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s',
_disabled: {
opacity: 0.5,
cursor: 'not-allowed',
},
_hover: {
transform: 'translateY(-2px)',
},
})}
>
Submit Guess
</button>
</>
) : (
<div
className={css({
textAlign: 'center',
padding: '16px',
})}
>
<div
className={css({
fontSize: '48px',
marginBottom: '16px',
})}
>
</div>
<p
className={css({
fontSize: 'lg',
color: 'gray.600',
})}
>
Waiting for {currentGuesserMetadata?.name || 'player'} to guess...
</p>
</div>
)}
</div>
)}
{/* Guess history */}
{state.guesses.length > 0 && (
<div
className={css({
background: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '12px',
padding: '16px',
marginBottom: '24px',
})}
>
<h3
className={css({
fontSize: 'md',
fontWeight: 'bold',
marginBottom: '12px',
})}
>
Guess History
</h3>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '8px',
})}
>
{state.guesses.map((guess, index) => {
const player = state.playerMetadata[guess.playerId]
return (
<div
key={index}
className={css({
padding: '12px',
background: guess.distance === 0 ? 'green.50' : 'gray.50',
borderRadius: '8px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '8px',
})}
>
<span>{player?.emoji || '🎮'}</span>
<span className={css({ fontWeight: '600' })}>{guess.playerName}</span>
<span className={css({ color: 'gray.600' })}>guessed</span>
<span className={css({ fontWeight: 'bold', fontSize: 'lg' })}>
{guess.guess}
</span>
</div>
<div
className={css({
fontWeight: 'bold',
color: guess.distance === 0 ? 'green.700' : 'orange.700',
})}
>
{getHotColdMessage(guess.distance)}
</div>
</div>
)
})}
</div>
</div>
)}
{/* Scoreboard */}
<div
className={css({
background: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '12px',
padding: '16px',
})}
>
<h3
className={css({
fontSize: 'md',
fontWeight: 'bold',
marginBottom: '12px',
textAlign: 'center',
})}
>
Scores (First to {state.roundsToWin} wins!)
</h3>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
justifyContent: 'center',
})}
>
{state.activePlayers.map((playerId) => {
const player = state.playerMetadata[playerId]
const score = state.scores[playerId] || 0
return (
<div
key={playerId}
className={css({
padding: '8px 16px',
background: 'gray.100',
borderRadius: '8px',
fontSize: 'sm',
})}
>
{player?.emoji} {player?.name}: {score}
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,208 @@
/**
* Results Phase - Shows winner and final scores
*/
'use client'
import { css } from '../../../../styled-system/css'
import { useNumberGuesser } from '../Provider'
export function ResultsPhase() {
const { state, goToSetup } = useNumberGuesser()
const winnerMetadata = state.winner ? state.playerMetadata[state.winner] : null
const winnerScore = state.winner ? state.scores[state.winner] : 0
// Sort players by score
const sortedPlayers = [...state.activePlayers].sort((a, b) => {
const scoreA = state.scores[a] || 0
const scoreB = state.scores[b] || 0
return scoreB - scoreA
})
return (
<div
className={css({
padding: '32px',
maxWidth: '600px',
margin: '0 auto',
})}
>
{/* Winner Celebration */}
<div
className={css({
textAlign: 'center',
marginBottom: '32px',
})}
>
<div
className={css({
fontSize: '96px',
marginBottom: '16px',
animation: 'bounce 1s ease-in-out infinite',
})}
>
{winnerMetadata?.emoji || '🏆'}
</div>
<h1
className={css({
fontSize: '3xl',
fontWeight: 'bold',
marginBottom: '8px',
background: 'linear-gradient(135deg, #fb923c, #f97316)',
backgroundClip: 'text',
color: 'transparent',
})}
>
{winnerMetadata?.name || 'Someone'} Wins!
</h1>
<p
className={css({
fontSize: 'xl',
color: 'gray.600',
})}
>
with {winnerScore} {winnerScore === 1 ? 'round' : 'rounds'} won
</p>
</div>
{/* Final Standings */}
<div
className={css({
background: 'white',
border: '2px solid',
borderColor: 'orange.200',
borderRadius: '12px',
padding: '24px',
marginBottom: '24px',
})}
>
<h3
className={css({
fontSize: 'lg',
fontWeight: 'bold',
marginBottom: '16px',
textAlign: 'center',
})}
>
Final Standings
</h3>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '12px',
})}
>
{sortedPlayers.map((playerId, index) => {
const player = state.playerMetadata[playerId]
const score = state.scores[playerId] || 0
const isWinner = playerId === state.winner
return (
<div
key={playerId}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px',
background: isWinner ? 'linear-gradient(135deg, #fed7aa, #fdba74)' : 'gray.100',
borderRadius: '8px',
border: isWinner ? '2px solid' : 'none',
borderColor: isWinner ? 'orange.300' : undefined,
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '12px',
})}
>
<span
className={css({
fontSize: '2xl',
fontWeight: 'bold',
color: 'gray.400',
width: '32px',
textAlign: 'center',
})}
>
{index + 1}
</span>
<span className={css({ fontSize: '32px' })}>{player?.emoji || '🎮'}</span>
<span className={css({ fontSize: 'lg', fontWeight: '600' })}>
{player?.name || 'Unknown'}
</span>
</div>
<div
className={css({
fontSize: '2xl',
fontWeight: 'bold',
color: isWinner ? 'orange.700' : 'gray.700',
})}
>
{score} {isWinner && '🏆'}
</div>
</div>
)
})}
</div>
</div>
{/* Game Stats */}
<div
className={css({
background: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '12px',
padding: '16px',
marginBottom: '24px',
textAlign: 'center',
})}
>
<h3
className={css({
fontSize: 'md',
fontWeight: 'bold',
marginBottom: '8px',
})}
>
Game Stats
</h3>
<p className={css({ color: 'gray.600', fontSize: 'sm' })}>
{state.roundNumber} {state.roundNumber === 1 ? 'round' : 'rounds'} played
</p>
<p className={css({ color: 'gray.600', fontSize: 'sm' })}>
{state.guesses.length} {state.guesses.length === 1 ? 'guess' : 'guesses'} made
</p>
</div>
{/* Actions */}
<button
type="button"
onClick={goToSetup}
className={css({
width: '100%',
padding: '16px',
background: 'linear-gradient(135deg, #fb923c, #f97316)',
color: 'white',
border: 'none',
borderRadius: '12px',
fontSize: 'lg',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
transform: 'translateY(-2px)',
boxShadow: '0 8px 16px rgba(249, 115, 22, 0.3)',
},
})}
>
Play Again
</button>
</div>
)
}

View File

@@ -0,0 +1,197 @@
/**
* Setup Phase - Game configuration
*/
'use client'
import { css } from '../../../../styled-system/css'
import { useNumberGuesser } from '../Provider'
export function SetupPhase() {
const { state, startGame, setConfig } = useNumberGuesser()
return (
<div
className={css({
padding: '32px',
maxWidth: '600px',
margin: '0 auto',
})}
>
<h2
className={css({
fontSize: '2xl',
fontWeight: 'bold',
marginBottom: '24px',
textAlign: 'center',
})}
>
🎯 Number Guesser Setup
</h2>
<div
className={css({
background: 'white',
border: '2px solid',
borderColor: 'orange.200',
borderRadius: '12px',
padding: '24px',
marginBottom: '24px',
})}
>
<h3
className={css({
fontSize: 'lg',
fontWeight: 'bold',
marginBottom: '16px',
})}
>
Game Rules
</h3>
<ul
className={css({
listStyle: 'disc',
paddingLeft: '24px',
lineHeight: '1.6',
color: 'gray.700',
})}
>
<li>One player chooses a secret number</li>
<li>Other players take turns guessing</li>
<li>Get feedback on how close your guess is</li>
<li>First to guess correctly wins the round!</li>
<li>First to {state.roundsToWin} rounds wins the game!</li>
</ul>
</div>
<div
className={css({
background: 'white',
border: '2px solid',
borderColor: 'orange.200',
borderRadius: '12px',
padding: '24px',
marginBottom: '24px',
})}
>
<h3
className={css({
fontSize: 'lg',
fontWeight: 'bold',
marginBottom: '16px',
})}
>
Configuration
</h3>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '16px',
})}
>
<div>
<label
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: '600',
marginBottom: '4px',
})}
>
Minimum Number
</label>
<input
type="number"
value={state.minNumber ?? 1}
onChange={(e) => setConfig('minNumber', Number.parseInt(e.target.value, 10))}
className={css({
width: '100%',
padding: '8px 12px',
border: '1px solid',
borderColor: 'gray.300',
borderRadius: '6px',
fontSize: 'md',
})}
/>
</div>
<div>
<label
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: '600',
marginBottom: '4px',
})}
>
Maximum Number
</label>
<input
type="number"
value={state.maxNumber ?? 100}
onChange={(e) => setConfig('maxNumber', Number.parseInt(e.target.value, 10))}
className={css({
width: '100%',
padding: '8px 12px',
border: '1px solid',
borderColor: 'gray.300',
borderRadius: '6px',
fontSize: 'md',
})}
/>
</div>
<div>
<label
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: '600',
marginBottom: '4px',
})}
>
Rounds to Win
</label>
<input
type="number"
value={state.roundsToWin ?? 3}
onChange={(e) => setConfig('roundsToWin', Number.parseInt(e.target.value, 10))}
className={css({
width: '100%',
padding: '8px 12px',
border: '1px solid',
borderColor: 'gray.300',
borderRadius: '6px',
fontSize: 'md',
})}
/>
</div>
</div>
</div>
<button
onClick={startGame}
className={css({
width: '100%',
padding: '16px',
background: 'linear-gradient(135deg, #fb923c, #f97316)',
color: 'white',
border: 'none',
borderRadius: '12px',
fontSize: 'lg',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
transform: 'translateY(-2px)',
boxShadow: '0 8px 16px rgba(249, 115, 22, 0.3)',
},
})}
>
Start Game
</button>
</div>
)
}

View File

@@ -0,0 +1,15 @@
name: number-guesser
displayName: Number Guesser
icon: 🎯
description: Classic turn-based number guessing game
longDescription: One player thinks of a number, others take turns guessing. Get hot/cold feedback as you try to find the secret number. Perfect for testing your deduction skills!
maxPlayers: 4
difficulty: Beginner
chips:
- 👥 Multiplayer
- 🎲 Turn-Based
- 🧠 Logic Puzzle
color: orange
gradient: linear-gradient(135deg, #fed7aa, #fdba74)
borderColor: orange.200
available: true

View File

@@ -0,0 +1,66 @@
/**
* Number Guesser Game Definition
* Exports the complete game using the Arcade SDK
*/
import { defineGame } from '@/lib/arcade/game-sdk'
import type { GameManifest } from '@/lib/arcade/game-sdk'
import { GameComponent } from './components/GameComponent'
import { NumberGuesserProvider } from './Provider'
import type { NumberGuesserConfig, NumberGuesserMove, NumberGuesserState } from './types'
import { numberGuesserValidator } from './Validator'
// Game manifest (matches game.yaml)
const manifest: GameManifest = {
name: 'number-guesser',
displayName: 'Number Guesser',
icon: '🎯',
description: 'Classic turn-based number guessing game',
longDescription:
'One player thinks of a number, others take turns guessing. Get hot/cold feedback to narrow down your guesses. First to guess wins the round!',
maxPlayers: 4,
difficulty: 'Beginner',
chips: ['👥 Multiplayer', '🎲 Turn-Based', '🧠 Logic Puzzle'],
color: 'orange',
gradient: 'linear-gradient(135deg, #fed7aa, #fdba74)',
borderColor: 'orange.200',
available: true,
}
// Default configuration
const defaultConfig: NumberGuesserConfig = {
minNumber: 1,
maxNumber: 100,
roundsToWin: 3,
}
// Config validation function
function validateNumberGuesserConfig(config: unknown): config is NumberGuesserConfig {
return (
typeof config === 'object' &&
config !== null &&
'minNumber' in config &&
'maxNumber' in config &&
'roundsToWin' in config &&
typeof config.minNumber === 'number' &&
typeof config.maxNumber === 'number' &&
typeof config.roundsToWin === 'number' &&
config.minNumber >= 1 &&
config.maxNumber > config.minNumber &&
config.roundsToWin >= 1
)
}
// Export game definition
export const numberGuesserGame = defineGame<
NumberGuesserConfig,
NumberGuesserState,
NumberGuesserMove
>({
manifest,
Provider: NumberGuesserProvider,
GameComponent,
validator: numberGuesserValidator,
defaultConfig,
validateConfig: validateNumberGuesserConfig,
})

View File

@@ -0,0 +1,116 @@
/**
* Type definitions for Number Guesser game
*/
import type { GameMove } from '@/lib/arcade/game-sdk'
/**
* Game configuration
*/
export type NumberGuesserConfig = {
minNumber: number
maxNumber: number
roundsToWin: number
}
/**
* A single guess attempt
*/
export interface Guess {
playerId: string
playerName: string
guess: number
distance: number // How far from the secret number
timestamp: number
}
/**
* Game phases
*/
export type GamePhase = 'setup' | 'choosing' | 'guessing' | 'results'
/**
* Game state
*/
export type NumberGuesserState = {
// Configuration
minNumber: number
maxNumber: number
roundsToWin: number
// Game phase
gamePhase: GamePhase
// Players
activePlayers: string[]
playerMetadata: Record<string, { name: string; emoji: string; color: string; userId: string }>
// Current round
secretNumber: number | null
chooser: string // Player ID who chose the number
currentGuesser: string // Player ID whose turn it is to guess
// Round history
guesses: Guess[]
roundNumber: number
// Scores
scores: Record<string, number>
// Game state
gameStartTime: number | null
gameEndTime: number | null
winner: string | null
}
/**
* Game moves
*/
export interface StartGameMove extends GameMove {
type: 'START_GAME'
data: {
activePlayers: string[]
playerMetadata: Record<string, unknown>
}
}
export interface ChooseNumberMove extends GameMove {
type: 'CHOOSE_NUMBER'
data: {
secretNumber: number
}
}
export interface MakeGuessMove extends GameMove {
type: 'MAKE_GUESS'
data: {
guess: number
playerName: string
}
}
export interface NextRoundMove extends GameMove {
type: 'NEXT_ROUND'
data: Record<string, never>
}
export interface GoToSetupMove extends GameMove {
type: 'GO_TO_SETUP'
data: Record<string, never>
}
export interface SetConfigMove extends GameMove {
type: 'SET_CONFIG'
data: {
field: 'minNumber' | 'maxNumber' | 'roundsToWin'
value: number
}
}
export type NumberGuesserMove =
| StartGameMove
| ChooseNumberMove
| MakeGuessMove
| NextRoundMove
| GoToSetupMove
| SetConfigMove

View File

@@ -1,26 +1,13 @@
'use client'
import { useMemo } from 'react'
import { css } from '../../styled-system/css'
import { useGameMode } from '../contexts/GameModeContext'
import { getAllGames } from '../lib/arcade/game-registry'
import { GameCard } from './GameCard'
// Game configuration defining player limits
export const GAMES_CONFIG = {
'memory-quiz': {
name: 'Memory Lightning',
fullName: 'Memory Lightning ⚡',
maxPlayers: 4,
description: 'Test your memory speed with rapid-fire abacus calculations',
longDescription:
'Challenge yourself or compete with friends in lightning-fast memory tests. Work together cooperatively or compete for the highest score!',
url: '/arcade/memory-quiz',
icon: '⚡',
chips: ['👥 Multiplayer', '🔥 Speed Challenge', '🧮 Abacus Focus'],
color: 'green',
gradient: 'linear-gradient(135deg, #dcfce7, #bbf7d0)',
borderColor: 'green.200',
difficulty: 'Beginner',
},
'battle-arena': {
name: 'Matching Pairs Battle',
fullName: 'Matching Pairs Battle ⚔️',
@@ -70,7 +57,39 @@ export const GAMES_CONFIG = {
},
} as const
export type GameType = keyof typeof GAMES_CONFIG
export type GameType = keyof typeof GAMES_CONFIG | string
/**
* Get all games from both legacy config and new registry
*/
function getAllGameConfigs() {
const legacyGames = Object.entries(GAMES_CONFIG).map(([gameType, config]) => ({
gameType,
config,
}))
// Get games from registry and transform to legacy format
const registryGames = getAllGames().map((gameDef) => ({
gameType: gameDef.manifest.name,
config: {
name: gameDef.manifest.displayName,
fullName: gameDef.manifest.displayName,
maxPlayers: gameDef.manifest.maxPlayers,
description: gameDef.manifest.description,
longDescription: gameDef.manifest.longDescription,
url: `/arcade/room?game=${gameDef.manifest.name}`, // Registry games load in room
icon: gameDef.manifest.icon,
chips: gameDef.manifest.chips,
color: gameDef.manifest.color,
gradient: gameDef.manifest.gradient,
borderColor: gameDef.manifest.borderColor,
difficulty: gameDef.manifest.difficulty,
available: gameDef.manifest.available,
},
}))
return [...legacyGames, ...registryGames]
}
interface GameSelectorProps {
variant?: 'compact' | 'detailed'
@@ -87,17 +106,17 @@ export function GameSelector({
}: GameSelectorProps) {
const { activePlayerCount } = useGameMode()
// Memoize the combined games list
const allGames = useMemo(() => getAllGameConfigs(), [])
return (
<div
className={css(
{
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
},
className
)}
className={`${css({
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
})} ${className || ''}`}
>
{showHeader && (
<h3
@@ -125,7 +144,7 @@ export function GameSelector({
overflow: 'hidden',
})}
>
{Object.entries(GAMES_CONFIG).map(([gameType, config]) => (
{allGames.map(({ gameType, config }) => (
<GameCard
key={gameType}
gameType={gameType as GameType}

View File

@@ -26,7 +26,7 @@ interface AddPlayerButtonProps {
// Context-aware: show different content based on room state
isInRoom?: boolean
// Game info for room creation
gameName?: 'matching' | 'memory-quiz' | 'complement-race'
gameName?: string | null
}
export function AddPlayerButton({
@@ -38,7 +38,7 @@ export function AddPlayerButton({
activeTab: activeTabProp,
setActiveTab: setActiveTabProp,
isInRoom = false,
gameName = 'Arcade',
gameName = null,
}: AddPlayerButtonProps) {
const popoverRef = React.useRef<HTMLDivElement>(null)
const router = useRouter()

View File

@@ -28,7 +28,7 @@ interface NetworkPlayer {
interface ArcadeRoomInfo {
roomId?: string
roomName?: string
gameName: string
gameName: string | null
playerCount: number
joinCode?: string
}
@@ -200,6 +200,7 @@ export function GameContextNav({
onSetup={onSetup}
onNewGame={onNewGame}
onQuit={onExitSession}
showMenu={true}
/>
<div style={{ marginLeft: 'auto' }}>
<GameModeIndicator
@@ -287,7 +288,7 @@ export function GameContextNav({
playerScores={playerScores}
playerStreaks={playerStreaks}
roomId={roomInfo?.roomId}
currentUserId={currentUserId}
currentUserId={currentUserId ?? undefined}
isCurrentUserHost={isCurrentUserHost}
/>
))}

View File

@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react'
import { io } from 'socket.io-client'
import { Modal } from '@/components/common/Modal'
import type { schema } from '@/db'
import { useRoomData } from '@/hooks/useRoomData'
import type { RoomData } from '@/hooks/useRoomData'
import { useGetRoomByCode, useJoinRoom } from '@/hooks/useRoomData'
export interface JoinRoomModalProps {
/**
@@ -25,12 +25,13 @@ export interface JoinRoomModalProps {
* Modal for joining a room by entering a 6-character code
*/
export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps) {
const { getRoomByCode, joinRoom } = useRoomData()
const { mutateAsync: getRoomByCode } = useGetRoomByCode()
const { mutateAsync: joinRoom } = useJoinRoom()
const [code, setCode] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [roomInfo, setRoomInfo] = useState<schema.ArcadeRoom | null>(null)
const [roomInfo, setRoomInfo] = useState<RoomData | null>(null)
const [needsPassword, setNeedsPassword] = useState(false)
const [needsApproval, setNeedsApproval] = useState(false)
const [approvalRequested, setApprovalRequested] = useState(false)
@@ -101,7 +102,7 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
}
// Join the room (with password if needed)
await joinRoom(room.id, password || undefined)
await joinRoom({ roomId: room.id, password: password || undefined })
// Success! Close modal
handleClose()
@@ -174,7 +175,7 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
if (data.roomId === roomInfo.id) {
console.log('[JoinRoomModal] Joining room automatically...')
try {
await joinRoom(roomInfo.id)
await joinRoom({ roomId: roomInfo.id })
handleClose()
onSuccess?.()
} catch (err) {

View File

@@ -1,7 +1,8 @@
import * as Dialog from '@radix-ui/react-dialog'
import { useEffect, useState } from 'react'
import { Modal } from '@/components/common/Modal'
import type { RoomBan, RoomMember, RoomReport } from '@/db/schema'
import type { RoomBan, RoomReport } from '@/db/schema'
import type { RoomMember } from '@/hooks/useRoomData'
export interface RoomPlayer {
id: string

View File

@@ -52,7 +52,7 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
const handleGenerateNewName = () => {
const allPlayers = Array.from(players.values())
const existingNames = allPlayers.filter((p) => p.id !== playerId).map((p) => p.name)
const newName = generateUniquePlayerName(existingNames)
const newName = generateUniquePlayerName(existingNames, player.emoji)
setLocalName(newName)
updatePlayer(playerId, { name: newName })

View File

@@ -4,7 +4,7 @@ import { getRoomDisplayWithEmoji } from '@/utils/room-display'
interface RecentRoom {
code: string
name: string | null
gameName: string
gameName: string | null
joinedAt: number
}
@@ -105,7 +105,7 @@ export function RecentRoomsList({ onSelectRoom }: RecentRoomsListProps) {
{getRoomDisplayWithEmoji({
name: room.name,
code: room.code,
gameName: room.gameName,
gameName: room.gameName ?? undefined,
})}
</span>
</div>
@@ -133,7 +133,7 @@ export function RecentRoomsList({ onSelectRoom }: RecentRoomsListProps) {
export function addToRecentRooms(room: {
code: string
name: string | null
gameName: string
gameName: string | null
}): void {
try {
const stored = localStorage.getItem(STORAGE_KEY)

View File

@@ -11,7 +11,7 @@ import {
} from '@/hooks/useUserPlayers'
import { useViewerId } from '@/hooks/useViewerId'
import { getNextPlayerColor } from '../types/player'
import { generateUniquePlayerName, generateUniquePlayerNames } from '../utils/playerNames'
import { generateUniquePlayerName } from '../utils/playerNames'
// Client-side Player type (compatible with old type)
export interface Player {
@@ -141,8 +141,13 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (!isLoading && !isInitialized) {
if (dbPlayers.length === 0) {
// Generate unique names for default players
const generatedNames = generateUniquePlayerNames(DEFAULT_PLAYER_CONFIGS.length)
// Generate unique names for default players, themed by their emoji
const existingNames: string[] = []
const generatedNames = DEFAULT_PLAYER_CONFIGS.map((config) => {
const name = generateUniquePlayerName(existingNames, config.emoji)
existingNames.push(name)
return name
})
// Create default players with generated names
DEFAULT_PLAYER_CONFIGS.forEach((config, index) => {
@@ -167,10 +172,11 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
const addPlayer = (playerData?: Partial<Player>) => {
const playerList = Array.from(players.values())
const existingNames = playerList.map((p) => p.name)
const emoji = playerData?.emoji ?? '🎮'
const newPlayer = {
name: playerData?.name ?? generateUniquePlayerName(existingNames),
emoji: playerData?.emoji ?? '🎮',
name: playerData?.name ?? generateUniquePlayerName(existingNames, emoji),
emoji,
color: playerData?.color ?? getNextPlayerColor(playerList),
isActive: playerData?.isActive ?? false,
}
@@ -254,8 +260,13 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
deletePlayer(player.id)
})
// Generate unique names for default players
const generatedNames = generateUniquePlayerNames(DEFAULT_PLAYER_CONFIGS.length)
// Generate unique names for default players, themed by their emoji
const existingNames: string[] = []
const generatedNames = DEFAULT_PLAYER_CONFIGS.map((config) => {
const name = generateUniquePlayerName(existingNames, config.emoji)
existingNames.push(name)
return name
})
// Create default players with generated names
DEFAULT_PLAYER_CONFIGS.forEach((config, index) => {

View File

@@ -33,9 +33,8 @@ export const arcadeRooms = sqliteTable('arcade_rooms', {
displayPassword: text('display_password', { length: 100 }), // Plain text password for display to room owner
// Game configuration (nullable to support game selection in room)
gameName: text('game_name', {
enum: ['matching', 'memory-quiz', 'complement-race'],
}),
// Accepts any string - validation happens at runtime against validator registry
gameName: text('game_name'),
gameConfig: text('game_config', { mode: 'json' }), // Game-specific settings (nullable when no game selected)
// Current state

View File

@@ -16,9 +16,8 @@ export const arcadeSessions = sqliteTable('arcade_sessions', {
.references(() => users.id, { onDelete: 'cascade' }),
// Session metadata
currentGame: text('current_game', {
enum: ['matching', 'memory-quiz', 'complement-race'],
}).notNull(),
// Accepts any string - validation happens at runtime against validator registry
currentGame: text('current_game').notNull(),
gameUrl: text('game_url').notNull(), // e.g., '/arcade/matching'

View File

@@ -15,5 +15,6 @@ export * from './room-invitations'
export * from './room-reports'
export * from './room-bans'
export * from './room-join-requests'
export * from './room-game-configs'
export * from './user-stats'
export * from './users'

View File

@@ -0,0 +1,49 @@
import { createId } from '@paralleldrive/cuid2'
import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
import { arcadeRooms } from './arcade-rooms'
/**
* Game-specific configuration settings for arcade rooms
* Each row represents one game's settings for one room
*/
export const roomGameConfigs = sqliteTable(
'room_game_configs',
{
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
// Room reference
roomId: text('room_id')
.notNull()
.references(() => arcadeRooms.id, { onDelete: 'cascade' }),
// Game identifier
// Accepts any string - validation happens at runtime against validator registry
gameName: text('game_name').notNull(),
// Game-specific configuration JSON
// Structure depends on gameName:
// - matching: { gameType, difficulty, turnTimer }
// - memory-quiz: { selectedCount, displayTime, selectedDifficulty, playMode }
// - complement-race: TBD
// - number-guesser: { minNumber, maxNumber, roundsToWin }
// - math-sprint: { difficulty, questionsPerRound, timePerQuestion }
config: text('config', { mode: 'json' }).notNull(),
// Timestamps
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
},
(table) => ({
// Ensure only one config per game per room
uniqueRoomGame: uniqueIndex('room_game_idx').on(table.roomId, table.gameName),
})
)
export type RoomGameConfig = typeof roomGameConfigs.$inferSelect
export type NewRoomGameConfig = typeof roomGameConfigs.$inferInsert

View File

@@ -46,6 +46,11 @@ export interface UseArcadeSessionReturn<TState> {
*/
hasPendingMoves: boolean
/**
* Last error from server (move rejection)
*/
lastError: string | null
/**
* Send a game move (applies optimistically and sends to server)
* Note: playerId must be provided by caller (not omitted)
@@ -57,6 +62,11 @@ export interface UseArcadeSessionReturn<TState> {
*/
exitSession: () => void
/**
* Clear the last error
*/
clearError: () => void
/**
* Manually sync with server (useful after reconnect)
*/
@@ -172,8 +182,10 @@ export function useArcadeSession<TState>(
version: optimistic.version,
connected,
hasPendingMoves: optimistic.hasPendingMoves,
lastError: optimistic.lastError,
sendMove,
exitSession,
clearError: optimistic.clearError,
refresh,
}
}

View File

@@ -46,6 +46,11 @@ export interface UseOptimisticGameStateReturn<TState> {
*/
hasPendingMoves: boolean
/**
* Last error from server (move rejection)
*/
lastError: string | null
/**
* Apply a move optimistically and send to server
*/
@@ -66,6 +71,11 @@ export interface UseOptimisticGameStateReturn<TState> {
*/
syncWithServer: (serverState: TState, serverVersion: number) => void
/**
* Clear the last error
*/
clearError: () => void
/**
* Reset to initial state
*/
@@ -94,6 +104,9 @@ export function useOptimisticGameState<TState>(
// Pending moves that haven't been confirmed by server yet
const [pendingMoves, setPendingMoves] = useState<PendingMove<TState>[]>([])
// Last error from move rejection
const [lastError, setLastError] = useState<string | null>(null)
// Ref for callbacks to avoid stale closures
const callbacksRef = useRef({ onMoveAccepted, onMoveRejected })
useEffect(() => {
@@ -152,6 +165,9 @@ export function useOptimisticGameState<TState>(
)
const handleMoveRejected = useCallback((error: string, rejectedMove: GameMove) => {
// Set the error for UI display
setLastError(error)
// Remove the rejected move and all subsequent moves from pending queue
setPendingMoves((prev) => {
const index = prev.findIndex(
@@ -176,20 +192,27 @@ export function useOptimisticGameState<TState>(
setPendingMoves([])
}, [])
const clearError = useCallback(() => {
setLastError(null)
}, [])
const reset = useCallback(() => {
setServerState(initialState)
setServerVersion(1)
setPendingMoves([])
setLastError(null)
}, [initialState])
return {
state: currentState,
version: serverVersion,
hasPendingMoves: pendingMoves.length > 0,
lastError,
applyOptimisticMove,
handleMoveAccepted,
handleMoveRejected,
syncWithServer,
clearError,
reset,
}
}

View File

@@ -353,7 +353,6 @@ export function useRoomData() {
// Moderation event handlers
const handleKickedFromRoom = (data: { roomId: string; kickedBy: string; reason?: string }) => {
console.log('[useRoomData] User was kicked from room:', data)
setModerationEvent({
type: 'kicked',
data: {
@@ -367,7 +366,6 @@ export function useRoomData() {
}
const handleBannedFromRoom = (data: { roomId: string; bannedBy: string; reason: string }) => {
console.log('[useRoomData] User was banned from room:', data)
setModerationEvent({
type: 'banned',
data: {
@@ -391,7 +389,6 @@ export function useRoomData() {
createdAt: Date
}
}) => {
console.log('[useRoomData] New report submitted:', data)
setModerationEvent({
type: 'report',
data: {
@@ -416,7 +413,6 @@ export function useRoomData() {
createdAt: Date
}
}) => {
console.log('[useRoomData] Room invitation received:', data)
setModerationEvent({
type: 'invitation',
data: {
@@ -439,7 +435,6 @@ export function useRoomData() {
createdAt: Date
}
}) => {
console.log('[useRoomData] New join request submitted:', data)
setModerationEvent({
type: 'join-request',
data: {
@@ -454,7 +449,7 @@ export function useRoomData() {
const handleRoomGameChanged = (data: {
roomId: string
gameName: string | null
gameConfig: Record<string, unknown>
gameConfig?: Record<string, unknown>
}) => {
console.log('[useRoomData] Room game changed:', data)
if (data.roomId === roomData?.id) {
@@ -463,6 +458,8 @@ export function useRoomData() {
return {
...prev,
gameName: data.gameName,
// Only update gameConfig if it was provided in the broadcast
...(data.gameConfig !== undefined ? { gameConfig: data.gameConfig } : {}),
}
})
}
@@ -496,7 +493,6 @@ export function useRoomData() {
// Function to notify room members of player updates
const notifyRoomOfPlayerUpdate = useCallback(() => {
if (socket && roomData?.id && userId) {
console.log('[useRoomData] Notifying room of player update')
socket.emit('players-updated', { roomId: roomData.id, userId })
}
}, [socket, roomData?.id, userId])
@@ -591,13 +587,20 @@ async function setRoomGameApi(params: {
gameName: string
gameConfig?: Record<string, unknown>
}): Promise<void> {
// Only include gameConfig in the request if it was explicitly provided
// Otherwise, we preserve the existing gameConfig in the database
const body: { gameName: string; gameConfig?: Record<string, unknown> } = {
gameName: params.gameName,
}
if (params.gameConfig !== undefined) {
body.gameConfig = params.gameConfig
}
const response = await fetch(`/api/arcade/rooms/${params.roomId}/settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
gameName: params.gameName,
gameConfig: params.gameConfig || {},
}),
body: JSON.stringify(body),
})
if (!response.ok) {
@@ -631,6 +634,8 @@ export function useSetRoomGame() {
/**
* Clear/reset game for a room (host only)
* This only clears gameName (returns to game selection) but preserves gameConfig
* so settings persist when the user selects a game again.
*/
async function clearRoomGameApi(roomId: string): Promise<void> {
const response = await fetch(`/api/arcade/rooms/${roomId}/settings`, {
@@ -638,7 +643,7 @@ async function clearRoomGameApi(roomId: string): Promise<void> {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
gameName: null,
gameConfig: null,
// DO NOT send gameConfig: null - we want to preserve settings!
}),
})
@@ -678,6 +683,18 @@ async function updateGameConfigApi(params: {
roomId: string
gameConfig: Record<string, unknown>
}): Promise<void> {
console.log(
'[updateGameConfigApi] Sending PATCH to server:',
JSON.stringify(
{
url: `/api/arcade/rooms/${params.roomId}/settings`,
gameConfig: params.gameConfig,
},
null,
2
)
)
const response = await fetch(`/api/arcade/rooms/${params.roomId}/settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -688,8 +705,11 @@ async function updateGameConfigApi(params: {
if (!response.ok) {
const errorData = await response.json()
console.error('[updateGameConfigApi] Server error:', JSON.stringify(errorData, null, 2))
throw new Error(errorData.error || 'Failed to update game config')
}
console.log('[updateGameConfigApi] Server responded OK')
}
/**
@@ -710,7 +730,10 @@ export function useUpdateGameConfig() {
gameConfig: variables.gameConfig,
}
})
console.log('[useUpdateGameConfig] Updated cache with new gameConfig:', variables.gameConfig)
console.log(
'[useUpdateGameConfig] Updated cache with new gameConfig:',
JSON.stringify(variables.gameConfig, null, 2)
)
},
})
}

View File

@@ -0,0 +1,241 @@
/**
* Game configuration helpers
*
* Centralized functions for reading and writing game configs from the database.
* Uses the room_game_configs table (one row per game per room).
*/
import { and, eq } from 'drizzle-orm'
import { createId } from '@paralleldrive/cuid2'
import { db, schema } from '@/db'
import type { GameName } from './validators'
import type { GameConfigByName } from './game-configs'
import {
DEFAULT_MATCHING_CONFIG,
DEFAULT_MEMORY_QUIZ_CONFIG,
DEFAULT_COMPLEMENT_RACE_CONFIG,
DEFAULT_NUMBER_GUESSER_CONFIG,
DEFAULT_MATH_SPRINT_CONFIG,
} from './game-configs'
// Lazy-load game registry to avoid loading React components on server
function getGame(gameName: string) {
// Only load game registry in browser environment
// On server, we fall back to switch statement validation
if (typeof window !== 'undefined') {
try {
const { getGame: registryGetGame } = require('./game-registry')
return registryGetGame(gameName)
} catch (error) {
console.warn('[GameConfig] Failed to load game registry:', error)
return undefined
}
}
return undefined
}
/**
* Extended game name type that includes both registered validators and legacy games
* TODO: Remove 'complement-race' once migrated to the new modular system
*/
type ExtendedGameName = GameName | 'complement-race'
/**
* Get default config for a game
*/
function getDefaultGameConfig(gameName: ExtendedGameName): GameConfigByName[ExtendedGameName] {
switch (gameName) {
case 'matching':
return DEFAULT_MATCHING_CONFIG
case 'memory-quiz':
return DEFAULT_MEMORY_QUIZ_CONFIG
case 'complement-race':
return DEFAULT_COMPLEMENT_RACE_CONFIG
case 'number-guesser':
return DEFAULT_NUMBER_GUESSER_CONFIG
case 'math-sprint':
return DEFAULT_MATH_SPRINT_CONFIG
default:
throw new Error(`Unknown game: ${gameName}`)
}
}
/**
* Get game-specific config from database with defaults
* Type-safe: returns the correct config type based on gameName
*/
export async function getGameConfig<T extends ExtendedGameName>(
roomId: string,
gameName: T
): Promise<GameConfigByName[T]> {
// Query the room_game_configs table for this specific room+game
const configRow = await db.query.roomGameConfigs.findFirst({
where: and(
eq(schema.roomGameConfigs.roomId, roomId),
eq(schema.roomGameConfigs.gameName, gameName)
),
})
// If no config exists, return defaults
if (!configRow) {
return getDefaultGameConfig(gameName) as GameConfigByName[T]
}
// Merge saved config with defaults to handle missing fields
const defaults = getDefaultGameConfig(gameName)
return { ...defaults, ...(configRow.config as object) } as GameConfigByName[T]
}
/**
* Set (upsert) a game's config in the database
* Creates a new row if it doesn't exist, updates if it does
*/
export async function setGameConfig<T extends ExtendedGameName>(
roomId: string,
gameName: T,
config: Partial<GameConfigByName[T]>
): Promise<void> {
const now = new Date()
// Check if config already exists
const existing = await db.query.roomGameConfigs.findFirst({
where: and(
eq(schema.roomGameConfigs.roomId, roomId),
eq(schema.roomGameConfigs.gameName, gameName)
),
})
if (existing) {
// Update existing config (merge with existing values)
const mergedConfig = { ...(existing.config as object), ...config }
await db
.update(schema.roomGameConfigs)
.set({
config: mergedConfig as any,
updatedAt: now,
})
.where(eq(schema.roomGameConfigs.id, existing.id))
} else {
// Insert new config (merge with defaults)
const defaults = getDefaultGameConfig(gameName)
const mergedConfig = { ...defaults, ...config }
await db.insert(schema.roomGameConfigs).values({
id: createId(),
roomId,
gameName,
config: mergedConfig as any,
createdAt: now,
updatedAt: now,
})
}
console.log(`[GameConfig] Updated ${gameName} config for room ${roomId}`)
}
/**
* Update a specific field in a game's config
* Convenience wrapper around setGameConfig
*/
export async function updateGameConfigField<
T extends ExtendedGameName,
K extends keyof GameConfigByName[T],
>(roomId: string, gameName: T, field: K, value: GameConfigByName[T][K]): Promise<void> {
// Create a partial config with just the field being updated
const partialConfig: Partial<GameConfigByName[T]> = {} as any
;(partialConfig as any)[field] = value
await setGameConfig(roomId, gameName, partialConfig)
}
/**
* Delete a game's config from the database
* Useful when clearing game selection or cleaning up
*/
export async function deleteGameConfig(roomId: string, gameName: ExtendedGameName): Promise<void> {
await db
.delete(schema.roomGameConfigs)
.where(
and(eq(schema.roomGameConfigs.roomId, roomId), eq(schema.roomGameConfigs.gameName, gameName))
)
console.log(`[GameConfig] Deleted ${gameName} config for room ${roomId}`)
}
/**
* Get all game configs for a room (all games)
* Returns a map of gameName -> config
*/
export async function getAllGameConfigs(
roomId: string
): Promise<Partial<Record<ExtendedGameName, unknown>>> {
const configs = await db.query.roomGameConfigs.findMany({
where: eq(schema.roomGameConfigs.roomId, roomId),
})
const result: Partial<Record<ExtendedGameName, unknown>> = {}
for (const config of configs) {
result[config.gameName as ExtendedGameName] = config.config
}
return result
}
/**
* Delete all game configs for a room
* Called when deleting a room (cascade should handle this, but useful for explicit cleanup)
*/
export async function deleteAllGameConfigs(roomId: string): Promise<void> {
await db.delete(schema.roomGameConfigs).where(eq(schema.roomGameConfigs.roomId, roomId))
console.log(`[GameConfig] Deleted all configs for room ${roomId}`)
}
/**
* Validate a game config at runtime
* Returns true if the config is valid for the given game
*
* NEW: Uses game registry validation functions instead of switch statements.
* Games now own their own validation logic!
*/
export function validateGameConfig(gameName: ExtendedGameName, config: any): boolean {
// Try to get game from registry
const game = getGame(gameName)
// If game has a validateConfig function, use it
if (game && game.validateConfig) {
return game.validateConfig(config)
}
// Fallback for legacy games without registry (e.g., complement-race, matching, memory-quiz)
switch (gameName) {
case 'matching':
return (
typeof config === 'object' &&
config !== null &&
['abacus-numeral', 'complement-pairs'].includes(config.gameType) &&
typeof config.difficulty === 'number' &&
[6, 8, 12, 15].includes(config.difficulty) &&
typeof config.turnTimer === 'number' &&
config.turnTimer >= 5 &&
config.turnTimer <= 300
)
case 'memory-quiz':
return (
typeof config === 'object' &&
config !== null &&
[2, 5, 8, 12, 15].includes(config.selectedCount) &&
typeof config.displayTime === 'number' &&
config.displayTime > 0 &&
['beginner', 'easy', 'medium', 'hard', 'expert'].includes(config.selectedDifficulty) &&
['cooperative', 'competitive'].includes(config.playMode)
)
case 'complement-race':
// TODO: Add validation when complement-race settings are defined
return typeof config === 'object' && config !== null
default:
// If no validator found, accept any object
return typeof config === 'object' && config !== null
}
}

View File

@@ -0,0 +1,132 @@
/**
* Shared game configuration types
*
* ARCHITECTURE: Phase 3 - Type Inference
* - Modern games (number-guesser, math-sprint, memory-quiz): Types inferred from game definitions
* - Legacy games (matching, complement-race): Manual types until migrated
*
* These types are used across:
* - Database storage (room_game_configs table)
* - Validators (getInitialState method signatures)
* - Client providers (settings UI and state management)
* - Helper functions (reading/writing configs)
*/
import type { Difficulty, GameType } from '@/app/games/matching/context/types'
// Type-only imports (won't load React components at runtime)
import type { numberGuesserGame } from '@/arcade-games/number-guesser'
import type { mathSprintGame } from '@/arcade-games/math-sprint'
import type { memoryQuizGame } from '@/arcade-games/memory-quiz'
/**
* Utility type: Extract config type from a game definition
* Uses TypeScript's infer keyword to extract the TConfig generic
*/
type InferGameConfig<T> = T extends { defaultConfig: infer Config } ? Config : never
// ============================================================================
// Modern Games (Type Inference from Game Definitions)
// ============================================================================
/**
* Configuration for number-guesser game
* INFERRED from numberGuesserGame.defaultConfig
*/
export type NumberGuesserGameConfig = InferGameConfig<typeof numberGuesserGame>
/**
* Configuration for math-sprint game
* INFERRED from mathSprintGame.defaultConfig
*/
export type MathSprintGameConfig = InferGameConfig<typeof mathSprintGame>
/**
* Configuration for memory-quiz (soroban lightning) game
* INFERRED from memoryQuizGame.defaultConfig
*/
export type MemoryQuizGameConfig = InferGameConfig<typeof memoryQuizGame>
// ============================================================================
// Legacy Games (Manual Type Definitions)
// TODO: Migrate these games to the modular system for type inference
// ============================================================================
/**
* Configuration for matching (memory pairs) game
*/
export interface MatchingGameConfig {
gameType: GameType
difficulty: Difficulty
turnTimer: number
}
/**
* Configuration for complement-race game
* TODO: Define when implementing complement-race settings
*/
export interface ComplementRaceGameConfig {
// Future settings will go here
placeholder?: never
}
// ============================================================================
// Combined Types
// ============================================================================
/**
* Union type of all game configs for type-safe access
* Modern games use inferred types, legacy games use manual types
*/
export type GameConfigByName = {
// Modern games (inferred types)
'number-guesser': NumberGuesserGameConfig
'math-sprint': MathSprintGameConfig
'memory-quiz': MemoryQuizGameConfig
// Legacy games (manual types)
matching: MatchingGameConfig
'complement-race': ComplementRaceGameConfig
}
/**
* Room's game configuration object (nested by game name)
* This matches the structure stored in room_game_configs table
*
* AUTO-DERIVED: Adding a game to GameConfigByName automatically adds it here
*/
export type RoomGameConfig = {
[K in keyof GameConfigByName]?: GameConfigByName[K]
}
/**
* Default configurations for each game
*/
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',
}
export const DEFAULT_COMPLEMENT_RACE_CONFIG: ComplementRaceGameConfig = {
// Future defaults will go here
}
export const DEFAULT_NUMBER_GUESSER_CONFIG: NumberGuesserGameConfig = {
minNumber: 1,
maxNumber: 100,
roundsToWin: 3,
}
export const DEFAULT_MATH_SPRINT_CONFIG: MathSprintGameConfig = {
difficulty: 'medium',
questionsPerRound: 10,
timePerQuestion: 30,
}

View File

@@ -0,0 +1,115 @@
/**
* Game Registry
*
* Central registry for all arcade games.
* Games are explicitly registered here after being defined.
*/
import type { GameConfig, GameDefinition, GameMove, GameState } from './game-sdk/types'
/**
* Global game registry
* Maps game name to game definition
* Using `any` for generics to allow different game types
*/
const registry = new Map<string, GameDefinition<any, any, any>>()
/**
* Register a game in the registry
*
* @param game - Game definition to register
* @throws Error if game with same name already registered
*/
export function registerGame<
TConfig extends GameConfig,
TState extends GameState,
TMove extends GameMove,
>(game: GameDefinition<TConfig, TState, TMove>): void {
const { name } = game.manifest
if (registry.has(name)) {
throw new Error(`Game "${name}" is already registered`)
}
// Verify validator is also registered server-side
try {
const { hasValidator, getValidator } = require('./validators')
if (!hasValidator(name)) {
console.error(
`⚠️ Game "${name}" registered but validator not found in server registry!` +
`\n Add to src/lib/arcade/validators.ts to enable multiplayer.`
)
} else {
const serverValidator = getValidator(name)
if (serverValidator !== game.validator) {
console.warn(
`⚠️ Game "${name}" has different validator instances (client vs server).` +
`\n This may cause issues. Ensure both use the same import.`
)
}
}
} catch (error) {
// If validators.ts can't be imported (e.g., in browser), skip check
// This is expected - validator registry is isomorphic but check only runs server-side
}
registry.set(name, game)
console.log(`✅ Registered game: ${name}`)
}
/**
* Get a game from the registry
*
* @param gameName - Internal game identifier
* @returns Game definition or undefined if not found
*/
export function getGame(gameName: string): GameDefinition<any, any, any> | undefined {
return registry.get(gameName)
}
/**
* Get all registered games
*
* @returns Array of all game definitions
*/
export function getAllGames(): GameDefinition<any, any, any>[] {
return Array.from(registry.values())
}
/**
* Get all available games (where available: true)
*
* @returns Array of available game definitions
*/
export function getAvailableGames(): GameDefinition<any, any, any>[] {
return getAllGames().filter((game) => game.manifest.available)
}
/**
* Check if a game is registered
*
* @param gameName - Internal game identifier
* @returns true if game is registered
*/
export function hasGame(gameName: string): boolean {
return registry.has(gameName)
}
/**
* Clear all games from registry (used for testing)
*/
export function clearRegistry(): void {
registry.clear()
}
// ============================================================================
// Game Registrations
// ============================================================================
import { numberGuesserGame } from '@/arcade-games/number-guesser'
import { mathSprintGame } from '@/arcade-games/math-sprint'
import { memoryQuizGame } from '@/arcade-games/memory-quiz'
registerGame(numberGuesserGame)
registerGame(mathSprintGame)
registerGame(memoryQuizGame)

View File

@@ -0,0 +1,124 @@
/**
* Error Boundary for Arcade Games
*
* Catches errors in game components and displays a friendly error message
* instead of crashing the entire app.
*/
'use client'
import { Component, type ReactNode } from 'react'
interface Props {
children: ReactNode
gameName?: string
}
interface State {
hasError: boolean
error?: Error
}
export class GameErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error,
}
}
componentDidCatch(error: Error, errorInfo: unknown) {
console.error('Game error:', error, errorInfo)
}
render() {
if (this.state.hasError) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px',
textAlign: 'center',
minHeight: '400px',
background: 'linear-gradient(135deg, #fef2f2, #fee2e2)',
}}
>
<div
style={{
fontSize: '64px',
marginBottom: '20px',
}}
>
</div>
<h2
style={{
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '12px',
color: '#dc2626',
}}
>
Game Error
</h2>
<p
style={{
fontSize: '16px',
color: '#6b7280',
marginBottom: '12px',
maxWidth: '500px',
}}
>
{this.props.gameName
? `There was an error loading the game "${this.props.gameName}".`
: 'There was an error loading the game.'}
</p>
{this.state.error && (
<pre
style={{
background: '#f9fafb',
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '16px',
marginTop: '12px',
maxWidth: '600px',
overflow: 'auto',
textAlign: 'left',
fontSize: '12px',
color: '#374151',
}}
>
{this.state.error.message}
</pre>
)}
<button
onClick={() => window.location.reload()}
style={{
marginTop: '24px',
padding: '12px 24px',
background: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
}}
>
Reload Page
</button>
</div>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1,84 @@
/**
* Game definition helper
* Provides type-safe game registration
*/
import type {
GameComponent,
GameConfig,
GameDefinition,
GameMove,
GameProviderComponent,
GameState,
GameValidator,
} from './types'
import type { GameManifest } from '../manifest-schema'
/**
* Options for defining a game
*/
export interface DefineGameOptions<
TConfig extends GameConfig,
TState extends GameState,
TMove extends GameMove,
> {
/** Game manifest (loaded from game.yaml) */
manifest: GameManifest
/** React provider component */
Provider: GameProviderComponent
/** Main game UI component */
GameComponent: GameComponent
/** Server-side validator */
validator: GameValidator<TState, TMove>
/** Default configuration for the game */
defaultConfig: TConfig
/** Optional: Runtime config validation function */
validateConfig?: (config: unknown) => config is TConfig
}
/**
* Define a game with full type safety
*
* This helper ensures all required parts of a game are provided
* and returns a properly typed GameDefinition.
*
* @example
* ```typescript
* export const myGame = defineGame({
* manifest: loadManifest('./game.yaml'),
* Provider: MyGameProvider,
* GameComponent: MyGameComponent,
* validator: myGameValidator,
* defaultConfig: {
* difficulty: 'easy',
* maxTime: 60
* }
* })
* ```
*/
export function defineGame<
TConfig extends GameConfig,
TState extends GameState,
TMove extends GameMove,
>(options: DefineGameOptions<TConfig, TState, TMove>): GameDefinition<TConfig, TState, TMove> {
const { manifest, Provider, GameComponent, validator, defaultConfig, validateConfig } = options
// Validate that manifest.name matches the game identifier
if (!manifest.name) {
throw new Error('Game manifest must have a "name" field')
}
return {
manifest,
Provider,
GameComponent,
validator,
defaultConfig,
validateConfig,
}
}

View File

@@ -0,0 +1,92 @@
/**
* Arcade Game SDK - Stable API Surface
*
* This is the ONLY module that games are allowed to import from.
* All game code must use this SDK - no direct imports from /src/
*
* @example
* ```typescript
* import {
* defineGame,
* useArcadeSession,
* useRoomData,
* type GameDefinition
* } from '@/lib/arcade/game-sdk'
* ```
*/
// ============================================================================
// Core Types
// ============================================================================
export type {
GameDefinition,
GameProviderComponent,
GameComponent,
GameValidator,
GameConfig,
GameState,
GameMove,
ValidationContext,
ValidationResult,
TeamMoveSentinel,
} from './types'
export { TEAM_MOVE } from './types'
export type { GameManifest } from '../manifest-schema'
// ============================================================================
// React Hooks (Controlled API)
// ============================================================================
/**
* Arcade session management hook
* Handles state synchronization, move validation, and multiplayer sync
*/
export { useArcadeSession } from '@/hooks/useArcadeSession'
/**
* Room data hook - access current room information
*/
export { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
/**
* Game mode context - access players and game mode
*/
export { useGameMode } from '@/contexts/GameModeContext'
/**
* Viewer ID hook - get current user's ID
*/
export { useViewerId } from '@/hooks/useViewerId'
// ============================================================================
// Utilities
// ============================================================================
/**
* Player ownership and metadata utilities
*/
export {
buildPlayerMetadata,
buildPlayerOwnershipFromRoomData,
} from '@/lib/arcade/player-ownership.client'
/**
* Helper for loading and validating game manifests
*/
export { loadManifest } from './load-manifest'
/**
* Game definition helper
*/
export { defineGame } from './define-game'
// ============================================================================
// Re-exports for convenience
// ============================================================================
/**
* Common types from contexts
*/
export type { Player } from '@/contexts/GameModeContext'

View File

@@ -0,0 +1,39 @@
/**
* Manifest loading and validation utilities
*/
import yaml from 'js-yaml'
import { readFileSync } from 'fs'
import { join } from 'path'
import { validateManifest, type GameManifest } from '../manifest-schema'
/**
* Load and validate a game manifest from a YAML file
*
* @param manifestPath - Absolute path to game.yaml file
* @returns Validated GameManifest object
* @throws Error if manifest is invalid or file doesn't exist
*/
export function loadManifest(manifestPath: string): GameManifest {
try {
const fileContents = readFileSync(manifestPath, 'utf8')
const data = yaml.load(fileContents)
return validateManifest(data)
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to load manifest from ${manifestPath}: ${error.message}`)
}
throw error
}
}
/**
* Load manifest from a game directory
*
* @param gameDir - Absolute path to game directory
* @returns Validated GameManifest object
*/
export function loadManifestFromDir(gameDir: string): GameManifest {
const manifestPath = join(gameDir, 'game.yaml')
return loadManifest(manifestPath)
}

View File

@@ -0,0 +1,89 @@
/**
* Type definitions for the Arcade Game SDK
* These types define the contract that all games must implement
*/
import type { ReactNode } from 'react'
import type { GameManifest } from '../manifest-schema'
import type {
GameMove as BaseGameMove,
GameValidator as BaseGameValidator,
ValidationContext,
ValidationResult,
} from '../validation/types'
/**
* Re-export base validation types from arcade system
*/
export type { GameMove, ValidationContext, ValidationResult } from '../validation/types'
export { TEAM_MOVE } from '../validation/types'
export type { TeamMoveSentinel } from '../validation/types'
/**
* Generic game configuration
* Each game defines its own specific config type
*/
export type GameConfig = Record<string, unknown>
/**
* Generic game state
* Each game defines its own specific state type
*/
export type GameState = Record<string, unknown>
/**
* Game validator interface
* Games must implement this to validate moves server-side
*/
export interface GameValidator<TState = GameState, TMove extends BaseGameMove = BaseGameMove>
extends BaseGameValidator<TState, TMove> {
validateMove(state: TState, move: TMove, context?: ValidationContext): ValidationResult
isGameComplete(state: TState): boolean
getInitialState(config: unknown): TState
}
/**
* Provider component interface
* Each game provides a React context provider that wraps the game UI
*/
export type GameProviderComponent = (props: { children: ReactNode }) => JSX.Element
/**
* Main game component interface
* The root component that renders the game UI
*/
export type GameComponent = () => JSX.Element
/**
* Complete game definition
* This is what games export after using defineGame()
*/
export interface GameDefinition<
TConfig extends GameConfig = GameConfig,
TState extends GameState = GameState,
TMove extends BaseGameMove = BaseGameMove,
> {
/** Parsed and validated manifest */
manifest: GameManifest
/** React provider component */
Provider: GameProviderComponent
/** Main game UI component */
GameComponent: GameComponent
/** Server-side validator */
validator: GameValidator<TState, TMove>
/** Default configuration */
defaultConfig: TConfig
/**
* Validate a config object at runtime
* Returns true if config is valid for this game
*
* @param config - Configuration object to validate
* @returns true if valid, false otherwise
*/
validateConfig?: (config: unknown) => config is TConfig
}

View File

@@ -0,0 +1,38 @@
/**
* Game manifest schema validation
* Validates game.yaml files using Zod
*/
import { z } from 'zod'
/**
* Schema for game manifest (game.yaml)
*/
export const GameManifestSchema = z.object({
name: z.string().min(1).describe('Internal game identifier (e.g., "matching")'),
displayName: z.string().min(1).describe('Display name shown to users'),
icon: z.string().min(1).describe('Emoji icon for the game'),
description: z.string().min(1).describe('Short description'),
longDescription: z.string().min(1).describe('Detailed description'),
maxPlayers: z.number().int().min(1).max(10).describe('Maximum number of players'),
difficulty: z
.enum(['Beginner', 'Intermediate', 'Advanced', 'Expert'])
.describe('Difficulty level'),
chips: z.array(z.string()).describe('Feature chips displayed on game card'),
color: z.string().min(1).describe('Color theme (e.g., "purple")'),
gradient: z.string().min(1).describe('CSS gradient for card background'),
borderColor: z.string().min(1).describe('Border color (e.g., "purple.200")'),
available: z.boolean().describe('Whether game is available to play'),
})
/**
* Inferred TypeScript type from schema
*/
export type GameManifest = z.infer<typeof GameManifestSchema>
/**
* Validate a parsed manifest object
*/
export function validateManifest(data: unknown): GameManifest {
return GameManifestSchema.parse(data)
}

View File

@@ -6,7 +6,8 @@
import { eq } from 'drizzle-orm'
import { db, schema } from '@/db'
import { buildPlayerOwnershipMap, type PlayerOwnershipMap } from './player-ownership'
import { type GameMove, type GameName, getValidator } from './validation'
import { getValidator, type GameName } from './validators'
import type { GameMove } from './validation/types'
export interface CreateSessionOptions {
userId: string // User who owns/created the session (typically room creator)

View File

@@ -3,15 +3,10 @@
* Validates all game moves and state transitions
*/
import type {
Difficulty,
GameCard,
GameType,
MemoryPairsState,
Player,
} from '@/app/games/matching/context/types'
import type { GameCard, MemoryPairsState, Player } from '@/app/games/matching/context/types'
import { generateGameCards } from '@/app/games/matching/utils/cardGeneration'
import { canFlipCard, validateMatch } from '@/app/games/matching/utils/matchValidation'
import type { MatchingGameConfig } from '@/lib/arcade/game-configs'
import type { GameValidator, MatchingGameMove, ValidationResult } from './types'
export class MatchingGameValidator implements GameValidator<MemoryPairsState, MatchingGameMove> {
@@ -536,11 +531,7 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
return state.gamePhase === 'results' || state.matchedPairs === state.totalPairs
}
getInitialState(config: {
difficulty: Difficulty
gameType: GameType
turnTimer: number
}): MemoryPairsState {
getInitialState(config: MatchingGameConfig): MemoryPairsState {
return {
cards: [],
gameCards: [],

View File

@@ -1,26 +1,19 @@
/**
* Game validator registry
* Maps game names to their validators
* @deprecated This file now re-exports from the unified registry
* New code should import from '@/lib/arcade/validators' instead
*/
import { matchingGameValidator } from './MatchingGameValidator'
import { memoryQuizGameValidator } from './MemoryQuizGameValidator'
import type { GameName, GameValidator } from './types'
// Re-export everything from unified registry
export {
getValidator,
hasValidator,
getRegisteredGameNames,
validatorRegistry,
matchingGameValidator,
memoryQuizGameValidator,
numberGuesserValidator,
} from '../validators'
const validators = new Map<GameName, GameValidator>([
['matching', matchingGameValidator],
['memory-quiz', memoryQuizGameValidator],
// Add other game validators here as they're implemented
])
export function getValidator(gameName: GameName): GameValidator {
const validator = validators.get(gameName)
if (!validator) {
throw new Error(`No validator found for game: ${gameName}`)
}
return validator
}
export { matchingGameValidator } from './MatchingGameValidator'
export { memoryQuizGameValidator } from './MemoryQuizGameValidator'
export type { GameName } from '../validators'
export * from './types'

View File

@@ -4,9 +4,13 @@
*/
import type { MemoryPairsState } from '@/app/games/matching/context/types'
import type { SorobanQuizState } from '@/app/arcade/memory-quiz/types'
import type { MemoryQuizState as SorobanQuizState } from '@/arcade-games/memory-quiz/types'
export type GameName = 'matching' | 'memory-quiz' | 'complement-race'
/**
* Game name type - auto-derived from validator registry
* @deprecated Import from '@/lib/arcade/validators' instead
*/
export type { GameName } from '../validators'
export interface ValidationResult {
valid: boolean

View File

@@ -0,0 +1,100 @@
/**
* Unified Validator Registry (Isomorphic - runs on client AND server)
*
* This is the single source of truth for game validators.
* Both client and server import validators from here.
*
* To add a new game:
* 1. Import the validator
* 2. Add to validatorRegistry Map
* 3. GameName type will auto-update
*/
import { matchingGameValidator } from './validation/MatchingGameValidator'
import { memoryQuizGameValidator } from '@/arcade-games/memory-quiz/Validator'
import { numberGuesserValidator } from '@/arcade-games/number-guesser/Validator'
import { mathSprintValidator } from '@/arcade-games/math-sprint/Validator'
import type { GameValidator } from './validation/types'
/**
* Central registry of all game validators
* Key: game name (matches manifest.name)
* Value: validator instance
*/
export const validatorRegistry = {
matching: matchingGameValidator,
'memory-quiz': memoryQuizGameValidator,
'number-guesser': numberGuesserValidator,
'math-sprint': mathSprintValidator,
// Add new games here - GameName type will auto-update
} as const
/**
* Auto-derived game name type from registry
* No need to manually update this!
*/
export type GameName = keyof typeof validatorRegistry
/**
* Get validator for a game
* @throws Error if game not found (fail fast)
*/
export function getValidator(gameName: string): GameValidator {
const validator = validatorRegistry[gameName as GameName]
if (!validator) {
throw new Error(
`No validator found for game: ${gameName}. ` +
`Available games: ${Object.keys(validatorRegistry).join(', ')}`
)
}
return validator
}
/**
* Check if a game has a registered validator
*/
export function hasValidator(gameName: string): gameName is GameName {
return gameName in validatorRegistry
}
/**
* Get all registered game names
*/
export function getRegisteredGameNames(): GameName[] {
return Object.keys(validatorRegistry) as GameName[]
}
/**
* Validate a game name at runtime
* Use this instead of TypeScript enums to check if a game is valid
*
* @param gameName - Game name to validate
* @returns true if game has a registered validator
*/
export function isValidGameName(gameName: unknown): gameName is GameName {
return typeof gameName === 'string' && hasValidator(gameName)
}
/**
* Assert that a game name is valid, throw if not
*
* @param gameName - Game name to validate
* @throws Error if game name is invalid
*/
export function assertValidGameName(gameName: unknown): asserts gameName is GameName {
if (!isValidGameName(gameName)) {
throw new Error(
`Invalid game name: ${gameName}. Must be one of: ${getRegisteredGameNames().join(', ')}`
)
}
}
/**
* Re-export validators for backwards compatibility
*/
export {
matchingGameValidator,
memoryQuizGameValidator,
numberGuesserValidator,
mathSprintValidator,
}

View File

@@ -13,8 +13,9 @@ import {
import { createRoom, getRoomById } from './lib/arcade/room-manager'
import { getRoomMembers, getUserRooms, setMemberOnline } from './lib/arcade/room-membership'
import { getRoomActivePlayers, getRoomPlayerIds } from './lib/arcade/player-manager'
import type { GameMove, GameName } from './lib/arcade/validation'
import { getValidator } from './lib/arcade/validation'
import { getValidator, type GameName } from './lib/arcade/validators'
import type { GameMove } from './lib/arcade/validation/types'
import { getGameConfig } from './lib/arcade/game-config-helpers'
// Use globalThis to store socket.io instance to avoid module isolation issues
// This ensures the same instance is accessible across dynamic imports
@@ -81,24 +82,9 @@ export function initializeSocketServer(httpServer: HTTPServer) {
const validator = getValidator(room.gameName as GameName)
console.log('[join-arcade-session] Got validator for:', room.gameName)
// Different games have different initial configs
let initialState: any
if (room.gameName === 'matching') {
initialState = validator.getInitialState({
difficulty: (room.gameConfig as any)?.difficulty || 6,
gameType: (room.gameConfig as any)?.gameType || 'abacus-numeral',
turnTimer: (room.gameConfig as any)?.turnTimer || 30,
})
} else if (room.gameName === 'memory-quiz') {
initialState = validator.getInitialState({
selectedCount: (room.gameConfig as any)?.selectedCount || 5,
displayTime: (room.gameConfig as any)?.displayTime || 2.0,
selectedDifficulty: (room.gameConfig as any)?.selectedDifficulty || 'easy',
})
} else {
// Fallback for other games
initialState = validator.getInitialState(room.gameConfig || {})
}
// Get game-specific config from database (type-safe)
const gameConfig = await getGameConfig(roomId, room.gameName as GameName)
const initialState = validator.getInitialState(gameConfig)
session = await createArcadeSession({
userId,

View File

@@ -47,7 +47,7 @@ describe('playerNames', () => {
it('should append number if all combinations are exhausted', () => {
// Create a mock with limited attempts
const existingNames = ['Swift Ninja']
const name = generateUniquePlayerName(existingNames, 1)
const name = generateUniquePlayerName(existingNames, undefined, 1)
// Should either be unique or have a number appended
expect(name).toBeTruthy()

View File

@@ -1,8 +1,14 @@
/**
* Fun automatic player name generation system
* Generates creative names by combining adjectives with nouns/roles
*
* Supports avatar-specific theming! Each emoji can have its own personality-matched words.
* Falls back gracefully: emoji-specific → category → generic abacus theme
*/
import { EMOJI_SPECIFIC_WORDS, EMOJI_TO_THEME, THEMED_WORD_LISTS } from './themedWords'
// Generic abacus-themed words (used as ultimate fallback)
const ADJECTIVES = [
// Abacus-themed adjectives
'Ancient',
@@ -114,32 +120,96 @@ const NOUNS = [
]
/**
* Generate a random player name by combining an adjective and noun
* Select a word list tier using weighted random selection
* Balanced mix: emoji-specific (50%), category (25%), global abacus (25%)
*/
export function generatePlayerName(): string {
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]
function selectWordListTier(emoji: string, wordType: 'adjectives' | 'nouns'): string[] {
// Collect available tiers
const availableTiers: Array<{ weight: number; words: string[] }> = []
// Emoji-specific tier (50% preference)
const emojiSpecific = EMOJI_SPECIFIC_WORDS[emoji]
if (emojiSpecific) {
availableTiers.push({ weight: 50, words: emojiSpecific[wordType] })
}
// Category tier (25% preference)
const category = EMOJI_TO_THEME[emoji]
if (category) {
const categoryTheme = THEMED_WORD_LISTS[category]
if (categoryTheme) {
availableTiers.push({ weight: 25, words: categoryTheme[wordType] })
}
}
// Global abacus tier (25% preference)
availableTiers.push({ weight: 25, words: wordType === 'adjectives' ? ADJECTIVES : NOUNS })
// Weighted random selection
const totalWeight = availableTiers.reduce((sum, tier) => sum + tier.weight, 0)
let random = Math.random() * totalWeight
for (const tier of availableTiers) {
random -= tier.weight
if (random <= 0) {
return tier.words
}
}
// Fallback (should never reach here)
return wordType === 'adjectives' ? ADJECTIVES : NOUNS
}
/**
* Generate a random player name by combining an adjective and noun
* Optionally themed based on avatar emoji for ultra-personalized names!
* Uses per-word-type probabilistic tier selection for natural variety
*
* @param emoji - Optional emoji avatar to theme the name around
* @returns A creative player name like "Grinning Calculator" or "Lightning Smiler"
*/
export function generatePlayerName(emoji?: string): string {
if (!emoji) {
// No emoji provided, use pure abacus theme
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]
return `${adjective} ${noun}`
}
// Select tier independently for each word type
// This creates natural mixing: adjective might be emoji-specific while noun is global
const adjectiveList = selectWordListTier(emoji, 'adjectives')
const nounList = selectWordListTier(emoji, 'nouns')
const adjective = adjectiveList[Math.floor(Math.random() * adjectiveList.length)]
const noun = nounList[Math.floor(Math.random() * nounList.length)]
return `${adjective} ${noun}`
}
/**
* Generate a unique player name that doesn't conflict with existing players
* @param existingNames - Array of names already in use
* @param emoji - Optional emoji avatar to theme the name around
* @param maxAttempts - Maximum attempts to find a unique name (default: 50)
* @returns A unique player name
*/
export function generateUniquePlayerName(existingNames: string[], maxAttempts = 50): string {
export function generateUniquePlayerName(
existingNames: string[],
emoji?: string,
maxAttempts = 50
): string {
const existingNamesSet = new Set(existingNames.map((name) => name.toLowerCase()))
for (let i = 0; i < maxAttempts; i++) {
const name = generatePlayerName()
const name = generatePlayerName(emoji)
if (!existingNamesSet.has(name.toLowerCase())) {
return name
}
}
// Fallback: if we can't find a unique name, append a number
const baseName = generatePlayerName()
const baseName = generatePlayerName(emoji)
let counter = 1
while (existingNamesSet.has(`${baseName} ${counter}`.toLowerCase())) {
counter++
@@ -150,12 +220,13 @@ export function generateUniquePlayerName(existingNames: string[], maxAttempts =
/**
* Generate a batch of unique player names
* @param count - Number of names to generate
* @param emoji - Optional emoji avatar to theme the names around
* @returns Array of unique player names
*/
export function generateUniquePlayerNames(count: number): string[] {
export function generateUniquePlayerNames(count: number, emoji?: string): string[] {
const names: string[] = []
for (let i = 0; i < count; i++) {
const name = generateUniquePlayerName(names)
const name = generateUniquePlayerName(names, emoji)
names.push(name)
}
return names

File diff suppressed because it is too large Load Diff

View File

@@ -19,10 +19,24 @@
"src/db/schema/**/*.ts",
"src/db/migrate.ts",
"src/lib/arcade/**/*.ts",
"src/arcade-games/**/Validator.ts",
"src/arcade-games/**/types.ts",
"src/app/games/matching/context/types.ts",
"src/app/games/matching/utils/cardGeneration.ts",
"src/app/games/matching/utils/matchValidation.ts",
"src/app/arcade/memory-quiz/types.ts",
"src/socket-server.ts"
],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts"]
"exclude": [
"node_modules",
"dist",
"src/components/**/*",
"src/contexts/**/*",
"src/hooks/**/*",
"src/stories/**/*",
"src/utils/**/*",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.spec.ts"
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "soroban-monorepo",
"version": "3.17.2",
"version": "4.1.0",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [

View File

@@ -6,12 +6,12 @@ case `uname` in
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../../../../../../node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/tsc" "$@"
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
else
exec node "$basedir/../../../../../../node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/tsc" "$@"
exec node "$basedir/../typescript/bin/tsc" "$@"
fi

View File

@@ -6,12 +6,12 @@ case `uname` in
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../../../../../../node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/tsserver" "$@"
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
else
exec node "$basedir/../../../../../../node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/tsserver" "$@"
exec node "$basedir/../typescript/bin/tsserver" "$@"
fi

View File

@@ -6,12 +6,12 @@ case `uname` in
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.0.0_@types+node@20.0.0/node_modules/vite/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.0.0_@types+node@20.0.0/node_modules/vite/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.0.0_@types+node@20.0.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.0.0_@types+node@20.0.0/node_modules/vite/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.0.0_@types+node@20.0.0/node_modules/vite/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.0.0_@types+node@20.0.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../../../../../../node_modules/.pnpm/vite@5.0.0_@types+node@20.0.0/node_modules/vite/bin/vite.js" "$@"
exec "$basedir/node" "$basedir/../vite/bin/vite.js" "$@"
else
exec node "$basedir/../../../../../../node_modules/.pnpm/vite@5.0.0_@types+node@20.0.0/node_modules/vite/bin/vite.js" "$@"
exec node "$basedir/../vite/bin/vite.js" "$@"
fi

View File

@@ -1 +1 @@
../../../../../node_modules/.pnpm/vite@5.0.0_@types+node@20.0.0/node_modules/vite
../../../../../node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite

View File

@@ -6,12 +6,12 @@ case `uname` in
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../../../../../../node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/tsc" "$@"
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
else
exec node "$basedir/../../../../../../node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/tsc" "$@"
exec node "$basedir/../typescript/bin/tsc" "$@"
fi

View File

@@ -6,12 +6,12 @@ case `uname` in
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../../../../../../node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/tsserver" "$@"
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
else
exec node "$basedir/../../../../../../node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/tsserver" "$@"
exec node "$basedir/../typescript/bin/tsserver" "$@"
fi

View File

@@ -6,12 +6,12 @@ case `uname` in
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../../../../../../node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/dist/cli-default.js" "$@"
exec "$basedir/node" "$basedir/../tsup/dist/cli-default.js" "$@"
else
exec node "$basedir/../../../../../../node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/dist/cli-default.js" "$@"
exec node "$basedir/../tsup/dist/cli-default.js" "$@"
fi

View File

@@ -6,12 +6,12 @@ case `uname` in
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../../../../../../node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/dist/cli-node.js" "$@"
exec "$basedir/node" "$basedir/../tsup/dist/cli-node.js" "$@"
else
exec node "$basedir/../../../../../../node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/dist/cli-node.js" "$@"
exec node "$basedir/../tsup/dist/cli-node.js" "$@"
fi

Some files were not shown because too many files have changed in this diff Show More