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
25 KiB
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:
- Static utility methods (
parsePlayersFromHere,parseRoomEntity,parsePlayerJoinFromTextDescriptions) — pure functions, tested above. - Constructor — takes
{ sessionId, gameId, roomCode, maxPlayers }, initializes internal state. connect(roomInfo)— accepts the result ofgetRoomInfo(). Opens a WebSocket towss://{host}/api/v2/rooms/{code}/play?role=shard&name=GamePicker&userId=gamepicker-{sessionId}&format=jsonwithSec-WebSocket-Protocol: ecast-v0andOrigin: https://jackbox.tv.handleMessage(data)— dispatcher that routesclient/welcome,object,error,client/connected,client/disconnectedto handler methods.handleWelcome(result)— parseshere,entities.room, storessecret/id.disconnect()— closes the WebSocket gracefully.- 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 anobjectmessage arrives withkey: "room"(orkey: "bc:room"for some games). Compares new state against stored state. Broadcasts:lobby.updatediflobbyStatechangedgame.startedifstatechanged to"Gameplay"andgameStartedwas falsegame.endedifgameFinishedchanged to true- Updates
playerCountin DB viaupdatePlayerCount()on game start and end.
-
handleTextDescriptionsUpdate(val)— called whenobjectwithkey: "textDescriptions"arrives. UsesparsePlayerJoinFromTextDescriptionsto detect joins. Broadcastslobby.player-joinedfor each new join. Updates internalplayerNameslist. -
handleClientConnected(result)— if shards receiveclient/connected, update internalheretracking and recount players. Broadcastlobby.player-joinedif the new connection is a player. -
updatePlayerCount(count, status)— writes tosession_gamesand callsthis.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 WebSocketcloseevent. IfgameFinishedormanuallyStopped, do nothing. Otherwise, callattemptReconnect().attemptReconnect()— callsgetRoomInfo(roomCode). If room gone, finalize. If room exists, tryreconnectWithBackoff().reconnectWithBackoff()— attempts up to 3 reconnections with 2s/4s/8s delays. UsesbuildReconnectUrl()with storedsecret/id. On success, resumes message handling transparently. On failure, set status'failed', broadcastroom.disconnected.buildReconnectUrl()— constructswss://{host}/api/v2/rooms/{code}/play?role=shard&name=GamePicker&format=json&secret={secret}&id={id}.handleError(err)— logs the error, defers tohandleClosefor 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 7–8 imports, lines 394–401, 617–624, 638–644, 844–875, 877–893) - 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 7–8:
// 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:
-
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) -
Lines ~620–621 (
PATCH .../status): Replace bothstopRoomMonitor(sessionId, gameId)andstopPlayerCountCheck(sessionId, gameId)with singlestopMonitor(sessionId, gameId) -
Lines ~640–641 (
DELETE .../games/:gameId): Same — replace two stop calls with singlestopMonitor(sessionId, gameId) -
Line ~866 (
POST .../start-player-check):startRoomMonitor(...)→startMonitor(...) -
Lines ~883–884 (
POST .../stop-player-check): Replace two stop calls with singlestopMonitor(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(removepuppeteerfrom 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:
- Start the backend:
cd backend && npm run dev - Create a session via API, add a game with a room code from an active Jackbox game
- Watch backend logs for
[Shard Monitor]messages:- REST room info fetched
- WebSocket connected as shard
client/welcomeparsed, 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
- Connect a WebSocket client to
/api/sessions/live, authenticate, subscribe to the session, and verify events arrive:room.connectedlobby.player-joinedgame.startedgame.endedroom.disconnected
- Test
stop-player-checkendpoint — verify shard disconnects cleanly - Test reconnection — kill and restart the backend mid-game, call
start-player-checkagain
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 | — |