Skip to main content

Wiki · replay-format

Replay format

ReadHistory
Last edited byDavid Bloomin··

The replay format

One UTF-8 JSON document at COGAME_SAVE_REPLAY_URI, self-sufficient by re-derivation, not by bulk.

{"format":"cogame-battlecode-replay","version":1,
 "protocol":"cogame.battlecode.v1","game_version":"GV03","year":"bc26",
 "config":{ /* the resolved game config, tokens EXCLUDED */ },
 "seed":871345,
 "aliases":["Clan Ash","Clan Basil"],
 "names":["daveey","daveey-1"],       // spectator-side only; agents never see these
 "seats":[{"slot":0,"alias":"Clan Ash","name":"daveey","policy":"llm",
           "sheet":{…as applied…},"sheet_submitted":"{…as received…}",
           "sheet_defaults_applied":["cat_trap_budget"],
           "sheet_unknown_fields":["swarm_mode"],
           "notes":"…","motto":"…","decision_ms":8123,
           "prompt":{ /* THE OBSERVATION: the payload composed for this seat */ },
           "fallback":null,"fallback_detail":null}, …],
 "prompt_preamble":"…",                 // the system half, identical per seat
 "games":[{"index":0,"map":"DefaultSmall","map_json_sha256":"…",
           "sides":["A","B"],"side_a_slot":0,"rounds":451,
           "hash_chain_sha256":"…",        // the chain after the LAST round
           "hash_chain_rounds":"…"}],      // 16 hex digits per round, in order
 "plan":{"maps":[…],"side_a_slots":[…],"abandon_after":[…],"max_rounds":2000},
 "events":[ … ],
 "result":{ /* identical to COGAME_RESULTS_URI */ }}

Decisions are taken server-side, so a seat's observation is the prompt payload the server composed for it: seats[].prompt is that object verbatim and prompt_preamble is the system half both seats received (recorded once — it is the same string). A seat that never called the provider records null. When a seat falls back, fallback names the cause in one word and fallback_detail carries the provider's own last words, cut to MaxFallbackDetailRunes (200) on a rune boundary.

Why this is enough

Names, config, seed, the map identity (with a sha256 of the committed converted map the bundle also ships), both doctrine sheets and the event list are all in the file, and the wasm sim replays every round from them. No engine bytes, no per-round state dump, no server contacted except S3 for the .replay file.

The hash chain lets the viewer prove its re-derivation matches the recording. hash_chain_rounds carries the chain as it stood at the end of every round (16 hex digits each, in round order), so the deriver compares one round at a time and bc_mismatch_round names the first divergent round — not merely the game's last, which is all a single per-game value can say. hash_chain_sha256 is the last round's value and is checked on its own, so a recording whose two records disagree is caught as well. static_replay.js writes the round onto <html> as data-replay-mismatch-round and the page shows it in #mmwarn.

Seven per-team stats enter the chain each round: cheese transferred, damage to cats, the packed kings + 10 × team cheese, baby rats, dirt (carried and placed), rat traps standing and cat traps standing.

The wall-clock deadline stop is recorded as one load-bearing record (plan.abandon_after[g]) applied by the same code on record and on playback, so a replay never re-derives to a different length than it was recorded at.

Event vocabulary

Pre-match events carry ms; in-match events carry game and round.

kindfieldsdrawn as
episode_startseed, year, maps, aliasesfeed line
doctrine_requestedslot, attempt, deadline_msfeed line
doctrine_receivedslot, attempt, latency_ms, defaults_applied, unknown_fieldsfeed line
doctrine_retryslot, cause (timeout | parse | throttled | transport)feed line (amber)
doctrine_fallbackslot, causefeed line (red)
game_startgame, map, sidesbeat game
king_builtgame, round, alias, kings_nowbeat king
backstabgame, round, by_alias, trigger (bite | ratnap | throw | trap)chapter marker: beat backstab, the scorebug flips COOPERATION → BACKSTAB
cat_fedgame, round, aliasbeat cat
game_endgame, round, winner_alias, end_reason, pointsbeat end
game_abandonedgame, round, mapbeat end
episode_endreasonendcard

The viewer

"replay_viewer": {"bundle": "static-replay-viewer"} — a static bundle, never a pod. tools/build_replay_viewer.sh compiles the same sim module to wasm (replay-viewer/bc_replay.nim, emscripten) and bundles it with the starter's chrome_common.js and broadcast_core.js (byte for byte) and the sprite atlas. One frame is one round: the browser steps runRound and emits bitworld sprite packets, so the viewer never re-implements a rule.

Load signalling: static_replay.js sets data-replay-loaded="true" on <html> on the first drawn frame (the worker's loaded message after the first board frame is composited), and the coworld-replay bridge posts ready from a callback fired after that attribute is set. On any failure it sets data-replay-error="<message>" and shows the failure card.


bc20

A bc20 recording is the same document with "year": "bc20". Everything the format promises holds unchanged — self-sufficient by re-derivation, no per-round state dump, no engine bytes — and three things are worth naming.

No .bc20 bytes, and no blockchain dump. The chain is a pure function of the sim, so the browser re-derives every block from the events, the config and the seed, and #bc20-chain on the endcard reads the re-derived blocks. There is no match_b64 field and there never was one.

The chassis rides on the seat, not in the sheet. chassis is not a doctrine knob in either year (D1). bc26 keeps a chassis field inside its applied sheet because its filler path sets it there; bc20 records it as a sibling of policy on the seat:

 "seats":[{"slot":0,"alias":"Clan Ash","name":"daveey","policy":"llm",
           "chassis":"bowl-of-chowder",
           "sheet":{"opening":"lattice", …the ten knobs…},
           "sheet_unknown_fields":["chassis"],   // if a reply sent one
           …}]

The deriver reads it back, because it has to run the bot the recording ran or every round mismatches.

results.games[] carries the year's own statistics. The five keys map, side, rounds_played, winner and end_reason are required and year-neutral; each year's statistics are optional siblings and the two sets do not collide. end_reason's enum is the union of both years' DominationFactor renderings plus our own wall-clock abandoned. This is deliberately not a nested stats object: nesting would change the bytes every shipped bc26 replay's result block carries.

bc20's optional keys, each a 2-array in seat order unless marked scalar: hq_alive, hq_lost_round (−1 if alive), hq_lost_cause (buried | drowned | none), soup_mined, soup_refined, net_worth, units_alive, units_built, miners_built, landscapers_built, drones_built, vaporators_built, net_guns_built, dirt_moved, drone_pickups, drone_water_drops, net_gun_kills, transactions_sent, transactions_minted, blockchain_soup_spent; scalars global_pollution_peak, flooded_tiles_end, water_level_end.

The bc20 event vocabulary

Ten beat kinds, and every one has CSS in client/replay_broadcast.html.

kindfieldsbeat
episode_startseed, year, maps, aliases—
doctrine_requested / _received / _retry / _fallbackslot, attempt, cause…doctrine
game_startgame, map, width, height, sidesgame
flood_stagelevel (1…7), flooded_tilesflood
first_buildalias, unitbuild
wall_closedalias, min_ring_elevationwall
rush_launchedalias, unitsrush
drone_water_dropalias, victim_alias, victim_unitdrop
hq_buriedalias (victim), by_alias, dirtbury (chapter marker)
hq_drownedalias (victim), water_leveldrown (chapter marker)
game_end / game_abandonedwinner_alias, end_reason, pointsend
episode_endreason—

flood_stage fires once per integer level reached, so a 1499-round game emits at most six; first_build fires once per team per unit kind.

first_build.unit is spelled with the engine's own RobotType names, which is why the drone is delivery_drone and not drone: miner, refinery, vaporator, design_school, fulfillment_center, landscaper, delivery_drone, net_gun. (hq and cow complete the type list and are never built by anyone, so they never appear.) The design note's shorter list — six kinds, with drone for the drone — is the subset it expected the chassis to put up; the chassis also builds miners and a refinery (§Divergences item 16 in docs/RULES-BC20.md), and naming a beat after anything but the type that was built would make the feed line disagree with the sim. Every one of them draws as the build beat, which has CSS.

hq_buried and hq_drowned are derived from the recorded per-game statistics rather than from a sim event, so the same two facts drive the endcard, the scrubber and results.games[].


bc21

A bc21 recording is the same document with "year": "bc21" and "game_version": "GV06". Everything the year-neutral reader looks at is in the same place; the differences are the sheet, the per-game statistics and the event vocabulary.

No .bc21 bytes, no per-round state dump, and no flag or bid dump. Flags and the auction are pure functions of the sim, so the browser re-derives them from events + config + seed: #bc21-votes and #bc21-bids read the re-derived tally, and tests/test_bc21_replay.nim asserts that nothing about either is stored. There is no match_b64 field.

seats[].chassis records the year-neutral ScriptedChassis string — california-roll or examplefuncsplayer21 on bc21 — beside the policy kind, because bc21 has no chassis field on its doctrine (D1).

bc21's optional per-game keys, each a 2-array in seat order unless marked scalar: centers_owned, centers_captured, centers_lost, neutrals_captured, votes, bids_placed, bid_influence_spent, top_bid, influence_spent, influence_end, income_end, units_built, politicians_built, slanderers_built, muckrakers_built, units_alive, politicians_alive, slanderers_alive, muckrakers_alive, empowers, empower_conviction, conversions, exposes, buff_peak, camouflaged, robots_lost; scalars votes_tied and rounds_no_bid. units_built and units_alive are reused from bc20 with the same meaning and type.

end_reason gains the 2021 DominationFactor values in snake_case: annihilated, more_votes, more_enlightenment_centers, more_influence. coin_flip and abandoned were already there.

The bc21 event vocabulary

Every kind is bounded per game — a 1500-round match with hundreds of robots cannot be allowed to emit an event per empower — and every one has CSS in the appended bc21 game block. tests/test_bc21_replay.nim asserts each bound against a real 1500-round match.

kindbound / gamebeat
game_start1game
first_build6build
center_taken24capture
vote_lead (only when the lead changes hands)40votes
bid_spike (the largest bid per 100-round window per team)30bid
expose_wave (each 5 % step of the speech buff)20expose
empower_big (converts a Centre, or removes ≥ 200 enemy conviction)40empower
annihilated1wipe (chapter marker)
game_end1end
game_abandoned1end

The pre-match doctrine_* events and episode_start / episode_end are year-neutral and unchanged.

bc24

year: "bc24", game_version: "GV07", everything else the same shape. The replay stores the events, the config, the seed, both doctrine sheets, the chassis each seat drove and the per-round hash chain — and nothing else. Traps, water, crumb piles, skill levels, flag positions and the jail rail are pure functions of the sim, so the browser re-derives them and the endcard reads the re-derived totals. There are no .map24 bytes, no per-round state dump, no per-duck dump and no trap dump anywhere in the document, and tests/test_bc24_replay.nim asserts each of those absences by string.

The bc24 event vocabulary

Every kind is bounded PER GAME — a 2000-round match with a hundred ducks cannot be allowed to emit an event per attack — and every one has CSS in the appended bc24 game block.

kindfieldsboundbeat
game_startmap, width, height, sides1/gamegame
setup_endtraps (per clan), teleported2/gamesetup
first_actionalias, action4/gamebuild
flag_takenalias, flag, x, y≤ 24/gamesteal
flag_droppedalias, flag, x, y, cause≤ 24/gamereturn
flag_returnedalias, flag≤ 24/gamereturn
flag_capturedalias, flag, total≤ 6/gamecapture
trap_wavealias, triggered_total, damage_total≤ 20/gametrap
upgradealias, upgrade≤ 6/gameupgrade
masteryalias, skill, level≤ 9/gamelevel
routalias, jailed≤ 20/gamerout
game_endwinner_alias, winner_slot, end_reason, points1/gameend
game_abandonedmap≤ 1/gameend

first_action's field is action, not the design note's kind. MatchEvent.toJson flattens fields into the same object as the event's own kind key, so a field called kind SILENTLY OVERWRITES THE EVENT KIND and the replay comes back carrying events of kind move and spawn. bc20 and bc21 avoided it by calling their field unit; bc24 calls its field action, and tests/test_bc24_replay.nim asserts that no first_action event carries a field called kind.

action takes its value from Bc24ActionNames (spawn, move, attack, heal, build, dig, fill, pickup, drop, upgrade), upgrade from Bc24UpgradeNames (attack, capture, heal — TeamInfo's own slot order) and mastery.skill from Bc24SkillNames (attack, build, heal).


bc16

year: "bc16", game_version: "GV11", everything else the same shape. The replay stores the events, the config, the seed, both doctrine sheets, the chassis each seat drove and the per-round hash chain — and nothing else. Rubble, parts, the zombie horde, infection timers, delays, den queues and the tiebreak ladder are pure functions of the sim, so the browser re-derives them and the endcard reads the re-derived totals. There are no map bytes, no per-round state dump, no per-robot dump and no rubble grid anywhere in the document, and tests/test_bc16_replay.nim asserts each of those absences by string.

Rounds are ZERO-BASED in bc16. The engine counts from round 0 and the tiebreak fires at the end of round 2999, not 3000 — the only year in this repository that does. years/dispatch.nim's currentRound arm therefore returns world.currentRound + 1 so that the replay's chain index stays 1-based like every other year's; the events carry the engine's own zero-based round.

The bc16 event vocabulary

Every kind is bounded PER GAME — a 3000-round match with a hundred robots and a horde cannot be allowed to emit an event per bite — and every one has CSS in the appended bc16 game block.

kindfieldsboundbeat
game_startmap, width, height, sides, archons, dens, parts_on_map, neutrals, schedule_rounds1/gamegame
first_actionalias, action2/gamebuild
unit_milestonealias, unit, total≤ 10/gamebuild
zombie_wavecounts (4-array), total, dens_spawning, outbreak_level≤ 30/gamewave
outbreaklevel, multiplier_permille≤ 10/gameoutbreak
den_destroyedalias, x, y, dens_left, bounty, queue_deleted≤ 12/gameden
neutral_activatedalias, unit, x, y, total≤ 20/gameactivate
infectionalias, victim_unit, source, turns≤ 20/gameinfect
turnedalias, unit, became, x, y, outbreak_level≤ 24/gameturned
archon_lostalias, archons_left, cause≤ 8/gamearchon
routalias, lost≤ 20/gamerout
duellost (2-array)≤ 20/gameduel
tiebreakrung, archons (2), archon_health_tenths (2), parts_worth (2)≤ 1/gameend
game_endwinner_alias, winner_slot, end_reason, points, archons1/gameend
game_abandonedmap≤ 1/gameend

first_action's field is action, not kind. MatchEvent.toJson flattens fields into the same object as the event's own kind key, so a field called kind SILENTLY OVERWRITES THE EVENT KIND — the bc24 scar. bc16 follows bc24 and calls it action.

action takes its value from Bc16ActionNames (clear_rubble, move, attack, broadcast, broadcast_message, build, activate, repair, pack, unpack, disintegrate — eleven, because bc16 has twelve unit types and naming the unit would say less than naming the act), unit / victim_unit / became from Bc16UnitNames (RobotType's own ordinals: zombieden, standardzombie, rangedzombie, fastzombie, bigzombie, archon, scout, soldier, guard, viper, turret, ttm) and tiebreak.rung from Bc16RungNames (Domination's own ordinals: -, archons_destroyed, more_archons, more_archon_health, more_parts_net_worth, highest_id).

Four names bc16 shares with other years

first_action, rout, duel and archon_lost are spelled the same by bc22, bc23 and bc25. Three of them carry the same shape; archon_lost does not — bc22 carries gold_dropped and bc16 carries cause — so broadcast.nim's label switch tests the replay header's year there, and only there. The beat kind is archon for both, and the CSS that colours it is scoped per year (html[data-year="bc16"] .beat-marker.archon).

outbreak and zombie_wave travel as integers

The outbreak multiplier is an integer per-mille (1000, 1100, … 3000) rather than a float, so the replay document stays float-free and re-derives bit for bit in wasm. The viewer renders it as 1.1x. This is also the field that cost a defect: the label originally read multiplier, which does not exist, and a missing key in a JsonNode {} lookup reads back as zero — so the spectator was shown 0 zombies from 0 dens. tests/test_bc16_beats.nim now asserts each label against the value in the event that produced it, not just against the words.

The thirteen beats

doctrine, game, build, wave, outbreak, den, activate, infect, turned, archon, rout, duel, end — and all thirteen are emitted by the committed fixture tests/fixtures/replay-bc16.json, so tests/test_bc16_beats.nim proves emission, label and CSS together rather than inventorying a hand-written list.


The bc19 payload

game_config.year = "bc19" selects Battlecode 2019 "Crusade". The protocol id is unchanged (cogame.battlecode.v1): the wire shape is identical and only the year-dependent payload differs.

The observation is a SEALED ONE-SHOT brief and there is no per-round observation of any kind. It carries, per game: map, map_seed, width, height, you_are (RED or BLUE), rounds, rounds_are_one_based, symmetry and a symmetry_note saying which midline the board is mirrored across, passable_squares / total_squares / passable_pct, both orders' castle positions, castle_separation_min and _max, and a karbonite_depots / fuel_depots block with the total, the per-side count, the nearest one to you in steps, and a note saying what a pilgrim standing on one actually does. Both orders' castle positions are given because they are not secret: every robot is handed the complete terrain, karbonite and fuel maps on its first turn (coldbrew/game.js:728-730) and the board is a mirror, so the enemy castles are derivable in a few operations on turn 1.

Beside the map cards the brief carries the economy block (the flat 25-fuel trickle, the mining yields and capacities, the reclaim formula and the barter), the six unit types with their exact costs, health, ranges — including the PROPHET's r² 16 MINIMUM and the PREACHER's nine-square team-blind blast — speeds, fuel per r² and vision, the combat rules (no vision test, no team check, no path check), the two comms channels, a HOW A GAME ENDS block, the eleven-knob sheet_schema with every default and range, the scoring weights and the deadlines.

Hidden: the opponent's doctrine, sheet, notes and motto (sealed and simultaneous, never sent in either direction at any time); the opponent's real player name; every in-match state; the other seat's fallback status; and robot.time, which is wall-clock derived and would make the prompt non-reproducible (divergence V7).

results.games[] carries bc19's optional statistics beside the five year-neutral required keys; end_reason is one of castles_destroyed, more_castles, more_unit_health, coin_flip or abandoned, and win_condition carries the engine's own integer beside it, so a replay always traces back to a branch of isOver — including the round-1000 all-square case, which the engine records as 1 while the winner really is a coin flip (docs/RULES-BC19.md).