# 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`: ```javascript 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). ```javascript 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`: ```javascript 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** ```bash 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. ```javascript 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: ```javascript 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** ```bash 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`: ```javascript 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: ```javascript 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: ```javascript 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** ```bash 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** ```javascript 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** ```bash 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** ```javascript 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`: ```javascript 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** ```bash 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: ```javascript // 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 ~620–621** (`PATCH .../status`): Replace both `stopRoomMonitor(sessionId, gameId)` and `stopPlayerCountCheck(sessionId, gameId)` with single `stopMonitor(sessionId, gameId)` 3. **Lines ~640–641** (`DELETE .../games/:gameId`): Same — replace two stop calls with single `stopMonitor(sessionId, gameId)` 4. **Line ~866** (`POST .../start-player-check`): `startRoomMonitor(...)` → `startMonitor(...)` 5. **Lines ~883–884** (`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** ```bash 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: ```javascript 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** ```bash 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** ```bash rm backend/utils/player-count-checker.js backend/utils/room-monitor.js ``` **Step 3: Remove Puppeteer dependency** ```bash 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** ```bash 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** ```bash 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 | — |