Files
OBS-overlay/docs/plans/2026-03-20-overlay-manager-player-list-design.md
cottongin c049cddb6d Add design doc for overlay manager and player list
Event-driven state machine to coordinate room code display, audio,
and a new player slot list. Fixes audio restart bug by centralizing
lifecycle management. Adds new shard-based WebSocket event handling.

Made-with: Cursor
2026-03-20 12:43:31 -04:00

6.9 KiB

Overlay Manager & Player List Design

Date: 2026-03-20 Status: Approved

Problem

The OBS overlay (optimized-controls.html) has two issues after the upstream Game Picker API migrated from Puppeteer to an ecast shard monitor:

  1. Audio doesn't restart on new room codes. The overlay updates the room code text when game.added fires, but audio playback fails to reinitialize because hideDisplay() (triggered by game.started or audience.joined) leaves the audio element in a state that startAnimation() doesn't fully recover from. There is no centralized lifecycle management — show/hide logic is scattered across event handlers.

  2. No player list. The new shard-based API pushes real-time player names and join events (room.connected, lobby.player-joined), but the overlay doesn't consume them. Players joining the lobby should be visible to viewers.

Additionally, several new WebSocket events (room.connected, lobby.player-joined, lobby.updated, game.ended, room.disconnected) are not handled, and the removed audience.joined event is still referenced.

Design Decisions

  • Event-driven state machine over reactive flags or per-component lifecycle management. A central OverlayManager coordinates all components through explicit state transitions, preventing the ad-hoc state bugs that cause the current audio issue.
  • ES module split (no build step) over single-file or bundled architecture. Keeps OBS browser source simplicity while improving maintainability.
  • WebSocket-only player data — no REST polling fallback for player lists.
  • Overlay visible only during lobby state — room code, audio, and player list all share the same lifecycle.

Architecture

State Machine

idle → lobby → playing → ended → idle
         ↑                         |
         └─────────────────────────┘
              (new game.added)

Any state → disconnected → idle (on reconnect, no active lobby)
                         → lobby (on reconnect, active lobby)
State Entry Trigger Visible Components
idle Initial load, session.ended, game.ended, room.disconnected None
lobby game.added, room.connected (lobby state) Room code + audio + player list
playing game.started None
ended game.ended None (transitions to idle after brief delay)
disconnected WebSocket close/error None (reconnect logic runs)

Key transition: game.added while already in lobby triggers a full reset — deactivate all components, update context, reactivate. This fixes the audio restart bug by design.

Component Interface

Every component implements:

  • activate(context) — enter active state with room/game/player context
  • deactivate() — fully clean up (stop audio, clear timers, hide elements)
  • update(context) — handle in-state updates (new player joined, etc.)
  • getStatus() — return current status for the debug panel

File Layout

OBS-stuff/
├── optimized-controls.html          # DOM + CSS + bootstrap script
├── js/
│   ├── state-manager.js             # OverlayManager: state machine, component registry
│   ├── websocket-client.js          # Auth, connect, reconnect, event routing
│   ├── room-code-display.js         # Room code animation component
│   ├── audio-controller.js          # Audio playback lifecycle
│   ├── player-list.js               # Player slot list component
│   └── controls.js                  # Settings panel + debug dashboard

Loaded via <script type="module"> — no build step.

Player List

Visual Design

Vertical numbered roster positioned to the left or right of the room code (configurable):

1. xXSlayerXx        ← filled (bright text)
2. CoolPlayer42      ← filled
3. ────────────      ← empty (dim placeholder)
4. ────────────
5. ────────────

Slot count equals maxPlayers from the shard's room.connected event (fallback: game catalog max_players from game.added, default: 8).

Configuration

  • Enable/disable toggle
  • Position: left or right of room code
  • Font size, text color, empty slot color
  • Vertical offset

Behavior

  1. activate(ctx): Create maxPlayers empty slots. Fade in using the same timing curve as the room code animation.
  2. lobby.player-joined: Fill next empty slot with player name (subtle fade-in on the name text). Uses the players[] array from the event to diff against displayed slots.
  3. room.connected (with existing players — e.g., reconnect): Bulk-fill all known players.
  4. deactivate(): Fade out all slots, clear the list.

Edge Cases

  • More players than maxPlayers (audience members): ignored, only player slots shown.
  • Player names unavailable: fallback to "Player 1", "Player 2", etc.
  • No maxPlayers data yet: default to 8 slots, update when data arrives.

Debug Dashboard & Manual Overrides

New collapsible "Overlay Manager" section in the controls panel.

State Display

  • Current state badge (color-coded): IDLE / LOBBY / PLAYING / ENDED / DISCONNECTED
  • Room code, session ID, game title + pack
  • Player count: 3 / 8

Component Override Table

Component State Override
Room Code Active (cycling) [Auto] / [Force Show] / [Force Hide]
Audio Active (playing) [Auto] / [Force Show] / [Force Hide]
Player List Active (3/8) [Auto] / [Force Show] / [Force Hide]
  • Auto (default): follows state machine
  • Force Show: always visible, uses last known context
  • Force Hide: always hidden regardless of state
  • Overrides are session-only (not persisted to localStorage)

Event Log

Scrollable log of last ~20 WebSocket events with timestamps.

WebSocket Event Mapping

Event New Behavior
game.added Transition to lobby with room code + game metadata
room.connected Enrich lobby context: maxPlayers, initial players[]
lobby.player-joined Update player list with new player
lobby.updated Update lobby context
game.started Transition to playing
game.ended Transition to endedidle
room.disconnected Transition to idle (with reason logging)
session.started Subscribe to session, set session context
session.ended Transition to idle, clear all context
player-count.updated Update player count (manual REST override, less common now)
vote.received Pass-through logging
audience.joined Removed — no longer exists in the new API

Auth flow unchanged: POST /api/auth/login with { key }, JWT response, Bearer header.

Reconnection preserves current exponential backoff. On reconnect: re-auth, fetch active session, re-subscribe. If lobby is active, room.connected fires with current state.