Files
jackboxpartypack-gamepicker/docs/api/websocket.md
cottongin 34637d6d2c feat: add periodic game.status broadcast and live status REST endpoint
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
2026-03-20 21:05:19 -04:00

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

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

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();
}