Watch it live
Live viewing is what the audience actually sees, and it is where handing the browser the wrong credential is easiest.
1. The browser gets a viewer token, not the API key
export async function POST(_request: Request, context: Context): Promise<Response> {
const { id } = await context.params
const token = await mintViewerToken(id)
return Response.json(token)
}
That route is what makes live viewing safe. The browser needs a credential to open the socket, and this hands it one scoped to one battle and valid 15 minutes, rather than one scoped to the entire casino that never expires.
Decoded, from a real instance:
{ "alg": "EdDSA", "typ": "JWT", "kid": "k1" }
{
"iss": "slotbattle",
"aud": "slotbattle-ws",
"tenant_id": "acme",
"battle_id": "btl_ClzIJ4ONnmwci6D87J9O",
"role": "viewer",
"exp": 1785731186
}
battle_id is baked in, so a token for one battle is useless for another.
Translating the no-signing-key error
if (error.status === 500) {
return Response.json({
code: 'no_signing_key',
message:
'This SlotBattle instance has no viewer signing key, so live viewing is unavailable. ' +
'Ask the operator to configure or rotate one.',
}, { status: 503 })
}
A 500 failed to mint viewer token is not a bug in your call: the instance has no viewer
signing key. Battles open and fill normally, only live viewing is unavailable, and ws_token
goes missing from create and seat responses because the field is optional.
2. The handshake
const socket = new WebSocket(ticket.ws_url, ['slotbattle.jwt', ticket.token])
Two entries: the fixed subprotocol name, then the raw token.
Browsers cannot set arbitrary headers on a WebSocket handshake, and a token in a query string
lands in access logs, proxy logs and browser history. The subprotocol list is the only
browser-reachable place to put a credential, which is why the API returns the token as its own
field and never inside ws_url.
Verified against a running instance:
| Handshake | Response |
|---|---|
| Valid token | 101 Switching Protocols |
| No token | 401 |
| Invalid or expired token | 401 |
It is a different server on a different port. Do not write a client that parses a failed upgrade as JSON.
3. Mint fresh, every time
const connect = useCallback(async () => {
// Always mint a FRESH token rather than reusing the one from the create
// response: by reconnect time it may be past its 15 minutes, and an
// expired token is a 401 on the handshake that looks like a revoked one.
const response = await fetch(`/api/battles/${battle.id}/viewer-token`, { method: 'POST' })
// …
})
Tokens are cheap and lobbies can stay open longer than 15 minutes. Reusing a stale one
produces a 401 that reads like a permissions problem.
4. Render from a snapshot, then apply events
const battle = await getBattle(id)
return <LiveBattle initialBattle={battle} />
There is no replay on the socket. A viewer who connects mid-battle sees what happens from then on; nothing is buffered and nothing is redelivered. A UI that assumes it saw the sequence from the start renders a broken lobby for everyone who arrived late or reconnected.
Fetch the snapshot on the server, hand it to the client, and let events apply on top.
socket.onmessage = (message) => {
const event = JSON.parse(message.data as string) as PublicEvent
if (isLobbyLifecycleEvent(event.type)) {
// Seat shape changed or the battle ended — re-read rather than trying
// to patch local state from the event payload.
void refreshSnapshot()
} else {
// spin.started / segment.new / playlist.update: per-seat playback.
setSeatsLive((current) => applyWorkerEvent(current, event))
}
}
Two branches, and the split matters. Battle-scoped events change the shape of the battle,
so they trigger a snapshot re-read. User-scoped events carry per-seat playback state and
are folded in locally: re-reading the snapshot on every playlist.update would be a request
storm for data that did not change, since those arrive every couple of seconds, per seat.
Re-reading on structural change is what keeps a client that missed events correct. Patching local state from event payloads makes every dropped frame a permanent divergence.
Only eight event types reach a browser. Everything else in the vocabulary, including
browser.ready, capture.*, click.* and user.*, is internal and will never arrive.
5. Reconnect properly
socket.onclose = () => {
if (stoppedRef.current) { setStatus('closed'); return }
const attempt = (attemptRef.current += 1)
const delay = Math.min(1000 * 2 ** attempt, 8000) + Math.random() * 400
setStatus('connecting')
window.setTimeout(() => {
void refreshSnapshot().then(connect)
}, delay)
}
Backoff, jitter, cap. Re-fetch the snapshot on the way back in, because events during the gap are gone for good.
Stop reconnecting once the battle is terminal. There is nothing more to receive, and a reconnect loop against a finished battle is noise.
6. Video
The socket carries no video segments, but it does carry the playlist, inline, on every
playlist.update:
{
"type": "playlist.update",
"scope": "user",
"user_id": "usr_s0",
"data": {
"m3u8": "#EXTM3U\n#EXT-X-VERSION:7\n…\nseg_0000.m4s\n",
"url": "https://cdn.example/acme/battles/btl_x/usr_s0/index.m3u8",
"ended": false
}
}
The obvious implementation points hls.js at the seat's hls_url on the CDN and lets it
poll:
// Looks right. Does not work for a LIVE battle.
const hls = new Hls()
hls.loadSource(seat.hls_url)
hls.attachMedia(video)
The recorder publishes its first playlist a few seconds after the battle starts. Until then
that URL is a 404, hls.js treats a manifest 404 as a fatal network error, and the tile
stays black for the rest of the run. There is no exception and no retry that succeeds.
Feed it the playlist that arrived on the socket instead, through a custom playlist loader, so the player only ever sees a playlist that already exists:
const hls = new Hls({ ...WS_HLS_CONFIG, pLoader: makePlaylistLoader() })
hls.loadSource('wsplaylist://seat/index.m3u8') // never fetched — served from memory
hls.attachMedia(video)
The CDN still serves the segments; only the playlist rides the socket. Two things have to be right for the segments to load:
Resolve the URIs. The recorder writes relative segment names (seg_0000.m4s), correct for
a playlist served from its own directory. Once that playlist travels over a WebSocket it has
no location, so a player resolves those names against the page URL and 404s on every one.
Rewrite them against data.url, including the URI="…" attributes, which is where
EXT-X-MAP hides the init segment. Without that init segment, fMP4 will not decode at all.
Set CORS on the CDN. hls.js fetches segments with XMLHttpRequest, so a bucket or
distribution that does not send Access-Control-Allow-Origin blocks every segment in the
browser while curl fetches them normally. The browser reports it as a CORS failure; the
player reports nothing.
Once the battle is over there is no socket and no race, so the finished recording plays
straight from hls_url. The demo keeps both paths and switches on battle status.
Segments are uploaded as they are produced, which is what lets the audience watch a seat while that seat is still spinning. The recorder uploads a segment before it publishes the playlist that lists it, so the playlist off the socket needs no live-edge trimming.
That ordering holds only for segments that actually upload. When object storage starts
refusing writes, the recorder logs segment upload failed after retries and moves on: the
playlist still advertises that segment, and the browser gets a 404 for a file that will
never exist.
Recognise this by its shape. A storm of segment 404s across every seat at once means the
write path is failing, not the read path. It scales with the seat count: an eight-seat table
has been observed producing connection reset by peer and retry quota exceeded on
PutObject for every seat while a two-seat table on the same instance produced no errors.
Keeping hls.js a few segments behind the live edge (liveSyncDurationCount: 3) with bounded
retries on fatal network errors lets playback ride through the transient case. It cannot
recover segments that were never stored.
→ Webhooks