Compare commits

...

6 Commits

Author SHA1 Message Date
semantic-release-bot
3541466630 chore(release): 2.12.2 [skip ci]
## [2.12.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.1...v2.12.2) (2025-10-09)

### Bug Fixes

* use RoomMemoryPairsProvider in room page ([c279731](c27973191f))
2025-10-09 19:51:13 +00:00
Thomas Hallock
c27973191f fix: use RoomMemoryPairsProvider in room page
Updates /arcade/room/page.tsx to use RoomMemoryPairsProvider instead
of ArcadeMemoryPairsProvider to match the updated component hooks.

The components now use useMemoryPairs() from MemoryPairsContext, which
is provided by RoomMemoryPairsProvider. This fixes the runtime error:
"useMemoryPairs must be used within a MemoryPairsProvider"

Changes:
- Import RoomMemoryPairsProvider instead of ArcadeMemoryPairsProvider
- Wrap matching game with RoomMemoryPairsProvider

Note: ArcadeMemoryPairsProvider can be deprecated in future cleanup.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 14:50:18 -05:00
semantic-release-bot
d423ff7612 chore(release): 2.12.1 [skip ci]
## [2.12.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.0...v2.12.1) (2025-10-09)

### Bug Fixes

* export MemoryPairsContext to fix provider hook error ([80ad33e](80ad33eec0))
2025-10-09 19:40:18 +00:00
Thomas Hallock
80ad33eec0 fix: export MemoryPairsContext to fix provider hook error
Exports MemoryPairsContext from MemoryPairsContext.tsx so that
RoomMemoryPairsProvider can properly provide to it. Also re-exports
useMemoryPairs hook from RoomMemoryPairsProvider for convenience.

This fixes the runtime error:
"useMemoryPairs must be used within a MemoryPairsProvider"

Changes:
- Export MemoryPairsContext in MemoryPairsContext.tsx
- Re-export useMemoryPairs from RoomMemoryPairsProvider.tsx

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 14:39:20 -05:00
semantic-release-bot
f160d2e4af chore(release): 2.12.0 [skip ci]
## [2.12.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.11.0...v2.12.0) (2025-10-09)

### Features

* add networked hover state infrastructure for multiplayer presence ([d149799](d14979907c))
2025-10-09 19:36:48 +00:00
Thomas Hallock
d14979907c feat: add networked hover state infrastructure for multiplayer presence
Implements the server-side and state management foundation for showing
which cards remote players are hovering over in real-time. This creates
"in-person feedback" for network games by transmitting hover cues.

Core changes:
- Add playerHovers state field mapping playerId -> cardId (or null)
- Add HOVER_CARD move type for transmitting hover state
- Add validateHoverCard() validator (always valid, lightweight UI state)
- Add optimistic hover updates in RoomMemoryPairsProvider
- Add hoverCard(cardId) action creator
- Initialize playerHovers in initial state

Remaining work to complete feature:
1. Wire up onMouseEnter/onMouseLeave in GameCard/MemoryGrid
2. Call hoverCard(cardId) on enter, hoverCard(null) on leave
3. Display remote player avatars floating over hovered cards
4. Add smooth transitions when hover changes

This establishes the data flow: hover event → HOVER_CARD move → server
validation → broadcast to room → all clients see hover state.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 14:35:55 -05:00
19 changed files with 1080 additions and 19 deletions

View File

@@ -1,3 +1,24 @@
## [2.12.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.1...v2.12.2) (2025-10-09)
### Bug Fixes
* use RoomMemoryPairsProvider in room page ([c279731](https://github.com/antialias/soroban-abacus-flashcards/commit/c27973191f0144604e17a8a14adf0a88df476e27))
## [2.12.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.0...v2.12.1) (2025-10-09)
### Bug Fixes
* export MemoryPairsContext to fix provider hook error ([80ad33e](https://github.com/antialias/soroban-abacus-flashcards/commit/80ad33eec0b6946702eaa9cf1b1c246852864b00))
## [2.12.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.11.0...v2.12.0) (2025-10-09)
### Features
* add networked hover state infrastructure for multiplayer presence ([d149799](https://github.com/antialias/soroban-abacus-flashcards/commit/d14979907c5df9b793a1c110028fc5b54457f507))
## [2.11.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.10.1...v2.11.0) (2025-10-09)

View File

@@ -0,0 +1,492 @@
# Arcade Game Architecture
## Overview
The arcade system supports two distinct game modes that must remain completely isolated from each other:
1. **Local Play** - Games without network synchronization (can be single-player OR local multiplayer)
2. **Room-Based Play** - Networked games with real-time synchronization across room members
## Core Terminology
Following `docs/terminology-user-player-room.md`:
- **USER** - Identity (guest or authenticated account), retrieved via `useViewerId()`, one per browser/account
- **PLAYER** - Game avatar/profile (e.g., "Alice 👧", "Bob 👦"), stored in `players` table
- **PLAYER ROSTER** - All PLAYERS belonging to a USER (can have many)
- **ACTIVE PLAYERS** - PLAYERS where `isActive = true` - these are the ones that actually participate in games
- **ROOM MEMBER** - A USER's participation in a multiplayer room (tracked in `room_members` table)
**Important:** A USER can have many PLAYERS in their roster, but only the ACTIVE PLAYERS (where `isActive = true`) participate in games. This enables "hot-potato" style local multiplayer where multiple people share the same device. This is LOCAL play (not networked), even though multiple PLAYERS participate.
In arcade sessions:
- `arcade_sessions.userId` - The USER who owns the session
- `arcade_sessions.activePlayers` - Array of PLAYER IDs (only active players with `isActive = true`)
- `arcade_sessions.roomId` - If present, the room ID for networked play (references `arcade_rooms.id`)
## Critical Architectural Requirements
### 1. Mode Isolation (MUST ENFORCE)
**Local Play** (`/arcade/[game-name]`)
- MUST NOT sync game state across the network
- MUST NOT use room data, even if the USER is currently a member of an active room
- MUST create isolated, per-USER game sessions
- Game state lives only in the current browser tab/session
- CAN have multiple ACTIVE PLAYERS from the same USER (local multiplayer / hot-potato)
- State is NOT shared across the network, only within the browser session
**Room-Based Play** (`/arcade/room`)
- MUST sync game state across all room members via network
- MUST use the USER's current active room
- MUST coordinate moves via server WebSocket
- Game state is shared across all ACTIVE PLAYERS from all USERS in the room
- When a PLAYER makes a move, all room members see it in real-time
- CAN ALSO have multiple ACTIVE PLAYERS per USER (networked + local multiplayer combined)
### 2. Room ID Usage Rules
```typescript
// ❌ WRONG: Always checking for room data
const { roomData } = useRoomData()
useArcadeSession({ roomId: roomData?.id }) // This causes the bug!
// ✅ CORRECT: Explicit mode control via separate providers
<LocalMemoryPairsProvider> {/* Never passes roomId */}
<RoomMemoryPairsProvider> {/* Always passes roomId */}
```
**Key principle:** The presence of a `roomId` parameter in `useArcadeSession` determines synchronization behavior:
- `roomId` present → room-wide network sync enabled (room-based play)
- `roomId` undefined → local play only (no network sync)
### 3. Composition Over Flags (PREFERRED APPROACH)
**✅ Option 1: Separate Providers (CLEAREST - USE THIS)**
Create two distinct provider components:
```typescript
// context/LocalMemoryPairsProvider.tsx
export function LocalMemoryPairsProvider({ children }) {
const { data: viewerId } = useViewerId()
const { activePlayers } = useGameMode() // Gets active players (isActive = true)
// NEVER fetch room data for local play
const { state, sendMove } = useArcadeSession<MemoryPairsState>({
userId: viewerId || '',
roomId: undefined, // Explicitly undefined - no network sync
initialState,
applyMove: applyMoveOptimistically,
})
// ... rest of provider logic
// Note: activePlayers contains only PLAYERS with isActive = true
}
// context/RoomMemoryPairsProvider.tsx
export function RoomMemoryPairsProvider({ children }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData() // OK to fetch for room-based play
const { activePlayers } = useGameMode() // Gets active players (isActive = true)
const { state, sendMove } = useArcadeSession<MemoryPairsState>({
userId: viewerId || '',
roomId: roomData?.id, // Pass roomId for network sync
initialState,
applyMove: applyMoveOptimistically,
})
// ... rest of provider logic
}
```
Then use them explicitly:
```typescript
// /arcade/matching/page.tsx (Local Play)
export default function MatchingPage() {
return (
<ArcadeGuardedPage>
<LocalMemoryPairsProvider>
<MemoryPairsGame />
</LocalMemoryPairsProvider>
</ArcadeGuardedPage>
)
}
// /arcade/room/page.tsx (Room-Based Play)
export default function RoomPage() {
// ... room validation logic
if (roomData.gameName === 'matching') {
return (
<RoomMemoryPairsProvider>
<MemoryPairsGame />
</RoomMemoryPairsProvider>
)
}
}
```
**Benefits of separate providers:**
- Compile-time safety - impossible to mix modes
- Clear intent - any developer can see which mode at a glance
- No runtime conditionals needed
- Easier to test - each provider tests separately
**❌ Avoid:** Runtime flag checking scattered throughout code
```typescript
// Anti-pattern: Too many conditionals
if (isRoomBased) { ... } else { ... }
```
### 4. How Synchronization Works
#### Local Play Flow
```
USER Action → useArcadeSession (roomId: undefined)
→ WebSocket emit('join-arcade-session', { userId })
→ Server creates isolated session for userId
→ Session key = userId
→ session.activePlayers = USER's active player IDs (isActive = true)
→ State changes only affect this USER's browser tabs
Note: Multiple ACTIVE PLAYERS from same USER can participate (local multiplayer),
but state is NEVER synced across network
```
#### Room-Based Play Flow
```
USER Action (on behalf of PLAYER)
→ useArcadeSession (roomId: 'room_xyz')
→ WebSocket emit('join-arcade-session', { userId, roomId })
→ Server creates/joins shared session for roomId
→ session.activePlayers = ALL active players from ALL room members
→ Socket joins TWO rooms: `arcade:${userId}` AND `game:${roomId}`
→ PLAYER makes move
→ Server validates PLAYER ownership (is this PLAYER owned by this USER?)
→ State changes broadcast to:
- arcade:${userId} - All tabs of this USER (for optimistic reconciliation)
- game:${roomId} - All USERS in the room (for network sync)
Note: Each USER can still have multiple ACTIVE PLAYERS (local + networked multiplayer)
```
The server-side logic uses `roomId` to determine session scope:
- No `roomId`: Session key = `userId` (isolated to USER's browser)
- With `roomId`: Session key = `roomId` (shared across all room members)
See `docs/MULTIPLAYER_SYNC_ARCHITECTURE.md` for detailed socket room mechanics.
### 5. USER vs PLAYER in Game Logic
**Important distinction:**
- **Session ownership**: Tracked by USER ID (`useViewerId()`)
- **Player roster**: All PLAYERS for a USER (can be many)
- **Active players**: PLAYERS with `isActive = true` (these join the game)
- **Game actions**: Performed by PLAYER ID (from `players` table)
- **Move validation**: Server checks that PLAYER ID belongs to the requesting USER
- **Local multiplayer**: One USER with multiple ACTIVE PLAYERS (hot-potato style, same device)
- **Networked multiplayer**: Multiple USERS, each with their own ACTIVE PLAYERS, in a room
```typescript
// ✅ Correct: USER owns session, ACTIVE PLAYERS participate
const { data: viewerId } = useViewerId() // USER ID
const { activePlayers } = useGameMode() // ACTIVE PLAYER IDs (isActive = true)
// activePlayers might be [player_001, player_002]
// even though USER has 5 total PLAYERS in their roster
const { state, sendMove } = useArcadeSession({
userId: viewerId, // Session owned by USER
roomId: undefined, // Local play (or roomData?.id for room-based)
// ...
})
// When PLAYER flips card:
sendMove({
type: 'FLIP_CARD',
playerId: currentPlayerId, // PLAYER ID from activePlayers
data: { cardId: '...' }
})
```
**Example Scenarios:**
1. **Single-player local game:**
- USER: "guest_abc"
- Player roster: ["player_001" (active), "player_002" (inactive), "player_003" (inactive)]
- Active PLAYERS in game: ["player_001"]
- Mode: Local play (no roomId)
2. **Local multiplayer (hot-potato):**
- USER: "guest_abc"
- Player roster: ["player_001" (active), "player_002" (active), "player_003" (active), "player_004" (inactive)]
- Active PLAYERS in game: ["player_001", "player_002", "player_003"] (3 kids sharing device)
- Mode: Local play (no roomId)
- Game rotates turns between the 3 active PLAYERS, but NO network sync
3. **Room-based networked play:**
- USER A: "guest_abc"
- Player roster: 5 total PLAYERS
- Active PLAYERS: ["player_001", "player_002"]
- USER B: "guest_def"
- Player roster: 3 total PLAYERS
- Active PLAYERS: ["player_003"]
- Mode: Room-based play (roomId: "room_xyz")
- Total PLAYERS in game: 3 (player_001, player_002, player_003)
- All 3 synced across network
4. **Room-based + local multiplayer combined:**
- USER A: "guest_abc" with 3 active PLAYERS (3 kids at Device A)
- USER B: "guest_def" with 2 active PLAYERS (2 kids at Device B)
- Mode: Room-based play (roomId: "room_xyz")
- 5 total active PLAYERS across 2 devices, all synced over network
## Common Mistakes to Avoid
### Mistake 1: Conditional Room Usage
```typescript
// ❌ BAD: Room sync leaks into local play
const { roomData } = useRoomData()
useArcadeSession({
roomId: roomData?.id // Local play will sync if USER is in a room!
})
```
### Mistake 2: Shared Components Without Mode Context
```typescript
// ❌ BAD: Same provider used for both modes
export default function LocalGamePage() {
return <GameProvider><Game /></GameProvider> // Which mode?
}
```
### Mistake 3: Confusing "multiplayer" with "networked"
```typescript
// ❌ BAD: Thinking multiple PLAYERS means room-based
if (activePlayers.length > 1) {
// Must be room-based! WRONG!
// Could be local multiplayer (hot-potato style)
}
// ✅ CORRECT: Check for roomId to determine network sync
const isNetworked = !!roomId
const isLocalMultiplayer = activePlayers.length > 1 && !roomId
```
### Mistake 4: Using all PLAYERS instead of only active ones
```typescript
// ❌ BAD: Including inactive players
const allPlayers = await db.query.players.findMany({
where: eq(players.userId, userId)
})
// ✅ CORRECT: Only active players join the game
const activePlayers = await db.query.players.findMany({
where: and(
eq(players.userId, userId),
eq(players.isActive, true)
)
})
```
### Mistake 5: Mixing USER ID and PLAYER ID
```typescript
// ❌ BAD: Using USER ID for game actions
sendMove({
type: 'FLIP_CARD',
playerId: viewerId, // WRONG! viewerId is USER ID, not PLAYER ID
data: { cardId: '...' }
})
// ✅ CORRECT: Use PLAYER ID from game state
sendMove({
type: 'FLIP_CARD',
playerId: state.currentPlayer, // PLAYER ID from activePlayers
data: { cardId: '...' }
})
```
### Mistake 6: Server-Side Ambiguity
```typescript
// ❌ BAD: Server can't distinguish intent
socket.on('join-arcade-session', ({ userId, roomId }) => {
// If roomId exists, did USER want local or room-based play?
// This happens when provider always passes roomData?.id
})
```
## Testing Requirements
Tests MUST verify mode isolation:
### Local Play Tests
```typescript
it('should NOT sync state when USER is in a room but playing locally', async () => {
// Setup: USER is a member of an active room
// Action: USER navigates to /arcade/matching
// Assert: Game state is NOT shared with other room members
// Assert: Other room members' actions do NOT affect this game
})
it('should create isolated sessions for concurrent local games', () => {
// Setup: Two USERS who are members of the same room
// Action: Both navigate to /arcade/matching separately
// Assert: Each has independent game state
// Assert: USER A's moves do NOT appear in USER B's game
})
it('should support local multiplayer without network sync', () => {
// Setup: USER with 3 active PLAYERS in roster (hot-potato style)
// Action: USER plays at /arcade/matching with the 3 active PLAYERS
// Assert: All 3 active PLAYERS participate in the same session
// Assert: Inactive PLAYERS do NOT participate
// Assert: State is NOT synced across network
// Assert: Game rotates turns between active PLAYERS locally
})
it('should only include active players in game', () => {
// Setup: USER has 5 PLAYERS in roster, but only 2 are active
// Action: USER starts a local game
// Assert: Only the 2 active PLAYERS are in activePlayers array
// Assert: Inactive PLAYERS are not included
})
it('should sync across USER tabs but not across network', () => {
// Setup: USER opens /arcade/matching in 2 browser tabs
// Action: PLAYER makes move in Tab 1
// Assert: Tab 2 sees the move (multi-tab sync)
// Assert: Other USERS do NOT see the move (no network sync)
})
```
### Room-Based Play Tests
```typescript
it('should sync state across all room members', async () => {
// Setup: Two USERS are members of the same room
// Action: USER A's PLAYER flips card at /arcade/room
// Assert: USER B sees the card flip in real-time
})
it('should sync across multiple active PLAYERS from multiple USERS', () => {
// Setup: USER A has 2 active PLAYERS, USER B has 1 active PLAYER in same room
// Action: USER A's PLAYER 1 makes move
// Assert: All 3 PLAYERS see the move (networked)
})
it('should only include active players in room games', () => {
// Setup: USER A (5 PLAYERS, 2 active), USER B (3 PLAYERS, 1 active) join room
// Action: Game starts
// Assert: session.activePlayers = [userA_player1, userA_player2, userB_player1]
// Assert: Inactive PLAYERS are NOT included
})
it('should handle combined local + networked multiplayer', () => {
// Setup: USER A (3 active PLAYERS), USER B (2 active PLAYERS) in same room
// Action: Any PLAYER makes a move
// Assert: All 5 active PLAYERS see the move across both devices
})
it('should fail gracefully when no room exists', () => {
// Setup: USER is not a member of any room
// Action: Navigate to /arcade/room
// Assert: Shows "No active room" message
// Assert: Does not create a session
})
it('should validate PLAYER ownership', async () => {
// Setup: USER A in room with active PLAYER 'alice'
// Action: USER A attempts move for PLAYER 'bob' (owned by USER B)
// Assert: Server rejects the move
// Assert: Error indicates unauthorized PLAYER
})
```
## Implementation Checklist
When adding a new game or modifying existing ones:
- [ ] Create separate `LocalGameProvider` and `RoomGameProvider` components
- [ ] Local provider never calls `useRoomData()`
- [ ] Local provider passes `roomId: undefined` to `useArcadeSession`
- [ ] Room provider calls `useRoomData()` and passes `roomId: roomData?.id`
- [ ] Both providers use `useGameMode()` to get active players
- [ ] Local play page uses `LocalGameProvider`
- [ ] `/arcade/room` page uses `RoomGameProvider`
- [ ] Game components correctly use PLAYER IDs (not USER IDs) for moves
- [ ] Game supports multiple active PLAYERS from same USER (local multiplayer)
- [ ] Inactive PLAYERS are never included in game sessions
- [ ] Tests verify mode isolation (local doesn't network sync, room-based does)
- [ ] Tests verify PLAYER ownership validation
- [ ] Tests verify only active PLAYERS participate
- [ ] Tests verify local multiplayer works (multiple active PLAYERS, one USER)
- [ ] Documentation updated if behavior changes
## File Organization
```
src/app/arcade/
├── [game-name]/ # Local play games
│ ├── page.tsx # Uses LocalGameProvider
│ └── context/
│ ├── LocalGameProvider.tsx # roomId: undefined
│ └── RoomGameProvider.tsx # roomId: roomData?.id
├── room/ # Room-based play
│ └── page.tsx # Uses RoomGameProvider
└── ...
```
## Architecture Decision Records
### Why separate providers instead of auto-detect from route?
While we could detect mode based on the route (`/arcade/room` vs `/arcade/matching`), separate providers are clearer and prevent accidental misuse. Future developers can immediately see the intent, and the type system can enforce correctness.
### Why being in a room doesn't mean all games sync?
A USER being a room member does NOT mean all their games should network sync. They should be able to play local games while remaining in a room for future room-based sessions. Mode is determined by the page they're on, not their room membership status.
### Why not use a single shared provider with mode props?
We tried that. It led to the current bug where local play accidentally synced with rooms. Separate providers make the distinction compile-time safe rather than runtime conditional, and eliminate the possibility of accidentally passing `roomId` when we shouldn't.
### Why do we track sessions by USER but moves by PLAYER?
- **Sessions** are per-USER because each USER can have their own game session
- **Moves** are per-PLAYER because PLAYERS are the game avatars that score points
- **Only active PLAYERS** (isActive = true) participate in games
- This allows:
- One USER with multiple active PLAYERS (local multiplayer / hot-potato)
- Multiple USERS in one room (networked play)
- Combined: Multiple USERS each with multiple active PLAYERS (local + networked)
- Proper ownership validation (server checks USER owns PLAYER)
- PLAYERS can be toggled active/inactive without deleting them
### Why use "local" vs "room-based" instead of "solo" vs "multiplayer"?
- **"Solo"** is misleading - a USER can have multiple active PLAYERS in local play (hot-potato style)
- **"Multiplayer"** is ambiguous - it could mean local multiplayer OR networked multiplayer
- **"Local play"** clearly means: no network sync (but can have multiple active PLAYERS)
- **"Room-based play"** clearly means: network sync across room members
## Related Files
- `src/hooks/useArcadeSession.ts` - Session management with optional roomId
- `src/hooks/useArcadeSocket.ts` - WebSocket connection with sync logic (socket rooms: `arcade:${userId}` and `game:${roomId}`)
- `src/hooks/useRoomData.ts` - Fetches USER's current room membership
- `src/hooks/useViewerId.ts` - Retrieves current USER ID
- `src/contexts/GameModeContext.tsx` - Provides active PLAYER information
- `src/app/arcade/matching/context/ArcadeMemoryPairsContext.tsx` - Game context (needs refactoring to separate providers)
- `src/app/arcade/matching/page.tsx` - Local play entry point
- `src/app/arcade/room/page.tsx` - Room-based play entry point
- `docs/terminology-user-player-room.md` - Terminology guide (USER/PLAYER/MEMBER)
- `docs/MULTIPLAYER_SYNC_ARCHITECTURE.md` - Technical details of room-based sync
## Version History
- **2025-10-09**: Initial documentation
- Issue identified: Local play was syncing with rooms over network
- Root cause: Same provider always fetched `roomData` and passed `roomId` to `useArcadeSession`
- Solution: Separate providers for local vs room-based play
- Terminology clarification: "local" vs "room-based" (not "solo" vs "multiplayer")
- Active players: Only PLAYERS with `isActive = true` participate in games

View File

@@ -0,0 +1,416 @@
# Arcade Setup Pattern
## Overview
This document describes the **standard synchronized setup pattern** for arcade games. Following this pattern ensures that:
1.**Setup is synchronized** - All room members see the same setup screen and config changes in real-time
2.**No local state hacks** - Configuration lives entirely in session state, no React state merging
3.**Optimistic updates** - Config changes feel instant with client-side prediction
4.**Consistent pattern** - All games follow the same architecture
**Reference Implementation**: `src/app/arcade/matching/*` (Matching game)
---
## Core Concept
Setup configuration is **game state**, not UI state. Configuration changes are **moves** that are validated, synchronized, and can be made by any room member.
### Key Principles
1. **Game state includes configuration** during ALL phases (setup, playing, results)
2. **No local React state** for configuration - use session state directly
3. **Standard move types** that all games should implement
4. **Setup phase is collaborative** - any room member can configure the game
---
## Required Move Types
Every arcade game must support these standard moves:
### 1. `GO_TO_SETUP`
Transitions game to setup phase, allowing reconfiguration.
```typescript
{
type: 'GO_TO_SETUP',
playerId: string,
data: {}
}
```
**Behavior:**
- Can be called from any phase (setup, playing, results)
- Sets `gamePhase: 'setup'`
- Resets game progression (scores, cards, etc.)
- Preserves configuration (players can modify it)
- Synchronized across all room members
### 2. `SET_CONFIG`
Updates a configuration field during setup phase.
```typescript
{
type: 'SET_CONFIG',
playerId: string,
data: {
field: string, // Config field name
value: any // New value
}
}
```
**Behavior:**
- Only allowed during setup phase
- Validates field name and value
- Updates immediately with optimistic update
- Synchronized across all room members
### 3. `START_GAME`
Starts the game with current configuration.
```typescript
{
type: 'START_GAME',
playerId: string,
data: {
activePlayers: string[],
playerMetadata: { [playerId: string]: PlayerMetadata },
// ... game-specific initial data
}
}
```
**Behavior:**
- Only allowed during setup phase
- Uses current session state configuration
- Initializes game-specific state
- Sets `gamePhase: 'playing'`
---
## Implementation Checklist
### 1. Update Validation Types
Add move types to your game's validation types:
```typescript
// src/lib/arcade/validation/types.ts
export interface YourGameGoToSetupMove extends GameMove {
type: 'GO_TO_SETUP'
data: Record<string, never>
}
export interface YourGameSetConfigMove extends GameMove {
type: 'SET_CONFIG'
data: {
field: 'configField1' | 'configField2' | 'configField3'
value: any
}
}
export type YourGameMove =
| YourGameStartGameMove
| YourGameGoToSetupMove
| YourGameSetConfigMove
| ... // other game-specific moves
```
### 2. Implement Validators
Add validators for setup moves in your game's validator class:
```typescript
// src/lib/arcade/validation/YourGameValidator.ts
export class YourGameValidator implements GameValidator<YourGameState, YourGameMove> {
validateMove(state, move, context) {
switch (move.type) {
case 'GO_TO_SETUP':
return this.validateGoToSetup(state)
case 'SET_CONFIG':
return this.validateSetConfig(state, move.data.field, move.data.value)
case 'START_GAME':
return this.validateStartGame(state, move.data)
// ... other moves
}
}
private validateGoToSetup(state: YourGameState): ValidationResult {
return {
valid: true,
newState: {
...state,
gamePhase: 'setup',
// Reset game progression, preserve configuration
// ... reset scores, game data, etc.
},
}
}
private validateSetConfig(
state: YourGameState,
field: string,
value: any
): ValidationResult {
// Only during setup
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Cannot change config outside setup' }
}
// Validate field-specific values
switch (field) {
case 'configField1':
if (!isValidValue(value)) {
return { valid: false, error: 'Invalid value' }
}
break
// ... validate other fields
}
return {
valid: true,
newState: { ...state, [field]: value },
}
}
private validateStartGame(state: YourGameState, data: any): ValidationResult {
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Can only start from setup' }
}
// Use current state configuration to initialize game
const initialGameData = initializeYourGame(state.configField1, state.configField2)
return {
valid: true,
newState: {
...state,
gamePhase: 'playing',
...initialGameData,
},
}
}
}
```
### 3. Add Optimistic Updates
Update `applyMoveOptimistically` in your providers:
```typescript
// src/app/arcade/your-game/context/YourGameProvider.tsx
function applyMoveOptimistically(state: YourGameState, move: GameMove): YourGameState {
switch (move.type) {
case 'GO_TO_SETUP':
return {
...state,
gamePhase: 'setup',
// Reset game state, preserve config
}
case 'SET_CONFIG':
const { field, value } = move.data
return {
...state,
[field]: value,
}
case 'START_GAME':
return {
...state,
gamePhase: 'playing',
// ... initialize game data from move
}
// ... other moves
}
}
```
### 4. Remove Local State from Providers
**❌ OLD PATTERN (Don't do this):**
```typescript
// DON'T: Local React state for configuration
const [localDifficulty, setLocalDifficulty] = useState(6)
// DON'T: Merge hack
const effectiveState = state.gamePhase === 'setup'
? { ...state, difficulty: localDifficulty }
: state
// DON'T: Direct setter
const setDifficulty = (value) => setLocalDifficulty(value)
```
**✅ NEW PATTERN (Do this):**
```typescript
// DO: Use session state directly
const { state, sendMove } = useArcadeSession(...)
// DO: Send move for config changes
const setDifficulty = useCallback((value) => {
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
data: { field: 'difficulty', value },
})
}, [activePlayers, sendMove])
// DO: Use state directly (no merging!)
const contextValue = { state: { ...state, gameMode }, ... }
```
### 5. Update Action Creators
All configuration actions should send moves:
```typescript
export function YourGameProvider({ children }) {
const { state, sendMove } = useArcadeSession(...)
const setConfigField = useCallback((value) => {
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
data: { field: 'configField', value },
})
}, [activePlayers, sendMove])
const goToSetup = useCallback(() => {
const playerId = activePlayers[0] || state.currentPlayer || ''
sendMove({
type: 'GO_TO_SETUP',
playerId,
data: {},
})
}, [activePlayers, state.currentPlayer, sendMove])
const startGame = useCallback(() => {
// Use current session state config (not local state!)
const initialData = initializeGame(state.config1, state.config2)
const playerId = activePlayers[0]
sendMove({
type: 'START_GAME',
playerId,
data: {
...initialData,
activePlayers,
playerMetadata: capturePlayerMetadata(players, activePlayers),
},
})
}, [state.config1, state.config2, activePlayers, sendMove])
return <YourGameContext.Provider value={...} />
}
```
---
## Benefits of This Pattern
### 1. **Synchronized Setup**
- User A clicks "Setup" → All room members see setup screen
- User B changes difficulty → All room members see the change
- User A clicks "Start" → All room members start playing
### 2. **No Special Cases**
- Setup works like gameplay (moves + validation)
- No conditional logic based on phase
- No React state merging hacks
### 3. **Easy to Extend**
- New games copy the same pattern
- Well-documented and tested
- Consistent developer experience
### 4. **Optimistic Updates**
- Config changes feel instant
- Client-side prediction + server validation
- Rollback on validation failure
---
## Testing Checklist
When implementing this pattern, test these scenarios:
### Local Mode
- [ ] Click setup button during game → returns to setup
- [ ] Change config fields → updates immediately
- [ ] Start game → uses current config
### Room Mode (Multi-User)
- [ ] User A clicks setup → User B sees setup screen
- [ ] User A changes difficulty → User B sees change in real-time
- [ ] User B changes game type → User A sees change in real-time
- [ ] User A starts game → Both users see game with same config
### Edge Cases
- [ ] Change config rapidly → no race conditions
- [ ] User with 0 players can see/modify setup
- [ ] Setup → Play → Setup preserves last config
- [ ] Invalid config values are rejected by validator
---
## Migration Guide
If you have an existing game using local state, follow these steps:
### Step 1: Add Move Types
Add `GO_TO_SETUP` and `SET_CONFIG` to your validation types.
### Step 2: Implement Validators
Add validators for the new moves in your game validator class.
### Step 3: Add Optimistic Updates
Update `applyMoveOptimistically` to handle the new moves.
### Step 4: Remove Local State
1. Delete all `useState` calls for configuration
2. Delete the `effectiveState` merging logic
3. Update action creators to send moves instead
### Step 5: Test
Run through the testing checklist above.
---
## Reference Implementation
See the Matching game for a complete reference implementation:
- **Types**: `src/lib/arcade/validation/types.ts`
- **Validator**: `src/lib/arcade/validation/MatchingGameValidator.ts`
- **Provider**: `src/app/arcade/matching/context/RoomMemoryPairsProvider.tsx`
- **Optimistic Updates**: `applyMoveOptimistically` function in provider
Look for comments marked with:
- `// STANDARD ARCADE PATTERN: GO_TO_SETUP`
- `// STANDARD ARCADE PATTERN: SET_CONFIG`
- `// NO LOCAL STATE`
- `// NO MORE effectiveState merging!`
---
## Questions?
If something is unclear or you encounter issues implementing this pattern, refer to the Matching game implementation or update this document with clarifications.

View File

@@ -1,6 +1,16 @@
{
"permissions": {
"allow": ["Bash(npm test:*)", "Read(//Users/antialias/projects/**)"],
"allow": [
"Bash(npm test:*)",
"Read(//Users/antialias/projects/**)",
"Bash(npm run lint:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(git pull:*)",
"Bash(git stash:*)",
"Bash(npm run format:*)"
],
"deny": [],
"ask": []
}

View File

@@ -3,12 +3,12 @@
import { css } from '../../../../../styled-system/css'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { pluralizeWord } from '../../../../utils/pluralization'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { MemoryGrid } from './MemoryGrid'
import { PlayerStatusBar } from './PlayerStatusBar'
export function GamePhase() {
const { state, resetGame: _resetGame, activePlayers } = useArcadeMemoryPairs()
const { state, resetGame: _resetGame, activePlayers } = useMemoryPairs()
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
// Convert Map to array and create mapping from numeric index to player

View File

@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from 'react'
import { css } from '../../../../../styled-system/css'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { getGridConfiguration } from '../utils/cardGeneration'
import { GameCard } from './GameCard'
@@ -81,7 +81,7 @@ function useGridDimensions(gridConfig: any, totalCards: number) {
}
export function MemoryGrid() {
const { state, flipCard } = useArcadeMemoryPairs()
const { state, flipCard } = useMemoryPairs()
// Hooks must be called before early return
const gridConfig = useMemo(() => getGridConfiguration(state.difficulty), [state.difficulty])

View File

@@ -7,14 +7,14 @@ import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
import { css } from '../../../../../styled-system/css'
import { StandardGameLayout } from '../../../../components/StandardGameLayout'
import { useFullscreen } from '../../../../contexts/FullscreenContext'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { GamePhase } from './GamePhase'
import { ResultsPhase } from './ResultsPhase'
import { SetupPhase } from './SetupPhase'
export function MemoryPairsGame() {
const router = useRouter()
const { state, exitSession, resetGame } = useArcadeMemoryPairs()
const { state, exitSession, resetGame } = useMemoryPairs()
const { setFullscreenElement } = useFullscreen()
const { canModifyPlayers } = useArcadeRedirect({ currentGame: 'matching' })
const gameRef = useRef<HTMLDivElement>(null)

View File

@@ -3,7 +3,7 @@
import { css } from '../../../../../styled-system/css'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { gamePlurals } from '../../../../utils/pluralization'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
import { useMemoryPairs } from '../context/MemoryPairsContext'
interface PlayerStatusBarProps {
className?: string
@@ -11,7 +11,7 @@ interface PlayerStatusBarProps {
export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
const { state } = useArcadeMemoryPairs()
const { state } = useMemoryPairs()
// Get active players array
const activePlayersData = Array.from(activePlayerIds)

View File

@@ -3,12 +3,12 @@
import { useRouter } from 'next/navigation'
import { css } from '../../../../../styled-system/css'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { formatGameTime, getMultiplayerWinner, getPerformanceAnalysis } from '../utils/gameScoring'
export function ResultsPhase() {
const router = useRouter()
const { state, resetGame, activePlayers, gameMode } = useArcadeMemoryPairs()
const { state, resetGame, activePlayers, gameMode, exitSession } = useMemoryPairs()
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
// Get active player data array

View File

@@ -251,7 +251,7 @@ function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction):
}
// Create context
const MemoryPairsContext = createContext<MemoryPairsContextValue | null>(null)
export const MemoryPairsContext = createContext<MemoryPairsContextValue | null>(null)
// Provider component
export function MemoryPairsProvider({ children }: { children: ReactNode }) {

View File

@@ -39,6 +39,8 @@ const initialState: MemoryPairsState = {
originalConfig: undefined,
pausedGamePhase: undefined,
pausedGameState: undefined,
// HOVER: Initialize hover state
playerHovers: {},
}
/**
@@ -194,6 +196,17 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
}
}
case 'HOVER_CARD': {
// Update player hover state for networked presence
return {
...state,
playerHovers: {
...state.playerHovers,
[move.playerId]: move.data.cardId,
},
}
}
default:
return state
}
@@ -514,6 +527,22 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
})
}, [canResumeGame, activePlayers, state.currentPlayer, sendMove])
const hoverCard = useCallback(
(cardId: string | null) => {
// HOVER: Send hover state for networked presence
// Use current player as the one hovering
const playerId = state.currentPlayer || activePlayers[0] || ''
if (!playerId) return // No active player to send hover for
sendMove({
type: 'HOVER_CARD',
playerId,
data: { cardId },
})
},
[state.currentPlayer, activePlayers, sendMove]
)
// NO MORE effectiveState merging! Just use session state directly with gameMode added
const effectiveState = { ...state, gameMode } as MemoryPairsState & { gameMode: GameMode }
@@ -536,6 +565,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
setGameType,
setDifficulty,
setTurnTimer,
hoverCard,
exitSession,
gameMode,
activePlayers,
@@ -543,3 +573,6 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
return <MemoryPairsContext.Provider value={contextValue}>{children}</MemoryPairsContext.Provider>
}
// Export the hook for this provider
export { useMemoryPairs } from './MemoryPairsContext'

View File

@@ -0,0 +1,20 @@
/**
* Central export point for arcade matching game context
* Re-exports the hook from the appropriate provider
*/
// Export the hook (works with both local and room providers)
export { useMemoryPairs } from './MemoryPairsContext'
// Export the room provider (networked multiplayer)
export { RoomMemoryPairsProvider } from './RoomMemoryPairsProvider'
// Export types
export type {
GameCard,
GameMode,
GamePhase,
GameType,
MemoryPairsState,
MemoryPairsContextValue,
} from './types'

View File

@@ -59,6 +59,7 @@ export interface MemoryPairsState {
moves: number
scores: PlayerScore
activePlayers: Player[] // Track active player IDs
playerMetadata?: { [playerId: string]: any } // Player metadata for cross-user visibility
consecutiveMatches: { [playerId: string]: number } // Track consecutive matches per player
// Timing
@@ -72,6 +73,24 @@ export interface MemoryPairsState {
isProcessingMove: boolean
showMismatchFeedback: boolean
lastMatchedPair: [string, string] | null
// PAUSE/RESUME: Paused game state
originalConfig?: { gameType: GameType; difficulty: Difficulty; turnTimer: number }
pausedGamePhase?: GamePhase
pausedGameState?: {
gameCards: GameCard[]
currentPlayer: Player
matchedPairs: number
moves: number
scores: PlayerScore
activePlayers: Player[]
playerMetadata: { [playerId: string]: any }
consecutiveMatches: { [playerId: string]: number }
gameStartTime: number | null
}
// HOVER: Networked hover state
playerHovers?: { [playerId: string]: string | null }
}
export type MemoryPairsAction =
@@ -102,12 +121,20 @@ export interface MemoryPairsContextValue {
gameMode: GameMode // Derived from global context
activePlayers: Player[] // Active player IDs from arena
// PAUSE/RESUME: Computed pause/resume values
hasConfigChanged?: boolean
canResumeGame?: boolean
// Actions
startGame: () => void
flipCard: (cardId: string) => void
resetGame: () => void
setGameType: (type: GameType) => void
setDifficulty: (difficulty: Difficulty) => void
setTurnTimer?: (timer: number) => void
goToSetup?: () => void
resumeGame?: () => void
hoverCard?: (cardId: string | null) => void
exitSession: () => void
}

View File

@@ -1,13 +1,13 @@
import { ArcadeGuardedPage } from '@/components/ArcadeGuardedPage'
import { MemoryPairsGame } from './components/MemoryPairsGame'
import { ArcadeMemoryPairsProvider } from './context/ArcadeMemoryPairsContext'
import { RoomMemoryPairsProvider } from './context/RoomMemoryPairsProvider'
export default function MatchingPage() {
return (
<ArcadeGuardedPage>
<ArcadeMemoryPairsProvider>
<RoomMemoryPairsProvider>
<MemoryPairsGame />
</ArcadeMemoryPairsProvider>
</RoomMemoryPairsProvider>
</ArcadeGuardedPage>
)
}

View File

@@ -2,7 +2,7 @@
import { useRoomData } from '@/hooks/useRoomData'
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
import { ArcadeMemoryPairsProvider } from '../matching/context/ArcadeMemoryPairsContext'
import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProvider'
/**
* /arcade/room - Renders the game for the user's current room
@@ -68,9 +68,9 @@ export default function RoomPage() {
switch (roomData.gameName) {
case 'matching':
return (
<ArcadeMemoryPairsProvider>
<RoomMemoryPairsProvider>
<MemoryPairsGame />
</ArcadeMemoryPairsProvider>
</RoomMemoryPairsProvider>
)
// TODO: Add other games (complement-race, memory-quiz, etc.)

View File

@@ -103,6 +103,9 @@ export interface MemoryPairsState {
isProcessingMove: boolean
showMismatchFeedback: boolean
lastMatchedPair: [string, string] | null
// Hover state for networked presence
playerHovers: { [playerId: string]: string | null } // playerId -> cardId (or null if not hovering)
}
export type MemoryPairsAction =
@@ -143,6 +146,7 @@ export interface MemoryPairsContextValue {
setGameType: (type: GameType) => void
setDifficulty: (difficulty: Difficulty) => void
setTurnTimer: (timer: number) => void
hoverCard: (cardId: string | null) => void // Send hover state for networked presence
goToSetup: () => void
exitSession: () => void // Exit arcade session (no-op for non-arcade mode)
}

View File

@@ -44,6 +44,9 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
case 'RESUME_GAME':
return this.validateResumeGame(state)
case 'HOVER_CARD':
return this.validateHoverCard(state, move.data.cardId, move.playerId)
default:
return {
valid: false,
@@ -479,6 +482,31 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
}
}
/**
* Validate hover state update for networked presence
*
* Hover moves are lightweight and always valid - they just update
* which card a player is hovering over for UI feedback to other players.
*/
private validateHoverCard(
state: MemoryPairsState,
cardId: string | null,
playerId: string
): ValidationResult {
// Hover is always valid - it's just UI state for networked presence
// Update the player's hover state
return {
valid: true,
newState: {
...state,
playerHovers: {
...state.playerHovers,
[playerId]: cardId,
},
},
}
}
isGameComplete(state: MemoryPairsState): boolean {
return state.gamePhase === 'results' || state.matchedPairs === state.totalPairs
}
@@ -516,6 +544,8 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
originalConfig: undefined,
pausedGamePhase: undefined,
pausedGameState: undefined,
// HOVER: Initialize hover state
playerHovers: {},
}
}
}

View File

@@ -61,6 +61,13 @@ export interface MatchingResumeGameMove extends GameMove {
data: Record<string, never>
}
export interface MatchingHoverCardMove extends GameMove {
type: 'HOVER_CARD'
data: {
cardId: string | null // null when mouse leaves card
}
}
export type MatchingGameMove =
| MatchingFlipCardMove
| MatchingStartGameMove
@@ -68,6 +75,7 @@ export type MatchingGameMove =
| MatchingGoToSetupMove
| MatchingSetConfigMove
| MatchingResumeGameMove
| MatchingHoverCardMove
// Generic game state union
export type GameState = MemoryPairsState // Add other game states as union later

View File

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