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": { }
}
| Field | Notes |
|---|---|
type | From the vocabulary below |
scope | battle or user: whether it describes the whole battle or one seat |
battle_id | Always present |
user_id | Present on user-scope events. The seat reference |
is_bot | Present on user-scope events |
ts | UTC, RFC 3339 |
data | Type-specific payload. Absent when the type carries none |
The vocabulary
Battle scope: the whole battle
| Type | Emitted when |
|---|---|
lobby.updated | A seat changed: someone joined, left, upgraded, or bots were seated |
battle.started | The battle left OPEN and seats were dispatched |
battle.completed | Every seat reported back |
battle.failed | The launch failed, a seat failed unrecoverably, or a window expired |
battle.cancelled | The creator cancelled or left, or the lobby window expired while OPEN |
User scope: one seat
| Type | Emitted when |
|---|---|
user.started | The seat's recorder began work |
browser.ready | The headless browser loaded the game |
capture.started | Video capture began |
capture.armed | The bonus started, and capture is recording the round |
click.started / click.done / click.failed | One scripted interaction with the game |
spin.started | A spin began |
segment.new | A new HLS video segment is available |
segment.failed | A segment could not be produced or uploaded |
playlist.update | The HLS playlist changed |
user.completed | The seat finished with a result |
user.failed | The seat failed |
user.stopped | The 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.updated | battle.started |
battle.completed | battle.failed |
battle.cancelled | spin.started |
segment.new | playlist.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 publicTerminal 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
| Consumer | Gets | Transport |
|---|---|---|
| Viewer browsers | The eight public types, for one battle | WebSocket, viewer token |
| Your backend | Terminal events only | Signed webhook, durable delivery |
| The instance itself | Everything | Redis 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
- WebSocket: connecting, authenticating, and reconnecting
- Webhooks: payload, signature, retries
- Battle lifecycle: what each transition means