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.