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
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:
-
Audio doesn't restart on new room codes. The overlay updates the room code text when
game.addedfires, but audio playback fails to reinitialize becausehideDisplay()(triggered bygame.startedoraudience.joined) leaves the audio element in a state thatstartAnimation()doesn't fully recover from. There is no centralized lifecycle management — show/hide logic is scattered across event handlers. -
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
OverlayManagercoordinates 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 contextdeactivate()— 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
activate(ctx): CreatemaxPlayersempty slots. Fade in using the same timing curve as the room code animation.lobby.player-joined: Fill next empty slot with player name (subtle fade-in on the name text). Uses theplayers[]array from the event to diff against displayed slots.room.connected(with existing players — e.g., reconnect): Bulk-fill all known players.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
maxPlayersdata 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 ended → idle |
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.