Wiki · wire-protocol

Wire protocol

Last edited by · ·

particle-worlds — wire protocol

Two audiences, two frames. A seat gets the player websocket and a Sprite v1 binary frame per tick; a spectator gets the global websocket and one JSON state object per presentation frame. Both are produced by the same server from the same sim, so live and replay are identical.


Routes

RoutePurpose
GET /healthzliveness. Answers for a bounded ~20 s grace after the artifacts are written.
GET /player?slot=N&token=Tthe seat websocket. A bad slot or token is 403.
GET /globalthe spectator websocket. Answers a WebSocket Ping with a Pong.
GET /client/globala real spectator page. Registered before any catch-all asset route, and it never opens the player socket.
GET /client/playera real seat page. Same two rules.
GET /client/replaythe replay page.
GET /replay-datathe replay bytes of the episode in progress.
GET /rewardthe live per-seat reward stream.

Runtime contract

COGAME_CONFIG_URI, COGAME_RESULTS_URI, COGAME_SAVE_REPLAY_URI, COGAME_PLAYER_FAILURE_URI, COGAME_LOAD_REPLAY_URI, COGAME_EVENTS_URI, COGAME_METRICS_URI, COGAME_HOST, COGAME_PORT. The game container also reads ANTHROPIC_API_KEY_URI (secret://coworld/particle-worlds/anthropic_api_key) — the decision layer runs in the game server, so that is the only container that needs a key.


The seat side

Registration

A seat sends one Sprite v1 chat message (0x81) carrying its registration, and re-sends it ten times about a second apart over the first ~10 s of frames. Joins are slot-sequential, so a seat whose slot is not the next open one is not admitted until the lower slots have joined; the server holds an unappliable registration and re-reads it when the slot lands.

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

The prompt is a secret: it is consumed as registration, never applied as a bubble and never written to the replay. What the replay gets is a redacted register record — the policy label and kind only. Any other chat text from a seat is dropped: particles emit symbols, seats do not chat.

A seat that never registers, or registers with neither field, is scripted: "drifter".

What a seat sends and receives

A seat sends no inputs at all — every actuator mask is computed by the server's control layer. It sends the Sprite v1 Ready packet (0x85) after each received frame, which is legitimate precisely because it never dead-reckons an input of its own, and it lets a fastMode server advance as soon as every seat has acknowledged the frame.

The frame a seat receives is the inherited Sprite v1 stream: the static board, the walkability sprite, all four particles, all four marks, and the four symbol bubbles. With fullyObservable: true the per-seat visibility mask is all-visible: MPE is a fully observable environment, so hiding positions would add a search puzzle the game never asks for and subtract the one it does — inference from behaviour and from symbols.

The per-seat view the decision layer builds

Numbers are map pixels, rounded to integers. This is a crypto round as Bob:

{"round": 3, "of": 4, "mode": "crypto",
 "turn": 5, "turns": 10, "clock": {"played_s": 22, "left_s": 23},
 "field": {"w": 1235, "h": 659, "centre": [617, 329]},
 "you": {"id": "BLUE-alpha", "role": "listener", "anchored": false,
         "pos": [640, 300], "vel": [-40, 210], "speed_px_s": 51,
         "accel_px_s2": 229, "max_px_s": 103},
 "marks": [{"i": 0, "pos": [300, 180], "colour": "amber", "r": 18}, "… 4 …"],
 "agents": [{"id": "RED-alpha", "role": "speaker", "anchored": true,
             "pos": [617, 79], "vel": [0, 0], "colour": "red"}, "… 4 …"],
 "radio": [{"id": "RED-alpha", "now": "F", "last": "F"}, "… 4 …"],
 "secret": {"goal": null, "goal_colour": null,
            "key": [["D", "amber"], ["F", "teal"], ["B", "violet"], ["G", "bone"]]},
 "beliefs": [{"id": "BLUE-alpha", "nearest_mark": 1, "settled_ticks": 12}, "…"],
 "score": {"this_round_so_far": 0.58, "rounds_banked": [0.83, 0.44],
           "episode_so_far": 0.62},
 "your_last_directive": "wait for F, then break for teal from the far side"}

secret is the only mode-conditional block, and a seat that is not entitled sees null in it — never an absent key, so a model never has to distinguish "hidden" from "malformed".

modewho sees goalwho sees keyextra
spreadnobodynobodycover_pct, your own bumps
deceivethe three good agentsnobodythe adversary also gets goal_is_one_of: [0,1,2,3]
cryptothe speaker onlythe speaker and the listener
tagnobodynobodycontact flags, tag_ticks

Hidden from every seat, always: the other seats' orders for the turn being decided (all four decide simultaneously — which is exactly why the radio matters); every seat's note, ever; every seat's PLAYER_PROMPT; the identity of any policy; the episode seed; the RNG state, and therefore the next round's mode, mark layout, colour permutation, goal and key.

Reply

See docs/RULES.md §Orders for the schema and the per-field caps. Parsing is tolerant: markdown fences are stripped, the outermost balanced {…} is taken if the model prefixed prose, cogs is accepted as an id-keyed object, numeric strings are accepted for coordinates, and the intent is normalised. Only when no object with at least one usable entry can be recovered do the single retry and then the scripted fallback fire.

Every recorded string is truncated on rune (Unicode codepoint) boundaries, never bytes: note ≤ 160 runes, register.policy ≤ 48, fallback.detail ≤ 200, the whole serialized directive record ≤ 900, and register.prompt ≤ 4000 at the transport (truncated, never rejected, and never written to the replay or the results).


The spectator side

The state JSON

One object per presentation frame, and the only thing the renderer reads. The inherited keys are unchanged: t (tick), mt, ph, lob, pl, sp, mx, st, lp, sk, ff, en, mm (hash-mismatch tick), bs (board scale), pov, teams, roster (per particle: s, team, name — the real policy name, spectator side — pol, col, alias, seat), events, directives, lead (sent once), and the static minimap silhouette. Particle worlds adds:

{"round": 3, "rounds": 4, "mode": "crypto", "turnTicks": 108, "turn": 5, "turns": 10,
 "marks": [{"i":0,"x":300,"y":180,"c":"amber","near":142,"close":716,"goal":false}, "… 4 …"],
 "comm": [{"seat":0,"sym":"F","since":432}, "… 4, seat order …"],
 "cover": 0,
 "crypto": {"goal": 1, "colour": "teal",
            "key": [["D","amber"],["F","teal"],["B","violet"],["G","bone"]],
            "beliefs": [{"seat":1,"mark":1,"settled":12,"right":true}, "…"]},
 "tag": {"contact": [false,false,false,false], "ticks": 0},
 "roundScores": [[912,688,402],[907,301,771],[884,702,771],[869,694,108]],
 "livePermille": [612, 388, 500, 500],
 "episodePermille": [641, 598, 487, 410],
 "bumps": [14, 9, 22, 6],
 "roles": ["eavesdropper","listener","speaker","eavesdropper"]}

crypto is present only in a crypto round, tag only in a tag round, cover only in spread; marks, comm, roundScores, roles, livePermille, episodePermille and bumps are on every frame. The crypto block is the spectator's view — the key and the goal are revealed to the audience, which is why it is in the frame and in no seat's observation.

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; roundstart {round, rounds, mode, roles}; word {by, alias, symbol, turn}; firstword (the first non-silent symbol of a round); bump {a, b} (throttled one per pair per 12 ticks); cover {pct} (spread only, on crossing a 10 % band); onpoint {who, mark, markColour}; decode {who, mark, markColour, right, settled}; tag {by, seconds, tick} (a contact after ≥ 12 quiet ticks, throttled to one beat per 48 ticks); roundover {round, mode, permille[4]}.

Scrubber beats — the only kinds the appended chrome block emits, and the only kinds it ships CSS for: roundstart, firstword, onpoint, tag, roundover. Bounded by construction: 4 + ≤ 4 + ≤ 8 + ≤ 22 + 4.

Tier-2 analysis stream

COGAME_EVENTS_URI gets JSON lines. Particle worlds emits phase, directive, symbol, bump, tag, onpoint, decode and roundover, plus the mandatory trailing summary row (type, ticks, events, gameVersion).


The replay

A binary COWLDMPE file, self-sufficient: everything the viewer needs is in the bytes and no server is contacted except S3 for the file.

ContentCarries
headermagic COWLDMPE, format version, game name particle-worlds, game version
config JSONseed, num_agents, mapSpec (the full resolved field geometry), maxTicks, maxGames, rounds, turnTicks, every physics/scoring constant, players[].name (real names), slots[], tokens[], fastMode, fullyObservable
joinsper seat: name (real policy name), slot, token
inputsper particle (0..3), on change: the uint8 actuator mask — the action log
chatsroundcard / register / directive / fallback / budget_guard / stop / result records
hashesone gameHash per tick — the integrity chain the viewer checks

Chat records

kFields
registerseat, alias, colour, policy (≤ 48 runes), kind (llm|scripted), baseline
roundcardround, mode, roles (4 role names, seat order), goal, goal_colour, key (4 [symbol, colour] pairs, null outside crypto), marks (4 [x, y, colour])
directiveround, mode, turn, seat, alias, role, source (llm|scripted|fallback), latency_ms, note, cogs:[{id, intent, target, face, symbol}]
fallbackround, turn, seat, attempt (1|2, the attempts the seat spent), cause, detail (≤ 200 runes) — exactly ONE per seat per turn, so a count of these records equals sum(results.fallbackTurns)
budget_guardturn, remaining_s
stopreason (deadline), rule (wall_clock), tick — the engine's wall-clock stop, written at the tick it fired. The ONE record playback applies into hashed state
resultthe full results document, written once at episode end — this is what makes the bytes self-sufficient

Chat records are re-applied at playback into non-hashed sim fields — the broadcast feed, the radio strip and tools/replay_summary.py — with exactly one exception: stop. The engine's wall-clock deadline is a wall-clock fact that does not follow from the sim's state, so no re-simulation can derive when it fired; the server records it and playback applies it through the same applyWallClockStop the server called, at the same tick and before that tick's step. That is what makes a deadline episode's final recorded hash re-derivable, and what lands the viewer on the same GameOver, winner and banked rounds the result record carries.

The mark layout, the colour permutation, the mode/role schedule, the goal and the key are all re-derived from the seeded RNG rather than being load-bearing records. The roundcard record is a convenience for tools/replay_summary.py, its only reader: playback drops it, so nothing compares it against the re-derivation. All of those values are in gameHash, so a divergence in any of them surfaces as a hash mismatch at the tick it happens. That is why the file stays around 300 KB and why a hash mismatch is a real integrity signal rather than a rendering nit.

Determinism

The server writes one gameHash per tick. In the browser the wasm bundle re-steps the same sim module from the recorded masks and compares its own gameHash() against the recorded one every tick, so a single divergent bit is caught at the tick it happens and surfaced as mismatchTick in #mmwarn.

gameHash carries, appended after the inherited mixes so their ordering stays stable: per particle (x, y, carryX, carryY, velX, velY, aimBrads, bumps, roundAccum, tagCredit, nearestMark, settledTicks, decodedMark, onPointDone, tagContact); roundIndex, modeCode, roleIndex[0..3], perm[0..3], spawnOffsetBrads, goalLandmark, keySymbols[0..3], landmarks[i].(x, y, colourCode), coverAccum, tagTicks, and every banked roundLog entry. commSymbol / commTurn, the directive and the note are excluded — the starter's rule for anything a commander says.

All new sim arithmetic is integer only. Nim's int is 32-bit under --cpu:wasm32 and the wasm build re-derives every tick, so accumulators use int64 intermediates where a 1080-tick sum of permille could approach the 32-bit range.

Results

Written to COGAME_RESULTS_URI, and equal to the manifest's results_schema key for key — that schema is additionalProperties: false. Exactly 22 keys; see the manifest and src/mpe/roster.nim's particleResultsJson. The ten seat-indexed arrays have exactly num_agents = 4 entries; the six round-indexed arrays carry one entry per round played (1..4).

bumps is the last round's bump ticks per seat, not an episode total: the counter is zeroed at the start of every round because the spread debit is a per-round term. Every other seat-indexed array (roundScores, llmTurns, fallbackTurns, means) is per episode.