Files
jackboxpartypack-gamepicker/docs/api/websocket.md
cottongin 59db8f6ed7 feat: poll countdown timer, game-selection sync, source tracking, and multi-admin fixes
Work spanning May 7-10 across multiple sessions:

Poll winner detection + source column (May 7):
- Fix race condition in handleEndPolling where WS voting.ended cleared
  leadingGame before the setTimeout could capture the winner
- Add pollActiveRef guard to prevent late poll.leading messages from
  re-activating an ended poll
- Add 'source' column to session_games (dice/manual/poll) with backward-
  compatible fallback from manually_added flag
- Show indigo "Poll" badge in game lists (Picker, Home, SessionDetail)
- Include source in session export (JSON and text formats)

Multi-admin poll state sync (May 9):
- Enrich poll.start broadcast with pollStartedAt timestamp so all admin
  clients can start their timers from the correct time
- Enrich voting.ended broadcast with winnerGameId/Label/Votes so all
  admins see the winner prompt, not just the one who clicked End Poll
- Add poll.start WS handler in SessionInfo so Admin B sees polls started
  by Admin A without refreshing
- Make handleStartPolling optimistic with rollback on failure

WebSocket keepalive + auto-reconnect (May 9):
- Add 30s ping interval to SessionInfo WS connection (matching server's
  60s timeout) to prevent silent disconnects
- Add auto-reconnect on close with 3s delay
- Proper cleanup of ping interval, reconnect timeout, and onclose handler

Sync selected game across admin clients (May 10):
- New POST/DELETE /sessions/:id/game-selection endpoints with DB
  persistence (pending_game_id, pending_game_source columns)
- Broadcast game.picked/game.dismissed WS events to session subscribers
- handleDismissGame replaces inline setSelectedGame(null) calls
- Restore pending game selection on page load for late-joining admins
- Clear pending selection when game is formally added to session

Poll ending countdown timer (May 10):
- POST /:id/voting/end now accepts optional { delay } (0-300 seconds)
- New POST /:id/voting/cancel-end to abort a scheduled end
- New poll.ending and poll.ending.cancelled WS events
- poll_ending_at column on sessions table for crash recovery
- rescheduleEndingPolls() called on server startup to resume countdowns
- End Poll button opens popover with End Now / 5s / 10s / 30s / custom
- Red "Poll Ending" card with countdown display and Cancel button
- Document new WS events in docs/api/websocket.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 20:33:00 -04:00

17 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 and wss:// 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
poll.leading sessionId, gameId, label, votes Report current poll leader (rebroadcast to subscribers)

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)
voting.ended Host ended the voting/polling period (broadcast to subscribers)
poll.start Host started a new poll (broadcast to subscribers)
poll.leading Current poll leader updated (broadcast to subscribers)
poll.ending Poll is ending after a countdown (broadcast to subscribers)
poll.ending.cancelled Scheduled poll end was cancelled (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-check or 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 textDescriptions entity updates or client/connected messages)

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
  }
}

voting.ended

  • Broadcast to: Clients subscribed to the session
  • Triggered by: POST /api/sessions/:id/voting/end (host ends the voting/polling period, either immediately or when a countdown reaches zero)

Data:

{
  "sessionId": 3,
  "winnerGameId": 42,
  "winnerLabel": "Quiplash 3",
  "winnerVotes": 7
}

poll.start

  • Broadcast to: Clients subscribed to the session
  • Triggered by: POST /api/sessions/:id/voting/start (host starts a new poll)

Data:

{
  "sessionId": 3
}

poll.leading

  • Broadcast to: Clients subscribed to the session
  • Triggered by: Downstream voting client sends poll.leading message (rebroadcast to all session subscribers)

Data:

{
  "sessionId": 3,
  "gameId": 42,
  "label": "Quiplash 3",
  "votes": 7
}

poll.ending

  • Broadcast to: Clients subscribed to the session
  • Triggered by: POST /api/sessions/:id/voting/end with { "delay": N } where N > 0

Signals that a countdown has started and the poll will automatically end when the timer reaches zero. All connected admin clients should display the countdown. The endsAt timestamp is authoritative; derive the remaining time on each client by comparing against the local clock.

Data:

{
  "sessionId": 3,
  "endsAt": "2026-05-10T20:15:30.000Z",
  "delaySeconds": 30
}
Field Type Description
sessionId number The session the poll belongs to
endsAt string ISO 8601 timestamp when the poll will auto-end
delaySeconds number Original delay requested (seconds, 1-300)

poll.ending.cancelled

  • Broadcast to: Clients subscribed to the session
  • Triggered by: POST /api/sessions/:id/voting/cancel-end

The scheduled poll end was cancelled by an admin. Clients should stop displaying the countdown and revert to the normal "Voting In Progress" state.

Data:

{
  "sessionId": 3
}

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:

  1. Re-authenticate with an auth message
  2. 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();
}