Add 20-second game.status WebSocket heartbeat from active shard monitors containing full game state, and GET /status-live REST endpoint for on-demand polling. Fix missing token destructuring in SessionInfo causing crash. Relax frontend polling from 3s to 60s since WebSocket events now cover real-time updates. Bump version to 0.6.0. Made-with: Cursor
15 KiB
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 andwss://for HTTPS - No query parameters are required
- Connection can be established without authentication (auth happens via a message after connect)
JavaScript example:
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):
{ "type": "auth", "token": "<jwt>" }
Success response:
{ "type": "auth_success", "message": "Authenticated successfully" }
Failure responses:
{ "type": "auth_error", "message": "Invalid or expired token" }
{ "type": "auth_error", "message": "Token required" }
JavaScript example:
// 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
{ "type": "auth", "token": "<jwt>" }
subscribe
Must be authenticated. You can subscribe to multiple sessions.
{ "type": "subscribe", "sessionId": 3 }
unsubscribe
Must be authenticated.
{ "type": "unsubscribe", "sessionId": 3 }
ping
{ "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) |
game.status |
Periodic game state heartbeat every 20s (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:
{
"type": "<event-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:
{
"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:
{
"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:
{
"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-checkor adding a game with a room code)
Data:
{
"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
textDescriptionsentity updates orclient/connectedmessages)
Data:
{
"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:
{
"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:
{
"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:
{
"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:
{
"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.
game.status
- Broadcast to: Clients subscribed to the session
- Triggered by: Periodic 20-second heartbeat from an active shard monitor. Also available on demand via
GET /api/sessions/{sessionId}/games/{sessionGameId}/status-live.
Data:
{
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"appTag": "drawful2international",
"maxPlayers": 8,
"playerCount": 4,
"players": ["Alice", "Bob", "Charlie", "Diana"],
"lobbyState": "CanStart",
"gameState": "Lobby",
"gameStarted": false,
"gameFinished": false,
"monitoring": true
}
player-count.updated
- Broadcast to: Clients subscribed to the session
- Triggered by:
PATCH /api/sessions/{sessionId}/games/{sessionGameId}/player-count(manual override only)
Data:
{
"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:
{
"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:
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:
- Re-authenticate with an
authmessage - Re-subscribe to any sessions you were tracking
Implement exponential backoff for reconnection attempts:
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:
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 'game.status':
console.log('Status heartbeat:', msg.data.roomCode, '- players:', msg.data.playerCount, '- state:', msg.data.gameState);
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();
}