WebSocket
The live feed. One connection per battle, per viewer.
wss://slotbattle.example.com/ws?battle_id=btl_CIkE8lctOrFDe1hJlG0T
Sec-WebSocket-Protocol: slotbattle.jwt, <viewer token>
The feed is on its own port
/ws does not run on the REST port. It is served by a separate HTTP server, because a
connection meant to stay open for a whole battle cannot survive the REST surface's global
write timeout. By default the REST API listens on 8070 and the feed on 8071.
The consequence for you: use the ws_url the API returns rather than assembling one from the
REST base URL. See Architecture.
ws_url on the REST port means one setting is missingIf the instance does not declare a public WebSocket base URL, ws_url is derived from the
request host and comes back pointing at the REST port, where nothing accepts the upgrade.
Verified live: with the setting empty, the control plane returned a ws_url on its own REST
port while the feed was listening on a different one.
If ws_url will not connect, this is the first thing to report to your host. You cannot fix
it from your side.
The handshake
Browsers cannot set arbitrary headers on a WebSocket handshake, so the token travels in the subprotocol list, the one header they can control:
const ws = new WebSocket(wsUrl, ['slotbattle.jwt', token])
Two entries: the fixed name slotbattle.jwt, then the raw token. The server negotiates back
slotbattle.jwt.
Verified against a running instance:
| Handshake | Response |
|---|---|
| Valid token | 101 Switching Protocols, Sec-WebSocket-Protocol: slotbattle.jwt |
| No token | 401 Unauthorized |
| Invalid or expired token | 401 Unauthorized |
Missing battle_id | 400 Bad Request |
A rejected handshake answers plain text, not {"error":{...}}, because it is a different
server. Do not write a client that parses a failed upgrade as JSON.
A token minted for a different battle than the one in battle_id is rejected: the claim and
the query parameter must agree.
What arrives
Frames are JSON events. Only eight types reach a browser:
lobby.updated | A seat changed |
battle.started | Seats dispatched, recording begins |
spin.started | A spin began on some seat |
segment.new | New video segment available |
playlist.update | The HLS playlist changed |
battle.completed | Every seat reported |
battle.failed | The battle failed |
battle.cancelled | The battle was cancelled |
Everything else in the vocabulary, including browser.ready, capture.*, click.* and
user.*, is internal and will never arrive. See Events for the envelope
and the full list.
ws.onmessage = (e) => {
const ev = JSON.parse(e.data)
switch (ev.type) {
case 'lobby.updated': return renderSeats(ev.data)
case 'battle.started': return startPlayers()
case 'spin.started': return flashSeat(ev.user_id)
case 'segment.new':
case 'playlist.update': return nudgePlayer(ev.user_id)
case 'battle.completed':
case 'battle.failed':
case 'battle.cancelled': return showTerminal(ev)
}
}
There is no replay
A viewer connecting mid-battle sees what happens from then on, not the history. Nothing is buffered and nothing is redelivered.
Design for it: fetch the battle's current state from your backend when the page loads, render that, and let the socket apply changes on top. A front end that assumes it saw the sequence from the beginning renders a broken lobby for anyone who arrived late or reconnected.
Reconnecting
The socket can close for ordinary reasons: a network change, laptop sleep, a proxy idle timeout. A reconnect loop that works:
- Reconnect with backoff and jitter, capped at a few seconds.
- Re-fetch the battle state, because you missed events while disconnected.
- If the token is within a minute of expiry, mint a fresh one through your backend first.
- Stop when the battle reaches a terminal state; there is nothing more to receive.
A 401 on reconnect almost always means an expired token rather than a revoked one. Mint and
retry once before surfacing an error.
Video
The feed does not carry video. segment.new and playlist.update report that a seat's HLS
stream advanced; the video itself comes from the CDN at the seat's hls_url.
Point one HLS player per seat at that URL and let the events prompt it to check for new segments. For a battle that is still running, see the playlist-over-socket pattern in Watch it live.
Next
- Events: envelope and full vocabulary
- Viewer tokens: minting and expiry
- Watch a battle live: a working implementation