Skip to content

The wire protocol (server/protocol)

server/protocol is the versioned wire between a match host and its clients. It has one envelope (Frame), a closed set of frame types, and plain JSON bodies. The HTTP API returns these bodies, and the event stream carries them as frames. This page covers the envelope, every frame type with its body, the vocabularies the bodies use, the versioning rule, and the tests that pin the wire. It is for client authors in any language who want to decode frames without reading Go.

Read from the code at commit ad3777023 (2026-10-20); the event-kind list re-checked at 513a2a0c3 (2026-10-21).

Why a separate package

server/protocol is types only. It never imports the rules engine or the bot packages (internal/archtest forbids server/protocol → engine/rules and server/protocol → bots). A client library can therefore depend on it without pulling in the engine. The board projection a frame carries, view.View, comes from engine/view. That package is also barred from importing engine/rules.

Machine-readable forms

Form Where Kept in sync by
JSON Schema 2020-12 schemas/gorge-protocol.schema.json: every type under $defs; validate a frame against #/$defs/StreamFrame TestOpenAPIProtocolSchemaIsRendered and TestOpenAPISchemasMatchGo in server/host/httpapi/openapi_test.go
OpenAPI components openapi.json components.schemas (the same schemas) the same tests
TypeScript web/src/protocol.ts, generated by cmd/gentypes (make gentypes) TestCommittedProtocolTSIsFresh and TestFrameTypeUnionListsEveryConstant in cmd/gentypes/main_test.go; make lint runs gentypes -check
Golden frames server/protocol/testdata/*.json: one fully populated frame per type TestGoldens and TestFramesRoundTrip in server/protocol/protocol_test.go

The JSON Schema and the TypeScript are both generated from the Go source by internal/tsgen, which type checks the structs from source. The JSON names come from the json tags; omitempty makes a field optional; a nil pointer, slice or map in a non-omitempty field is null. All 16 goldens validate against the JSON Schema (checked 2026-10-20 with a stdlib validator).

The envelope

{
  "v": 1,
  "t": "decision",
  "id": 4182,
  "table": "t1",
  "match": 7,
  "seq": 9131,
  "body": {"player": 2, "kind": "priority", "prompt": "You have priority."}
}

(testdata/decision.json, the golden.)

Field Type Meaning
v int protocol.Version, currently 1
t string The frame type, which selects the body
id uint64, optional The session-wide frame counter and the SSE id. Omitted (0) on frames that cannot be resumed: hello, widget and overflow.
table string, optional The table the frame is about
match int, optional The match index on that table, from 1
seq uint64 The engine's event sequence the body is located at: the number the match's hash chain covers
body object The frame type's body, below

Frame types

The set is closed: these are the FrameType constants, and the generated FrameType enum and the TypeScript union are both checked against them.

t Body Golden Sent
hello Hello {session, tables[]} hello.json The first frame of a fresh stream
widget Widget {turn, step, phase, active, priority, life[], lost[], stack_depth, last, state} widget.json Overview: the table's cell, coalesced
match_start MatchStart {seats[], seed, spectator, bot_policy} match_start.json A match begins
snapshot Snapshot {view, turn_starts[], head, seats[]} snapshot.json Focus: the whole board at head
event EventBody {event, line} event.json Focus: one redacted engine event and its transcript line
decision DecisionBody {player, kind, prompt} decision.json Focus: who is being asked what; the options are not included
match_end MatchEnd {result, winner, head} match_end.json A match ends. result is win or draw; winner is null for a draw; head is the chain head the match's .events file replays to.
table_halted TableHaltedBody {reason} table_halted.json A crashed table stops and says why
table_state TableStateBody {state} table_state.json A series table's state: idle, live, between_games
rewind Snapshot (the same shape) rewind.json An undo: discard everything past head, then apply the body as a snapshot
overflow Overflow {dropped} overflow.json The last frame before a session that fell behind is closed
error ErrorBody {code, message, head?} error.json Reserved. ErrorBody is also every HTTP error reply.
event_state EventState event_state.json Draft events only
draft_pack DraftPack draft_pack.json Draft events only
pod_progress PodProgress pod_progress.json Draft events only
series_update SeriesUpdate series_update.json Draft events only

The draft-event frames are part of the package, and so of the wire. They are sent only by a server that runs a draft-event runtime (see HTTP API: Draft events).

Vocabularies

Name Values Where
Subscription mode overview, focus; table * = every table (overview only) Subscribe.mode
Table state idle, live, cooldown, halted TableInfo.state, Widget.state
Table-state frame idle, live, between_games TableStateBody.state
Match state live, finished, aborted, crashed MatchInfo.state
Visibility seat, public, omniscient View.visibility, TableInfo.spectator
Format constructed, commander TableInfo.format, CreateGameRequest.format
Bot tier production, experimental BotPolicyInfo.tier
Seat colours #e5484d #3b82f6 #22c55e #eab308 #a855f7 #f97316 #14b8a6 #ec4899, by seat index SeatInfo.colour
Decision kind the 13 kinds of the decision loop DecisionBody.kind, Decision.kind
Event kind events.Kind names, below Event.kind

Events on the wire

An EventBody carries one engine event (protocol.Event) and its English transcript line:

{"event": {"seq": 9131, "kind": "move_zone", "player": 1, "obj": 12, "from": "hand", "to": "battlefield"},
 "line": "Goblin Guide #12 moves from hand to battlefield"}

Zone names (from, to) are set only for move_zone, draw and stack_push, and step only for step. A tap therefore never reads as "from library". secret marks an event whose hidden payload was redacted for this viewer: a draw you may not see keeps its shape and loses its card. Events are redacted against the state at the head of the request, for the viewer's visibility, before the line is described. A hidden card's name never reaches the line.

kind is the name of an events.Kind. That enum is append-only: new kinds are added at the end, so existing ordinals never move. Recorded logs, hash chains and golden replays depend on them. A client must treat an unknown kind as displayable but opaque, and use line.

The 144 event kinds at 513a2a0c3

game_start shuffle move_zone draw life damage tap untap step turn priority stack_push stack_resolve mana_add mana_clear counter declare_attackers declare_blockers player_lost game_over decision_ask decision_made note land_played targets_chosen flip_face clock_tick trigger_push end_combat_reset cast_info choose token_create stack_copy attach ability_push mode_chosen commander_damage delayed_register delayed_push library_order extra_turn door_unlock speed_change monarch_change control_change card_token keyword_trigger_push goad player_counter imprint starting_player_change pair myriad_copy myriad_cleanup grant_trigger_push mana_activate token_attacks x_change note_number extra_phase copy_token exert planar_roll explore combat_retarget ring_tempts_you ring_emblem_push grant_ability_push investigate blessing_change clone_permanent mutate merged_trigger_push discover seek connive enlist exploit alter_attribute gained_ability_push gained_trigger_push surveil unattached player_noted player_note_cleared delayed_remove turn_face_up searched_library keyword_ability_push scry store_svar turn_face_down clone_static damage_provenance enduring_story_change phase_out gift_promise give_gift roll_dice proliferate evolved delayed_forget card_noted cascade clash planar_deck_shuffle planar_reveal planar_walk specialize chaos_ensues mana_undo end_turn dungeon_create dungeon_room dungeon_complete dungeon_remove initiative_change skip_turn control_player_change crew elemental_bend setup_entered door_lock saddle class_level mill_proposal meld excess_damage forage_action manifest_dread_action collect_evidence_action ability_triggered cast_battlefield draw_batch_proposal spell_cast_reversed hidden_identity draw_empty setup_settled blocked_by_effect copy_except game_restart activation_reversed text_change had_to_attack

(Listed in ordinal order, from events.Kind.String() over 0..255.)

Versioning

The rule is the doc comment on protocol.Version:

Version bumps only on a breaking change to a frame body. Additive fields do not bump it.

What this means in practice, and how firmly each part is held:

Promise Held by
Every frame carries v, and it is 1 today NewFrame always stamps Version; TestFramesRoundTrip checks it
A field's name, type and presence do not change silently TestGoldens (byte-identical goldens), TestCommittedProtocolTSIsFresh and TestOpenAPISchemasMatchGo. Each of them fails on any change, additive or not, until its committed artifact is regenerated, so every wire change is a visible diff in review.
The frame-type set and the decision-kind set are closed TestFrameTypeUnionListsEveryConstant, and the Kind check in TestOpenAPISchemasMatchGo
A breaking change bumps v A convention, stated in the doc comment. No test can tell a breaking change from an additive one, so the bump is a reviewer's call.
New fields are additive and may appear at any time The convention above. Clients must ignore unknown fields. Fields added so far are mostly omitempty, so payloads that do not use them serialise byte-identically.

There is no negotiation: a server speaks one version, and URLs carry none. A client should read v and refuse a version it does not know.

Not in this package's contract

server/protocol/manabrew is a separate wire, used by the deprioritised ManaBrew adapter. It has its own JSON schema and equivalence tests (schema_equiv_test.go), and it is not described here.