From 505c335d20dfab31065e525a6174754c7724dac9 Mon Sep 17 00:00:00 2001 From: cottongin Date: Sun, 8 Mar 2026 18:25:52 -0400 Subject: [PATCH] Decouple room monitoring from player count, fix Jackbox API fetch Extracts checkRoomStatus into shared jackbox-api.js with proper User-Agent header (bare fetch was silently rejected by Jackbox API) and always-on error logging (previously gated behind DEBUG flag). Splits room-start detection (room-monitor.js) from audience-based player counting (player-count-checker.js) to eliminate circular dependency and allow immediate game.started detection. Room monitor now polls immediately instead of waiting 10 seconds for first check. Made-with: Cursor --- backend/routes/sessions.js | 33 +++-- backend/utils/jackbox-api.js | 42 ++++++ backend/utils/player-count-checker.js | 205 ++------------------------ backend/utils/room-monitor.js | 135 +++++++++++++++++ docs/BOT_INTEGRATION.md | 16 +- 5 files changed, 221 insertions(+), 210 deletions(-) create mode 100644 backend/utils/jackbox-api.js create mode 100644 backend/utils/room-monitor.js diff --git a/backend/routes/sessions.js b/backend/routes/sessions.js index 47e6acb..091ceb1 100644 --- a/backend/routes/sessions.js +++ b/backend/routes/sessions.js @@ -4,7 +4,8 @@ const { authenticateToken } = require('../middleware/auth'); const db = require('../database'); const { triggerWebhook } = require('../utils/webhooks'); const { getWebSocketManager } = require('../utils/websocket-manager'); -const { startPlayerCountCheck, stopPlayerCountCheck } = require('../utils/player-count-checker'); +const { stopPlayerCountCheck } = require('../utils/player-count-checker'); +const { startRoomMonitor, stopRoomMonitor } = require('../utils/room-monitor'); const router = express.Router(); @@ -356,13 +357,12 @@ router.post('/:id/games', authenticateToken, (req, res) => { console.error('Error triggering notifications:', error); } - // Automatically start player count check if room code was provided + // Automatically start room monitoring if room code was provided if (room_code) { try { - startPlayerCountCheck(req.params.id, result.lastInsertRowid, room_code, game.max_players); + startRoomMonitor(req.params.id, result.lastInsertRowid, room_code, game.max_players); } catch (error) { - console.error('Error starting player count check:', error); - // Don't fail the request if player count check fails + console.error('Error starting room monitor:', error); } } @@ -580,12 +580,13 @@ router.patch('/:sessionId/games/:gameId/status', authenticateToken, (req, res) = return res.status(404).json({ error: 'Session game not found' }); } - // Stop player count check if game is no longer playing + // Stop room monitor and player count check if game is no longer playing if (status !== 'playing') { try { + stopRoomMonitor(sessionId, gameId); stopPlayerCountCheck(sessionId, gameId); } catch (error) { - console.error('Error stopping player count check:', error); + console.error('Error stopping room monitor/player count check:', error); } } @@ -600,11 +601,12 @@ router.delete('/:sessionId/games/:gameId', authenticateToken, (req, res) => { try { const { sessionId, gameId } = req.params; - // Stop player count check before deleting + // Stop room monitor and player count check before deleting try { + stopRoomMonitor(sessionId, gameId); stopPlayerCountCheck(sessionId, gameId); } catch (error) { - console.error('Error stopping player count check:', error); + console.error('Error stopping room monitor/player count check:', error); } const result = db.prepare(` @@ -826,12 +828,12 @@ router.post('/:sessionId/games/:gameId/start-player-check', authenticateToken, ( return res.status(400).json({ error: 'Game does not have a room code' }); } - // Start the check - startPlayerCountCheck(sessionId, gameId, game.room_code, game.max_players); + // Start room monitoring (will hand off to player count check when game starts) + startRoomMonitor(sessionId, gameId, game.room_code, game.max_players); res.json({ - message: 'Player count check started', - status: 'waiting' + message: 'Room monitor started', + status: 'monitoring' }); } catch (error) { res.status(500).json({ error: error.message }); @@ -843,11 +845,12 @@ router.post('/:sessionId/games/:gameId/stop-player-check', authenticateToken, (r try { const { sessionId, gameId } = req.params; - // Stop the check + // Stop both room monitor and player count check + stopRoomMonitor(sessionId, gameId); stopPlayerCountCheck(sessionId, gameId); res.json({ - message: 'Player count check stopped', + message: 'Room monitor and player count check stopped', status: 'stopped' }); } catch (error) { diff --git a/backend/utils/jackbox-api.js b/backend/utils/jackbox-api.js new file mode 100644 index 0000000..ff17e8c --- /dev/null +++ b/backend/utils/jackbox-api.js @@ -0,0 +1,42 @@ +const JACKBOX_API_BASE = 'https://ecast.jackboxgames.com/api/v2'; + +const DEFAULT_HEADERS = { + 'User-Agent': 'Mozilla/5.0 (compatible; GamePicker/1.0)' +}; + +/** + * Check room status via the Jackbox ecast REST API. + * Shared by room-monitor (polling for lock) and player-count-checker (room existence). + */ +async function checkRoomStatus(roomCode) { + try { + const response = await fetch(`${JACKBOX_API_BASE}/rooms/${roomCode}`, { + headers: DEFAULT_HEADERS + }); + + if (!response.ok) { + console.log(`[Jackbox API] Room ${roomCode}: HTTP ${response.status}`); + return { exists: false }; + } + + const data = await response.json(); + const roomData = data.body || data; + + if (process.env.DEBUG === 'true') { + console.log('[Jackbox API] Room data:', JSON.stringify(roomData, null, 2)); + } + + return { + exists: true, + locked: roomData.locked || false, + full: roomData.full || false, + maxPlayers: roomData.maxPlayers || 8, + minPlayers: roomData.minPlayers || 0 + }; + } catch (e) { + console.error(`[Jackbox API] Error checking room ${roomCode}:`, e.message); + return { exists: false }; + } +} + +module.exports = { checkRoomStatus }; diff --git a/backend/utils/player-count-checker.js b/backend/utils/player-count-checker.js index 15d8f00..79813e0 100644 --- a/backend/utils/player-count-checker.js +++ b/backend/utils/player-count-checker.js @@ -1,40 +1,11 @@ const puppeteer = require('puppeteer'); const db = require('../database'); const { getWebSocketManager } = require('./websocket-manager'); +const { checkRoomStatus } = require('./jackbox-api'); // Store active check jobs const activeChecks = new Map(); -/** - * Check room status via Jackbox API - */ -async function checkRoomStatus(roomCode) { - try { - const response = await fetch(`https://ecast.jackboxgames.com/api/v2/rooms/${roomCode}`); - if (response.ok) { - const data = await response.json(); - const roomData = data.body || data; - - if (process.env.DEBUG) { - console.log('[API] Room data:', JSON.stringify(roomData, null, 2)); - } - return { - exists: true, - locked: roomData.locked || false, - full: roomData.full || false, - maxPlayers: roomData.maxPlayers || 8, - minPlayers: roomData.minPlayers || 0 - }; - } - return { exists: false }; - } catch (e) { - if (process.env.DEBUG) { - console.error('[API] Error checking room:', e.message); - } - return { exists: false }; - } -} - /** * Watch a game from start to finish as audience member * Collects analytics throughout the entire game lifecycle @@ -67,7 +38,7 @@ async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) { let bestPlayerCount = null; let startPlayerCount = null; // Authoritative count from 'start' action let gameEnded = false; - let audienceJoined = false; // Track whether we've confirmed audience join + let audienceJoined = false; let frameCount = 0; // Enable CDP and listen for WebSocket frames BEFORE navigating @@ -98,7 +69,6 @@ async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) { audienceJoined = true; console.log(`[Audience] Successfully joined room ${roomCode} as audience`); - // Broadcast audience.joined event via WebSocket const wsManager = getWebSocketManager(); if (wsManager) { wsManager.broadcastEvent('audience.joined', { @@ -121,7 +91,6 @@ async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) { if (process.env.DEBUG) { console.log(`[Frame ${frameCount}] 🎉 GAME ENDED - Final count: ${finalCount} players`); - // Verify it matches start count if we had one if (startPlayerCount !== null && startPlayerCount !== finalCount) { console.log(`[Frame ${frameCount}] ⚠️ WARNING: Start count (${startPlayerCount}) != Final count (${finalCount})`); } else if (startPlayerCount !== null) { @@ -130,7 +99,6 @@ async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) { } bestPlayerCount = finalCount; gameEnded = true; - // Update immediately with final count updatePlayerCount(sessionId, gameId, finalCount, 'completed'); return; } @@ -138,7 +106,6 @@ async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) { // Extract player counts from analytics (game in progress) if (roomVal.analytics && Array.isArray(roomVal.analytics)) { for (const analytic of roomVal.analytics) { - // Check for 'start' action - this is authoritative if (analytic.action === 'start' && analytic.value && typeof analytic.value === 'number') { if (startPlayerCount === null) { startPlayerCount = analytic.value; @@ -146,25 +113,20 @@ async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) { if (process.env.DEBUG) { console.log(`[Frame ${frameCount}] 🎯 Found 'start' action: ${analytic.value} players (authoritative)`); } - // Update UI with authoritative start count updatePlayerCount(sessionId, gameId, startPlayerCount, 'checking'); } - continue; // Skip to next analytic + continue; } - // If we already have start count, we don't need to keep counting if (startPlayerCount !== null) { continue; } - // Otherwise, look for any numeric value that could be a player count if (analytic.value && typeof analytic.value === 'number' && analytic.value > 0 && analytic.value <= 100) { seenPlayerCounts.add(analytic.value); - // Clamp to maxPlayers to avoid cumulative stats inflating count const clampedValue = Math.min(analytic.value, maxPlayers); - // Update best guess (highest count seen so far, clamped to maxPlayers) if (bestPlayerCount === null || clampedValue > bestPlayerCount) { bestPlayerCount = clampedValue; if (process.env.DEBUG) { @@ -174,7 +136,6 @@ async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) { console.log(`[Frame ${frameCount}] 📊 Found player count ${analytic.value} in action '${analytic.action}' (best so far)`); } } - // Update UI with current best guess updatePlayerCount(sessionId, gameId, bestPlayerCount, 'checking'); } } @@ -237,9 +198,7 @@ async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) { if (process.env.DEBUG) console.log('[Audience] 👀 Watching game... (will monitor until game ends)'); // Keep watching until game ends or we're told to stop - // Check every 5 seconds if we should still be watching const checkInterval = setInterval(async () => { - // Check if we should stop const game = db.prepare(` SELECT status, player_count_check_status FROM session_games @@ -256,14 +215,12 @@ async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) { return; } - // Check if game ended if (gameEnded) { clearInterval(checkInterval); if (browser) await browser.close(); return; } - // Check if room still exists const roomStatus = await checkRoomStatus(roomCode); if (!roomStatus.exists) { if (process.env.DEBUG) { @@ -281,7 +238,6 @@ async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) { } }, 5000); - // Store the interval so we can clean it up const check = activeChecks.get(checkKey); if (check) { check.watchInterval = checkInterval; @@ -293,7 +249,6 @@ async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) { if (browser) { await browser.close(); } - // If we had a best guess, use it; otherwise fail if (bestPlayerCount !== null) { updatePlayerCount(sessionId, gameId, bestPlayerCount, 'completed'); } else { @@ -303,27 +258,7 @@ async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) { } /** - * Broadcast game.started event when room becomes locked - */ -function broadcastGameStarted(sessionId, gameId, roomCode, maxPlayers) { - try { - const wsManager = getWebSocketManager(); - if (wsManager) { - wsManager.broadcastEvent('game.started', { - sessionId, - gameId, - roomCode, - maxPlayers - }, parseInt(sessionId)); - } - console.log(`[Player Count] Broadcasted game.started for room ${roomCode} (max: ${maxPlayers})`); - } catch (error) { - console.error('[Player Count] Failed to broadcast game.started:', error.message); - } -} - -/** - * Update player count in database + * Update player count in database and broadcast via WebSocket */ function updatePlayerCount(sessionId, gameId, playerCount, status) { try { @@ -333,7 +268,6 @@ function updatePlayerCount(sessionId, gameId, playerCount, status) { WHERE session_id = ? AND id = ? `).run(playerCount, status, sessionId, gameId); - // Broadcast via WebSocket const wsManager = getWebSocketManager(); if (wsManager) { wsManager.broadcastEvent('player-count.updated', { @@ -351,25 +285,18 @@ function updatePlayerCount(sessionId, gameId, playerCount, status) { } /** - * Start checking player count for a game - * Strategy: - * 1. Wait 10 seconds for initial room setup - * 2. Poll every 10 seconds until game is locked (started) - * 3. Broadcast game.started event when locked detected - * 4. Join audience and watch entire game - * 5. Update UI as we learn more - * 6. Finalize when game ends + * Start player count checking for a game. + * Called by room-monitor once the game is confirmed started (room locked). + * Goes straight to joining the audience — no polling needed. */ async function startPlayerCountCheck(sessionId, gameId, roomCode, maxPlayers = 8) { const checkKey = `${sessionId}-${gameId}`; - // If already checking, don't start again if (activeChecks.has(checkKey)) { console.log(`[Player Count] Already checking ${checkKey}`); return; } - // Check if already completed (but allow retrying failed checks) const game = db.prepare(` SELECT player_count_check_status FROM session_games @@ -381,122 +308,27 @@ async function startPlayerCountCheck(sessionId, gameId, roomCode, maxPlayers = 8 return; } - // If retrying a failed check, reset the status if (game && game.player_count_check_status === 'failed') { console.log(`[Player Count] Retrying failed check for ${checkKey}`); } - console.log(`[Player Count] Starting check for game ${gameId} with room code ${roomCode}`); + console.log(`[Player Count] Starting audience watch for game ${gameId} (room ${roomCode}, max ${maxPlayers})`); - // Update status to waiting db.prepare(` UPDATE session_games - SET player_count_check_status = 'waiting' + SET player_count_check_status = 'checking' WHERE session_id = ? AND id = ? `).run(sessionId, gameId); - // Function to check if game is ready (locked) - const waitForGameStart = async () => { - const roomStatus = await checkRoomStatus(roomCode); - - if (!roomStatus.exists) { - console.log(`[Player Count] Room ${roomCode} does not exist`); - updatePlayerCount(sessionId, gameId, null, 'failed'); - stopPlayerCountCheck(sessionId, gameId); - return false; - } - - // If locked, game has started - ready to watch - if (roomStatus.locked) { - console.log(`[Player Count] Room is LOCKED - game in progress, starting watch`); - return { ready: true, maxPlayers: roomStatus.maxPlayers }; - } - - // Log if full but not yet started - if (roomStatus.full) { - console.log(`[Player Count] Room is FULL but not locked yet - waiting for game start`); - } else { - console.log(`[Player Count] Room not ready yet (lobby still open)`); - } - - return null; // Not ready, keep polling - }; - - // Wait 10 seconds before first check - const initialTimeout = setTimeout(async () => { - try { - // Update status to checking - db.prepare(` - UPDATE session_games - SET player_count_check_status = 'checking' - WHERE session_id = ? AND id = ? - `).run(sessionId, gameId); - - console.log(`[Player Count] Initial check after 10s for ${checkKey}`); - const result = await waitForGameStart(); - - if (result && result.ready === true) { - // Game is locked, broadcast game.started and start watching - const realMaxPlayers = result.maxPlayers; - broadcastGameStarted(sessionId, gameId, roomCode, realMaxPlayers); - console.log(`[Player Count] Using real maxPlayers from Jackbox: ${realMaxPlayers} (database had: ${maxPlayers})`); - await watchGameAsAudience(sessionId, gameId, roomCode, realMaxPlayers); - } else if (result === null) { - // Not ready yet, poll every 10 seconds - const checkInterval = setInterval(async () => { - // Check if we should stop - const game = db.prepare(` - SELECT status, player_count_check_status - FROM session_games - WHERE session_id = ? AND id = ? - `).get(sessionId, gameId); - - if (!game || game.status === 'skipped' || game.status === 'played' || game.player_count_check_status === 'stopped' || game.player_count_check_status === 'completed') { - console.log(`[Player Count] Stopping check for ${checkKey} - game status changed`); - stopPlayerCountCheck(sessionId, gameId); - return; - } - - const result = await waitForGameStart(); - if (result && result.ready === true) { - // Game is now locked, stop interval, broadcast game.started, and start watching - clearInterval(checkInterval); - const check = activeChecks.get(checkKey); - if (check) check.interval = null; - const realMaxPlayers = result.maxPlayers; - broadcastGameStarted(sessionId, gameId, roomCode, realMaxPlayers); - console.log(`[Player Count] Using real maxPlayers from Jackbox: ${realMaxPlayers} (database had: ${maxPlayers})`); - await watchGameAsAudience(sessionId, gameId, roomCode, realMaxPlayers); - } else if (result === false) { - // Check failed or completed, stop - clearInterval(checkInterval); - stopPlayerCountCheck(sessionId, gameId); - } - }, 10000); // Poll every 10 seconds - - // Store the interval - const check = activeChecks.get(checkKey); - if (check) check.interval = checkInterval; - } - // If ready === false, check already stopped/completed - - } catch (error) { - console.error(`[Player Count] Error starting check for ${checkKey}:`, error.message); - updatePlayerCount(sessionId, gameId, null, 'failed'); - stopPlayerCountCheck(sessionId, gameId); - } - }, 10000); // Wait 10 seconds before first check - - // Store the check references activeChecks.set(checkKey, { sessionId, gameId, roomCode, - initialTimeout, - interval: null, watchInterval: null, browser: null }); + + await watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers); } /** @@ -507,12 +339,6 @@ async function stopPlayerCountCheck(sessionId, gameId) { const check = activeChecks.get(checkKey); if (check) { - if (check.initialTimeout) { - clearTimeout(check.initialTimeout); - } - if (check.interval) { - clearInterval(check.interval); - } if (check.watchInterval) { clearInterval(check.watchInterval); } @@ -525,7 +351,6 @@ async function stopPlayerCountCheck(sessionId, gameId) { } activeChecks.delete(checkKey); - // Update status to stopped if not already completed or failed const game = db.prepare(` SELECT player_count_check_status FROM session_games @@ -548,13 +373,7 @@ async function stopPlayerCountCheck(sessionId, gameId) { * Clean up all active checks (for graceful shutdown) */ async function cleanupAllChecks() { - for (const [checkKey, check] of activeChecks.entries()) { - if (check.initialTimeout) { - clearTimeout(check.initialTimeout); - } - if (check.interval) { - clearInterval(check.interval); - } + for (const [, check] of activeChecks.entries()) { if (check.watchInterval) { clearInterval(check.watchInterval); } diff --git a/backend/utils/room-monitor.js b/backend/utils/room-monitor.js new file mode 100644 index 0000000..ee5cc3f --- /dev/null +++ b/backend/utils/room-monitor.js @@ -0,0 +1,135 @@ +const db = require('../database'); +const { getWebSocketManager } = require('./websocket-manager'); +const { checkRoomStatus } = require('./jackbox-api'); + +const POLL_INTERVAL_MS = 10000; + +// Active room monitors, keyed by "{sessionId}-{gameId}" +const activeMonitors = new Map(); + +/** + * Broadcast game.started event when room becomes locked + */ +function broadcastGameStarted(sessionId, gameId, roomCode, maxPlayers) { + try { + const wsManager = getWebSocketManager(); + if (wsManager) { + wsManager.broadcastEvent('game.started', { + sessionId, + gameId, + roomCode, + maxPlayers + }, parseInt(sessionId)); + } + console.log(`[Room Monitor] Broadcasted game.started for room ${roomCode} (max: ${maxPlayers})`); + } catch (error) { + console.error('[Room Monitor] Failed to broadcast game.started:', error.message); + } +} + +/** + * Start monitoring a Jackbox room for game start (locked state). + * + * Polls the Jackbox REST API every 10 seconds. When the room becomes + * locked, broadcasts a game.started WebSocket event and then hands off + * to the player-count-checker to join as audience. + */ +async function startRoomMonitor(sessionId, gameId, roomCode, maxPlayers = 8) { + const monitorKey = `${sessionId}-${gameId}`; + + if (activeMonitors.has(monitorKey)) { + console.log(`[Room Monitor] Already monitoring ${monitorKey}`); + return; + } + + console.log(`[Room Monitor] Starting monitor for room ${roomCode} (${monitorKey})`); + + const onGameStarted = (realMaxPlayers) => { + broadcastGameStarted(sessionId, gameId, roomCode, realMaxPlayers); + // Lazy require breaks the circular dependency with player-count-checker + const { startPlayerCountCheck } = require('./player-count-checker'); + console.log(`[Room Monitor] Room ${roomCode} locked — handing off to player count checker`); + startPlayerCountCheck(sessionId, gameId, roomCode, realMaxPlayers); + }; + + const pollRoom = async () => { + const game = db.prepare(` + SELECT status FROM session_games + WHERE session_id = ? AND id = ? + `).get(sessionId, gameId); + + if (!game || game.status === 'skipped' || game.status === 'played') { + console.log(`[Room Monitor] Stopping — game status changed for ${monitorKey}`); + stopRoomMonitor(sessionId, gameId); + return; + } + + const roomStatus = await checkRoomStatus(roomCode); + + if (!roomStatus.exists) { + console.log(`[Room Monitor] Room ${roomCode} does not exist — stopping`); + stopRoomMonitor(sessionId, gameId); + return; + } + + if (roomStatus.locked) { + stopRoomMonitor(sessionId, gameId); + onGameStarted(roomStatus.maxPlayers); + return; + } + + if (roomStatus.full) { + console.log(`[Room Monitor] Room ${roomCode} is full but not locked yet — waiting`); + } else { + console.log(`[Room Monitor] Room ${roomCode} lobby still open — waiting`); + } + }; + + // Poll immediately, then every POLL_INTERVAL_MS + activeMonitors.set(monitorKey, { + sessionId, + gameId, + roomCode, + interval: null + }); + + await pollRoom(); + + // If the monitor was already stopped (room locked or gone on first check), bail + if (!activeMonitors.has(monitorKey)) return; + + const interval = setInterval(() => pollRoom(), POLL_INTERVAL_MS); + const monitor = activeMonitors.get(monitorKey); + if (monitor) monitor.interval = interval; +} + +/** + * Stop monitoring a room + */ +function stopRoomMonitor(sessionId, gameId) { + const monitorKey = `${sessionId}-${gameId}`; + const monitor = activeMonitors.get(monitorKey); + + if (monitor) { + if (monitor.interval) clearInterval(monitor.interval); + activeMonitors.delete(monitorKey); + console.log(`[Room Monitor] Stopped monitor for ${monitorKey}`); + } +} + +/** + * Clean up all active monitors (for graceful shutdown) + */ +function cleanupAllMonitors() { + for (const [, monitor] of activeMonitors.entries()) { + if (monitor.interval) clearInterval(monitor.interval); + } + activeMonitors.clear(); + console.log('[Room Monitor] Cleaned up all active monitors'); +} + +module.exports = { + startRoomMonitor, + stopRoomMonitor, + cleanupAllMonitors +}; diff --git a/docs/BOT_INTEGRATION.md b/docs/BOT_INTEGRATION.md index aa324a5..0a860f3 100644 --- a/docs/BOT_INTEGRATION.md +++ b/docs/BOT_INTEGRATION.md @@ -693,12 +693,24 @@ curl -X GET "http://localhost:5000/api/webhooks/1/logs" \ - `game.added` - Triggered when a game is added to an active session. Sent to clients subscribed to that session. Includes `room_code`. - `session.started` - Triggered when a new session is created. Broadcast to **all** authenticated clients (no subscription required). - `session.ended` - Triggered when a session is closed. Sent to clients subscribed to that session. -- `game.started` - Triggered when the Jackbox room becomes locked (gameplay has begun). Detected by polling the Jackbox REST API every 10 seconds. Sent to clients subscribed to that session. Includes `roomCode` and `maxPlayers`. -- `audience.joined` - Triggered when the app successfully joins a Jackbox room as an audience member. Sent to clients subscribed to that session. This confirms the room code is valid and the game is being monitored. +- `game.started` - Triggered when the Jackbox room becomes locked (gameplay has begun). Detected by polling the Jackbox REST API every 10 seconds via the room monitor. Sent to clients subscribed to that session. Includes `roomCode` and `maxPlayers`. +- `audience.joined` - Triggered when the app successfully joins a Jackbox room as an audience member for player count tracking. Sent to clients subscribed to that session. - `player-count.updated` - Triggered when the player count for a game is updated. Sent to clients subscribed to that session. > **Tip:** To receive `session.started` events, your bot only needs to authenticate — no subscription is needed. Once you receive a `session.started` event, subscribe to the new session ID to receive `game.added` and `session.ended` events for it. +### Event Lifecycle (for a game with room code) + +When a game is added with a room code, events fire in this order: + +1. **`game.added`** — Game added to the session (immediate). +2. **`game.started`** — Jackbox room becomes locked, gameplay has begun. Detected by a room monitor that polls the Jackbox REST API every 10 seconds. This is independent of the player count system. +3. **`audience.joined`** — The player count bot successfully joined the Jackbox room as an audience member (seconds after `game.started`). +4. **`player-count.updated`** (status: `checking`) — Player count data received from the game's WebSocket traffic (ongoing). +5. **`player-count.updated`** (status: `completed`) — Game ended, final player count confirmed. + +Room monitoring and player counting are separate systems. The room monitor (`room-monitor.js`) handles steps 1-2 and then hands off to the player count checker (`player-count-checker.js`) for steps 3-5. + More events may be added in the future (e.g., `vote.recorded`). ---