# WebSocket Protocol Specification ## Strat-O-Matic Baseball Game Engine **Version**: 1.1 **Date**: 2025-11-21 **Status**: Analysis Complete + Issue #1 Resolved (decision_required implemented) --- ## Executive Summary This document defines the complete WebSocket communication protocol between the frontend (Vue 3/Nuxt) and backend (FastAPI/Socket.io) for real-time baseball gameplay. It identifies the complete game workflow, all 15 backend event handlers, all frontend event listeners, and critical gaps/mismatches discovered during analysis. ## Table of Contents 1. [Game Workflow Overview](#game-workflow-overview) 2. [WebSocket Events - Complete Reference](#websocket-events---complete-reference) 3. [Game State Flow](#game-state-flow) 4. [Critical Issues Identified](#critical-issues-identified) 5. [Event Sequences by Workflow](#event-sequences-by-workflow) 6. [Type Mappings](#type-mappings) 7. [Recommendations](#recommendations) --- ## Game Workflow Overview ### High-Level Flow ``` 1. Game Creation (REST API) ├─→ POST /games (create game) ├─→ POST /games/{id}/lineups (submit lineups) └─→ Game auto-starts after lineups submitted 2. WebSocket Connection ├─→ Client connects with JWT token ├─→ Backend validates and accepts connection ├─→ Client joins game room └─→ Client requests game state 3. Decision Phase Workflow (Per Play) ├─→ Backend emits decision_required (defense) ├─→ Defense submits decision ├─→ Backend emits decision_required (offense) ├─→ Offense submits decision └─→ Backend advances to resolution phase 4. Resolution Workflow ├─→ Manual Mode: │ ├─→ Client rolls dice │ ├─→ Backend broadcasts dice results │ ├─→ Client submits outcome from card │ └─→ Backend resolves play and broadcasts result │ └─→ Auto Mode (future): └─→ Backend auto-resolves and broadcasts 5. Play Completion ├─→ Backend broadcasts play_resolved ├─→ Backend broadcasts game_state_update └─→ Loop back to step 3 (next play) 6. Game End └─→ Backend broadcasts game_ended ``` --- ## WebSocket Events - Complete Reference ### Connection Events #### `connect` (Internal Socket.io) **Direction**: Client → Server **Purpose**: Establish WebSocket connection with JWT authentication **Backend**: `handlers.py:26-50` **Frontend**: N/A (Socket.io internal) **Auth Data**: ```typescript { token: string // JWT from Discord OAuth } ``` **Backend Behavior**: - Validates JWT token - Extracts user_id from token - Registers connection in ConnectionManager - Returns `true` to accept, `false` to reject **Success**: Emits `connected` event **Failure**: Rejects connection --- #### `connected` ✅ **Direction**: Server → Client **Purpose**: Confirm connection successful **Backend**: `handlers.py:43` **Frontend**: `useWebSocket.ts:234` **Payload**: ```typescript { user_id: string } ``` **Frontend Handling**: - Logs user_id - No state changes (already set `isConnected=true` from Socket.io `connect`) --- #### `disconnect` (Internal Socket.io) **Direction**: Client → Server **Purpose**: Clean up when client disconnects **Backend**: `handlers.py:53-55` **Frontend**: `useWebSocket.ts:201` **Backend Behavior**: - Removes from all game rooms - Cleans up user_sessions map - No broadcast to other clients **Frontend Behavior**: - Sets `isConnected = false` - Shows warning toast - Attempts auto-reconnection - Broadcasts to game store: `gameStore.setConnected(false)` --- #### `heartbeat` / `heartbeat_ack` ✅ **Direction**: Bidirectional **Purpose**: Keep connection alive **Backend**: `handlers.py:91-93` **Frontend**: `useWebSocket.ts:238, 455-463` **Flow**: 1. Client sends `heartbeat` every 30 seconds 2. Server responds with `heartbeat_ack` 3. Client receives ack (connection healthy) **No payload required** --- ### Game Room Management #### `join_game` ✅ **Direction**: Client → Server **Purpose**: Join game room as player or spectator **Backend**: `handlers.py:58-77` **Frontend**: `useGameActions.ts:69-78` **Request**: ```typescript { game_id: string, role: 'player' | 'spectator' } ``` **Backend Behavior**: - Validates game_id - TODO: Verify user has access to game - Adds client to game room - Emits `game_joined` confirmation **Success Response**: `game_joined` event --- #### `game_joined` ✅ **Direction**: Server → Client **Purpose**: Confirm successful join **Backend**: `handlers.py:71-73` **Frontend**: Not explicitly handled (should be added) **Payload**: ```typescript { game_id: string, role: 'player' | 'spectator' } ``` ⚠️ **ISSUE**: Frontend doesn't have explicit handler for this event --- #### `leave_game` ✅ **Direction**: Client → Server **Purpose**: Leave game room **Backend**: `handlers.py:80-88` **Frontend**: `useGameActions.ts:83-94` **Request**: ```typescript { game_id: string } ``` **Backend Behavior**: - Removes client from game room - No broadcast (silent exit) **Frontend Behavior**: - Resets game store - Called on component unmount --- ### Game State Synchronization #### `request_game_state` ✅ **Direction**: Client → Server **Purpose**: Request full game state (initial load or reconnection) **Backend**: `handlers.py:96-138` **Frontend**: `useGameActions.ts:283-293` **Request**: ```typescript { game_id: string } ``` **Backend Behavior**: - Validates game_id UUID format - Checks in-memory state first - If not in memory, recovers from database - Serializes with `model_dump(mode='json')` for UUIDs **Success Response**: `game_state` event **Failure Response**: `error` event with "Game not found" --- #### `game_state` ✅ **Direction**: Server → Client **Purpose**: Send complete game state (response to request_game_state) **Backend**: `handlers.py:127` **Frontend**: `useWebSocket.ts:247-250` **Payload**: Full `GameState` object ```typescript { game_id: string, league_id: 'sba' | 'pd', home_team_id: number, away_team_id: number, status: 'pending' | 'active' | 'paused' | 'completed', inning: number, half: 'top' | 'bottom', outs: number, balls: number, strikes: number, home_score: number, away_score: number, on_first: LineupPlayerState | null, on_second: LineupPlayerState | null, on_third: LineupPlayerState | null, current_batter: LineupPlayerState, current_pitcher: LineupPlayerState | null, current_catcher: LineupPlayerState | null, decision_phase: 'defense' | 'stolen_base' | 'offensive_approach' | 'resolution' | 'complete', pending_decision: string | null, decisions_this_play: Record, pending_defensive_decision: DefensiveDecision | null, pending_offensive_decision: OffensiveDecision | null, pending_manual_roll: RollData | null, play_count: number, // ... additional fields } ``` **Frontend Handling**: - Calls `gameStore.setGameState(state)` - Completely replaces existing game state - Does NOT update play history (use `game_state_sync` for that) --- #### `game_state_update` ✅ **Direction**: Server → Client **Purpose**: Broadcast incremental state change to all players **Backend**: Not directly emitted (only via game_engine) **Frontend**: `useWebSocket.ts:252-255` **Payload**: Full `GameState` object (same as `game_state`) **Frontend Handling**: - Calls `gameStore.setGameState(state)` - Replaces state entirely (not incremental) **Usage**: Backend broadcasts this after: - Decision submissions - Play resolutions - Inning changes - Score changes ⚠️ **NAMING CONFUSION**: Both `game_state` and `game_state_update` send full state, not partial updates --- #### `game_state_sync` ✅ **Direction**: Server → Client **Purpose**: Full state + recent play history (for reconnection) **Backend**: Not implemented yet **Frontend**: `useWebSocket.ts:257-264` **Payload**: ```typescript { state: GameState, recent_plays: PlayResult[], timestamp: string } ``` **Frontend Handling**: - Sets game state - Adds recent plays to history - Used for reconnection recovery ⚠️ **IMPLEMENTATION STATUS**: Frontend has handler, backend doesn't emit yet --- ### Strategic Decisions #### `submit_defensive_decision` ✅ **Direction**: Client → Server **Purpose**: Submit defensive team's strategic setup **Backend**: `handlers.py:1048-1137` **Frontend**: `useGameActions.ts:103-114` **Request**: ```typescript { game_id: string, infield_depth: 'infield_in' | 'normal' | 'corners_in', outfield_depth: 'normal' | 'shallow', hold_runners: number[] // Bases to hold (e.g., [1, 3]) } ``` **Backend Behavior**: - Validates game exists - TODO: Verify user is fielding team manager - Calls `game_engine.submit_defensive_decision()` - Updates state.decision_phase - Broadcasts result **Success Response**: `defensive_decision_submitted` event --- #### `defensive_decision_submitted` ✅ **Direction**: Server → Client (Broadcast) **Purpose**: Notify all players defense is set **Backend**: `handlers.py:1116-1129` **Frontend**: `useWebSocket.ts:294-310` **Payload**: ```typescript { game_id: string, decision: { infield_depth: string, outfield_depth: string, hold_runners: number[] }, pending_decision: 'offensive' | 'resolution' | null } ``` **Frontend Handling**: - Clears decision prompt - Requests updated game state - Shows toast based on pending_decision - If still pending → "Waiting for offense..." - If no pending → "Ready to play!" --- #### `submit_offensive_decision` ✅ **Direction**: Client → Server **Purpose**: Submit offensive team's strategy **Backend**: `handlers.py:1140-1217` **Frontend**: `useGameActions.ts:119-129` **Request**: ```typescript { game_id: string, action: 'swing_away' | 'steal' | 'check_jump' | 'hit_and_run' | 'sac_bunt' | 'squeeze_bunt', steal_attempts: number[] // Required when action="steal" } ``` **Backend Behavior**: - Validates game exists - TODO: Verify user is batting team manager - Creates OffensiveDecision model - Calls `game_engine.submit_offensive_decision()` - Broadcasts result **Success Response**: `offensive_decision_submitted` event --- #### `offensive_decision_submitted` ✅ **Direction**: Server → Client (Broadcast) **Purpose**: Notify all players offense is set **Backend**: `handlers.py:1200-1209` **Frontend**: `useWebSocket.ts:312-324` **Payload**: ```typescript { game_id: string, decision: { action: string, steal_attempts: number[] }, pending_decision: 'resolution' | null } ``` **Frontend Handling**: - Clears decision prompt - Requests updated game state - Shows "Ready to play!" toast --- #### `decision_required` ✅ IMPLEMENTED **Direction**: Server → Client **Purpose**: Prompt player for decision **Backend**: `game_engine.py:60-100, 308-310` (Implemented 2025-11-21) **Frontend**: `useWebSocket.ts:289-292` **Payload**: ```typescript { phase: 'awaiting_defensive' | 'awaiting_offensive' | 'awaiting_stolen_base' | 'resolution', role: 'home' | 'away', timeout_seconds: number, message: string } ``` **Backend Implementation**: - Emitted at game start (awaiting_defensive) - Emitted after defensive decision submitted (awaiting_offensive) - Auto-determines team role based on inning half - Uses `_emit_decision_required()` helper method **Frontend Handling**: - Calls `gameStore.setDecisionPrompt(prompt)` - DecisionPanel component shows UI based on phase - Updates proactively without polling ✅ **RESOLVED**: Backend now emits this event at appropriate phase transitions --- ### Manual Outcome Workflow #### `roll_dice` ✅ **Direction**: Client → Server **Purpose**: Roll dice for manual play resolution **Backend**: `handlers.py:141-219` **Frontend**: `useGameActions.ts:138-153` **Request**: ```typescript { game_id: string } ``` **Backend Behavior**: - Validates game exists - TODO: Verify user is participant - Calls `dice_system.roll_ab()` for cryptographic random rolls - Stores roll in `state.pending_manual_roll` - Broadcasts dice results to all players **Success Response**: `dice_rolled` event (broadcast) --- #### `dice_rolled` ✅ **Direction**: Server → Client (Broadcast) **Purpose**: Show dice results to all players **Backend**: `handlers.py:198-213` **Frontend**: `useWebSocket.ts:330-345` **Payload**: ```typescript { game_id: string, roll_id: string, d6_one: number, // First d6 d6_two_total: number, // Sum of second 2d6 chaos_d20: number, // Chaos die resolution_d20: number, // Resolution die check_wild_pitch: boolean, check_passed_ball: boolean, timestamp: string, message: string } ``` **Frontend Handling**: - Stores in `gameStore.setPendingRoll()` - Shows toast with message - Enables outcome submission UI - NOTE: Frontend adds d6_two_a and d6_two_b (not provided by backend) ⚠️ **MINOR ISSUE**: Frontend expects individual d6_two dice, backend only sends total --- #### `submit_manual_outcome` ✅ **Direction**: Client → Server **Purpose**: Submit card reading result **Backend**: `handlers.py:222-437` **Frontend**: `useGameActions.ts:158-175` **Request**: ```typescript { game_id: string, outcome: string, // PlayOutcome enum value hit_location?: string // Required for groundballs/flyballs } ``` **Backend Behavior**: - Validates game exists - Validates using ManualOutcomeSubmission model - Checks for pending_manual_roll - Validates hit_location if required - Confirms acceptance - Clears pending_manual_roll (one-time use) - Calls `game_engine.resolve_manual_play()` - Broadcasts play result **Success Responses**: 1. `outcome_accepted` (to submitter) 2. `play_resolved` (broadcast to all) **Failure Response**: `outcome_rejected` --- #### `outcome_accepted` ✅ **Direction**: Server → Client (To submitter only) **Purpose**: Confirm outcome was valid **Backend**: `handlers.py:341-349` **Frontend**: `useWebSocket.ts:347-351` **Payload**: ```typescript { game_id: string, outcome: string, hit_location?: string } ``` **Frontend Handling**: - Clears pending roll - Shows "Submitting..." toast --- #### `play_resolved` ✅ **Direction**: Server → Client (Broadcast) **Purpose**: Broadcast complete play result **Backend**: `handlers.py:409-411` **Frontend**: `useWebSocket.ts:353-376` **Payload**: ```typescript { game_id: string, play_number: number, outcome: string, // Resolved outcome (may differ from submitted) hit_location?: string, description: string, // Human-readable play description outs_recorded: number, runs_scored: number, batter_result: number | null, // Where batter ended up (1-4, null=out) runners_advanced: RunnerAdvancement[], is_hit: boolean, is_out: boolean, is_walk: boolean, roll_id: string, x_check_details?: XCheckResult // If defensive play required X-Check } ``` **Frontend Handling**: - Converts to PlayResult type - Adds to play history: `gameStore.addPlayToHistory()` - Stores last result: `gameStore.setLastPlayResult()` - Shows description toast --- #### `outcome_rejected` ✅ **Direction**: Server → Client (To submitter only) **Purpose**: Notify submission was invalid **Backend**: `handlers.py:297-298, 420-421` **Frontend**: `useWebSocket.ts:424-427` **Payload**: ```typescript { message: string, field: string, errors?: Array<{ loc: string[], msg: string, type: string }> } ``` **Frontend Handling**: - Shows error toast with message **Common Rejection Reasons**: - Missing hit_location for groundball/flyball - No pending dice roll - Invalid outcome enum value - Invalid hit_location format --- ### Play Progression #### `play_completed` ✅ **Direction**: Server → Client (Broadcast) **Purpose**: Legacy event (may be redundant with play_resolved) **Backend**: Not explicitly emitted in handlers **Frontend**: `useWebSocket.ts:266-270` **Payload**: `PlayResult` object **Frontend Handling**: - Adds to play history - Shows description toast ⚠️ **REDUNDANCY**: Both `play_completed` and `play_resolved` exist. Recommend consolidating. --- #### `inning_change` ✅ **Direction**: Server → Client (Broadcast) **Purpose**: Notify inning transition **Backend**: Not explicitly emitted in handlers **Frontend**: `useWebSocket.ts:272-275` **Payload**: ```typescript { inning: number, half: 'top' | 'bottom' } ``` **Frontend Handling**: - Shows toast: "Top 1" or "Bottom 9", etc. --- #### `game_ended` ✅ **Direction**: Server → Client (Broadcast) **Purpose**: Notify game completion **Backend**: Not explicitly emitted in handlers **Frontend**: `useWebSocket.ts:277-283` **Payload**: ```typescript { game_id: string, winner_team_id: number, final_score: { home: number, away: number }, completed_at: string } ``` **Frontend Handling**: - Shows game over toast with final score --- ### Substitutions #### `request_pinch_hitter` ✅ **Direction**: Client → Server **Purpose**: Replace current batter **Backend**: `handlers.py:442-593` **Frontend**: `useGameActions.ts:184-201` **Request**: ```typescript { game_id: string, player_out_lineup_id: number, player_in_card_id: number, team_id: number } ``` **Backend Behavior**: - Validates all required fields - TODO: Verify user is team manager - Creates SubstitutionManager instance - Calls `sub_manager.pinch_hit()` - Broadcasts result **Success Responses**: 1. `player_substituted` (broadcast) 2. `substitution_confirmed` (to requester) **Failure Response**: `substitution_error` --- #### `request_defensive_replacement` ✅ **Direction**: Client → Server **Purpose**: Replace defensive player **Backend**: `handlers.py:596-761` **Frontend**: `useGameActions.ts:206-225` **Request**: ```typescript { game_id: string, player_out_lineup_id: number, player_in_card_id: number, new_position: string, // e.g., "SS" team_id: number } ``` **Backend Behavior**: - Validates all required fields - Creates SubstitutionManager instance - Calls `sub_manager.defensive_replace()` - Broadcasts result **Success Responses**: 1. `player_substituted` (broadcast) 2. `substitution_confirmed` (to requester) **Failure Response**: `substitution_error` --- #### `request_pitching_change` ✅ **Direction**: Client → Server **Purpose**: Replace current pitcher **Backend**: `handlers.py:764-917` **Frontend**: `useGameActions.ts:230-247` **Request**: ```typescript { game_id: string, player_out_lineup_id: number, player_in_card_id: number, team_id: number } ``` **Backend Behavior**: - Validates all required fields - Creates SubstitutionManager instance - Calls `sub_manager.change_pitcher()` - Validates pitcher has faced 1+ batter (unless injury) - Broadcasts result **Success Responses**: 1. `player_substituted` (broadcast) 2. `substitution_confirmed` (to requester) **Failure Response**: `substitution_error` --- #### `player_substituted` ✅ **Direction**: Server → Client (Broadcast) **Purpose**: Notify all players of substitution **Backend**: `handlers.py:544-557, 710-723, 866-879` **Frontend**: `useWebSocket.ts:382-392` **Payload**: ```typescript { type: 'pinch_hitter' | 'defensive_replacement' | 'pitching_change', player_out_lineup_id: number, player_in_card_id: number, new_lineup_id: number, position: string, batting_order: number | null, team_id: number, message: string } ``` **Frontend Handling**: - Shows toast with message - Requests updated lineup for affected team --- #### `substitution_confirmed` ✅ **Direction**: Server → Client (To requester only) **Purpose**: Confirm substitution success **Backend**: `handlers.py:560-568, 726-734, 882-890` **Frontend**: `useWebSocket.ts:394-397` **Payload**: ```typescript { type: string, new_lineup_id: number, success: true, message: string } ``` **Frontend Handling**: - Shows success toast --- #### `substitution_error` ✅ **Direction**: Server → Client (To requester only) **Purpose**: Notify substitution failed **Backend**: `handlers.py:576-587, 742-753, 898-909` **Frontend**: `useWebSocket.ts:429-432` **Payload**: ```typescript { message: string, code: string, type: string } ``` **Frontend Handling**: - Shows error toast **Common Error Codes**: - `MISSING_FIELD`: Required parameter missing - `INVALID_FORMAT`: game_id not valid UUID - `PLAYER_NOT_FOUND`: lineup_id doesn't exist - `PLAYER_NOT_ACTIVE`: Player already substituted - `NO_ELIGIBLE_REPLACEMENT`: Bench player not eligible --- ### Data Requests #### `get_lineup` ✅ **Direction**: Client → Server **Purpose**: Get full lineup with player data **Backend**: `handlers.py:920-1045` **Frontend**: `useGameActions.ts:256-265` **Request**: ```typescript { game_id: string, team_id: number } ``` **Backend Behavior**: - Checks state_manager cache first (O(1) lookup) - If not cached, loads from database with player data - Caches for future requests - Returns full lineup including nested player info **Success Response**: `lineup_data` event --- #### `lineup_data` ✅ **Direction**: Server → Client (To requester only) **Purpose**: Return lineup with player details **Backend**: `handlers.py:964-989, 1006-1030` **Frontend**: `useWebSocket.ts:403-406` **Payload**: ```typescript { game_id: string, team_id: number, players: Array<{ lineup_id: number, card_id: number, position: string, batting_order: number | null, is_active: boolean, is_starter: boolean, player: { id: number, name: string, image: string, headshot: string } }> } ``` **Frontend Handling**: - Calls `gameStore.updateLineup(team_id, players)` - Caches for player name/headshot lookups **Critical Optimization**: Lineup sent once per team, not on every state update --- #### `get_box_score` ✅ **Direction**: Client → Server **Purpose**: Get game statistics **Backend**: `handlers.py:1220-1276` **Frontend**: `useGameActions.ts:270-278` **Request**: ```typescript { game_id: string } ``` **Backend Behavior**: - Queries materialized views for stats - Returns batting and pitching stats for both teams **Success Response**: `box_score_data` event --- #### `box_score_data` ✅ **Direction**: Server → Client (To requester only) **Purpose**: Return game statistics **Backend**: `handlers.py:1257-1260` **Frontend**: `useWebSocket.ts:408-412` **Payload**: ```typescript { game_id: string, box_score: { game_stats: { home_runs: number, away_runs: number, home_hits: number, away_hits: number, home_errors: number, away_errors: number, linescore_home: number[], linescore_away: number[] }, batting_stats: BattingStatLine[], pitching_stats: PitchingStatLine[] } } ``` **Frontend Handling**: - Logs for now - TODO: Display in BoxScore component --- ### Error Events #### `error` ✅ **Direction**: Server → Client **Purpose**: Generic error message **Backend**: Used throughout handlers **Frontend**: `useWebSocket.ts:418-422` **Payload**: ```typescript { message: string, code?: string, field?: string, hint?: string } ``` **Frontend Handling**: - Sets error in game store - Shows error toast (7 seconds) **Usage**: Validation failures, game not found, unauthorized actions --- #### `invalid_action` ❓ **Direction**: Server → Client **Purpose**: Action not allowed in current state **Backend**: Not emitted in handlers **Frontend**: `useWebSocket.ts:434-437` **Payload**: ```typescript { action: string, reason: string } ``` **Frontend Handling**: - Shows error toast ⚠️ **IMPLEMENTATION STATUS**: Frontend has handler, backend never emits --- #### `connection_error` ❓ **Direction**: Server → Client **Purpose**: Connection-level error **Backend**: Not emitted in handlers **Frontend**: `useWebSocket.ts:439-443` **Payload**: ```typescript { error: string, details?: string } ``` **Frontend Handling**: - Stores error - Shows error toast ⚠️ **IMPLEMENTATION STATUS**: Frontend has handler, backend never emits --- ## Game State Flow ### Complete Play Lifecycle ``` START: Game active, awaiting decisions 1. Backend checks decision_phase └─→ 'defense' or 'awaiting_defensive' 2. Frontend checks gameState.decision_phase └─→ Shows DefensiveSetup UI if fielding team 3. Defense submits decision Client: submit_defensive_decision → Server 4. Backend processes └─→ Calls game_engine.submit_defensive_decision() └─→ Updates state.decision_phase = 'offensive_approach' └─→ Broadcasts defensive_decision_submitted 5. Frontend receives defensive_decision_submitted └─→ Clears decision prompt └─→ Requests updated game_state └─→ Checks pending_decision field 6. Frontend checks updated gameState.decision_phase └─→ Now 'offensive_approach' or 'awaiting_offensive' └─→ Shows OffensiveApproach UI if batting team 7. Offense submits decision Client: submit_offensive_decision → Server 8. Backend processes └─→ Calls game_engine.submit_offensive_decision() └─→ Updates state.decision_phase = 'resolution' └─→ Broadcasts offensive_decision_submitted 9. Frontend receives offensive_decision_submitted └─→ Clears decision prompt └─→ Requests updated game_state └─→ Decision phase now 'resolution' 10. Frontend shows GameplayPanel └─→ Checks canRollDice = (decision_phase === 'resolution' && !pendingRoll) └─→ Enables "Roll Dice" button 11. User clicks "Roll Dice" Client: roll_dice → Server 12. Backend rolls dice └─→ Calls dice_system.roll_ab() └─→ Stores in state.pending_manual_roll └─→ Broadcasts dice_rolled to all players 13. Frontend receives dice_rolled └─→ Stores in gameStore.pendingRoll └─→ Shows dice animation └─→ Enables outcome submission UI 14. User reads card and selects outcome Client: submit_manual_outcome → Server 15. Backend validates and accepts └─→ Sends outcome_accepted to submitter └─→ Clears state.pending_manual_roll └─→ Calls game_engine.resolve_manual_play() 16. Game engine resolves play └─→ Processes outcome with X-Check if needed └─→ Updates runners, score, outs └─→ Advances batter └─→ Checks for inning change └─→ Persists play to database 17. Backend broadcasts results └─→ Emits play_resolved to all players └─→ Emits game_state_update with new state 18. Frontend receives play_resolved └─→ Adds to play history └─→ Shows play description └─→ Clears pending roll 19. Frontend receives game_state_update └─→ Updates game state └─→ Checks decision_phase 20. Loop back to step 1 for next play ``` ### State Transition Diagram ``` ┌─────────────────────────┐ │ Game Created │ │ (status: pending) │ └────────────┬────────────┘ │ │ Lineups submitted ↓ ┌─────────────────────────┐ │ Game Active │ │ (status: active) │ │ inning: 1, half: top │ └────────────┬────────────┘ │ ┌───────────────┴───────────────┐ │ │ ↓ ↓ ┌─────────────────────┐ ┌─────────────────────┐ │ Defense Phase │ │ Stored in DB │ │ (decision_phase: │ │ (persisted state) │ │ 'defense') │ └─────────────────────┘ └──────────┬──────────┘ │ submit_defensive_decision ↓ ┌─────────────────────┐ │ Offense Phase │ │ (decision_phase: │ │ 'offensive_ │ │ approach') │ └──────────┬──────────┘ │ submit_offensive_decision ↓ ┌─────────────────────┐ │ Resolution Phase │ │ (decision_phase: │ │ 'resolution') │ └──────────┬──────────┘ │ ├─ roll_dice → dice_rolled │ ├─ submit_manual_outcome → outcome_accepted │ └─ play_resolved → game_state_update │ ↓ ┌────────────────────────────┐ │ Check Inning Complete? │ └────────┬───────────────────┘ │ ┌─────────┴─────────┐ │ No │ Yes ↓ ↓ Loop to next ┌─────────────────┐ at-bat │ Inning Change │ (Defense Phase) │ inning_change │ └────────┬────────┘ │ ├─ Advance to next half/inning │ └─ Check Game Complete? │ ┌─────────┴─────────┐ │ No │ Yes ↓ ↓ Loop to next ┌─────────────────┐ inning │ Game Ended │ (Defense Phase) │ game_ended │ │ (status: │ │ completed) │ └─────────────────┘ ``` --- ## Critical Issues Identified ### 1. Missing Backend Event Emission ✅ RESOLVED (2025-11-21) **Issue**: Frontend expects `decision_required` event, backend never emits it. **Resolution**: Implemented `decision_required` event emission in three locations: 1. **Game Start** (`game_engine.py:253`): Emits `awaiting_defensive` when game starts 2. **After Defensive Decision** (`game_engine.py:308-310`): Emits `awaiting_offensive` after defense submits 3. **Event Helper** (`game_engine.py:60-100`): `_emit_decision_required()` method with team determination logic **Implementation Details**: - Added `_connection_manager` attribute to `GameEngine` singleton - Added `set_connection_manager()` called from `main.py:88` - Auto-determines team role based on inning half and decision phase - Broadcasts to entire game room via WebSocket **Critical Fix Applied During Testing**: - Also updated `state.decision_phase` field in both `submit_defensive_decision` (line 291) and `submit_offensive_decision` (line 335) - Frontend checks `gameState.decision_phase`, so state must be in sync with events - See `.claude/DECISION_REQUIRED_IMPLEMENTATION.md` Issue #4 for details **Testing Results**: - ✅ Event emitted at game start (awaiting_defensive) - ✅ Event emitted after defensive decision (awaiting_offensive) - ✅ Frontend DecisionPanel updates proactively without polling - ✅ Game state `decision_phase` field stays in sync **Files Modified**: - `backend/app/core/game_engine.py` (+65 lines) - `backend/app/main.py` (+3 lines) - Frontend already had handler implemented --- ### 2. Event Naming Inconsistency ⚠️ MEDIUM PRIORITY **Issue**: Three different events send full game state: - `game_state` (response to request) - `game_state_update` (broadcast after changes) - `game_state_sync` (reconnection with plays) **Problem**: All three send the FULL GameState object, but naming suggests `game_state_update` is incremental. **Recommendation**: - Rename `game_state_update` → `game_state_broadcast` - Reserve `game_state_update` for future incremental updates - Or consolidate: only use `game_state` for all cases --- ### 3. Missing `d6_two_a` and `d6_two_b` in Dice Roll ℹ️ LOW PRIORITY **Backend** (`handlers.py:204`): ```python "d6_two_total": ab_roll.d6_two_total, ``` **Frontend** (`useWebSocket.ts:335-336`): ```typescript d6_two_a: 0, // Not provided by server d6_two_b: 0, // Not provided by server ``` **Impact**: Frontend can't show individual 2d6 dice animation **Recommendation**: Add to backend emission: ```python "d6_two_a": ab_roll.d6_two_a, "d6_two_b": ab_roll.d6_two_b, ``` --- ### 4. Redundant Events ℹ️ LOW PRIORITY **Issue**: Both `play_completed` and `play_resolved` exist for same purpose. **Frontend Handling**: - `play_completed`: Adds to history, shows toast - `play_resolved`: Adds to history, shows toast (identical behavior) **Recommendation**: Remove `play_completed`, use only `play_resolved` --- ### 5. Missing Error Event Implementations ℹ️ LOW PRIORITY **Frontend Handlers with No Backend Emission**: - `invalid_action` (defined in types, handler exists) - `connection_error` (defined in types, handler exists) **Recommendation**: - Either implement backend emission - Or remove unused frontend handlers --- ### 6. `game_joined` Not Handled ⚠️ MEDIUM PRIORITY **Backend** (`handlers.py:71-73`): ```python await manager.emit_to_user( sid, "game_joined", {"game_id": game_id, "role": role} ) ``` **Frontend**: No handler for this event **Impact**: Client doesn't confirm successful room join **Recommendation**: Add handler in `useWebSocket.ts`: ```typescript socketInstance.on('game_joined', (data) => { console.log('[WebSocket] Successfully joined game:', data.game_id, 'as', data.role) gameStore.setGameJoined(true) uiStore.showSuccess(`Joined game as ${data.role}`) }) ``` --- ### 7. Decision Phase Naming Mismatch ✅ RESOLVED (2025-01-21) **Issue**: Frontend and backend used inconsistent names for decision phases. **Resolution**: Standardized on backend convention using `'awaiting_*'` prefix: - `'awaiting_defensive'` - Defense must submit - `'awaiting_offensive'` - Offense must submit - `'awaiting_stolen_base'` - Stolen base decision - `'resolution'` - Ready for dice/outcome - `'complete'` - Play resolved **Changes Made**: - ✅ Updated `frontend-sba/types/game.ts` DecisionPhase type - ✅ Simplified `frontend-sba/store/game.ts` conditionals (removed dual checks) - ✅ Updated test files with new phase names - ✅ Verified backend already uses standard naming - ✅ Type checking passes **Impact**: Cleaner code, no more dual-condition checks, consistent naming across stack --- ### 8. Missing Auto Mode Implementation ℹ️ INFO **Current State**: Only manual mode implemented (roll dice + submit outcome) **Future**: Auto mode should: 1. Skip `roll_dice` step 2. Backend automatically resolves outcome 3. Emits `play_resolved` directly **No Code Changes Needed**: This is expected future work, not a bug. --- ### 9. TODO Comments Need Resolution ⚠️ MEDIUM PRIORITY **Across Backend Handlers**: ```python # TODO: Verify user is participant in this game # TODO: Verify user is authorized to submit for this team # TODO: Verify user has access to view lineup ``` **Impact**: No authorization checks currently enforced **Security Risk**: Any authenticated user can: - Submit decisions for any team - Make substitutions for any team - View any game's data **Recommendation**: Implement authorization layer before production: ```python def is_team_manager(user_id: str, team_id: int) -> bool: # Check user owns this team pass def is_game_participant(user_id: str, game_id: UUID) -> bool: # Check user is player or spectator in game pass ``` --- ## Event Sequences by Workflow ### Game Creation and Start ``` 1. REST: POST /games Response: { game_id, status: "pending" } 2. REST: POST /games/{id}/lineups Backend: Auto-starts game Response: { message: "Lineups submitted" } 3. WebSocket: connect (with JWT) Server → Client: connected { user_id } 4. WebSocket: join_game { game_id, role: "player" } Server → Client: game_joined { game_id, role } 5. WebSocket: request_game_state { game_id } Server → Client: game_state { ...full state } 6. WebSocket: get_lineup { game_id, team_id: home } Server → Client: lineup_data { players: [...] } 7. WebSocket: get_lineup { game_id, team_id: away } Server → Client: lineup_data { players: [...] } READY TO PLAY ``` --- ### Decision Submission Workflow ``` SCENARIO: Top of 1st, no outs, bases empty 1. Frontend checks gameState.decision_phase Value: 'defense' or 'awaiting_defensive' 2. Fielding team (home) sees DefensiveSetup UI User selects: infield_depth='normal', outfield_depth='normal' 3. Client: submit_defensive_decision Request: { game_id, infield_depth: 'normal', outfield_depth: 'normal', hold_runners: [] } 4. Backend: game_engine.submit_defensive_decision() - Stores decision in state.pending_defensive_decision - Transitions state.decision_phase = 'offensive_approach' - Persists to database 5. Server → All Clients: defensive_decision_submitted Payload: { decision: {...}, pending_decision: 'offensive' } 6. Frontend: Clears decision UI, requests game_state Server → Client: game_state { decision_phase: 'offensive_approach' } 7. Batting team (away) sees OffensiveApproach UI User selects: action='swing_away', steal_attempts=[] 8. Client: submit_offensive_decision Request: { game_id, action: 'swing_away', steal_attempts: [] } 9. Backend: game_engine.submit_offensive_decision() - Stores decision in state.pending_offensive_decision - Transitions state.decision_phase = 'resolution' - Persists to database 10. Server → All Clients: offensive_decision_submitted Payload: { decision: {...}, pending_decision: 'resolution' } 11. Frontend: Shows GameplayPanel with "Roll Dice" button ``` --- ### Manual Outcome Workflow ``` CONTINUING FROM ABOVE: decision_phase = 'resolution' 1. User clicks "Roll Dice" Client: roll_dice { game_id } 2. Backend: dice_system.roll_ab() Result: { d6_one: 4, d6_two_total: 7, chaos_d20: 12, resolution_d20: 8 } Stores in state.pending_manual_roll 3. Server → All Clients: dice_rolled (broadcast) Payload: { roll_id, d6_one: 4, d6_two_total: 7, chaos: 12, resolution: 8, ... } 4. Frontend: Stores pending roll, shows dice animation Enables outcome selection UI 5. User reads batter card (4-7 column, d20=12) Card shows: "SINGLE_1" (single, batter to 1st) 6. User clicks "Submit" with outcome Client: submit_manual_outcome { game_id, outcome: 'SINGLE_1' } 7. Backend: Validates outcome, clears pending roll Server → Client: outcome_accepted (to submitter) 8. Backend: game_engine.resolve_manual_play() - Processes SINGLE_1 - Moves batter to 1st base - Increments play count - Advances batting order - Checks decision_phase → transitions back to 'defense' - Persists play to database 9. Server → All Clients: play_resolved (broadcast) Payload: { play_number: 1, outcome: 'SINGLE_1', description: 'Mike Trout singles to right field', outs_recorded: 0, runs_scored: 0, batter_result: 1, runners_advanced: [{ from: 0, to: 1 }], ... } 10. Server → All Clients: game_state_update (broadcast) Payload: { ...full updated game state } 11. Frontend: Updates game state - Adds play to history - Shows play description toast - Clears pending roll - Updates scoreboard (on_first now populated) 12. Frontend checks decision_phase Value: 'defense' (back to step 1 for next batter) ``` --- ### Substitution Workflow ``` SCENARIO: Bottom of 3rd, manager wants pinch hitter 1. User clicks floating "Substitutions" button Shows SubstitutionPanel modal 2. User selects: - Substitute type: Pinch Hitter - Player out: Lineup ID 42 (batting order 5) - Player in: Card ID 1234 (from bench) 3. Client: request_pinch_hitter Request: { game_id, player_out_lineup_id: 42, player_in_card_id: 1234, team_id: 101 } 4. Backend: SubstitutionManager.pinch_hit() - Validates player_out is active - Validates player_in exists and not used - Marks player_out as inactive - Creates new lineup entry for player_in - Assigns batting order from player_out - Persists to database 5. Server → All Clients: player_substituted (broadcast) Payload: { type: 'pinch_hitter', player_out_lineup_id: 42, player_in_card_id: 1234, new_lineup_id: 98, position: 'RF', batting_order: 5, team_id: 101, message: 'Pinch hitter: #5 now batting' } 6. Server → Requester: substitution_confirmed Payload: { type: 'pinch_hitter', new_lineup_id: 98, success: true } 7. Frontend: Shows success toast, requests updated lineup Client: get_lineup { game_id, team_id: 101 } 8. Server → Client: lineup_data Payload: { players: [...updated lineup with new player] } 9. Frontend: Updates cached lineup gameStore.updateLineup(101, players) 10. User closes substitution modal ``` --- ## Type Mappings ### Backend Models → Frontend Types | Backend (Python) | Frontend (TypeScript) | File | |-----------------|----------------------|------| | `GameState` | `GameState` | types/game.ts:61 | | `LineupPlayerState` | `LineupPlayerState` | types/game.ts:44 | | `DefensiveDecision` | `DefensiveDecision` | types/game.ts:124 | | `OffensiveDecision` | `OffensiveDecision` | types/game.ts:136 | | `PlayResult` | `PlayResult` | types/game.ts:217 | | `AbRoll` | `RollData` | types/game.ts:145 | | `PlayOutcome` (enum) | `PlayOutcome` (union) | types/game.ts:161 | | `Lineup` (db model) | `Lineup` | types/player.ts | ### WebSocket Event Types Complete type definitions: **Client → Server**: ```typescript interface ClientToServerEvents { // Connection join_game: (data: JoinGameRequest) => void leave_game: (data: LeaveGameRequest) => void heartbeat: () => void // Decisions submit_defensive_decision: (data: DefensiveDecisionRequest) => void submit_offensive_decision: (data: OffensiveDecisionRequest) => void // Manual workflow roll_dice: (data: RollDiceRequest) => void submit_manual_outcome: (data: SubmitManualOutcomeRequest) => void // Substitutions request_pinch_hitter: (data: PinchHitterRequest) => void request_defensive_replacement: (data: DefensiveReplacementRequest) => void request_pitching_change: (data: PitchingChangeRequest) => void // Data get_lineup: (data: GetLineupRequest) => void get_box_score: (data: GetBoxScoreRequest) => void request_game_state: (data: RequestGameStateRequest) => void } ``` **Server → Client**: ```typescript interface ServerToClientEvents { // Connection connected: (data: ConnectedEvent) => void game_joined: (data: GameJoinedEvent) => void heartbeat_ack: () => void // Decisions decision_required: (data: DecisionPrompt) => void defensive_decision_submitted: (data: DefensiveDecisionSubmittedEvent) => void offensive_decision_submitted: (data: OffensiveDecisionSubmittedEvent) => void // State game_state: (data: GameState) => void game_state_update: (data: GameState) => void game_state_sync: (data: GameStateSyncEvent) => void // Plays play_completed: (data: PlayResult) => void play_resolved: (data: PlayResult) => void inning_change: (data: InningChangeEvent) => void game_ended: (data: GameEndedEvent) => void // Manual workflow dice_rolled: (data: DiceRolledEvent) => void outcome_accepted: (data: OutcomeAcceptedEvent) => void // Substitutions player_substituted: (data: SubstitutionResult) => void substitution_confirmed: (data: SubstitutionConfirmedEvent) => void // Data lineup_data: (data: LineupDataResponse) => void box_score_data: (data: BoxScoreDataEvent) => void // Errors error: (data: ErrorEvent) => void outcome_rejected: (data: OutcomeRejectedEvent) => void substitution_error: (data: SubstitutionError) => void invalid_action: (data: InvalidActionEvent) => void connection_error: (data: ConnectionErrorEvent) => void } ``` --- ## Recommendations ### Immediate Action Items (Pre-Launch) 1. ~~**Implement `decision_required` emission**~~ ✅ COMPLETED (2025-11-21) - Added to game_engine after phase transitions - Real-time decision prompting now working - Also fixed `decision_phase` field synchronization 2. **Add `game_joined` frontend handler** ⚠️ MEDIUM - Confirm room join success - Better error handling if join fails 3. ~~**Standardize decision phase naming**~~ ✅ COMPLETED (2025-01-21) - Standardized on `'awaiting_*'` convention - Reduces conditional complexity 4. **Add authorization checks** ⚠️ HIGH SECURITY - Verify team ownership before actions - Prevent unauthorized substitutions/decisions 5. **Include d6_two individual dice** ℹ️ LOW - Better dice animation UX - Trivial backend change ### Future Improvements 6. **Consolidate state broadcast events** ℹ️ MEDIUM - Unify `game_state`, `game_state_update`, `game_state_sync` - Clearer semantics 7. **Remove redundant `play_completed`** ℹ️ LOW - Use only `play_resolved` - Simplifies protocol 8. **Implement auto mode** ℹ️ FUTURE - Skip manual dice/outcome workflow - Faster gameplay option 9. **Add incremental state updates** ⚠️ OPTIMIZATION - Instead of full GameState (~2KB), send diffs (~200 bytes) - Reduces bandwidth 10x - Important for mobile 3G connections 10. **Add connection recovery logic** ⚠️ MEDIUM - Detect missed events during brief disconnect - Request only missing plays, not full state --- ## Appendix A: Backend Handlers Summary **File**: `backend/app/websocket/handlers.py` | Handler | Lines | Description | |---------|-------|-------------| | `connect` | 26-50 | JWT auth and accept | | `disconnect` | 53-55 | Cleanup | | `join_game` | 58-77 | Join room | | `leave_game` | 80-88 | Leave room | | `heartbeat` | 91-93 | Keep-alive | | `request_game_state` | 96-138 | Get full state | | `roll_dice` | 141-219 | Manual dice roll | | `submit_manual_outcome` | 222-437 | Manual outcome | | `request_pinch_hitter` | 442-593 | Pinch hitter sub | | `request_defensive_replacement` | 596-761 | Defensive sub | | `request_pitching_change` | 764-917 | Pitcher sub | | `get_lineup` | 920-1045 | Get lineup data | | `submit_defensive_decision` | 1048-1137 | Defense strategy | | `submit_offensive_decision` | 1140-1217 | Offense strategy | | `get_box_score` | 1220-1276 | Game statistics | **Total**: 15 handlers, 1,251 lines --- ## Appendix B: Frontend Event Listeners Summary **File**: `frontend-sba/composables/useWebSocket.ts` | Event | Lines | Description | |-------|-------|-------------| | `connect` (Socket.io) | 187-199 | Connection established | | `disconnect` (Socket.io) | 201-216 | Connection lost | | `connect_error` (Socket.io) | 218-232 | Connection failed | | `connected` | 234-236 | Server confirms | | `heartbeat_ack` | 238-241 | Keep-alive ack | | `game_state` | 247-250 | Full state | | `game_state_update` | 252-255 | State broadcast | | `game_state_sync` | 257-264 | State + plays | | `play_completed` | 266-270 | Play finished | | `inning_change` | 272-275 | Inning transition | | `game_ended` | 277-283 | Game over | | `decision_required` | 289-292 | Need input | | `defensive_decision_submitted` | 294-310 | Defense set | | `offensive_decision_submitted` | 312-324 | Offense set | | `dice_rolled` | 330-345 | Dice results | | `outcome_accepted` | 347-351 | Outcome valid | | `play_resolved` | 353-376 | Play complete | | `player_substituted` | 382-392 | Sub broadcast | | `substitution_confirmed` | 394-397 | Sub success | | `lineup_data` | 403-406 | Lineup info | | `box_score_data` | 408-412 | Stats data | | `error` | 418-422 | Generic error | | `outcome_rejected` | 424-427 | Invalid outcome | | `substitution_error` | 429-432 | Sub failed | | `invalid_action` | 434-437 | Action denied | | `connection_error` | 439-443 | Connection issue | **Total**: 26 listeners, 257 lines --- ## Document Change Log | Date | Version | Changes | |------|---------|---------| | 2025-01-21 | 1.0 | Initial comprehensive specification | | 2025-11-21 | 1.1 | Issue #1 resolved: Implemented `decision_required` event emission in backend, updated event descriptions, marked recommendations as complete | --- **End of WebSocket Protocol Specification**