8 tasks covering: state machine, WebSocket client extraction, room code display component, audio controller (restart fix), player list, debug dashboard, HTML integration, and end-to-end verification. Made-with: Cursor
52 KiB
Overlay Manager & Player List Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Refactor the OBS overlay from a monolithic single-file design to a modular state-machine architecture, fix the audio restart bug, add a player list component, and handle all new shard-based WebSocket events.
Architecture: Central OverlayManager state machine coordinates registered components (room code display, audio controller, player list) through explicit lifecycle transitions. WebSocket client routes events to the manager. Components implement a standard activate/deactivate/update interface.
Tech Stack: Vanilla JS (ES modules), no build step, loaded via <script type="module"> in OBS browser source.
Reference: Upstream API Event Payloads
These are the WebSocket event payload shapes from /api/sessions/live (see docs/api/websocket.md in the jackboxpartypack-gamepicker repo):
game.addeddata:{ session: { id, is_active, games_played }, game: { id, title, pack_name, min_players, max_players, manually_added, room_code } }room.connecteddata:{ sessionId, gameId, roomCode, appTag, maxPlayers, playerCount, players[], lobbyState, gameState }lobby.player-joineddata:{ sessionId, gameId, roomCode, playerName, playerCount, players[], maxPlayers }lobby.updateddata:{ sessionId, gameId, roomCode, lobbyState, gameCanStart, gameIsStarting, playerCount }game.starteddata:{ sessionId, gameId, roomCode, playerCount, players[], maxPlayers }game.endeddata:{ sessionId, gameId, roomCode, playerCount, players[] }room.disconnecteddata:{ sessionId, gameId, roomCode, reason, finalPlayerCount }session.starteddata:{ session: { id, is_active, created_at, notes } }session.endeddata:{ session: { id, is_active, games_played } }player-count.updateddata:{ sessionId, gameId, playerCount, status }vote.receiveddata:{ sessionId, game: { id, title, pack_name }, vote: { username, type, timestamp }, totals: { upvotes, downvotes, popularity_score } }
Task 1: Create js/state-manager.js — OverlayManager State Machine
Files:
- Create:
js/state-manager.js
Step 1: Create the OverlayManager class
Create js/state-manager.js with the following:
const VALID_STATES = ['idle', 'lobby', 'playing', 'ended', 'disconnected'];
const VALID_TRANSITIONS = {
idle: ['lobby', 'disconnected'],
lobby: ['lobby', 'playing', 'ended', 'idle', 'disconnected'],
playing: ['ended', 'lobby', 'idle', 'disconnected'],
ended: ['idle', 'lobby', 'disconnected'],
disconnected: ['idle', 'lobby'],
};
const OVERRIDE_MODES = { AUTO: 'auto', FORCE_SHOW: 'force_show', FORCE_HIDE: 'force_hide' };
export class OverlayManager {
constructor() {
this._state = 'idle';
this._components = new Map();
this._overrides = new Map();
this._context = {};
this._eventLog = [];
this._listeners = new Set();
this._endedTimer = null;
}
get state() { return this._state; }
get context() { return { ...this._context }; }
registerComponent(name, component) {
this._components.set(name, component);
this._overrides.set(name, OVERRIDE_MODES.AUTO);
}
onChange(listener) {
this._listeners.add(listener);
return () => this._listeners.delete(listener);
}
_notify() {
for (const fn of this._listeners) {
try { fn(this._state, this._context); } catch (e) { console.error('[Manager] Listener error:', e); }
}
}
setOverride(componentName, mode) {
if (!this._components.has(componentName)) return;
if (!Object.values(OVERRIDE_MODES).includes(mode)) return;
this._overrides.set(componentName, mode);
this._applyOverride(componentName);
this._notify();
}
getOverride(componentName) {
return this._overrides.get(componentName) || OVERRIDE_MODES.AUTO;
}
_applyOverride(name) {
const component = this._components.get(name);
const mode = this._overrides.get(name);
if (!component) return;
if (mode === OVERRIDE_MODES.FORCE_SHOW) {
component.activate(this._context);
} else if (mode === OVERRIDE_MODES.FORCE_HIDE) {
component.deactivate();
}
// AUTO: no action here, state transitions handle it
}
transition(newState, contextUpdates = {}) {
if (!VALID_STATES.includes(newState)) {
console.error('[Manager] Invalid state:', newState);
return;
}
if (!VALID_TRANSITIONS[this._state]?.includes(newState)) {
console.warn(`[Manager] Invalid transition: ${this._state} → ${newState}`);
return;
}
const oldState = this._state;
const wasLobby = oldState === 'lobby';
const isLobby = newState === 'lobby';
Object.assign(this._context, contextUpdates);
this._state = newState;
if (this._endedTimer) {
clearTimeout(this._endedTimer);
this._endedTimer = null;
}
if (wasLobby && !isLobby) {
this._deactivateAllComponents();
} else if (wasLobby && isLobby) {
// lobby → lobby (new room code): full reset
this._deactivateAllComponents();
this._activateAllComponents();
} else if (!wasLobby && isLobby) {
this._activateAllComponents();
}
if (newState === 'ended') {
this._endedTimer = setTimeout(() => {
this.transition('idle');
}, 2000);
}
console.log(`[Manager] ${oldState} → ${newState}`);
this._notify();
}
updateComponents(contextUpdates = {}) {
Object.assign(this._context, contextUpdates);
for (const [name, component] of this._components) {
const mode = this._overrides.get(name);
if (mode === OVERRIDE_MODES.AUTO && this._state === 'lobby') {
component.update(this._context);
} else if (mode === OVERRIDE_MODES.FORCE_SHOW) {
component.update(this._context);
}
}
this._notify();
}
_activateAllComponents() {
for (const [name, component] of this._components) {
const mode = this._overrides.get(name);
if (mode === OVERRIDE_MODES.AUTO) {
component.activate(this._context);
} else if (mode === OVERRIDE_MODES.FORCE_HIDE) {
// Respect hide override even during lobby
}
}
}
_deactivateAllComponents() {
for (const [name, component] of this._components) {
const mode = this._overrides.get(name);
if (mode === OVERRIDE_MODES.AUTO) {
component.deactivate();
}
}
}
logEvent(eventType, data) {
const entry = { type: eventType, timestamp: new Date().toISOString(), data };
this._eventLog.push(entry);
if (this._eventLog.length > 50) this._eventLog.shift();
this._notify();
}
getEventLog() { return [...this._eventLog]; }
getComponentStatuses() {
const statuses = {};
for (const [name, component] of this._components) {
statuses[name] = {
status: component.getStatus(),
override: this._overrides.get(name),
};
}
return statuses;
}
handleEvent(eventType, data) {
this.logEvent(eventType, data);
switch (eventType) {
case 'game.added': {
const game = data.game;
const roomCode = game?.room_code;
if (!roomCode) break;
this.transition('lobby', {
roomCode,
gameTitle: game.title,
packName: game.pack_name,
maxPlayers: game.max_players,
minPlayers: game.min_players,
gameId: game.id,
sessionId: data.session?.id,
players: [],
playerCount: 0,
});
break;
}
case 'room.connected':
this.updateComponents({
maxPlayers: data.maxPlayers,
playerCount: data.playerCount,
players: data.players || [],
lobbyState: data.lobbyState,
gameState: data.gameState,
appTag: data.appTag,
});
if (this._state === 'idle' || this._state === 'ended') {
this.transition('lobby', {
roomCode: data.roomCode,
maxPlayers: data.maxPlayers,
playerCount: data.playerCount,
players: data.players || [],
});
}
break;
case 'lobby.player-joined':
this.updateComponents({
players: data.players || [],
playerCount: data.playerCount,
maxPlayers: data.maxPlayers,
lastJoinedPlayer: data.playerName,
});
break;
case 'lobby.updated':
this.updateComponents({
lobbyState: data.lobbyState,
playerCount: data.playerCount,
});
break;
case 'game.started':
this.transition('playing', {
playerCount: data.playerCount,
players: data.players || [],
});
break;
case 'game.ended':
this.transition('ended', {
playerCount: data.playerCount,
players: data.players || [],
});
break;
case 'room.disconnected':
this.transition('idle', {
disconnectReason: data.reason,
});
break;
case 'session.started':
this.updateComponents({
sessionId: data.session?.id,
});
break;
case 'session.ended':
this.transition('idle', {
sessionId: null,
roomCode: null,
players: [],
playerCount: 0,
});
break;
case 'player-count.updated':
this.updateComponents({
playerCount: data.playerCount,
});
break;
default:
break;
}
}
}
export { OVERRIDE_MODES };
Step 2: Verify module loads
Open the overlay HTML in a browser, open DevTools console, and run:
import('./js/state-manager.js').then(m => { window._sm = m; console.log('loaded', m); });
Expected: Module loads without errors.
Step 3: Commit
git add js/state-manager.js
git commit -m "feat: add OverlayManager state machine module"
Task 2: Create js/websocket-client.js — Extract WebSocket Logic
Files:
- Create:
js/websocket-client.js - Reference:
optimized-controls.html:996-1344(current inline WebSocket code)
Step 1: Create the WebSocketClient class
Extract the auth, connection, reconnection, and heartbeat logic from optimized-controls.html lines 996-1344 into js/websocket-client.js:
export class WebSocketClient {
constructor({ onStatusChange, onEvent, onSessionSubscribed }) {
this._ws = null;
this._jwtToken = null;
this._heartbeatInterval = null;
this._reconnectTimeout = null;
this._reconnectDelay = 1000;
this._maxReconnectDelay = 30000;
this._intentionalDisconnect = false;
this._apiUrl = '';
this._apiKey = '';
this._onStatusChange = onStatusChange || (() => {});
this._onEvent = onEvent || (() => {});
this._onSessionSubscribed = onSessionSubscribed || (() => {});
}
get isConnected() {
return this._ws && this._ws.readyState === WebSocket.OPEN;
}
async connect(apiUrl, apiKey) {
this._apiUrl = apiUrl.replace(/\/+$/, '');
this._apiKey = apiKey;
this._intentionalDisconnect = false;
if (!this._apiUrl || !this._apiKey) {
this._onStatusChange('error', 'API URL and Key are required');
return;
}
this._onStatusChange('connecting', 'Authenticating...');
try {
const response = await fetch(this._apiUrl + '/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: this._apiKey }),
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
this._onStatusChange('error', 'Auth failed: ' + (errData.error || response.statusText));
return;
}
const data = await response.json();
this._jwtToken = data.token;
if (!this._jwtToken) {
this._onStatusChange('error', 'No token in auth response');
return;
}
this._onStatusChange('connecting', 'Connecting WebSocket...');
this._connectWebSocket();
} catch (err) {
console.error('[WSClient] Auth error:', err);
this._onStatusChange('error', 'Auth error: ' + err.message);
}
}
disconnect() {
this._intentionalDisconnect = true;
if (this._reconnectTimeout) {
clearTimeout(this._reconnectTimeout);
this._reconnectTimeout = null;
}
this._stopHeartbeat();
if (this._ws) {
this._ws.close();
this._ws = null;
}
this._jwtToken = null;
this._onStatusChange('disconnected', 'Disconnected');
}
_connectWebSocket() {
const wsUrl = this._apiUrl.replace(/^http/, 'ws') + '/api/sessions/live';
try {
this._ws = new WebSocket(wsUrl);
} catch (err) {
this._onStatusChange('error', 'WebSocket error: ' + err.message);
return;
}
this._ws.addEventListener('open', () => {
console.log('[WSClient] Connected, authenticating...');
this._onStatusChange('connecting', 'Authenticating via WS...');
this._reconnectDelay = 1000;
this._ws.send(JSON.stringify({ type: 'auth', token: this._jwtToken }));
});
this._ws.addEventListener('message', (event) => {
let message;
try {
message = JSON.parse(event.data);
} catch (e) {
console.error('[WSClient] Failed to parse message:', event.data);
return;
}
this._handleMessage(message);
});
this._ws.addEventListener('close', (event) => {
console.log('[WSClient] Disconnected, code:', event.code);
this._stopHeartbeat();
if (!this._intentionalDisconnect) {
this._onStatusChange('connecting', 'Reconnecting in ' + Math.round(this._reconnectDelay / 1000) + 's...');
this._scheduleReconnect();
} else {
this._onStatusChange('disconnected', 'Disconnected');
}
});
this._ws.addEventListener('error', (err) => {
console.error('[WSClient] Error:', err);
});
}
_handleMessage(message) {
switch (message.type) {
case 'auth_success':
console.log('[WSClient] Authenticated');
this._onStatusChange('connected', 'Connected');
this._startHeartbeat();
this._fetchActiveSessionAndSubscribe();
break;
case 'auth_error':
console.error('[WSClient] Auth error:', message.message);
this._onStatusChange('error', 'WS auth failed: ' + message.message);
this._intentionalDisconnect = true;
if (this._ws) this._ws.close();
break;
case 'subscribed':
console.log('[WSClient] Subscribed to session:', message.sessionId);
this._onStatusChange('connected', 'Connected (session ' + message.sessionId + ')');
this._onSessionSubscribed(message.sessionId);
break;
case 'pong':
break;
case 'error':
console.error('[WSClient] Server error:', message.message);
break;
default:
// All domain events route to the manager
if (message.data !== undefined) {
this._onEvent(message.type, message.data);
} else {
console.log('[WSClient] Unhandled message type:', message.type);
}
break;
}
}
async _fetchActiveSessionAndSubscribe() {
if (!this._apiUrl || !this._jwtToken) return;
try {
const response = await fetch(this._apiUrl + '/api/sessions/active', {
headers: { 'Authorization': 'Bearer ' + this._jwtToken },
});
if (response.ok) {
const session = await response.json();
if (session && session.id) {
console.log('[WSClient] Found active session:', session.id);
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({ type: 'subscribe', sessionId: session.id }));
}
} else {
console.log('[WSClient] No active session, waiting for session.started');
}
}
} catch (err) {
console.error('[WSClient] Error fetching active session:', err);
}
}
subscribeToSession(sessionId) {
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({ type: 'subscribe', sessionId }));
}
}
_startHeartbeat() {
this._stopHeartbeat();
this._heartbeatInterval = setInterval(() => {
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
}
_stopHeartbeat() {
if (this._heartbeatInterval) {
clearInterval(this._heartbeatInterval);
this._heartbeatInterval = null;
}
}
_scheduleReconnect() {
if (this._reconnectTimeout) clearTimeout(this._reconnectTimeout);
this._reconnectTimeout = setTimeout(() => {
if (this._apiUrl && this._jwtToken && !this._intentionalDisconnect) {
console.log('[WSClient] Attempting reconnect...');
this._onStatusChange('connecting', 'Reconnecting...');
this._connectWebSocket();
}
this._reconnectDelay = Math.min(this._reconnectDelay * 2, this._maxReconnectDelay);
}, this._reconnectDelay);
}
}
The key difference from existing code: session.started is now handled as a domain event (forwarded via _onEvent) instead of being handled inline. The WebSocket client auto-subscribes to the session from the event data via the _onEvent → handleEvent flow in the manager. However, session.started needs special handling because it requires a subscribe call. Add this to _handleMessage:
After the default case forwards to _onEvent, the caller (bootstrap code) will also listen for session.started and call subscribeToSession. Alternatively, handle it directly:
In the _handleMessage method, add a case before default:
case 'session.started':
console.log('[WSClient] Session started:', message.data?.session?.id);
if (message.data?.session?.id && this._ws?.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({
type: 'subscribe',
sessionId: message.data.session.id,
}));
}
this._onEvent(message.type, message.data);
break;
Step 2: Verify module loads
Same browser console check:
import('./js/websocket-client.js').then(m => { window._wsc = m; console.log('loaded', m); });
Step 3: Commit
git add js/websocket-client.js
git commit -m "feat: extract WebSocket client into ES module"
Task 3: Create js/room-code-display.js — Extract Room Code Animation
Files:
- Create:
js/room-code-display.js - Reference:
optimized-controls.html:739-873(applySettings + startAnimation),optimized-controls.html:886-910(hideDisplay)
Step 1: Create the RoomCodeDisplay component
Extract the animation logic into a component that implements the standard interface. The component reads settings from DOM inputs (same as current behavior) but lifecycle is managed externally.
export class RoomCodeDisplay {
constructor() {
this._timers = [];
this._active = false;
this._elements = null;
this._inputs = null;
}
init(elements, inputs) {
this._elements = elements;
this._inputs = inputs;
}
activate(ctx) {
this.deactivate();
this._active = true;
if (ctx.roomCode) {
const midpoint = Math.ceil(ctx.roomCode.length / 2);
this._inputs.code1.value = ctx.roomCode.substring(0, midpoint).toUpperCase();
this._inputs.code2.value = ctx.roomCode.substring(midpoint).toUpperCase();
}
this._applySettings();
this._startAnimation();
}
deactivate() {
this._active = false;
this._clearTimers();
if (this._elements) {
const fadeTime = '0.3s';
for (const el of [this._elements.header, this._elements.footer, this._elements.codePart1, this._elements.codePart2]) {
el.style.transition = `opacity ${fadeTime} ease-out`;
el.style.opacity = 0;
}
}
}
update(ctx) {
if (!this._active) return;
// If room code changed mid-lobby, restart
if (ctx.roomCode) {
const midpoint = Math.ceil(ctx.roomCode.length / 2);
const first = ctx.roomCode.substring(0, midpoint).toUpperCase();
const second = ctx.roomCode.substring(midpoint).toUpperCase();
if (first !== this._inputs.code1.value || second !== this._inputs.code2.value) {
this.activate(ctx);
}
}
}
getStatus() {
return {
active: this._active,
roomCode: this._inputs ? (this._inputs.code1.value + this._inputs.code2.value) : '',
timersRunning: this._timers.length,
};
}
_clearTimers() {
this._timers.forEach(t => clearTimeout(t));
this._timers = [];
}
_applySettings() {
const { header, footer, codePart1, codePart2 } = this._elements;
const inp = this._inputs;
codePart1.textContent = inp.code1.value.toUpperCase();
codePart2.textContent = inp.code2.value.toUpperCase();
codePart1.style.color = inp.color1.value;
codePart2.style.color = inp.color2.value;
codePart1.style.transform = `translateY(${inp.offset1.value}px)`;
codePart2.style.transform = `translateY(${inp.offset2.value}px)`;
codePart1.style.fontSize = `${inp.size.value}px`;
codePart2.style.fontSize = `${inp.size.value}px`;
header.textContent = inp.headerText.value;
header.style.color = inp.headerColor.value;
header.style.fontSize = `${inp.headerSize.value}px`;
header.style.transform = `translateY(${inp.headerOffset.value}px)`;
footer.textContent = inp.footerText.value;
footer.style.color = inp.footerColor.value;
footer.style.fontSize = `${inp.footerSize.value}px`;
footer.style.transform = `translateY(${inp.footerOffset.value}px)`;
}
_startAnimation() {
this._clearTimers();
const { header, footer, codePart1, codePart2 } = this._elements;
const inp = this._inputs;
header.style.opacity = 0;
footer.style.opacity = 0;
codePart1.style.opacity = 0;
codePart2.style.opacity = 0;
const cycleDuration = parseInt(inp.cycle.value) * 1000;
const headerAppearDelayMs = parseInt(inp.headerAppearDelay.value) * 1000;
const headerAppearDurationMs = parseInt(inp.headerAppearDuration.value) * 1000;
this._timers.push(setTimeout(() => {
header.style.transition = `opacity ${headerAppearDurationMs / 1000}s ease-out`;
header.style.opacity = 1;
footer.style.transition = `opacity ${headerAppearDurationMs / 1000}s ease-out`;
footer.style.opacity = 1;
}, headerAppearDelayMs));
const line1AppearDelayMs = parseInt(inp.line1AppearDelay.value) * 1000;
const line1AppearDurationMs = parseInt(inp.line1AppearDuration.value) * 1000;
this._timers.push(setTimeout(() => {
codePart1.style.transition = `opacity ${line1AppearDurationMs / 1000}s ease-out`;
codePart1.style.opacity = 1;
}, line1AppearDelayMs));
const line1HideTimeMs = parseInt(inp.line1HideTime.value) * 1000;
const line1HideDurationMs = parseInt(inp.line1HideDuration.value) * 1000;
this._timers.push(setTimeout(() => {
codePart1.style.transition = `opacity ${line1HideDurationMs / 1000}s ease-out`;
codePart1.style.opacity = 0;
}, line1HideTimeMs));
const line2AppearDelayMs = parseInt(inp.line2AppearDelay.value) * 1000;
const line2AppearDurationMs = parseInt(inp.line2AppearDuration.value) * 1000;
this._timers.push(setTimeout(() => {
codePart2.style.transition = `opacity ${line2AppearDurationMs / 1000}s ease-out`;
codePart2.style.opacity = 1;
}, line2AppearDelayMs));
const line2HideTimeMs = parseInt(inp.line2HideTime.value) * 1000;
const line2HideDurationMs = parseInt(inp.line2HideDuration.value) * 1000;
this._timers.push(setTimeout(() => {
codePart2.style.transition = `opacity ${line2HideDurationMs / 1000}s ease-out`;
codePart2.style.opacity = 0;
}, line2HideTimeMs));
const headerHideTimeMs = parseInt(inp.headerHideTime.value) * 1000;
const headerHideDurationMs = parseInt(inp.headerHideDuration.value) * 1000;
this._timers.push(setTimeout(() => {
header.style.transition = `opacity ${headerHideDurationMs / 1000}s ease-out`;
header.style.opacity = 0;
footer.style.transition = `opacity ${headerHideDurationMs / 1000}s ease-out`;
footer.style.opacity = 0;
}, headerHideTimeMs));
// Restart animation loop
this._timers.push(setTimeout(() => {
if (this._active) this._startAnimation();
}, cycleDuration));
}
}
Step 2: Commit
git add js/room-code-display.js
git commit -m "feat: extract room code display into ES module component"
Task 4: Create js/audio-controller.js — Extract Audio Logic
Files:
- Create:
js/audio-controller.js - Reference:
optimized-controls.html:784-793(play in startAnimation),optimized-controls.html:857-866(fade out),optimized-controls.html:904-905(stop in hideDisplay)
This is the core of the audio restart bug fix. The current code's audio state gets tangled because hideDisplay() pauses and resets the audio, but subsequent startAnimation() calls don't reload the source or handle the paused+reset state consistently (especially after the fade-out interval modifies volume).
export class AudioController {
constructor() {
this._active = false;
this._audioEl = null;
this._inputs = null;
this._fadeInterval = null;
}
init(audioElement, inputs) {
this._audioEl = audioElement;
this._inputs = inputs;
}
activate(ctx) {
this.deactivate();
this._active = true;
if (!this._inputs.enabled.checked) return;
const audio = this._audioEl;
// Reload source to ensure clean state
audio.src = this._inputs.soundUrl.value;
audio.volume = parseFloat(this._inputs.volume.value);
audio.loop = true;
audio.currentTime = 0;
audio.load();
audio.play().catch(err => {
console.log('[Audio] Playback failed:', err.message);
});
}
deactivate() {
this._active = false;
this._stopFade();
if (this._audioEl) {
this._audioEl.pause();
this._audioEl.currentTime = 0;
// Restore volume to slider value for next activation
if (this._inputs) {
this._audioEl.volume = parseFloat(this._inputs.volume.value);
}
}
}
update(_ctx) {
// No in-state updates needed for audio
}
getStatus() {
const audio = this._audioEl;
return {
active: this._active,
enabled: this._inputs?.enabled.checked ?? false,
playing: audio ? !audio.paused : false,
volume: audio ? Math.round(audio.volume * 100) : 0,
src: audio?.src || '',
};
}
startFadeOut(durationMs) {
if (!this._audioEl || this._audioEl.paused) return;
this._stopFade();
const steps = 10;
const stepInterval = durationMs / steps;
const volumeStep = this._audioEl.volume / steps;
this._fadeInterval = setInterval(() => {
if (this._audioEl.volume > volumeStep) {
this._audioEl.volume -= volumeStep;
} else {
this._audioEl.volume = 0;
this._audioEl.pause();
this._stopFade();
}
}, stepInterval);
}
_stopFade() {
if (this._fadeInterval) {
clearInterval(this._fadeInterval);
this._fadeInterval = null;
}
}
}
The key fix: activate() always calls deactivate() first, which stops any in-progress fade, pauses audio, and resets state. Then it reloads the source (audio.src = ..., audio.load()), restores volume to the slider value, and plays from the beginning. This guarantees clean audio state on every lobby entry.
Step 2: Commit
git add js/audio-controller.js
git commit -m "feat: extract audio controller into ES module, fix restart bug"
Task 5: Create js/player-list.js — New Player List Component
Files:
- Create:
js/player-list.js
Step 1: Create the PlayerList component
const DEFAULT_MAX_PLAYERS = 8;
export class PlayerList {
constructor() {
this._active = false;
this._container = null;
this._inputs = null;
this._slots = [];
this._players = [];
this._maxPlayers = DEFAULT_MAX_PLAYERS;
this._animationTimers = [];
}
init(container, inputs, animationTimingInputs) {
this._container = container;
this._inputs = inputs;
this._timingInputs = animationTimingInputs;
}
activate(ctx) {
this.deactivate();
this._active = true;
if (!this._inputs.enabled.checked) return;
this._maxPlayers = ctx.maxPlayers || DEFAULT_MAX_PLAYERS;
this._players = ctx.players || [];
this._buildSlots();
this._fillSlots(this._players);
this._animateIn();
}
deactivate() {
this._active = false;
this._clearTimers();
if (this._container) {
this._container.style.transition = 'opacity 0.3s ease-out';
this._container.style.opacity = 0;
setTimeout(() => {
if (!this._active) {
this._container.innerHTML = '';
this._slots = [];
this._players = [];
}
}, 300);
}
}
update(ctx) {
if (!this._active || !this._inputs.enabled.checked) return;
if (ctx.maxPlayers && ctx.maxPlayers !== this._maxPlayers) {
this._maxPlayers = ctx.maxPlayers;
this._buildSlots();
this._fillSlots(ctx.players || this._players);
return;
}
if (ctx.players) {
const newPlayers = ctx.players.filter(p => !this._players.includes(p));
this._players = ctx.players;
this._fillSlots(this._players, newPlayers);
}
}
getStatus() {
return {
active: this._active,
enabled: this._inputs?.enabled.checked ?? false,
playerCount: this._players.length,
maxPlayers: this._maxPlayers,
players: [...this._players],
};
}
_buildSlots() {
this._container.innerHTML = '';
this._slots = [];
for (let i = 0; i < this._maxPlayers; i++) {
const slot = document.createElement('div');
slot.className = 'player-slot';
slot.dataset.index = i;
const number = document.createElement('span');
number.className = 'player-slot-number';
number.textContent = `${i + 1}.`;
const name = document.createElement('span');
name.className = 'player-slot-name empty';
name.textContent = '────────';
slot.appendChild(number);
slot.appendChild(name);
this._container.appendChild(slot);
this._slots.push({ element: slot, nameEl: name, filled: false });
}
}
_fillSlots(players, newlyJoined = []) {
for (let i = 0; i < this._slots.length; i++) {
const slot = this._slots[i];
if (i < players.length) {
const playerName = players[i];
const isNew = newlyJoined.includes(playerName);
slot.nameEl.textContent = playerName;
slot.nameEl.classList.remove('empty');
slot.nameEl.classList.add('filled');
slot.filled = true;
if (isNew) {
slot.nameEl.style.opacity = 0;
requestAnimationFrame(() => {
slot.nameEl.style.transition = 'opacity 0.4s ease-out';
slot.nameEl.style.opacity = 1;
});
}
} else if (slot.filled) {
slot.nameEl.textContent = '────────';
slot.nameEl.classList.remove('filled');
slot.nameEl.classList.add('empty');
slot.filled = false;
}
}
}
_animateIn() {
this._container.style.opacity = 0;
this._container.style.display = 'flex';
const headerAppearDelay = parseInt(this._timingInputs.headerAppearDelay.value) * 1000;
const headerAppearDuration = parseInt(this._timingInputs.headerAppearDuration.value) * 1000;
this._animationTimers.push(setTimeout(() => {
this._container.style.transition = `opacity ${headerAppearDuration / 1000}s ease-out`;
this._container.style.opacity = 1;
}, headerAppearDelay));
}
_clearTimers() {
this._animationTimers.forEach(t => clearTimeout(t));
this._animationTimers = [];
}
}
Step 2: Commit
git add js/player-list.js
git commit -m "feat: add player list component with slot-based display"
Task 6: Create js/controls.js — Debug Dashboard & Control Bindings
Files:
- Create:
js/controls.js
Step 1: Create the Controls module
This module handles the debug dashboard, manual overrides, and binds existing settings UI elements. It exports an initControls function rather than a class.
import { OVERRIDE_MODES } from './state-manager.js';
const STATE_COLORS = {
idle: '#888',
lobby: '#4CAF50',
playing: '#f0ad4e',
ended: '#d9534f',
disconnected: '#d9534f',
};
export function initControls(manager, wsClient, components) {
const dashboardEl = document.getElementById('manager-dashboard');
if (!dashboardEl) return;
const stateEl = dashboardEl.querySelector('#manager-state');
const roomCodeEl = dashboardEl.querySelector('#manager-room-code');
const sessionIdEl = dashboardEl.querySelector('#manager-session-id');
const gameTitleEl = dashboardEl.querySelector('#manager-game-title');
const playerCountEl = dashboardEl.querySelector('#manager-player-count');
const eventLogEl = dashboardEl.querySelector('#manager-event-log');
// Override toggles
for (const name of Object.keys(components)) {
const select = dashboardEl.querySelector(`#override-${name}`);
if (select) {
select.addEventListener('change', () => {
manager.setOverride(name, select.value);
});
}
}
// Update dashboard on state change
manager.onChange((state, ctx) => {
if (stateEl) {
stateEl.textContent = state.toUpperCase();
stateEl.style.backgroundColor = STATE_COLORS[state] || '#888';
}
if (roomCodeEl) roomCodeEl.textContent = ctx.roomCode || '—';
if (sessionIdEl) sessionIdEl.textContent = ctx.sessionId || '—';
if (gameTitleEl) gameTitleEl.textContent = ctx.gameTitle ? `${ctx.gameTitle} (${ctx.packName || ''})` : '—';
if (playerCountEl) {
const count = ctx.playerCount ?? 0;
const max = ctx.maxPlayers ?? '?';
playerCountEl.textContent = `${count} / ${max}`;
}
// Update component status rows
const statuses = manager.getComponentStatuses();
for (const [name, info] of Object.entries(statuses)) {
const statusEl = dashboardEl.querySelector(`#status-${name}`);
if (statusEl) {
const s = info.status;
let text = s.active ? 'Active' : 'Inactive';
if (name === 'playerList' && s.active) {
text = `Active (${s.playerCount}/${s.maxPlayers})`;
}
if (name === 'audio' && s.active) {
text = s.playing ? 'Playing' : 'Active (muted)';
}
statusEl.textContent = text;
}
}
// Event log
if (eventLogEl) {
const log = manager.getEventLog();
const lastEvents = log.slice(-20);
eventLogEl.innerHTML = lastEvents.map(e => {
const time = new Date(e.timestamp).toLocaleTimeString();
return `<div class="event-log-entry"><span class="event-time">${time}</span> <span class="event-type">${e.type}</span></div>`;
}).join('');
eventLogEl.scrollTop = eventLogEl.scrollHeight;
}
});
// Connection buttons
const connectBtn = document.getElementById('ws-connect-btn');
const disconnectBtn = document.getElementById('ws-disconnect-btn');
const apiUrlInput = document.getElementById('api-url-input');
const apiKeyInput = document.getElementById('api-key-input');
if (connectBtn) {
connectBtn.addEventListener('click', () => {
const url = apiUrlInput.value.trim();
const key = apiKeyInput.value.trim();
if (url) localStorage.setItem('jackbox-api-url', url);
if (key) localStorage.setItem('jackbox-api-key', key);
wsClient.connect(url, key);
});
}
if (disconnectBtn) {
disconnectBtn.addEventListener('click', () => {
wsClient.disconnect();
manager.transition('idle');
});
}
// Settings change handlers
apiUrlInput?.addEventListener('change', () => {
const url = apiUrlInput.value.trim();
if (url) localStorage.setItem('jackbox-api-url', url);
});
apiKeyInput?.addEventListener('change', () => {
localStorage.setItem('jackbox-api-key', apiKeyInput.value.trim());
});
// Volume slider
const volumeSlider = document.getElementById('volume-slider');
const volumeValue = document.getElementById('volume-value');
const themeSound = document.getElementById('theme-sound');
if (volumeSlider && volumeValue) {
volumeSlider.addEventListener('input', () => {
volumeValue.textContent = Math.round(volumeSlider.value * 100) + '%';
if (themeSound) themeSound.volume = volumeSlider.value;
});
}
// Test sound button
const testSoundBtn = document.getElementById('test-sound-btn');
const soundUrlInput = document.getElementById('sound-url-input');
if (testSoundBtn && themeSound && soundUrlInput) {
testSoundBtn.addEventListener('click', () => {
themeSound.src = soundUrlInput.value;
themeSound.volume = volumeSlider?.value || 0.5;
themeSound.currentTime = 0;
themeSound.loop = false;
themeSound.play().catch(err => {
console.log('[Test] Playback failed:', err.message);
alert('Audio playback failed. Check URL or browser autoplay permissions.');
});
setTimeout(() => {
themeSound.pause();
themeSound.loop = true;
}, 5000);
});
}
// Manual Update/Preview buttons
const updateBtn = document.getElementById('update-btn');
const previewBtn = document.getElementById('preview-btn');
if (updateBtn) {
updateBtn.addEventListener('click', () => {
// Manually trigger lobby state with current input values
const code1 = document.getElementById('code1-input')?.value || '';
const code2 = document.getElementById('code2-input')?.value || '';
const roomCode = code1 + code2;
if (roomCode) {
manager.handleEvent('game.added', {
session: {},
game: { room_code: roomCode, title: 'Manual', max_players: 8 },
});
}
});
}
if (previewBtn) {
previewBtn.addEventListener('click', () => {
const code1 = document.getElementById('code1-input')?.value || '';
const code2 = document.getElementById('code2-input')?.value || '';
const roomCode = code1 + code2;
if (roomCode) {
manager.handleEvent('game.added', {
session: {},
game: { room_code: roomCode, title: 'Preview', max_players: 8 },
});
}
});
}
// Toggle display button
const toggleDisplayBtn = document.getElementById('toggle-display-btn');
if (toggleDisplayBtn) {
toggleDisplayBtn.addEventListener('click', () => {
if (manager.state === 'lobby') {
manager.transition('idle');
toggleDisplayBtn.textContent = 'Show Display';
} else {
const code1 = document.getElementById('code1-input')?.value || '';
const code2 = document.getElementById('code2-input')?.value || '';
const roomCode = code1 + code2;
if (roomCode) {
manager.transition('lobby', { roomCode });
toggleDisplayBtn.textContent = 'Hide Display';
}
}
});
}
// Show/hide controls panel
const showControlsBtn = document.getElementById('show-controls-btn');
const controls = document.getElementById('controls');
if (showControlsBtn && controls) {
showControlsBtn.addEventListener('click', () => {
if (controls.style.display === 'block') {
controls.style.display = 'none';
showControlsBtn.textContent = 'Show Controls';
} else {
controls.style.display = 'block';
showControlsBtn.textContent = 'Hide Controls';
}
});
}
// Collapsible sections
document.querySelectorAll('.section-header').forEach(hdr => {
hdr.addEventListener('click', () => {
const content = hdr.nextElementSibling;
const indicator = hdr.querySelector('.toggle-indicator');
if (content.style.display === 'block') {
content.style.display = 'none';
indicator?.classList.remove('active');
hdr.classList.remove('active');
} else {
content.style.display = 'block';
indicator?.classList.add('active');
hdr.classList.add('active');
}
});
});
// Position toggle
const positionToggle = document.getElementById('position-toggle');
if (positionToggle && controls) {
positionToggle.addEventListener('change', () => {
controls.classList.toggle('bottom-position', positionToggle.checked);
});
}
// Inactivity timer
let inactivityTimer;
function resetInactivityTimer() {
clearTimeout(inactivityTimer);
inactivityTimer = setTimeout(() => {
if (controls) controls.style.display = 'none';
if (showControlsBtn) showControlsBtn.textContent = 'Show Controls';
}, 20000);
}
document.addEventListener('mousemove', resetInactivityTimer);
document.addEventListener('keypress', resetInactivityTimer);
document.addEventListener('click', resetInactivityTimer);
resetInactivityTimer();
}
export function initConnectionStatusHandler(wsClient) {
const wsStatusDot = document.getElementById('ws-status-dot');
const wsStatusText = document.getElementById('ws-status-text');
const wsConnectBtn = document.getElementById('ws-connect-btn');
const wsDisconnectRow = document.getElementById('ws-disconnect-row');
const apiUrlInput = document.getElementById('api-url-input');
const apiKeyInput = document.getElementById('api-key-input');
return (state, message) => {
if (wsStatusDot) wsStatusDot.className = 'status-dot ' + state;
if (wsStatusText) wsStatusText.textContent = message || state;
if (state === 'connected') {
if (wsConnectBtn) wsConnectBtn.style.display = 'none';
if (wsDisconnectRow) wsDisconnectRow.style.display = 'flex';
if (apiUrlInput) apiUrlInput.disabled = true;
if (apiKeyInput) apiKeyInput.disabled = true;
} else {
if (wsConnectBtn) wsConnectBtn.style.display = 'block';
if (wsDisconnectRow) wsDisconnectRow.style.display = 'none';
if (apiUrlInput) apiUrlInput.disabled = false;
if (apiKeyInput) apiKeyInput.disabled = false;
}
};
}
Step 2: Commit
git add js/controls.js
git commit -m "feat: add controls module with debug dashboard and override support"
Task 7: Update optimized-controls.html — Add Player List DOM, Dashboard DOM, CSS, Bootstrap
Files:
- Modify:
optimized-controls.html
This is the largest task. The HTML file needs:
- New CSS for player list and debug dashboard
- New DOM elements for player list container
- New DOM elements for debug dashboard section in controls
- New player list settings section in controls
- Replace the entire
<script>block with a module bootstrap - Remove the old inline JS (lines 634-1345)
Step 1: Add player list and dashboard CSS
Add new CSS rules after the existing .mode-label rule (around line 360, before </style>). These style the player list, the debug dashboard, and override controls.
Step 2: Add player list container to the DOM
After the <div id="footer"> element, add a <div id="player-list-container"> that will be positioned to the left or right of the room code.
Step 3: Add player list settings section to controls
After the Audio Settings section (around line 572), add a new collapsible "Player List Settings" section with:
- Enable/disable checkbox (
#player-list-enabled) - Position select: left/right (
#player-list-position) - Font size input (
#player-list-font-size) - Text color input (
#player-list-text-color) - Empty slot color input (
#player-list-empty-color) - Vertical offset input (
#player-list-offset)
Step 4: Add Overlay Manager dashboard section to controls
After the Player List Settings section, add a new collapsible "Overlay Manager" section with:
- State badge (
#manager-state) - Room code display (
#manager-room-code) - Session ID (
#manager-session-id) - Game title (
#manager-game-title) - Player count (
#manager-player-count) - Component status table with override selects for
roomCode,audio,playerList - Event log container (
#manager-event-log)
Step 5: Replace inline script with module bootstrap
Replace the entire <script> block (lines 634-1345) with:
<script type="module">
import { OverlayManager } from './js/state-manager.js';
import { WebSocketClient } from './js/websocket-client.js';
import { RoomCodeDisplay } from './js/room-code-display.js';
import { AudioController } from './js/audio-controller.js';
import { PlayerList } from './js/player-list.js';
import { initControls, initConnectionStatusHandler } from './js/controls.js';
const manager = new OverlayManager();
// Room Code Display component
const roomCodeDisplay = new RoomCodeDisplay();
roomCodeDisplay.init(
{
header: document.getElementById('header'),
footer: document.getElementById('footer'),
codePart1: document.getElementById('code-part1'),
codePart2: document.getElementById('code-part2'),
},
{
code1: document.getElementById('code1-input'),
code2: document.getElementById('code2-input'),
color1: document.getElementById('color1-input'),
color2: document.getElementById('color2-input'),
offset1: document.getElementById('offset1-input'),
offset2: document.getElementById('offset2-input'),
size: document.getElementById('size-input'),
cycle: document.getElementById('cycle-input'),
headerText: document.getElementById('header-text-input'),
headerColor: document.getElementById('header-color-input'),
headerSize: document.getElementById('header-size-input'),
headerOffset: document.getElementById('header-offset-input'),
footerText: document.getElementById('footer-text-input'),
footerColor: document.getElementById('footer-color-input'),
footerSize: document.getElementById('footer-size-input'),
footerOffset: document.getElementById('footer-offset-input'),
headerAppearDelay: document.getElementById('header-appear-delay'),
headerAppearDuration: document.getElementById('header-appear-duration'),
headerHideTime: document.getElementById('header-hide-time'),
headerHideDuration: document.getElementById('header-hide-duration'),
line1AppearDelay: document.getElementById('line1-appear-delay'),
line1AppearDuration: document.getElementById('line1-appear-duration'),
line1HideTime: document.getElementById('line1-hide-time'),
line1HideDuration: document.getElementById('line1-hide-duration'),
line2AppearDelay: document.getElementById('line2-appear-delay'),
line2AppearDuration: document.getElementById('line2-appear-duration'),
line2HideTime: document.getElementById('line2-hide-time'),
line2HideDuration: document.getElementById('line2-hide-duration'),
}
);
manager.registerComponent('roomCode', roomCodeDisplay);
// Audio Controller component
const audioController = new AudioController();
audioController.init(
document.getElementById('theme-sound'),
{
enabled: document.getElementById('sound-enabled'),
volume: document.getElementById('volume-slider'),
soundUrl: document.getElementById('sound-url-input'),
}
);
manager.registerComponent('audio', audioController);
// Player List component
const playerList = new PlayerList();
playerList.init(
document.getElementById('player-list-container'),
{
enabled: document.getElementById('player-list-enabled'),
position: document.getElementById('player-list-position'),
fontSize: document.getElementById('player-list-font-size'),
textColor: document.getElementById('player-list-text-color'),
emptyColor: document.getElementById('player-list-empty-color'),
offset: document.getElementById('player-list-offset'),
},
{
headerAppearDelay: document.getElementById('header-appear-delay'),
headerAppearDuration: document.getElementById('header-appear-duration'),
}
);
manager.registerComponent('playerList', playerList);
// WebSocket Client
const statusHandler = initConnectionStatusHandler();
const wsClient = new WebSocketClient({
onStatusChange: statusHandler,
onEvent: (type, data) => manager.handleEvent(type, data),
onSessionSubscribed: (sessionId) => {
console.log('[Bootstrap] Subscribed to session:', sessionId);
},
});
// Initialize controls, dashboard, and event bindings
initControls(manager, wsClient, {
roomCode: roomCodeDisplay,
audio: audioController,
playerList: playerList,
});
// Player list position reactivity
const plPositionSelect = document.getElementById('player-list-position');
const plContainer = document.getElementById('player-list-container');
if (plPositionSelect && plContainer) {
plPositionSelect.addEventListener('change', () => {
plContainer.classList.toggle('position-left', plPositionSelect.value === 'left');
plContainer.classList.toggle('position-right', plPositionSelect.value === 'right');
});
// Set initial position
plContainer.classList.add('position-' + (plPositionSelect.value || 'right'));
}
// Auto-connect from localStorage
const savedUrl = localStorage.getItem('jackbox-api-url');
const savedKey = localStorage.getItem('jackbox-api-key');
if (savedUrl) document.getElementById('api-url-input').value = savedUrl;
if (savedKey) document.getElementById('api-key-input').value = savedKey;
if (savedUrl && savedKey) {
setTimeout(() => wsClient.connect(savedUrl, savedKey), 500);
}
// Expose for debugging
window.__overlay = { manager, wsClient, roomCodeDisplay, audioController, playerList };
</script>
Step 6: Verify in browser
- Open the HTML file in a browser
- Open DevTools console — should see no import errors
- Open controls panel — should see all existing settings plus new Player List and Overlay Manager sections
- Type a code in First/Second Line inputs, click Update — room code animation should play with audio
- Check
window.__overlay.manager.state— should be'lobby' - Click Hide Display — should transition to
'idle'and stop everything
Step 7: Commit
git add optimized-controls.html
git commit -m "feat: integrate module system, add player list + dashboard DOM"
Task 8: End-to-End Verification & Polish
Files:
- Possibly modify: any of the above files for bug fixes
Step 1: Test the full WebSocket flow
If you have access to a running Game Picker API instance:
- Connect via the overlay's Connection Settings
- Verify
Connectedstatus - Add a game with a room code in the Game Picker
- Verify: room code appears, audio plays, player list shows empty slots
- If shard monitor is running, verify players populate the list as they join
- When game starts: verify everything hides
- Add another game: verify full reset and clean restart
If no API instance is available, test manually:
- Open DevTools console
- Run:
window.__overlay.manager.handleEvent('game.added', { session: { id: 1 }, game: { room_code: 'ABCD', title: 'Test Game', max_players: 6 } }) - Verify: room code displays "AB" / "CD", audio plays, 6 empty player slots appear
- Run:
window.__overlay.manager.handleEvent('lobby.player-joined', { players: ['Alice'], playerCount: 1, maxPlayers: 6, playerName: 'Alice' }) - Verify: first slot fills with "Alice"
- Run:
window.__overlay.manager.handleEvent('game.started', { playerCount: 1, players: ['Alice'] }) - Verify: everything hides
- Run the
game.addedevent again — verify audio restarts cleanly
Step 2: Test overrides
- Set Room Code override to "Force Show"
- Verify it stays visible even when state transitions to
idle - Set Audio override to "Force Hide"
- Trigger a lobby — verify room code and player list appear but audio does not play
- Reset all overrides to "Auto"
Step 3: Test edge cases
- Rapid
game.addedevents (new code while animation is running) room.disconnectedduring lobbysession.endedduring lobby- Player list with 0 maxPlayers (should default to 8)
- Player list disabled in settings
Step 4: Fix any issues found
Address bugs discovered during testing.
Step 5: Final commit
git add -A
git commit -m "fix: address issues found during end-to-end testing"
Summary of Changes
| File | Action | Purpose |
|---|---|---|
js/state-manager.js |
Create | OverlayManager state machine |
js/websocket-client.js |
Create | Extracted WebSocket auth/connect/events |
js/room-code-display.js |
Create | Extracted room code animation component |
js/audio-controller.js |
Create | Extracted audio lifecycle (fixes restart bug) |
js/player-list.js |
Create | New player slot list component |
js/controls.js |
Create | Settings bindings + debug dashboard |
optimized-controls.html |
Modify | Add player list/dashboard DOM + CSS, replace inline JS with module bootstrap |