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 | 35 |
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, v30 to v33, which had no seat_keys, as naming no seat's key, and
v30 to v34, which had no run.cl_build, as playing 1.9.93; writers always write v35.
Rules 2 includes exact-zero reload completion, Jinxed/chain living-target
selection, independent randomized perk intervals and swap cooldowns, post-swap
readiness, nonnegative quest life bonuses, single rocket death handling, DoT
death boundaries, impact-aligned corpse slides with corrected impulses, and complete
Gauss Shotgun/Multi-Plasma Fire Bullets volleys at normal multi-pellet cooldowns.
Both rules 1 and 2 are playable and verifiable using explicit simulation profiles;
new runs default to rules 2. preserve_bugs selects original behavior, not the
previous fixed default. Existing payloads, signatures and results remain intact.
run.cl_build selects the original build whose rules the run plays, 1.9.93 or
1.9.8. The 1.9.8 rules arrived with rules 2 and have
no rules-1 profile, so readers refuse a 1.9.8 run with rules 1. They apply under
both bug policies; preserve_bugs also keeps 1.9.8's own bugs
(original bugs 56-60).
The Python port and the core verifier play both builds' runs, and the web and
native clients start either; the leaderboard ranks 1.9.8 runs on boards of
their own (ranked rules).
Historical profile selection, identity stability and the reload exception are specified under historical rules profiles.
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) |
cl_build |
int | The original build whose rules the run plays: 10993 for 1.9.93 or 10908 for the 1.9.8 rules, which need rules 2 (since v35) |
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: the last Typ-o run played the same build and
left the score-table name cache loaded, so this run's first highscore-name pick
skips the table load's RNG draws; a run on another build than the last loads
its own build's table).
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 as provenance. The service selects the recorded rules
profile, including documented historical exceptions, and verifies the derived
result. A different recording build warns without changing the selected rules.
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.
Historical rules profiles¶
New runs use rules 2; playback and verification use the recorded rules. Rules 1
keeps the previous fixed-default behavior, while rules 2 adds the bundle above.
preserve_bugs retains the original branches for those fixes in either profile;
it does not reproduce the previous fixed default. Unsupported rules are errors.
The ranked rules policy
retains every published ranked version.
Resolve the profile once at run initialization and carry it through recording,
reset, snapshots, state hashes, keyframes, seeking, retry, practice takeover and
co-op rollback. Co-op setup agrees on rules. A fresh ordinary run returns to the
current default; continuation keeps its source profile. The internal core config
has 69 little-endian uint32 words (276 bytes): rules at word 66, the legacy
co-op reload flag at word 67 and run.cl_build at word 68. The flag is valid
only with rules 1. These internal words do not change the replay format, and
run identities hash none of the three words, though a rules-2 ID names its
rules and a 1.9.8 run's ID its build, as below.
Rules-1 IDs retain the original hash input byte for byte: the old 66-word
transport including player count for co-op, with the player-count word removed
for solo, followed by quest/input bytes. Rules-2 IDs hash UTF-8
crimson:run:rules, a NUL byte, a little-endian uint32 rules number, and that old
66-word transport including player count and quest/input bytes. Internal profile
words are excluded. A run of another build than 1.9.93 (run.cl_build 10908)
hashes UTF-8 crimson:run:build, a NUL byte, a little-endian uint32 build and
then the input its rules-2 ID would hash, so the same inputs under 1.9.8's
rules are another run, while 1.9.93's IDs stay as they were. The leaderboard
ranks the same inputs on one board only
(leaderboard identity):
it refuses a run whose inputs it accepted under the other build, whatever rules
either claims, and a one-player run whose inputs it accepted from the other
seed, run.seed with bit 31 flipped, which the game never reads, under the
run's own build and rules or under the other build. The same inputs under one
build's two rules are two runs that both rank, and a replay whose inputs differ
in anything is another run.
Existing payloads and signatures are never rewritten.
Unversioned co-op reload exception¶
The manual-reload fix (original bug 40) reached master in 6d1073f6c (#758),
before ranked co-op and recorded seat keys in bde24bef7 (#768). The earlier
99cc34261 is the development-branch commit included by #758. Every ranked
co-op recording therefore uses corrected manual reload: the service always
passes zero for the legacy flag, regardless of its recording build label.
Python and game playback likewise select corrected reload when co-op seat keys
are recorded. Signatures and room attestation remain mandatory for uploads;
this profile selection does not substitute for them.
Older keyless local co-op replays still need the exception: plain releases
through 0.18.0 use legacy reload, and 0.19.0 onward use the fix. The known
post-fix build 0.18.0+build.64a9589f5fbefdc036f0559b also uses the fix.
Other ambiguous keyless 0.18 development builds require explicit provenance;
readers do not try both simulations and choose whichever matches a claimed
score. The format version alone is not provenance because saving an older replay
upgrades its format. Solo, rules 2 and preserve_bugs do not need this exception.