Skip to main content

Events

Every meaningful change to a battle publishes an event. The same vocabulary feeds three consumers, and each sees a different subset.

The envelope

Every event, on every channel, has the same shape:

{
"type": "spin.started",
"scope": "user",
"battle_id": "btl_01J8ZQ...",
"user_id": "seat_2",
"is_bot": false,
"ts": "2026-08-03T02:14:07.113Z",
"data": { }
}
FieldNotes
typeFrom the vocabulary below
scopebattle or user: whether it describes the whole battle or one seat
battle_idAlways present
user_idPresent on user-scope events. The seat reference
is_botPresent on user-scope events
tsUTC, RFC 3339
dataType-specific payload. Absent when the type carries none

The vocabulary

Battle scope: the whole battle

TypeEmitted when
lobby.updatedA seat changed: someone joined, left, upgraded, or bots were seated
battle.startedThe battle left OPEN and seats were dispatched
battle.completedEvery seat reported back
battle.failedThe launch failed, a seat failed unrecoverably, or a window expired
battle.cancelledThe creator cancelled or left, or the lobby window expired while OPEN

User scope: one seat

TypeEmitted when
user.startedThe seat's recorder began work
browser.readyThe headless browser loaded the game
capture.startedVideo capture began
capture.armedThe bonus started, and capture is recording the round
click.started / click.done / click.failedOne scripted interaction with the game
spin.startedA spin began
segment.newA new HLS video segment is available
segment.failedA segment could not be produced or uploaded
playlist.updateThe HLS playlist changed
user.completedThe seat finished with a result
user.failedThe seat failed
user.stoppedThe seat was stopped before finishing

Only eight events reach a browser

The WebSocket feed is a filtered view of the vocabulary. Exactly these eight are public:

Public on WebSocket
lobby.updatedbattle.started
battle.completedbattle.failed
battle.cancelledspin.started
segment.newplaylist.update

Everything else, including browser.ready, capture.*, click.*, user.* and segment.failed, is internal. It exists for operators and for the recorder's own coordination, and no viewer token will surface it.

Build your front end against those eight. lobby.updated and battle.* drive the state, spin.started drives per-seat animation, and segment.new and playlist.update drive the video player.

user.completed is not public

Terminal seat data reaches your backend through the webhook, which is signed and delivered durably. Publishing per-seat outcomes to every connected browser would put results in the audience's hands ahead of your own settlement, over a weaker channel. The battle's terminal event carries what the audience is allowed to know.

Three consumers, three subsets

ConsumerGetsTransport
Viewer browsersThe eight public types, for one battleWebSocket, viewer token
Your backendTerminal events onlySigned webhook, durable delivery
The instance itselfEverythingRedis pub/sub, internal

Treat the webhook as the source of truth for results, not the WebSocket. The WebSocket is a live view that a viewer may have missed, joined late, or been disconnected from. The webhook retries and is idempotent.

What never appears in any event

The game session URL. Not in data, not in a log line, not on any channel, not in any HTTP response. It is encrypted at rest and never crosses the process boundary, and the end-to-end test asserts its absence.

An integration that needs it is reaching across the boundary. See What SlotBattle does not do.

Ordering and delivery

Events are published as they happen and delivered best-effort over the WebSocket. There is no replay: a viewer connecting mid-battle sees what happens from then on, not the history. Design the front end so that a fresh connection renders from the battle's current snapshot plus new events, rather than assuming it saw the sequence from the start.

Terminal webhook delivery is the opposite: durable, retried, and idempotent by battle_id : status : version, so duplicate terminal events never double-POST.

Next