Wiki · wire-protocol

Wire protocol

Last edited by · ·

grf-football — wire protocol

Two protocols matter here: the player protocol (what a policy container speaks to the game) and the global protocol (what a spectator or the static replay viewer speaks). Both are Sprite v1 binary websocket streams, exactly as in the paintbot lineage.

Runtime contract

The game container reads and writes the standard COGAME_* URIs:

variablemeaning
COGAME_CONFIG_URIthe episode config JSON (read at startup)
COGAME_RESULTS_URIthe results document (written once, at game over)
COGAME_SAVE_REPLAY_URIthe COWLDFTB replay bytes
COGAME_LOAD_REPLAY_URIa replay to serve instead of playing a match
COGAME_PLAYER_FAILURE_URI{"failed_policy_index": N, "message": "…"}
COGAME_EVENTS_URIthe tier-2 JSON-lines analysis stream (file:// only)
COGAME_HOST / COGAME_PORTthe bind address (default 0.0.0.0:8080)
ANTHROPIC_API_KEY / ANTHROPIC_API_KEY_URIthe decision credential

Routes

routemethodpurpose
/healthzGETliveness; returns healthy
/player?slot=N&token=TGET (ws)one seat's stream
/globalGET (ws)the spectator board
/replayGET (ws)the replay board (replay mode)
/client/global, /client/playerGETthe bitworld generic clients
/client/replayGETthe designed broadcast client, live pod only
/client/font.ttfGETthe chrome font
/replay-dataGETthe current replay bytes

The hosted replay viewer is never one of these routes. It is the STATIC wasm bundle: coworld_manifest_template.json declares "replay_viewer": {"bundle": "static-replay-viewer"}, .github/workflows/coworld-release.yml hard-fails certification if the certifier reports anything else, and the bundle re-simulates in the browser and fetches nothing but the S3 replay object. The /client/* pages exist for a locally running game poddocker run plus a browser.

A bad slot or a token that does not match the configured slot is refused with 403 before the websocket upgrade. A viewer socket that carries player credentials is refused the same way.

The player protocol

Registration — the only thing a seat ever sends that matters

On connect, a seat sends one Sprite v1 chat message (0x81, u16 length, then the raw payload) carrying:

{"type":"register",
 "prompt":"<strategy text or empty>",
 "scripted":"zonal"|"gegenpress"|null,
 "policy":"<free label>"}
  • A non-empty prompt makes the seat an LLM seat: the game server sends that text to Claude once per decision turn.
  • Otherwise scripted selects a built-in baseline; an unknown or absent value is zonal.
  • policy is a free label, capped at 48 runes, recorded in the replay.
  • prompt is capped at 4000 runes at the transport (over-long is truncated, never rejected) and is never written to the replay or the results.

The payload is read WITHOUT an ASCII filter, so a non-ASCII policy label survives to the replay intact. Registration is re-sent once after the first received frame, in case the first send raced the slot registration.

The server intercepts the registration: it is consumed, not written to the replay chat stream. A redacted register record is written instead. Any other chat text from a player is dropped.

Frames

Each seat's websocket receives one binary Sprite v1 message per tick.

Visible: the whole pitch and all 22 cogs — football is a perfect-information sport, so there is no fog of war; the ball; the score; the clock, half and turn; the restart phase; a self marker on its own shirt; and an invisible own seat <alias> marker naming it.

Hidden: every other seat's directive, prompt, note, say and view; the episode seed; the built-in AI's internal target points; real policy names (board labels carry only RED-9-style aliases); and every future tick.

A seat sends no inputs — the server computes every action byte — so the Sprite v1 Ready packet (0x85) is legitimate after each received frame and is what lets fastMode pace the match by readiness.

The per-seat view given to the LLM

View coordinates (metres, centred), rounded to one decimal.

{"turn": 7, "of": 24, "half": 1,
 "clock": {"played_s": 70.0, "left_s": 170.0},
 "score": {"you": 1, "them": 0},
 "you": {"id": "RED-9", "role": "striker", "team": "RED",
         "attacking_goal": [42.0, 0.0], "defending_goal": [-42.0, 0.0]},
 "pitch": {"x_min": -42, "x_max": 42, "y_min": -27, "y_max": 27,
           "goal_half_width": 4.0, "your_penalty_area": "x <= -26, |y| <= 20",
           "offside": false},
 "phase": "playing" | "throw_in" | "corner" | "goal_kick" | "free_kick" | "kickoff",
 "restart": {"kind": "corner", "team": "RED", "taker": "RED-7", "ticks_left": 24},
 "ball": {"pos": [3.2, -1.0], "vel": [4.1, 0.6], "speed": 4.2, "height": 0.0,
          "controller": "BLUE-4", "in_your_half": false},
 "your_cog": {"id": "RED-9", "pos": [6.1, -2.4], "vel": [2.0, -0.4],
              "speed": 2.0, "stamina": 780, "sprinting": false,
              "dribbling": false, "grounded": false, "dist_to_ball": 3.4,
              "has_ball": false,
              "nearest_opponent": {"id": "BLUE-5", "dist": 2.1}},
 "your_team": [{"id": "RED-10", "pos": [-1.2, 4.0], "role": "playmaker",
                "driver": "seat", "dist_to_ball": 5.5}, "… 11 …"],
 "their_team": [{"id": "BLUE-4", "pos": [4.0, -1.0], "dist_to_ball": 1.1,
                 "has_ball": true}, "… 11 …"],
 "last_turn": {"your_passes": 3, "your_passes_completed": 2, "your_shots": 1,
               "your_tackles": 0, "team_possession_pct": 58,
               "goals": [{"tick": 1440, "by": "RED-9", "for": "you"}]},
 "your_last_directive": "… your seat's note last turn, or null on turn 0 …"}

Reply schema and per-field caps

{"note": "sitting on their last man",
 "cogs": [{"id": "RED-9", "role": "striker", "intent": "make_run",
           "target": [24.0, -6.0], "on_ball": "shoot", "pass_to": "RED-10",
           "sprint": "auto", "tackle": "auto", "say": "in behind"}]}
fieldcap / legal valuesrepair
note≤ 160 runestruncated on a rune boundary
cogsexactly 1 entry, the seat's shirtextras dropped; a missing entry is filled from last turn's directive, else from zonal
cogs[].idthe seat's own shirt alias, case-insensitive, ≤ 8 runesan unmatched id is assigned to the seat's shirt by position
cogs[].rolestriker winger playmaker anchor→ the shirt's table role
cogs[].intentpress hold_shape make_run support drop_deep carry switch_play shadowsupport
cogs[].target[x, y] metresclamped to x ∈ [−42, 42], y ∈ [−27, 27]; non-finite/missing → the cog's current position
cogs[].on_ballshoot pass_short pass_long pass_high dribble holdpass_short
cogs[].pass_toa teammate shirt id ≠ selfnull (the controller picks the best passScore receiver)
cogs[].sprintauto always neverauto
cogs[].tackleauto neverauto
cogs[].say≤ 48 runestruncated on a rune boundary

Three further caps on strings that reach the replay: register.policy ≤ 48 runes, any recorded error text (fallback.detail) ≤ 200 runes, and the whole serialized directive record ≤ 900 runes.

Truncation is on rune (Unicode codepoint) boundaries, never bytes. Slicing a string by byte index on any path to the replay is forbidden: a byte-truncated multi-byte character renders fine in a browser and then fails a strict UTF-8 parser.

Parsing is tolerant: markdown fences are stripped, the outermost balanced {…} is taken if the model prefixed prose, cogs is accepted as a bare object or as an object keyed by id, and numeric strings in target are accepted. Only when no object with a usable entry can be recovered do the retry and then the fallback fire.

The replay bytes

The replay is the starter's binary COWLDFTB format — the same format the static wasm viewer parses. Everything the viewer needs is in the bytes; no server is contacted except S3 for the file.

contentcarries
headermagic COWLDFTB, format version, game name grf-football, game version (GameVersion in src/grf_football/sim_types.nim, 6 today)
config JSONseed, num_agents, maxTicks, halfTicks, turnTicks, every pitch and physics constant, players[].name, slots[].team, fastMode
joinsper seat: name, slot, token
inputsper cog (0..21), on change: the uint8 action byte — the action log
chatsthe register / directive / fallback / budget_guard / stop / result game records, plus the diagnostic state checkpoints
hashesone gameHash per tick — the integrity chain the viewer checks

Action bytes are indexed by cog, not by roster slot, and a player leaving does not shift the arrays: the 22 cogs are fixed for the whole match.

Record vocabulary

kfields
registerseat, team, shirt, id, policy (≤48 runes), kind (llm|scripted), baseline
directiveturn, half, seat, id, team, source (llm|scripted|fallback), latency_ms, note, cogs:[{id,role,intent,target,on_ball,pass_to,sprint,tackle,say}]
fallbackturn, seat, attempt (1|2), cause, detail (≤200 runes)
budget_guardturn, remaining_s
stopreason, rule, tick — a wall-clock fact, see below
resultthe full results document, written once at game over

stop is not in the design note's five-record vocabulary and is a deliberate addition (GV3). A wall-clock stop and a host error are facts about the HOST, not about sim state: nothing in the recorded bodies implies them, so a viewer re-simulating the action log has no way to reach deadline/wall_clock on its own and every such replay diverged from itself at the stop tick. The stop therefore travels as a record, and broadcast.applyRecord applies it by calling the same idempotent finishGame on playback that the server called on recording. tests/test_replay.nim:188-261 re-derives all three of deadline/wall_clock, fault/host_error and fault/sim_fault through it.

The state checkpoint (diagnostic, not a game record)

Every StateDigestTicks (120 ticks = 5 s) the server also writes {"k":"state","t":<tick>,"d":"<field=value …>"}. It is NOT part of the game record vocabulary and nothing in the game reads it: the hash chain says a replay diverged, and this says WHICH FIELD did, so tools/ci/rehash_probe.nim can name the field instead of only the tick. It bypasses the 900-rune record cap for that reason — a shrunk checkpoint would name the wrong field — and a reader that does not know the kind ignores it, as tools/replay_summary.py does.

Every record is capped at 900 runes, on a rune boundary, and an over-long record is shrunk STRUCTURALLY (its string values are clipped) so it always stays parseable JSON.

Reading a replay without Nim

tools/replay_summary.py (Python 3 standard library only — no Nim, no Docker) prints one strict-UTF-8 JSON object describing a .replay:

python3 tools/replay_summary.py /tmp/ep.replay | jq .
{"protocol":"grf-football/v1","gameVersion":"6","seed":679961,
 "names":[…],"aliases":["RED-10","BLUE-10","…"],"policyKinds":[…],
 "tickCount":5760,"directives":[…],"fallbacks":0,"results":{…}}

Derived broadcast events

stepEvents derives these from state deltas during playback, so they cost no replay bytes and are identical live and in replay: phase, gamestart, touch, pass, shot, save, goal, tackle, foul, restart, drop, halftime, turn_end, gameover. The scrubber beats — the subset with a CSS rule in the appended chrome block — are gamestart, goal (red/blue), shot (on target only), save, foul, halftime and gameover. touch is throttled to at most one per cog per 8 ticks.

Results document

Written to COGAME_RESULTS_URI. It equals the manifest's results_schema key for key; that schema is additionalProperties: false and the certifier rejects any unknown field.

{"names": ["daveey", "daveey-1", "grf-football-zonal", "…8…"],
 "scores": [0.667, 0.333, 0.667, 0.333, 0.667, 0.333, 0.667, 0.333],
 "win": [true, false, true, false, true, false, true, false],
 "team": ["red", "blue", "red", "blue", "red", "blue", "red", "blue"],
 "shirt": [10, 10, 9, 9, 7, 7, 6, 6],
 "goals": [0, 1, 2, 0, 0, 0, 0, 0],
 "assists": [1, 0, 0, 0, 1, 0, 0, 0],
 "passes": [14, 11, 9, 8, 12, 10, 16, 15],
 "passesCompleted": [11, 7, 6, 5, 8, 6, 14, 12],
 "shots": [2, 1, 5, 2, 1, 0, 0, 0],
 "tackles": [1, 2, 0, 1, 2, 3, 5, 4],
 "fouls": [0, 0, 0, 1, 0, 0, 1, 0],
 "llmTurns": [24, 24, 24, 24, 0, 0, 0, 0],
 "fallbackTurns": [0, 0, 0, 0, 0, 0, 0, 0],
 "teamGoals": [2, 1],
 "teamShots": [11, 6],
 "teamShotsOnTarget": [5, 2],
 "teamPossessionTicks": [3100, 2660],
 "reason": "complete",
 "endRule": "full_time",
 "finalTick": 5760,
 "seed": 679961}

names are the real policy names (spectator side). team and shirt carry the in-game identity. Team arrays are [red, blue].