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(neverfloat32, never an integer) holding a finite value exactly representable asfloat32; - strings, arrays and maps use their shortest length headers;
nullis 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: exactlyplayer_countarrays[move_x, move_y, aim_x, aim_y, flags], four f32 axes and an integer flag word (bit layout incrimson/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 atypekey 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.