strat-gameplay-webapp/.claude/WEBSOCKET_PROTOCOL_SPEC.md
Cal Corum f3eb5e8200 CLAUDE: Add WebSocket protocol specification and implementation guides
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>
2025-11-21 15:41:16 -06:00

48 KiB
Raw Blame History

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
  2. WebSocket Events - Complete Reference
  3. Game State Flow
  4. Critical Issues Identified
  5. Event Sequences by Workflow
  6. Type Mappings
  7. 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 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:

{
  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:

{
  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:

{
  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_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:

{
  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:

  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:

{
  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:

  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:

{
  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:

{
  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:

{
  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 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:

{
  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:

  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_updategame_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):

"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 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):

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.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:

# 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)

  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

  1. Consolidate state broadcast events MEDIUM

    • Unify game_state, game_state_update, game_state_sync
    • Clearer semantics
  2. Remove redundant play_completed LOW

    • Use only play_resolved
    • Simplifies protocol
  3. Implement auto mode FUTURE

    • Skip manual dice/outcome workflow
    • Faster gameplay option
  4. Add incremental state updates ⚠️ OPTIMIZATION

    • Instead of full GameState (~2KB), send diffs (~200 bytes)
    • Reduces bandwidth 10x
    • Important for mobile 3G connections
  5. 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