Skip to main content

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.

A ws_url on the REST port means one setting is missing

If 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:

HandshakeResponse
Valid token101 Switching Protocols, Sec-WebSocket-Protocol: slotbattle.jwt
No token401 Unauthorized
Invalid or expired token401 Unauthorized
Missing battle_id400 Bad Request
The feed does not use the JSON error envelope

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.updatedA seat changed
battle.startedSeats dispatched, recording begins
spin.startedA spin began on some seat
segment.newNew video segment available
playlist.updateThe HLS playlist changed
battle.completedEvery seat reported
battle.failedThe battle failed
battle.cancelledThe 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:

  1. Reconnect with backoff and jitter, capped at a few seconds.
  2. Re-fetch the battle state, because you missed events while disconnected.
  3. If the token is within a minute of expiry, mint a fresh one through your backend first.
  4. 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