# WebSocket Protocol ## 1. Overview The WebSocket API provides real-time updates for Jackbox gaming sessions. Use it to: - Receive notifications when sessions start, end, or when games are added - Monitor Jackbox room state in real-time (lobby, player joins, game start/end) - Track player counts automatically via shard connection - Receive live vote updates (upvotes/downvotes) as viewers vote - Avoid polling REST endpoints for session state changes The WebSocket server runs on the same host and port as the HTTP API. Connect to `/api/sessions/live` to establish a live connection. --- ## 2. Connection Setup **URL:** `ws://host:port/api/sessions/live` - Use `ws://` for HTTP and `wss://` for HTTPS - No query parameters are required - Connection can be established without authentication (auth happens via a message after connect) **JavaScript example:** ```javascript const host = 'localhost'; const port = 5000; const protocol = 'ws'; const ws = new WebSocket(`${protocol}://${host}:${port}/api/sessions/live`); ws.onopen = () => { console.log('Connected'); }; ``` --- ## 3. Authentication Authentication is required for subscribing to sessions and for receiving most events. Send your JWT token in an `auth` message after connecting. **Send (client → server):** ```json { "type": "auth", "token": "" } ``` **Success response:** ```json { "type": "auth_success", "message": "Authenticated successfully" } ``` **Failure responses:** ```json { "type": "auth_error", "message": "Invalid or expired token" } ``` ```json { "type": "auth_error", "message": "Token required" } ``` **JavaScript example:** ```javascript // After opening the connection... ws.send(JSON.stringify({ type: 'auth', token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' })); ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'auth_success') { console.log('Authenticated'); } else if (msg.type === 'auth_error') { console.error('Auth failed:', msg.message); } }; ``` Obtain a JWT by calling `POST /api/auth/login` with your admin key. --- ## 4. Message Types — Client to Server | Type | Required Fields | Description | |-------------|-----------------|--------------------------------------| | `auth` | `token` | Authenticate with a JWT | | `subscribe` | `sessionId` | Subscribe to a session's events | | `unsubscribe`| `sessionId` | Unsubscribe from a session | | `ping` | — | Heartbeat; server responds with `pong` | ### auth ```json { "type": "auth", "token": "" } ``` ### subscribe Must be authenticated. You can subscribe to multiple sessions. ```json { "type": "subscribe", "sessionId": 3 } ``` ### unsubscribe Must be authenticated. ```json { "type": "unsubscribe", "sessionId": 3 } ``` ### ping ```json { "type": "ping" } ``` --- ## 5. Message Types — Server to Client | Type | Description | |---------------|------------------------------------------| | `auth_success`| Authentication succeeded | | `auth_error` | Authentication failed | | `subscribed` | Successfully subscribed to a session | | `unsubscribed`| Successfully unsubscribed from a session | | `pong` | Response to client `ping` | | `error` | General error (e.g., not authenticated) | | `session.started` | New session created (broadcast to all authenticated clients) | | `game.added` | Game added to a session (broadcast to subscribers) | | `session.ended` | Session closed (broadcast to subscribers) | | `room.connected` | Shard connected to Jackbox room (broadcast to subscribers) | | `lobby.player-joined` | Player joined the Jackbox lobby (broadcast to subscribers) | | `lobby.updated` | Lobby state changed (broadcast to subscribers) | | `game.started` | Game transitioned to Gameplay (broadcast to subscribers) | | `game.ended` | Game finished (broadcast to subscribers) | | `room.disconnected` | Shard lost connection to Jackbox room (broadcast to subscribers) | | `player-count.updated` | Manual player count override (broadcast to subscribers) | | `vote.received` | Live vote recorded (broadcast to subscribers) | --- ## 6. Event Reference All server-sent events use this envelope: ```json { "type": "", "timestamp": "2026-03-15T20:30:00.000Z", "data": { ... } } ``` ### session.started - **Broadcast to:** All authenticated clients (not session-specific) - **Triggered by:** `POST /api/sessions` (creating a new session) **Data:** ```json { "session": { "id": 3, "is_active": 1, "created_at": "2026-03-15T20:00:00", "notes": "Friday game night" } } ``` ### game.added - **Broadcast to:** Clients subscribed to the session - **Triggered by:** `POST /api/sessions/{id}/games` (adding a game) **Data:** ```json { "session": { "id": 3, "is_active": true, "games_played": 5 }, "game": { "id": 42, "title": "Quiplash 3", "pack_name": "Jackbox Party Pack 7", "min_players": 3, "max_players": 8, "manually_added": false, "room_code": "ABCD" } } ``` ### session.ended - **Broadcast to:** Clients subscribed to the session - **Triggered by:** `POST /api/sessions/{id}/close` (closing a session) **Data:** ```json { "session": { "id": 3, "is_active": 0, "games_played": 8 } } ``` ### room.connected - **Broadcast to:** Clients subscribed to the session - **Triggered by:** Shard WebSocket successfully connecting to a Jackbox room (after `POST .../start-player-check` or adding a game with a room code) **Data:** ```json { "sessionId": 1, "gameId": 5, "roomCode": "LSBN", "appTag": "drawful2international", "maxPlayers": 8, "playerCount": 2, "players": ["Alice", "Bob"], "lobbyState": "CanStart", "gameState": "Lobby" } ``` ### lobby.player-joined - **Broadcast to:** Clients subscribed to the session - **Triggered by:** A new player joining the Jackbox room lobby (detected via `textDescriptions` entity updates or `client/connected` messages) **Data:** ```json { "sessionId": 1, "gameId": 5, "roomCode": "LSBN", "playerName": "Charlie", "playerCount": 3, "players": ["Alice", "Bob", "Charlie"], "maxPlayers": 8 } ``` ### lobby.updated - **Broadcast to:** Clients subscribed to the session - **Triggered by:** Lobby state change in the Jackbox room (e.g., enough players to start, countdown started) **Data:** ```json { "sessionId": 1, "gameId": 5, "roomCode": "LSBN", "lobbyState": "Countdown", "gameCanStart": true, "gameIsStarting": true, "playerCount": 4 } ``` ### game.started - **Broadcast to:** Clients subscribed to the session - **Triggered by:** Jackbox game transitioning from Lobby to Gameplay state **Data:** ```json { "sessionId": 1, "gameId": 5, "roomCode": "LSBN", "playerCount": 4, "players": ["Alice", "Bob", "Charlie", "Diana"], "maxPlayers": 8 } ``` ### game.ended - **Broadcast to:** Clients subscribed to the session - **Triggered by:** Jackbox game finishing (`gameFinished: true`) or room closing **Data:** ```json { "sessionId": 1, "gameId": 5, "roomCode": "LSBN", "playerCount": 4, "players": ["Alice", "Bob", "Charlie", "Diana"] } ``` ### room.disconnected - **Broadcast to:** Clients subscribed to the session - **Triggered by:** Shard losing connection to the Jackbox room (room closed, connection failed, manually stopped) **Data:** ```json { "sessionId": 1, "gameId": 5, "roomCode": "LSBN", "reason": "room_closed", "finalPlayerCount": 4 } ``` Possible `reason` values: `room_closed`, `room_not_found`, `connection_failed`, `role_rejected`, `manually_stopped`. ### player-count.updated - **Broadcast to:** Clients subscribed to the session - **Triggered by:** `PATCH /api/sessions/{sessionId}/games/{sessionGameId}/player-count` (manual override only) **Data:** ```json { "sessionId": "3", "gameId": "7", "playerCount": 6, "status": "completed" } ``` ### vote.received - **Broadcast to:** Clients subscribed to the session - **Triggered by:** `POST /api/votes/live` (recording a live vote). Only fires for live votes, NOT chat-import. **Data:** ```json { "sessionId": 5, "game": { "id": 42, "title": "Quiplash 3", "pack_name": "Party Pack 7" }, "vote": { "username": "viewer123", "type": "up", "timestamp": "2026-03-15T20:29:55.000Z" }, "totals": { "upvotes": 14, "downvotes": 3, "popularity_score": 11 } } ``` --- ## 7. Error Handling | Type | Message | When | |--------------|----------------------------------------|-----------------------------------------| | `error` | `Not authenticated` | subscribe/unsubscribe without auth | | `error` | `Session ID required` | subscribe without `sessionId` | | `error` | `Unknown message type: foo` | Unknown `type` in client message | | `error` | `Invalid message format` | Unparseable or non-JSON message | | `auth_error` | `Token required` | auth without token | | `auth_error` | `Invalid or expired token` | auth with invalid/expired JWT | --- ## 8. Heartbeat and Timeout - **Client → Server:** Send `{ "type": "ping" }` periodically - **Server → Client:** Responds with `{ "type": "pong" }` - **Timeout:** If no ping is received for **60 seconds**, the server terminates the connection - **Server check:** The server checks for stale connections every **30 seconds** Implement a heartbeat on the client to keep the connection alive: ```javascript let pingInterval; function startHeartbeat() { pingInterval = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping' })); } }, 30000); // every 30 seconds } ws.onopen = () => { startHeartbeat(); }; ws.onclose = () => { clearInterval(pingInterval); }; ``` --- ## 9. Reconnection The server does **not** maintain state across disconnects. After reconnecting: 1. **Re-authenticate** with an `auth` message 2. **Re-subscribe** to any sessions you were tracking Implement exponential backoff for reconnection attempts: ```javascript let reconnectAttempts = 0; const maxReconnectAttempts = 10; const baseDelay = 1000; function connect() { const ws = new WebSocket('ws://localhost:5000/api/sessions/live'); ws.onopen = () => { reconnectAttempts = 0; ws.send(JSON.stringify({ type: 'auth', token: jwt })); // After auth_success, re-subscribe to sessions... }; ws.onclose = () => { if (reconnectAttempts < maxReconnectAttempts) { const delay = Math.min(baseDelay * Math.pow(2, reconnectAttempts), 60000); reconnectAttempts++; setTimeout(connect, delay); } }; } connect(); ``` --- ## 10. Complete Example Full session lifecycle from connect to disconnect: ```javascript const JWT = 'your-jwt-token'; const WS_URL = 'ws://localhost:5000/api/sessions/live'; const ws = new WebSocket(WS_URL); let pingInterval; let subscribedSessions = new Set(); function send(msg) { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(msg)); } } ws.onopen = () => { console.log('Connected'); send({ type: 'auth', token: JWT }); pingInterval = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { send({ type: 'ping' }); } }, 30000); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); switch (msg.type) { case 'auth_success': console.log('Authenticated'); send({ type: 'subscribe', sessionId: 3 }); break; case 'auth_error': console.error('Auth failed:', msg.message); break; case 'subscribed': subscribedSessions.add(msg.sessionId); console.log('Subscribed to session', msg.sessionId); break; case 'unsubscribed': subscribedSessions.delete(msg.sessionId); console.log('Unsubscribed from session', msg.sessionId); break; case 'pong': // Heartbeat acknowledged break; case 'session.started': console.log('New session:', msg.data.session); break; case 'game.added': console.log('Game added:', msg.data.game.title, 'to session', msg.data.session.id); break; case 'session.ended': console.log('Session ended:', msg.data.session.id); subscribedSessions.delete(msg.data.session.id); break; case 'room.connected': console.log('Room connected:', msg.data.roomCode, '- players:', msg.data.players.join(', ')); break; case 'lobby.player-joined': console.log('Player joined:', msg.data.playerName, '- count:', msg.data.playerCount); break; case 'lobby.updated': console.log('Lobby:', msg.data.lobbyState); break; case 'game.started': console.log('Game started with', msg.data.playerCount, 'players'); break; case 'game.ended': console.log('Game ended with', msg.data.playerCount, 'players'); break; case 'room.disconnected': console.log('Room disconnected:', msg.data.reason); break; case 'player-count.updated': console.log('Player count:', msg.data.playerCount, 'for game', msg.data.gameId); break; case 'vote.received': console.log('Vote:', msg.data.vote.type, 'from', msg.data.vote.username, 'for', msg.data.game.title, '- totals:', msg.data.totals); break; case 'error': case 'auth_error': console.error('Error:', msg.message); break; default: console.log('Unknown message:', msg); } }; ws.onerror = (err) => console.error('WebSocket error:', err); ws.onclose = () => { clearInterval(pingInterval); console.log('Disconnected'); }; // Later: unsubscribe and close function disconnect() { subscribedSessions.forEach((sessionId) => { send({ type: 'unsubscribe', sessionId }); }); ws.close(); } ```