Skip to content

Replay format

A replay (.crd) records one run: the settings it started from, every tick's inputs, and the result the recording game derived when the run ended. A verifier re-simulates the ticks and must derive the identical result.

Where replays are kept

Every client records its runs: the Python port's live game in every mode, and the web and native clients' sessions (crimson-core/host/replay.inc), which play Survival, Rush and quests. Each run's replay is saved in the runtime directory as replays/<n>-<mode>.crd, with <mode> one of survival, rush, quest, typo and tutorial. n is one more than the highest number handed out so far; replays/.last-number keeps it, so a deleted replay's number is never reused. A file appears only once it is written whole. The run's high score record names its replay by n (local high scores). A leaderboard run downloaded for watching is kept as replays/online/<run id>.crd.

Envelope

A file is exactly one zstd frame (no trailing bytes) whose content is a msgpack payload. Limits: 65 MiB file, 64 MiB payload, 8 MiB zstd window. Writers enforce the same limits, so a very long run can outgrow the format; the game then logs that the replay was not saved.

The payload must be canonical: it must equal the byte-for-byte encoding of the value it decodes to. Concretely:

  • maps are encoded with their keys in the declared order below, every key present, no duplicates, no extra keys;
  • integers use the shortest msgpack encoding (positive values as fixint/uint*, negative values as fixint/int*);
  • every float field is a msgpack float64 (never float32, never an integer) holding a finite value exactly representable as float32;
  • strings, arrays and maps use their shortest length headers;
  • null is msgpack nil, booleans are msgpack booleans.

One canonical byte string per replay means the SHA-256 of the payload identifies the run, and no two parsers can disagree about which duplicate or alternative encoding "wins".

Payload

Replay is a map:

Key Type Meaning
format_version int 34
game_version str The build that recorded the run, which boards are versioned by (see below)
rules int The simulation rules the run plays under (since v31)
recorder Recorder The program that recorded the run (since v30)
pilot Pilot or nil The program that played the run, when one did and says so (since v32)
seat_keys array of bin or nil Each seat's leaderboard public key, when the run names who plays which seat (since v34)
run RunSpec Run start settings
result RunResult Result the recorder derived
ticks array of Tick At least one tick

game_version identifies the recording implementation as <package version>+build.<24 hex characters>. The full SHA-256 fingerprint is in its build manifest. It hashes actual source inputs and the build recipe; Git commit and dirty status are provenance beside the identity, not part of it. Python distributions carry generated _build.json metadata; editable checkouts hash current inputs, including uncommitted files. The recovered module includes its compiler/target recipe. A tagged release keeps its fingerprint too.

Existing plain release versions and +g<commit>[.dirty] labels remain readable. Neither label style decides compatibility: rules gates playback and the service still re-simulates ranked runs.

rules is a number each build carries (REPLAY_RULES), raised whenever a change makes earlier replays play differently: a replay plays back only under the rules it was recorded under. Readers accept v30, which had no rules, as rules 1, v30 and v31, which had no pilot, as declaring none, v30 to v32, which had no run.custom_quest, as playing stock quests, and v30 to v33, which had no seat_keys, as naming no seat's key; writers always write v34.

One change played earlier replays differently without raising rules: original bug 40, the reload key in co-op with preserve_bugs off. A co-op replay recorded before it that pressed Reload can drift; one-player and preserve_bugs replays play as before.

Recorder is a map of client (crimson for the Python port, crimson-core for the web and native clients), version (that client's own content identity, in game_version's form) and platform (the Python port's <os>-<cpu>, such as macos-arm64; web or native from the other clients; unknown for the fixtures recorded before v30). Each is 1..64 printable ASCII characters. The packaged web/desktop host supplies its own fingerprint, covering the game module bytes, client sources and client toolchain. A raw module uses its game identity as the default recorder version.

Verification ignores it: game_version names the build, recorder who recorded the run, so boards can show, filter or withdraw runs by client.

Pilot is a map of name (the bot's name, 1..31 printable ASCII characters), model (the model or tool behind it, such as gpt-5 or TAS) and url (an https:// page about it, such as the harness's repository), the last two empty when not given: at most 64 and 200 printable ASCII characters. Verification ignores it; a run that declares one ranks on the bot boards (bots and moderation). The Python port writes it when CRIMSON_PILOT_NAME is set, with CRIMSON_PILOT_MODEL and CRIMSON_PILOT_URL.

seat_keys binds a co-op run's signatures to its seats: entry i is the 32-byte Ed25519 public key (leaderboard identity) of the player in seat i, as msgpack bin. When present it has exactly player_count entries, all distinct. The payload's SHA-256, which every upload signature covers, covers the keys too, so the leaderboard accepts a two-player run only when it is signed by exactly the keys its seats record, one signature each: a partner who holds the replay cannot sign both seats with keys of their own. The seats' keys must be known when the run starts, so co-op peers agree on them in the replay header (network co-op). Verification ignores them, as it does recorder and pilot.

RunSpec

Key Type Meaning
game_mode_id int 1 Survival, 2 Rush, 3 Quests, 4 Typ-o, 8 Tutorial
seed u32 CRT RNG state entering gameplay_reset_state() at run start
quest_level {major, minor} map or nil A stock quest, 1..5 / 1..10; quests carry it or custom_quest, other modes neither
player_count int 1..4; Typ-o and Tutorial require 1
hardcore bool The saved Hardcore box; outside quests it applies only with preserve_bugs (original bug 38)
preserve_bugs bool Native quirks mode
quest_fail_retry_count int 0..2³¹−1 Native retry scaling counter; outside quests it applies only with preserve_bugs (original bug 38)
detail_preset int 1..5 Presentation detail; native presentation code consumes RNG
violence_disabled int 0..255 Native config byte; likewise
friendly_fire bool cv_friendlyFire at run start: player shots carry -1 - player_index and can hit other players (since v27)
status RunStatus Save-status fields that influence the run
typo_dictionary_words array of str Optional custom Typ-o dictionary: at most 2048 words of 1..15 printable ASCII characters
typo_highscore_names array of str Typ-o name pool from the local score table: at most 512 names of 1..31 ASCII letters or .
typo_carry TypoCarry Typ-o state the game keeps between runs (since v28); defaults outside Typ-o
custom_quest CustomQuest or nil The quest a custom run plays in place of quest_level (since v33)

TypoCarry is a map of target_world ({x, y} f32 map or nil: the aim point the last Typ-o run left, nil before the game's first Typ-o run), submit_count and match_count (i32 word counters the game never resets; a run scores only its own words unless preserve_bugs), and highscore_names_loaded (bool: an earlier run already loaded the score-table name cache, so this run's first highscore-name pick skips the table load's RNG draws).

CustomQuest is a map of title (1..64 printable ASCII characters), author (1..31 printable ASCII characters or nil; like the title, presentation), start_weapon_id (int, not 0), terrain_slots (three ints 0..7), entries (1..256 SpawnEntry maps), no_bonuses (strictly ascending bonus IDs in 2..14), no_perks (strictly ascending perk IDs in 1..57, excluding instant_winner = 7) and no_weapons (strictly ascending weapon IDs in 2..33). A SpawnEntry is a map of pos ({x, y} f32 map, each coordinate in −1024..2048), heading (f32), spawn_id (a spawn template other than 0x02 and 0x37), trigger_ms (int 0..3600000) and count (int). Custom quests never use hardcore, and their entries must respect the quest spawn limits. The run starts as quest_start_selected does, loading the table and restrictions where a stock builder would run, and leaves the stock quest counters and unlocks alone. Both crimson-core (at stage 0.0 via portable_quest) and the leaderboard service play, verify, and rank custom quest runs under the canonical ranked profile (quest_unlock_index = 50, quest_unlock_index_hardcore = 50).

RunStatus is a map of quest_unlock_index (i32), quest_unlock_index_hardcore (i32) and weapon_usage_counts (exactly 53 u32 values). Unlock indices gate weapons, perks and terrain; usage counts steer native weapon-drop rerolls.

Tick

A tick is a two-element array [inputs, commands]:

  • inputs: exactly player_count arrays [move_x, move_y, aim_x, aim_y, flags], four f32 axes and an integer flag word (bit layout in crimson/replay/types.py; unknown bits and inconsistent presence bits are invalid). Keyboard and joystick aim turn with their own held bits (the aim keys or the POV hat, since v25); the movement turn bits only steer movement. The aim axes are the world aim point, except under relative mouse aim (the screen cursor) and dual action pad aim (the stick's reach, which the simulation adds to the position the tick's movement produced; since v29).
  • commands: ordered array of command maps, each with a type key first:
type Other keys Rule
perk_menu_open player_index a perk must be pending and a player alive
perk_pick player_index, choice_index 0..6 as above, the menu opened on the tick before and no pick spent it yet, and the index must name an offered choice; a tick without a pick closes the menu, as Cancel does
typo_char player_index, ch (one character) Typ-o only
typo_backspace player_index Typ-o only
typo_submit player_index Typ-o only

player_index must be below player_count. Command rules are checked against the state left by the commands before them.

Every tick advances the simulation by float32(1/60) seconds in the native 1024×1024 arena. Perk picks apply at the start of the tick, before frame timing is derived; a perk menu open generates its choices after the tick's level-up check and before bonus_update, where native opens the menu; Typ-o commands apply after the mode's pre-step hook, as in live play. Typ-o fires and reloads only through typed words: the input fire and reload flags have no effect there. Each tick ends with native game_frame_update's discarded RNG draw, and so does run setup; frames outside gameplay add none.

RunResult

Key Type Meaning
outcome str death, quest_completed, tutorial_completed or incomplete
elapsed_ms int Quest spawn timeline for quests, session time otherwise (truncated)
kills int Creature kill count (all players)
shots_fired int Shots fired by all players, native highscore_record_shots_fired (since v26)
shots_hit int Creature hits by any shot, native highscore_record_shots_hit
rng_state u32 CRT RNG state after the final tick
pending_perks int Unpicked perks
quest_final_ms int or nil Set only for quest_completed: base time minus life and unpicked-perk bonuses; may be negative, exactly zero becomes 1
players array of PlayerResult Exactly player_count entries

PlayerResult is a map of experience (int), health (f32) and most_used_weapon_id (int). As in the high-score record, hits are clamped to shots fired (a piercing shot can hit several creatures). Typ-o reports submitted words as shots fired and matched words as hits.

Run end

The run ends on the first tick after which the mode's end condition holds:

Mode End condition after a tick
Survival every player has health ≤ 0 and a negative death timer
Rush every player has health ≤ 0
Quests as Survival (death), else the quest completion transition finished (quest_completed)
Typ-o the player has health ≤ 0
Tutorial none; the player leaves from the UI

After the tick that ends the run the game keeps simulating while the gameplay timeline runs down, as native does: from at most 500 ms, by the whole milliseconds of each tick's simulated time (int(dt_sim * 1000)), before it leaves for the game-over or quest screen. A replay may continue through that run-down but no further: counting 500 down from the end tick by each tick's milliseconds, a replay with ticks left once the count drops below 0 is invalid. The outcome is the one standing after the last tick. When no tick ends the run, the outcome is incomplete, except:

  • a quest where every player has health ≤ 0 is death (the failed-quest countdown keeps running while paused, so the run can close between ticks);
  • a tutorial at stage 8 or later is tutorial_completed.

Verification

A verifier decodes the payload (rejecting non-canonical encodings), checks the constraints above, simulates every tick from the RunSpec, rejects illegal commands and ticks past the run-down, derives the RunResult and compares it with the recorded one field by field. It reports the payload SHA-256, the derived result and any mismatched fields. A verifier may simulate a prefix for debugging, but a prefix never verifies a result.

Verification establishes that the recorded inputs, replayed by the verifier's simulation, produce the recorded result. The verifier reports the replay's game_version; a service that ranks runs must verify each one with the build that version names. Any build plays back and verifies a replay of its own format version, warning when the recording build differs: a rules change between the two shows up as a result mismatch. Verification does not establish who produced the inputs or in what real time.

The live game simulates exactly the inputs it records: it rounds each tick's inputs to the stored form (f32 axes, packed flags) before stepping, so no precision the file cannot hold reaches the simulation.

The live game records only runs that stay within these rules: it stops recording when a debug cheat changes the run outside recorded ticks, and the perk prompt stays closed while a pick is waiting for the next tick.