Wiki · wire-protocol

Wire protocol

Last edited by · ·

THE CABINET — wire protocol

The player socket

ws://<game>:8080/player?slot=<N>&token=<T> — a bad slot or token is a 403 before the upgrade.

A seat sends exactly one kind of message it authors: its registration, as a Sprite v1 chat frame (0x81), re-sent on the first ~10 s of frames because joins are slot-sequential and the lobby streams frames to a socket before it is admitted (the server holds an unappliable registration rather than dropping it):

{"type":"register","prompt":"<strategy text or empty>",
 "scripted":"bulwark"|"spinner"|null,"policy":"<free label>"}

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. A seat that never registers, or registers with neither field, is bulwark.

After that the seat only receives: one binary Sprite v1 frame per tick, and it answers each with the Ready packet (0x85). SEATS SEND NO INPUTS. The server computes every command byte, so any input mask arriving on a player socket is discarded — which is also why the Ready packet is legitimate here in a way it is not for an ordinary client: there is no dead-reckoned input to corrupt.

The per-seat frame carries the whole board: the arena, all four mouths (open or welded), every paddle, every brick and every ball with its trail. The cabinet is a CRT and the physics is public. Board labels carry only the colour aliases (showPlayerLabels is forced false on the player stream), so no real policy name is ever on a seat's screen.

The board view a policy is asked about

Every 5 s of sim time (120 ticks) the server composes this object, appends it to the seat's PLAYER_PROMPT under a "GUIDANCE FROM YOUR OPERATOR" heading, and asks Claude for a stance. All numbers are in cabinet coordinates (0..100 from the bottom-left corner, x right, y up), rounded to 2 decimals, with along/depth in the seat's own side-local frame.

{"turn": 11, "of": 24,
 "clock": {"tick": 1320, "of": 2880, "left_s": 65.0},
 "rom": "warlords",
 "you": {"alias": "GREEN", "side": "NORTH", "lives": 2, "out": false,
         "paddle": {"along": -6.40, "vel": 0.80, "half": 7.00, "depth": 14.00,
                    "travel_half": 43.00},
         "far_paddle": null, "holding": null,
         "mouth": {"half": 18.00, "open": true},
         "bricks": {"left": 5, "of": 9, "cols": [false, true, "…9…"]},
         "score": 41.750},
 "balls": [{"id": "B1", "state": "live", "pos": [62.10, 71.44],
            "vel": [0.61, -0.42], "speed": 0.74, "deg": 325.4,
            "last_touch": "BLUE", "held_by": null,
            "arrive_at": "GREEN", "arrive_in_ticks": 31,
            "arrive_along": 3.20}, "… ballCount entries …"],
 "rivals": ["… exactly three, alias / side / lives / out / bricks_left / paddle_along / score …"],
 "neighbours": {"plus_along": "YELLOW", "minus_along": "BLUE"},
 "rules": {"starting_lives": 3, "ball_count": 2, "brick_rows": 1,
           "catch_enabled": true, "far_paddle": false,
           "points": {"per_life_kept": 20.0, "crown": 15.0, "knockout": 2.0,
                      "chip": 0.5, "save": 0.25}},
 "your_last_stance": {"…": "the stance this seat set last turn, or null"}}

arrive_along is null when that ball will not reach this seat's line inside the prediction bound; arrive_at names whichever cabinet's line it reaches first. The arrive_* triple is computed by the same walk the autopilot uses, so a policy never has to guess at a quantity the engine already knows.

Hidden from every seat, with no exception: which entrant holds any other seat; any other seat's stance, note, say, prompt, latency, policy label or fallback state; perm; config.seed; the RNG state; every future serve direction; every real player name; and any host or wall-clock fact.

The stance reply

{"note": "BLUE is on 1 life and its wall is down to 2; take the free shot",
 "stance": "aim", "target_ball": "B1", "aim_at": "BLUE",
 "post": 0.0, "lead_ticks": 12, "aggression": 0.8, "say": "BLUE first"}
fieldcap / legal valuesrepair when violated
note≤ 160 runestruncated to 160 runes
stanceguard, aim, camp, catch, chaseunrecognised → last turn's, else guard; catch without catchEnabledguard
target_ball≤ 4 runes, B1B<ballCount> or anyan id outside the set, or a ball not currently live → any
aim_at≤ 8 runes, a colour alias or none; never my own, never an out cabinetunrecognised / missing / self / out → none (the autopilot then behaves as guard)
postfinite, clamped ±43.0, quantised to µunon-finite / missing → last turn's, else 0.0; a value beyond ±43 is read as a percent and rescaled
lead_ticksinteger, clamped 0..48non-finite / missing → 12
aggressionfinite, clamped 0..1, quantised to 0..255a value above 1 is divided by 100; missing → 0.8
say≤ 48 runestruncated to 48 runes, then the printable-ASCII shout sanitiser (which strips a leading {)

Parsing is deliberately tolerant: markdown fences are stripped, the outermost balanced {…} is taken (so prose before or after the object is fine), numeric strings are accepted, stance/target_ball/aim_at are matched case-insensitively and inside prose ("the red cabinet", "ball 2"), and the documented synonyms (defendguard, shoot/attackaim, hold/sitcamp, grabcatch, rushchase) are accepted. Only when no object with at least one usable field can be recovered does the single retry fire, and then the bulwark fallback.

Every recorded string is truncated on RUNE boundaries, never bytes: a byte-truncated multi-byte character renders in a browser and then fails a strict UTF-8 parser.

The results document

COGAME_RESULTS_URI receives exactly these 22 keys — the manifest's results_schema is additionalProperties: false, so adding or removing one here means editing coworld_manifest_template.json in the same commit. Every per-seat array is in seat order and has exactly 4 entries; names are the real policy names, aliases are the in-game ones, cabinets is perm.

{"names": [], "aliases": [], "cabinets": [], "policyKinds": [], "scores": [],
 "win": [], "placements": [], "rom": "warlords", "startingLives": 3,
 "livesLeft": [], "concedes": [], "knockouts": [], "chips": [], "saves": [],
 "catches": [], "bricksLeft": [], "llmTurns": [], "fallbackTurns": [],
 "finalTick": 2604, "reason": "complete", "endRule": "last_standing",
 "seed": 5140913}

The /global spectator snapshot and the replay

ws://<game>:8080/global streams the same binary sprite protocol plus the broadcast chrome JSON, which rides as the label of a reserved never-drawn 1×1 sprite (id 4090) — the only channel that survives a hosted replay. The state frame keeps the starter's key names (t, mt, ph, lob, pl, sp, mx, st, lp, sk, ff, en, mm, bs, pov, teams, roster, events, lead, beats, lulls, over, hold) so the byte-identical chrome_common.js runs unmodified; everything cabinet-specific lives under cab and stances.

The replay is the starter's binary COWLDCAB format:

contentcarries
headermagic COWLDCAB, format version, gameName atari-cabinet, gameVersion
config JSONseed, rom, the fully resolved ROM preset, perm, num_agents, maxTicks, turnTicks, the whole geometry table, the reward constants, players[].name (real names), slots[].alias, fastMode
joins / leavesper seat: name, slot, token
inputsthe action log: one command byte per seat per tick, written on change only
chatsregister / stance / fallback / budget_guard / result control records
hashesone gameHash per tick

tools/replay_summary.py (Python 3 stdlib only) turns those bytes into one strict-UTF-8 JSON object for forensics, and /replay-data serves them back from a replay-mode server.

Runtime contract

COGAME_CONFIG_URI, COGAME_RESULTS_URI, COGAME_SAVE_REPLAY_URI, COGAME_LOAD_REPLAY_URI, COGAME_PLAYER_FAILURE_URI, COGAME_EVENTS_URI, COGAME_METRICS_URI, COGAME_HOST, COGAME_PORT — the starter's, unchanged. GET /healthz and GET /global keep answering for a bounded ~20 s after the artifacts are written, because the episode runner pings /global with a 2 s deadline after the player pods start and a short episode can already be gone.