Files
jackboxpartypack-gamepicker/docs/plans/2026-03-20-shard-monitor-implementation.md
cottongin 7712ebeb04 Add implementation plan for ecast shard monitor
10-task TDD plan covering: jackbox-api extension, EcastShardClient
class, event broadcasting, reconnection, route rewiring, graceful
shutdown, old module removal, doc updates, and manual smoke test.

Made-with: Cursor
2026-03-20 10:53:19 -04:00

25 KiB
Raw Permalink Blame History

Ecast Shard Monitor Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Replace the Puppeteer-based audience join and REST-polling room monitor with a single WebSocket shard client that monitors Jackbox rooms in real-time.

Architecture: A new EcastShardClient class connects as a shard to the Jackbox ecast server via the ws library. One REST call validates the room and gets the host field. The shard connection then handles lobby monitoring, player counting, game start/end detection, and reconnection. The module exports startMonitor, stopMonitor, and cleanupAllShards as drop-in replacements for the old two-module API.

Tech Stack: Node.js, ws library (already installed), ecast WebSocket protocol (ecast-v0), Jest for tests.

Design doc: docs/plans/2026-03-20-shard-monitor-design.md

Ecast API reference: docs/jackbox-ecast-api.md


Task 1: Extend jackbox-api.js with getRoomInfo

Files:

  • Modify: backend/utils/jackbox-api.js
  • Test: tests/api/jackbox-api.test.js (create)

Step 1: Write the failing test

Create tests/api/jackbox-api.test.js:

const { getRoomInfo } = require('../../backend/utils/jackbox-api');

describe('getRoomInfo', () => {
  test('is exported as a function', () => {
    expect(typeof getRoomInfo).toBe('function');
  });
});

Step 2: Run test to verify it fails

Run: cd backend && npx jest ../tests/api/jackbox-api.test.js --verbose --forceExit Expected: FAIL — getRoomInfo is not exported.

Step 3: Implement getRoomInfo

In backend/utils/jackbox-api.js, add a new function that calls GET /api/v2/rooms/{code} and returns the full room body including host, appTag, audienceEnabled, maxPlayers, locked, full. On failure, return { exists: false }.

The existing checkRoomStatus stays for now (other code may still reference it during migration).

async function getRoomInfo(roomCode) {
  try {
    const response = await fetch(`${JACKBOX_API_BASE}/rooms/${roomCode}`, {
      headers: DEFAULT_HEADERS
    });

    if (!response.ok) {
      return { exists: false };
    }

    const data = await response.json();
    const body = data.body || data;

    return {
      exists: true,
      host: body.host,
      audienceHost: body.audienceHost,
      appTag: body.appTag,
      appId: body.appId,
      code: body.code,
      locked: body.locked || false,
      full: body.full || false,
      maxPlayers: body.maxPlayers || 8,
      minPlayers: body.minPlayers || 0,
      audienceEnabled: body.audienceEnabled || false,
    };
  } catch (e) {
    console.error(`[Jackbox API] Error getting room info for ${roomCode}:`, e.message);
    return { exists: false };
  }
}

Export it alongside checkRoomStatus:

module.exports = { checkRoomStatus, getRoomInfo };

Step 4: Run test to verify it passes

Run: cd backend && npx jest ../tests/api/jackbox-api.test.js --verbose --forceExit Expected: PASS

Step 5: Commit

git add backend/utils/jackbox-api.js tests/api/jackbox-api.test.js
git commit -m "feat: add getRoomInfo to jackbox-api for full room data including host"

Task 2: Create EcastShardClient — connection and welcome handling

Files:

  • Create: backend/utils/ecast-shard-client.js
  • Test: tests/api/ecast-shard-client.test.js (create)

This task builds the core class with: constructor, connect(), client/welcome parsing, here map player counting, and the disconnect() method. No event broadcasting yet — that's Task 3.

Step 1: Write failing tests

Create tests/api/ecast-shard-client.test.js. Since we can't connect to real Jackbox servers in tests, test the pure logic: here map parsing, player counting, entity parsing. Export these as static/utility methods on the class for testability.

const { EcastShardClient } = require('../../backend/utils/ecast-shard-client');

describe('EcastShardClient', () => {
  describe('parsePlayersFromHere', () => {
    test('counts only player roles, excludes host and shard', () => {
      const here = {
        '1': { id: 1, roles: { host: {} } },
        '2': { id: 2, roles: { player: { name: 'Alice' } } },
        '3': { id: 3, roles: { player: { name: 'Bob' } } },
        '5': { id: 5, roles: { shard: {} } },
      };
      const result = EcastShardClient.parsePlayersFromHere(here);
      expect(result.playerCount).toBe(2);
      expect(result.playerNames).toEqual(['Alice', 'Bob']);
    });

    test('returns zero for empty here or host-only', () => {
      const here = { '1': { id: 1, roles: { host: {} } } };
      const result = EcastShardClient.parsePlayersFromHere(here);
      expect(result.playerCount).toBe(0);
      expect(result.playerNames).toEqual([]);
    });

    test('handles null or undefined here', () => {
      expect(EcastShardClient.parsePlayersFromHere(null).playerCount).toBe(0);
      expect(EcastShardClient.parsePlayersFromHere(undefined).playerCount).toBe(0);
    });
  });

  describe('parseRoomEntity', () => {
    test('extracts lobby state from room entity val', () => {
      const roomVal = {
        state: 'Lobby',
        lobbyState: 'CanStart',
        gameCanStart: true,
        gameIsStarting: false,
        gameFinished: false,
      };
      const result = EcastShardClient.parseRoomEntity(roomVal);
      expect(result.gameState).toBe('Lobby');
      expect(result.lobbyState).toBe('CanStart');
      expect(result.gameCanStart).toBe(true);
      expect(result.gameStarted).toBe(false);
      expect(result.gameFinished).toBe(false);
    });

    test('detects game started from Gameplay state', () => {
      const roomVal = { state: 'Gameplay', lobbyState: 'Countdown', gameCanStart: true, gameIsStarting: false, gameFinished: false };
      const result = EcastShardClient.parseRoomEntity(roomVal);
      expect(result.gameStarted).toBe(true);
    });

    test('detects game finished', () => {
      const roomVal = { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: true };
      const result = EcastShardClient.parseRoomEntity(roomVal);
      expect(result.gameFinished).toBe(true);
    });
  });

  describe('parsePlayerJoinFromTextDescriptions', () => {
    test('extracts player name from join description', () => {
      const val = {
        latestDescriptions: [
          { category: 'TEXT_DESCRIPTION_PLAYER_JOINED', text: 'Charlie joined.' }
        ]
      };
      const result = EcastShardClient.parsePlayerJoinFromTextDescriptions(val);
      expect(result).toEqual([{ name: 'Charlie', isVIP: false }]);
    });

    test('extracts VIP join', () => {
      const val = {
        latestDescriptions: [
          { category: 'TEXT_DESCRIPTION_PLAYER_JOINED_VIP', text: 'Alice joined and is the VIP.' }
        ]
      };
      const result = EcastShardClient.parsePlayerJoinFromTextDescriptions(val);
      expect(result).toEqual([{ name: 'Alice', isVIP: true }]);
    });

    test('returns empty array for no joins', () => {
      const val = { latestDescriptions: [] };
      expect(EcastShardClient.parsePlayerJoinFromTextDescriptions(val)).toEqual([]);
    });
  });
});

Step 2: Run tests to verify they fail

Run: cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit Expected: FAIL — module does not exist.

Step 3: Implement EcastShardClient

Create backend/utils/ecast-shard-client.js with:

  1. Static utility methods (parsePlayersFromHere, parseRoomEntity, parsePlayerJoinFromTextDescriptions) — pure functions, tested above.
  2. Constructor — takes { sessionId, gameId, roomCode, maxPlayers }, initializes internal state.
  3. connect(roomInfo) — accepts the result of getRoomInfo(). Opens a WebSocket to wss://{host}/api/v2/rooms/{code}/play?role=shard&name=GamePicker&userId=gamepicker-{sessionId}&format=json with Sec-WebSocket-Protocol: ecast-v0 and Origin: https://jackbox.tv.
  4. handleMessage(data) — dispatcher that routes client/welcome, object, error, client/connected, client/disconnected to handler methods.
  5. handleWelcome(result) — parses here, entities.room, stores secret/id.
  6. disconnect() — closes the WebSocket gracefully.
  7. Internal state: playerCount, playerNames, lobbyState, gameState, gameStarted, gameFinished, maxPlayers, secret, id, ws.

Do NOT add broadcasting or reconnection yet — those are Tasks 3 and 4.

Key implementation details for the WebSocket connection:

const WebSocket = require('ws');

// In connect():
this.ws = new WebSocket(url, ['ecast-v0'], {
  headers: { 'Origin': 'https://jackbox.tv' },
  handshakeTimeout: 10000,
});

Step 4: Run tests to verify they pass

Run: cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit Expected: PASS (all 8 tests)

Step 5: Commit

git add backend/utils/ecast-shard-client.js tests/api/ecast-shard-client.test.js
git commit -m "feat: add EcastShardClient with connection, welcome parsing, and player counting"

Task 3: Add event broadcasting and entity update handling

Files:

  • Modify: backend/utils/ecast-shard-client.js
  • Modify: tests/api/ecast-shard-client.test.js

This task wires up the WebSocket message handlers to broadcast events via WebSocketManager and update the session_games DB row.

Step 1: Write failing tests for entity update handlers

Add tests to tests/api/ecast-shard-client.test.js:

describe('handleRoomUpdate', () => {
  test('detects game start transition', () => {
    // Create client instance, set initial state to Lobby
    // Call handleRoomUpdate with Gameplay state
    // Verify gameStarted flipped and handler would broadcast
  });

  test('detects game end transition', () => {
    // Create client, set gameStarted = true
    // Call handleRoomUpdate with gameFinished: true
    // Verify gameFinished flipped
  });

  test('detects lobby state change', () => {
    // Create client, set lobbyState to WaitingForMore
    // Call handleRoomUpdate with CanStart
    // Verify lobbyState updated
  });
});

Since broadcasting and DB writes involve external dependencies, use a test approach where the client accepts a broadcaster callback in its constructor options. The callback receives (eventType, data). This makes the class testable without mocking the WebSocketManager singleton.

Constructor signature becomes:

constructor({ sessionId, gameId, roomCode, maxPlayers, onEvent })

Where onEvent is (eventType, eventData) => void. The module-level startMonitor function provides a default onEvent that calls wsManager.broadcastEvent(...) and writes to the DB.

Step 2: Run tests to verify they fail

Run: cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit Expected: FAIL on new tests.

Step 3: Implement entity update handlers

Add to EcastShardClient:

  • handleRoomUpdate(roomVal) — called when an object message arrives with key: "room" (or key: "bc:room" for some games). Compares new state against stored state. Broadcasts:

    • lobby.updated if lobbyState changed
    • game.started if state changed to "Gameplay" and gameStarted was false
    • game.ended if gameFinished changed to true
    • Updates playerCount in DB via updatePlayerCount() on game start and end.
  • handleTextDescriptionsUpdate(val) — called when object with key: "textDescriptions" arrives. Uses parsePlayerJoinFromTextDescriptions to detect joins. Broadcasts lobby.player-joined for each new join. Updates internal playerNames list.

  • handleClientConnected(result) — if shards receive client/connected, update internal here tracking and recount players. Broadcast lobby.player-joined if the new connection is a player.

  • updatePlayerCount(count, status) — writes to session_games and calls this.onEvent('player-count.updated', ...) for DB-triggered updates.

Add the module-level startMonitor function:

async function startMonitor(sessionId, gameId, roomCode, maxPlayers = 8) {
  const monitorKey = `${sessionId}-${gameId}`;
  if (activeShards.has(monitorKey)) return;

  const roomInfo = await getRoomInfo(roomCode);
  if (!roomInfo.exists) {
    // set failed status in DB, broadcast room.disconnected
    return;
  }

  const client = new EcastShardClient({
    sessionId, gameId, roomCode,
    maxPlayers: roomInfo.maxPlayers || maxPlayers,
    onEvent: (type, data) => {
      const wsManager = getWebSocketManager();
      if (wsManager) wsManager.broadcastEvent(type, data, parseInt(sessionId));
    }
  });

  activeShards.set(monitorKey, client);
  await client.connect(roomInfo);
}

Step 4: Run tests to verify they pass

Run: cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit Expected: PASS

Step 5: Commit

git add backend/utils/ecast-shard-client.js tests/api/ecast-shard-client.test.js
git commit -m "feat: add event broadcasting and entity update handlers to shard client"

Task 4: Add reconnection logic

Files:

  • Modify: backend/utils/ecast-shard-client.js
  • Modify: tests/api/ecast-shard-client.test.js

Step 1: Write failing test

describe('reconnection state machine', () => {
  test('buildReconnectUrl uses stored secret and id', () => {
    const client = new EcastShardClient({
      sessionId: 1, gameId: 1, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
    });
    client.secret = 'abc-123';
    client.shardId = 5;
    client.host = 'ecast-prod-use2.jackboxgames.com';

    const url = client.buildReconnectUrl();
    expect(url).toContain('secret=abc-123');
    expect(url).toContain('id=5');
    expect(url).toContain('role=shard');
    expect(url).toContain('ecast-prod-use2.jackboxgames.com');
  });
});

Step 2: Run test to verify it fails

Run: cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit Expected: FAIL — buildReconnectUrl doesn't exist.

Step 3: Implement reconnection

Add to EcastShardClient:

  • handleClose(code, reason) — called on WebSocket close event. If gameFinished or manuallyStopped, do nothing. Otherwise, call attemptReconnect().
  • attemptReconnect() — calls getRoomInfo(roomCode). If room gone, finalize. If room exists, try reconnectWithBackoff().
  • reconnectWithBackoff() — attempts up to 3 reconnections with 2s/4s/8s delays. Uses buildReconnectUrl() with stored secret/id. On success, resumes message handling transparently. On failure, set status 'failed', broadcast room.disconnected.
  • buildReconnectUrl() — constructs wss://{host}/api/v2/rooms/{code}/play?role=shard&name=GamePicker&format=json&secret={secret}&id={id}.
  • handleError(err) — logs the error, defers to handleClose for reconnection decisions.

Also handle ecast error opcode 2027 ("room already closed") in handleMessage — treat as game-ended.

Step 4: Run tests to verify they pass

Run: cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit Expected: PASS

Step 5: Commit

git add backend/utils/ecast-shard-client.js tests/api/ecast-shard-client.test.js
git commit -m "feat: add reconnection logic with exponential backoff to shard client"

Task 5: Add module exports (startMonitor, stopMonitor, cleanupAllShards)

Files:

  • Modify: backend/utils/ecast-shard-client.js
  • Modify: tests/api/ecast-shard-client.test.js

Step 1: Write failing tests

const { startMonitor, stopMonitor, cleanupAllShards } = require('../../backend/utils/ecast-shard-client');

describe('module exports', () => {
  test('startMonitor is exported', () => {
    expect(typeof startMonitor).toBe('function');
  });

  test('stopMonitor is exported', () => {
    expect(typeof stopMonitor).toBe('function');
  });

  test('cleanupAllShards is exported', () => {
    expect(typeof cleanupAllShards).toBe('function');
  });
});

Step 2: Run tests to verify they fail

Run: cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit Expected: FAIL if not yet exported.

Step 3: Finalize module exports

Ensure these are all exported from backend/utils/ecast-shard-client.js:

const activeShards = new Map();

async function startMonitor(sessionId, gameId, roomCode, maxPlayers = 8) {
  // ... (implemented in Task 3)
}

async function stopMonitor(sessionId, gameId) {
  const monitorKey = `${sessionId}-${gameId}`;
  const client = activeShards.get(monitorKey);
  if (client) {
    client.manuallyStopped = true;
    client.disconnect();
    activeShards.delete(monitorKey);

    // Update DB status unless already completed
    const game = db.prepare(
      'SELECT player_count_check_status FROM session_games WHERE session_id = ? AND id = ?'
    ).get(sessionId, gameId);

    if (game && game.player_count_check_status !== 'completed' && game.player_count_check_status !== 'failed') {
      db.prepare(
        'UPDATE session_games SET player_count_check_status = ? WHERE session_id = ? AND id = ?'
      ).run('stopped', sessionId, gameId);
    }

    client.onEvent('room.disconnected', {
      sessionId, gameId, roomCode: client.roomCode,
      reason: 'manually_stopped',
      finalPlayerCount: client.playerCount
    });
  }
}

async function cleanupAllShards() {
  for (const [, client] of activeShards) {
    client.manuallyStopped = true;
    client.disconnect();
  }
  activeShards.clear();
  console.log('[Shard Monitor] Cleaned up all active shards');
}

module.exports = { EcastShardClient, startMonitor, stopMonitor, cleanupAllShards };

Step 4: Run tests to verify they pass

Run: cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit Expected: PASS

Step 5: Commit

git add backend/utils/ecast-shard-client.js tests/api/ecast-shard-client.test.js
git commit -m "feat: add startMonitor, stopMonitor, cleanupAllShards module exports"

Task 6: Rewire sessions.js routes

Files:

  • Modify: backend/routes/sessions.js (lines 78 imports, lines 394401, 617624, 638644, 844875, 877893)
  • Test: tests/api/regression-sessions.test.js (verify existing tests still pass)

Step 1: Run existing session tests as baseline

Run: cd backend && npx jest ../tests/api/regression-sessions.test.js --verbose --forceExit Expected: PASS (capture baseline)

Step 2: Replace imports

In backend/routes/sessions.js, replace lines 78:

// Old
const { stopPlayerCountCheck } = require('../utils/player-count-checker');
const { startRoomMonitor, stopRoomMonitor } = require('../utils/room-monitor');

// New
const { startMonitor, stopMonitor } = require('../utils/ecast-shard-client');

Step 3: Replace call sites

Search and replace across the file. There are 5 call sites:

  1. Line ~397 (POST /:id/games): startRoomMonitor(req.params.id, result.lastInsertRowid, room_code, game.max_players)startMonitor(req.params.id, result.lastInsertRowid, room_code, game.max_players)

  2. Lines ~620621 (PATCH .../status): Replace both stopRoomMonitor(sessionId, gameId) and stopPlayerCountCheck(sessionId, gameId) with single stopMonitor(sessionId, gameId)

  3. Lines ~640641 (DELETE .../games/:gameId): Same — replace two stop calls with single stopMonitor(sessionId, gameId)

  4. Line ~866 (POST .../start-player-check): startRoomMonitor(...)startMonitor(...)

  5. Lines ~883884 (POST .../stop-player-check): Replace two stop calls with single stopMonitor(sessionId, gameId)

Step 4: Run tests to verify nothing broke

Run: cd backend && npx jest ../tests/api/regression-sessions.test.js --verbose --forceExit Expected: PASS (same as baseline — these tests don't exercise actual Jackbox connections)

Also run the full test suite:

Run: cd backend && npx jest --config ../jest.config.js --runInBand --verbose --forceExit Expected: PASS

Step 5: Commit

git add backend/routes/sessions.js
git commit -m "refactor: rewire sessions routes to use ecast shard client"

Task 7: Wire graceful shutdown in server.js

Files:

  • Modify: backend/server.js

Step 1: Add shutdown handler

In backend/server.js, import cleanupAllShards and add signal handlers inside the if (require.main === module) block:

const { cleanupAllShards } = require('./utils/ecast-shard-client');

// Inside the if (require.main === module) block, after server.listen:
const shutdown = async () => {
  console.log('Shutting down gracefully...');
  await cleanupAllShards();
  server.close(() => process.exit(0));
};

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

Step 2: Verify server still starts

Run: cd backend && timeout 5 node server.js || true Expected: Server starts, prints port message, exits on timeout.

Step 3: Run full test suite

Run: cd backend && npx jest --config ../jest.config.js --runInBand --verbose --forceExit Expected: PASS

Step 4: Commit

git add backend/server.js
git commit -m "feat: wire graceful shutdown for shard connections on SIGTERM/SIGINT"

Task 8: Delete old files and remove Puppeteer dependency

Files:

  • Delete: backend/utils/player-count-checker.js
  • Delete: backend/utils/room-monitor.js
  • Modify: backend/package.json (remove puppeteer from dependencies)

Step 1: Verify no remaining imports of old modules

Search the codebase for any remaining require('./player-count-checker'), require('./room-monitor'), require('../utils/player-count-checker'), require('../utils/room-monitor'). After Task 6, sessions.js should be the only file that imported them and it now imports from ecast-shard-client. The old room-monitor.js had a lazy require of player-count-checker which is going away with it.

If any other files reference these modules, update them first.

Step 2: Delete the files

rm backend/utils/player-count-checker.js backend/utils/room-monitor.js

Step 3: Remove Puppeteer dependency

cd backend && npm uninstall puppeteer

Step 4: Run full test suite

Run: cd backend && npx jest --config ../jest.config.js --runInBand --verbose --forceExit Expected: PASS — no test should depend on the deleted files.

Step 5: Commit

git add -A
git commit -m "chore: remove Puppeteer and old room-monitor/player-count-checker modules"

Task 9: Update WebSocket documentation

Files:

  • Modify: docs/api/websocket.md

Step 1: Read current websocket.md

Read docs/api/websocket.md and identify the server-to-client event table.

Step 2: Update the event table

Replace the old events with the new contract:

Event Description
room.connected Shard connected to Jackbox room (replaces audience.joined)
lobby.player-joined A player joined the lobby
lobby.updated Lobby state changed
game.started Game transitioned to Gameplay
game.ended Game finished
room.disconnected Shard lost connection to room
game.added New game added to session (unchanged)
session.started Session created (unchanged)
session.ended Session closed (unchanged)
vote.received Vote recorded (unchanged)
player-count.updated Manual player count override (unchanged)

Add payload examples for each new event (from design doc).

Step 3: Commit

git add docs/api/websocket.md
git commit -m "docs: update websocket event reference with new shard monitor events"

Task 10: Smoke test with a real Jackbox room (manual)

This task is manual verification — not automated.

Steps:

  1. Start the backend: cd backend && npm run dev
  2. Create a session via API, add a game with a room code from an active Jackbox game
  3. Watch backend logs for [Shard Monitor] messages:
    • REST room info fetched
    • WebSocket connected as shard
    • client/welcome parsed, player count reported
    • Player join detected when someone joins the lobby
    • Game start detected when the game begins
    • Game end detected when the game finishes
  4. Connect a WebSocket client to /api/sessions/live, authenticate, subscribe to the session, and verify events arrive:
    • room.connected
    • lobby.player-joined
    • game.started
    • game.ended
    • room.disconnected
  5. Test stop-player-check endpoint — verify shard disconnects cleanly
  6. Test reconnection — kill and restart the backend mid-game, call start-player-check again

Summary

Task Description Files
1 getRoomInfo in jackbox-api jackbox-api.js, test
2 EcastShardClient core + parsing ecast-shard-client.js, test
3 Event broadcasting + entity handlers ecast-shard-client.js, test
4 Reconnection logic ecast-shard-client.js, test
5 Module exports ecast-shard-client.js, test
6 Rewire sessions routes sessions.js
7 Graceful shutdown server.js
8 Delete old files + remove Puppeteer player-count-checker.js, room-monitor.js, package.json
9 Update docs websocket.md
10 Manual smoke test