Skip to main content

Snapshot stream

Every frame the server sends carries two things: the event that just happened, and the full match snapshot after that event is applied. You never have to rebuild state from the events yourself — each snapshot is complete and already computed.
This has a useful consequence: if you miss a frame, you are only ever one event behind, and the next frame’s state brings you fully back in sync. There is no resend mechanism, and none is needed.

outSeq is for gap detection only

outSeq is a counter that increases by one on every frame. Use it to spot missed frames: a gap means you missed something. It is not a way to request a resend — if you need the missing events, re-backfill from /history and re-subscribe.

Sessions

Every endpoint includes a {session} segment in its path. Each session is a separate match: a press on one never affects another, and your consumer token works across every session you are allowed to read. Session ids are short alphanumeric slugs, one per match. An id that doesn’t match the expected format returns 404 before the token is even checked.

The A/B side model

On the wire, teams are always A or B — never named. The display names are set when the session is created and travel in the top-level matchup {a, b} on every frame and on the /api/history and /api/health responses, not in the game state. Every team-attributed field — the score, basketball’s possession, baseball’s run credit, soccer’s card counts — refers to the abstract A and B sides. The sides are stable for the whole game: A and B keep pointing at the same teams even when the teams switch ends at half-time (soccer) or the batting side alternates each half-inning (baseball). Some sports extend the side model into space. Soccer’s zone field marks which half the ball is in: a_half is the half where Team A’s goal is (Team B attacks into it), and inBox adds depth — zone: "a_half", inBox: true means Team B is in a dangerous spot inside A’s box. See each sport’s page for its own state fields.

Pulses vs. open/resolve

The event vocabulary uses two patterns, in every sport. Pulses. Most events are self-contained facts: basketball’s possession says who has the ball, baseball’s run credits a run, soccer’s goal scores one. Apply the frame and move on — nothing is left pending. Open then resolve. Some events open a state that stays open until a later event closes it. For example:
  • Basketball: 2pt_attempt / 3pt_attempt / ft_attempt (the ball is in the air) → made or miss
  • Soccer: penalty_awardedpen_kickpen_scored or pen_missed
  • Soccer: var_review → any corrections (each one leaves the review open) → var_resolve (the one event that closes it)
  • Baseball: pitch (a play opens) → end (the play closes)
The snapshot always shows what is currently open (basketball openAttempt, soccer pendingSetPiece / openVar, baseball live), so a consumer that reconnects sees the current state without replaying the event log. At most one of a given kind is ever open at a time, so there’s never an ambiguity to resolve: the next resolving event always closes the one thing that’s currently open. Because of that, there’s no separate id to thread across frames — each sport’s resolving event already carries what it needs:
  • Basketball — the resolving made / miss self-describes the shot it resolved on its own payload (team, shot_type); no lookup needed.
  • Soccer — a VAR review’s identity, if you need it (e.g. "v2"), is on state.openVar.varId for every frame the review is open; var_resolve clears it.
  • Baseball / football — the open thing is a plain flag (live, flagOpen), not an id’d pair.

Advisories

Some frames come from the server itself rather than from a scout. The liveness monitor sends scout_status frames to tell you whether the scout is still active. They look like normal frames, but with event.type === "scout_status" and event.seq === null (no seq, because no scout pressed anything). The payload is { "status": "dark" | "live", "lastHeartbeat": number | null }.
  • "dark" — the scout’s heartbeat hasn’t been seen recently. Expect no new events until they reconnect.
  • "live" — the scout is active again.
lastHeartbeat is the Unix timestamp (seconds) of the last heartbeat from the scout, or null if none has been seen yet for this session. Advisory frames still include the full match snapshot in state, so there is nothing to re-sync.

Reconnection

The close code decides whether to come back — some closes are deliberate refusals that will never change, and looping on them is a bug:
  • 4000 — the game is over; stop. The normal end of every session, preceded by a {"type": "session_closed"} control message. Not a failure — do not reconnect; the full tape stays readable via GET /api/history/{session}.
  • 4404 / 4403 — stop. These arrive after a {"type": "error", "code": ...} frame (close codes): the game has ended, was never published, or your account is not entitled to it. Reconnecting gets the same refusal forever. Fetch the tape from GET /api/history/{session} instead (finished games stay replayable), or re-check your entitlements.
  • A failed handshake — fix the credential, then retry. A missing or invalid token rejects the upgrade request itself with HTTP 403, before any WebSocket exists (a browser observes a failed connection / 1006 with no server frames; a non-browser client sees the 403 response). Retrying with the same token loops.
  • 1011 — the server’s fault; retry with backoff. It arrives after a {"type": "error", "code": "unavailable"} frame: the server could not verify any credential (its auth infrastructure is down or misconfigured). Your token was never examined — do not rotate it; reconnect with backoff until the server recovers.
  • Anything else — transient; reconnect with backfill. A network drop (1006 mid-stream), a server restart, a proxy timeout:
    1. Re-fetch the event log from GET /api/history/{session} to fill any tape rows you missed (history returns events only — your state re-syncs from the snapshot on the first frame after the socket reopens).
    2. Re-open the WebSocket to start receiving live frames again.
    3. Before appending, remove duplicates against your existing tape by seq — events you already have will appear again in the backfill.