# 04 — WebSocket Protocol > **Purpose:** Deep dive into WebSocket lifecycle, state management, and edge cases for LLMs working on multiplayer features. Concatenate with 00-project-overview.md and 99-current-task.md. > **Last updated:** 2026-05-15 > **Depends on:** 00-project-overview.md, 03-api-contract.md --- ## Connection Lifecycle ### 1. Upgrade ``` Client: GET wss://api.lilastudy.com/ws Headers: Cookie: better-auth.session=... Server: Validates session via Better Auth (reads cookie, looks up in DB) → Valid: 101 Switching Protocols, connection established → Invalid: 401 Unauthorized, connection rejected ``` **Auth is mandatory.** No anonymous WebSocket connections. Guest play (if implemented) would need a different auth strategy here. ### 2. Message Routing After connection, all messages flow through: ``` Raw JSON message ↓ Zod safeParse against WebSocketMessageSchema (discriminated union on `type`) ↓ Router switches on `type` → dispatches to handler ↓ Handler executes business logic → broadcasts to room ``` **Invalid messages:** Parse failures are logged and silently dropped. The client receives no error response — this is intentional to prevent error spam from malformed clients. ### 3. Disconnect When a client disconnects (browser close, network loss, page navigate): ``` Connection close event ↓ Handler removes player from lobby (if in one) ↓ Broadcasts updated lobby:state to remaining players ↓ If game in progress and player disconnects: → Player is marked as "disconnected" (not removed from game state) → Their answer slot is treated as "no answer" (timeout) → Game continues ``` **No automatic reconnect.** The client must manually reconnect and re-join the lobby. Graceful reconnect with state restoration is planned (BACKLOG.md `next`). --- ## State Management ### Two-Tier Storage | State Type | Storage | Durability | Use Case | | ---------------- | ---------------------------------------------------------------- | ---------- | ------------------------------------------------- | | Lobby membership | PostgreSQL (`lobbies`, `lobby_players`) | Durable | Who is in which room, who is host | | Game state | In-memory (`InMemoryLobbyGameStore`, `InMemoryGameSessionStore`) | Ephemeral | Current question, scores, timer, answers received | **Why the split?** Lobby membership must survive server restarts (players shouldn't be kicked on deploy). Game state is ephemeral by design — a game lasts minutes, and losing state on restart is acceptable for MVP. ### In-Memory Store Structure ```typescript // Conceptual — actual implementation in apps/api/src/gameSessionStore/ interface InMemoryGameState { [lobbyCode: string]: { status: "waiting" | "question" | "result" | "finished"; currentRound: number; totalRounds: number; currentQuestion: GameQuestion | null; answers: Map; scores: Map; timer: NodeJS.Timeout | null; // 15s server timer questionStartTime: number; // For speed-based tiebreaking }; } ``` --- ## The 15-Second Timer ### Implementation ``` Host sends lobby:start ↓ Server generates questions, stores in game state ↓ Broadcast game:question to all players ↓ START 15-second timer (NodeJS setTimeout) ↓ Player answers collected in Map ↓ Timer expires OR all players answered ↓ STOP timer, evaluate answers, broadcast game:answer_result ↓ If more rounds: wait 3s → broadcast next game:question → restart timer ↓ If last round: broadcast game:finished ``` ### Timer Edge Cases | Scenario | Behavior | | -------------------------- | ------------------------------------------------------------------------- | | Player answers at 14.9s | Valid, collected before timer expiry | | Player answers at 15.1s | Rejected, treated as timeout. Timer already fired. | | All players answer early | Timer is cleared early, round proceeds immediately | | No one answers | All players get 0 points for that round, next round starts | | Host disconnects mid-game | Game continues, any player can see results. No "host transfer" logic yet. | | Non-host sends lobby:start | Silently ignored (or rejected — check implementation) | --- ## Message Broadcasting ### Room-Based Broadcasting The server maintains a mapping of `lobbyCode → Set`. When a message needs to broadcast: ```typescript // Pseudo-code from ws/connections.ts function broadcastToRoom(code: string, message: WebSocketMessage) { const connections = roomConnections.get(code); for (const ws of connections) { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(message)); } } } ``` **Self-broadcast:** The sender receives their own broadcast. The frontend must handle this (e.g., ignore their own lobby:state if they already updated optimistically). ### Message Ordering WebSocket guarantees in-order delivery per connection. However, race conditions can occur: - Player A sends `game:answer` at 14.5s - Player B's connection lags, receives `game:answer_result` before their own `game:answer` ack - **Frontend must handle out-of-order messages gracefully** --- ## Edge Cases & Failure Modes ### Mid-Game Disconnect ``` Player disconnects during question phase ↓ Connection close handler triggered ↓ Player NOT removed from game state (they might reconnect) ↓ Timer continues ↓ On timer expiry: player has no answer → treated as wrong ↓ Result broadcast includes "disconnected" status for that player ``` **Current gap:** No reconnect-with-state-restoration. Player must re-join lobby and game state is not recovered. Planned in BACKLOG.md `next`. ### Double Join ``` Player joins lobby ABC ↓ Player joins lobby ABC again (accidental double-click, retry) ↓ Server: idempotent — player already in lobby, return 200 ↓ No duplicate entries in lobby_players table ``` ### Rapid Start/Stop ``` Host clicks "Start" twice rapidly ↓ First click: game starts, state changes to "in_progress" ↓ Second click: server checks state, sees "in_progress", ignores ``` ### Client-Side Message Loss If a client's `game:answer` never reaches the server (network blip): - Server never receives the answer - Timer expires - Player gets 0 points for that round - **No retry mechanism** — client sends once, no ack expected --- ## Planned Improvements (Not Yet Implemented) From BACKLOG.md `next`: 1. **Graceful WS reconnect** — Exponential back-off, restore game state on reconnection if game still in progress 2. **Heartbeat/ping** — Detect stale connections faster than TCP timeout 3. **Valkey for game state** — Replace in-memory store with Redis-compatible storage for horizontal scaling and persistence across restarts 4. **Configurable game settings** — Host sets round count, timer duration, target score via lobby settings jsonb column 5. **Additional game modes** — TV Quiz Show, Race to the Top, Chain Link, Elimination Round, Cooperative Challenge (see design/GAME_MODES.md) --- ## Key Files | File | Purpose | | ------------------------------------------------- | --------------------------------------------- | | `apps/api/src/ws/index.ts` | WebSocket server setup, attach to HTTP server | | `apps/api/src/ws/auth.ts` | Session validation on upgrade | | `apps/api/src/ws/router.ts` | Message routing by `type` | | `apps/api/src/ws/connections.ts` | Connection management, room mapping | | `apps/api/src/ws/handlers/lobbyHandlers.ts` | lobby:join, lobby:leave, lobby:start | | `apps/api/src/ws/handlers/gameHandlers.ts` | game:answer | | `apps/api/src/services/multiplayerGameService.ts` | Game logic, timer, scoring | | `apps/api/src/lobbyGameStore/` | In-memory lobby state storage | | `packages/shared/src/schemas/lobby.ts` | WS message Zod schemas | | `packages/shared/src/schemas/game.ts` | Game state Zod schemas |