Comprehensive documentation for real-time gameplay workflow: **New Documentation**: 1. WEBSOCKET_PROTOCOL_SPEC.md (49KB) - Complete catalog of all 15 backend WebSocket event handlers - Complete catalog of all frontend event listeners - Game workflow sequences (connection → game start → play resolution) - Critical issues identified and resolution status - Event payload specifications with examples - Timing and performance expectations 2. DECISION_REQUIRED_IMPLEMENTATION.md (11KB) - Issue #1 detailed analysis and resolution - Backend implementation of decision_required event - Frontend integration approach - Before/After workflow comparison - Test validation results 3. GAMEPLAY_SESSION_HANDOFF.md (10KB) - Session work summary and accomplishments - Live testing results and observations - Known issues and next steps - Quick start guide for next session - Technical architecture notes **Why**: - Provides single source of truth for WebSocket protocol - Documents complete event flow for frontend/backend alignment - Captures implementation decisions and rationale - Enables faster onboarding for new developers - Creates reference for debugging WebSocket issues **Impact**: - Reduces protocol confusion between frontend and backend - Accelerates future WebSocket feature development - Provides clear integration patterns for new events 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
48 KiB
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
- Game Workflow Overview
- WebSocket Events - Complete Reference
- Game State Flow
- Critical Issues Identified
- Event Sequences by Workflow
- Type Mappings
- 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:
{
token: string // JWT from Discord OAuth
}
Backend Behavior:
- Validates JWT token
- Extracts user_id from token
- Registers connection in ConnectionManager
- Returns
trueto accept,falseto 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:
{
user_id: string
}
Frontend Handling:
- Logs user_id
- No state changes (already set
isConnected=truefrom Socket.ioconnect)
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:
- Client sends
heartbeatevery 30 seconds - Server responds with
heartbeat_ack - 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:
{
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_joinedconfirmation
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:
{
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:
{
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:
{
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
{
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<string, boolean>,
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_syncfor 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:
{
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:
{
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:
{
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:
{
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:
{
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:
{
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:
{
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:
{
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:
{
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:
outcome_accepted(to submitter)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:
{
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:
{
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:
{
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:
{
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:
{
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:
{
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:
player_substituted(broadcast)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:
{
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:
player_substituted(broadcast)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:
{
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:
player_substituted(broadcast)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:
{
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:
{
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:
{
message: string,
code: string,
type: string
}
Frontend Handling:
- Shows error toast
Common Error Codes:
MISSING_FIELD: Required parameter missingINVALID_FORMAT: game_id not valid UUIDPLAYER_NOT_FOUND: lineup_id doesn't existPLAYER_NOT_ACTIVE: Player already substitutedNO_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:
{
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:
{
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:
{
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:
{
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:
{
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:
{
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:
{
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:
- Game Start (
game_engine.py:253): Emitsawaiting_defensivewhen game starts - After Defensive Decision (
game_engine.py:308-310): Emitsawaiting_offensiveafter defense submits - Event Helper (
game_engine.py:60-100):_emit_decision_required()method with team determination logic
Implementation Details:
- Added
_connection_managerattribute toGameEnginesingleton - Added
set_connection_manager()called frommain.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_phasefield in bothsubmit_defensive_decision(line 291) andsubmit_offensive_decision(line 335) - Frontend checks
gameState.decision_phase, so state must be in sync with events - See
.claude/DECISION_REQUIRED_IMPLEMENTATION.mdIssue #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_phasefield 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_updatefor future incremental updates - Or consolidate: only use
game_statefor all cases
3. Missing d6_two_a and d6_two_b in Dice Roll ℹ️ LOW PRIORITY
Backend (handlers.py:204):
"d6_two_total": ab_roll.d6_two_total,
Frontend (useWebSocket.ts:335-336):
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:
"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 toastplay_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):
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:
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.tsDecisionPhase type - ✅ Simplified
frontend-sba/store/game.tsconditionals (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:
- Skip
roll_dicestep - Backend automatically resolves outcome
- Emits
play_resolveddirectly
No Code Changes Needed: This is expected future work, not a bug.
9. TODO Comments Need Resolution ⚠️ MEDIUM PRIORITY
Across Backend Handlers:
# 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:
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:
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:
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)
-
Implement✅ COMPLETED (2025-11-21)decision_requiredemission- Added to game_engine after phase transitions
- Real-time decision prompting now working
- Also fixed
decision_phasefield synchronization
-
Add
game_joinedfrontend handler ⚠️ MEDIUM- Confirm room join success
- Better error handling if join fails
-
Standardize decision phase naming✅ COMPLETED (2025-01-21)- Standardized on
'awaiting_*'convention - Reduces conditional complexity
- Standardized on
-
Add authorization checks ⚠️ HIGH SECURITY
- Verify team ownership before actions
- Prevent unauthorized substitutions/decisions
-
Include d6_two individual dice ℹ️ LOW
- Better dice animation UX
- Trivial backend change
Future Improvements
-
Consolidate state broadcast events ℹ️ MEDIUM
- Unify
game_state,game_state_update,game_state_sync - Clearer semantics
- Unify
-
Remove redundant
play_completedℹ️ LOW- Use only
play_resolved - Simplifies protocol
- Use only
-
Implement auto mode ℹ️ FUTURE
- Skip manual dice/outcome workflow
- Faster gameplay option
-
Add incremental state updates ⚠️ OPTIMIZATION
- Instead of full GameState (~2KB), send diffs (~200 bytes)
- Reduces bandwidth 10x
- Important for mobile 3G connections
-
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