Files
OBS-overlay/docs/plans/2026-03-20-overlay-manager-implementation.md
cottongin 41773d0fef Add implementation plan for overlay manager and player list
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
2026-03-20 12:47:37 -04:00

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.added data: { session: { id, is_active, games_played }, game: { id, title, pack_name, min_players, max_players, manually_added, room_code } }
  • room.connected data: { sessionId, gameId, roomCode, appTag, maxPlayers, playerCount, players[], lobbyState, gameState }
  • lobby.player-joined data: { sessionId, gameId, roomCode, playerName, playerCount, players[], maxPlayers }
  • lobby.updated data: { sessionId, gameId, roomCode, lobbyState, gameCanStart, gameIsStarting, playerCount }
  • game.started data: { sessionId, gameId, roomCode, playerCount, players[], maxPlayers }
  • game.ended data: { sessionId, gameId, roomCode, playerCount, players[] }
  • room.disconnected data: { sessionId, gameId, roomCode, reason, finalPlayerCount }
  • session.started data: { session: { id, is_active, created_at, notes } }
  • session.ended data: { session: { id, is_active, games_played } }
  • player-count.updated data: { sessionId, gameId, playerCount, status }
  • vote.received data: { 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 _onEventhandleEvent 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:

  1. New CSS for player list and debug dashboard
  2. New DOM elements for player list container
  3. New DOM elements for debug dashboard section in controls
  4. New player list settings section in controls
  5. Replace the entire <script> block with a module bootstrap
  6. 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

  1. Open the HTML file in a browser
  2. Open DevTools console — should see no import errors
  3. Open controls panel — should see all existing settings plus new Player List and Overlay Manager sections
  4. Type a code in First/Second Line inputs, click Update — room code animation should play with audio
  5. Check window.__overlay.manager.state — should be 'lobby'
  6. 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:

  1. Connect via the overlay's Connection Settings
  2. Verify Connected status
  3. Add a game with a room code in the Game Picker
  4. Verify: room code appears, audio plays, player list shows empty slots
  5. If shard monitor is running, verify players populate the list as they join
  6. When game starts: verify everything hides
  7. Add another game: verify full reset and clean restart

If no API instance is available, test manually:

  1. Open DevTools console
  2. Run: window.__overlay.manager.handleEvent('game.added', { session: { id: 1 }, game: { room_code: 'ABCD', title: 'Test Game', max_players: 6 } })
  3. Verify: room code displays "AB" / "CD", audio plays, 6 empty player slots appear
  4. Run: window.__overlay.manager.handleEvent('lobby.player-joined', { players: ['Alice'], playerCount: 1, maxPlayers: 6, playerName: 'Alice' })
  5. Verify: first slot fills with "Alice"
  6. Run: window.__overlay.manager.handleEvent('game.started', { playerCount: 1, players: ['Alice'] })
  7. Verify: everything hides
  8. Run the game.added event again — verify audio restarts cleanly

Step 2: Test overrides

  1. Set Room Code override to "Force Show"
  2. Verify it stays visible even when state transitions to idle
  3. Set Audio override to "Force Hide"
  4. Trigger a lobby — verify room code and player list appear but audio does not play
  5. Reset all overrides to "Auto"

Step 3: Test edge cases

  • Rapid game.added events (new code while animation is running)
  • room.disconnected during lobby
  • session.ended during 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