Skip to content

Deterministic session

All five gameplay modes initialize through RunSpec and initialize_run; see run startup. Live gameplay, replay verification/playback, and headless harnesses step the same DeterministicSession in src/crimson/sim/sessions.py.

Tick contract

A tick is a ReplayTick: packed per-player inputs in slot order (f32 axes and flag words) and ordered commands, stepped with the fixed REPLAY_TICK_DT. step_replay_tick in src/crimson/replay/ticks.py is the only way live play and replays advance a session. Live play packs each tick with LiveTickSource, records it, then steps that same tick, so the simulation never sees input the replay cannot store.

The session returns one DeterministicSessionTick containing:

  • effective delta and native frame timing;
  • simulation events and optional presentation RNG trace;
  • an immutable DeterministicPresentationPlan, including quest sound and music requests;
  • elapsed time, creature count, and quest completion state for that tick.

Replay drivers wrap it in a TickResult with the tick index.

flowchart LR
    Local[Local input] --> Source[LiveTickSource]
    Source --> Tick[ReplayTick]
    Tick --> Recorder[ReplayRecorder]
    File[Replay file] --> Tick
    Tick --> Step[step_replay_tick]
    Step --> Session[DeterministicSession]
    Session --> Present[Apply presentation plans]

Step and application order

For each live tick, the mode builds the tick, records it, steps it, advances the presentation clock, records the checkpoint, and evaluates the mode callback before advancing another tick. The tick that ends the run starts the run-down: ticks keep coming until the gameplay timeline is out (at most 500 ms of simulated time), then the mode leaves gameplay and the frame stops. Every tick is recorded before a callback can save the finished replay.

Audio, camera, and terrain application can be batched after simulation because all presentation requests belong to their producing tick. The shared consumer in src/crimson/sim/batch_apply.py calls AudioBridge.apply_plan, applies camera and terrain output, then calls AudioBridge.apply_post_plan for bonus/quest sounds and completion music. Consumers do not reconstruct reactions from current quest state. Replay fast-forward can suppress audio without changing simulation RNG.

Sound requests carry their ID, event position (or None for centered UI audio), and gain. Their plan captures the Reflex Boost pitch timer. The consumer uses the viewport before each tick's camera update, so deferred sounds never read a later player's or creature's position.

SFX cooldowns advance after each consumed tick's sound requests, using FrameTiming.dt_audio: the native frame delta after Reflex Boosted perk scaling but before Reflex Boost slow motion. Music streams are serviced every render frame; gameplay passes advance_sfx=False to that service to avoid advancing cooldowns twice. Screens without simulation ticks advance cooldowns with their audio update. Headless runs still consume sound-selection RNG, but do not own device playback or cooldown state.

Native hit audio and terrain effects consume authoritative RNG, so headless verification still builds the presentation plan even without rendering or audio.

A FixedStepClock turns render-frame time into ticks for live play, debug views and replay playback (src/crimson/replay/driver/playback_driver.py).

Input and timer ownership

LiveTickSource polls local input once per render frame. It keeps undelivered button edges across zero-tick frames, uses the latest held controls and aim, and clears edges after the first tick. A pending fire press resolves to fire_down=True for one tick, so wheel input and clicks released before a tick still fire. Pausing clears pending edges and clock debt while retaining queued commands. Movement fields named *_pressed represent held controls in the existing format.

Survival and rush time belongs to DeterministicSession.elapsed_ms; quest time belongs to QuestSpawnState.spawn_timeline_ms. Render/HUD animation time is a separate WorldRuntime.presentation_elapsed_ms clock.

Custom network play has been removed; see status.

Phase ownership

Perk effects live in their native functions: player_update, perks_update_effects (perks/effects.py) and player_take_damage. See Perks architecture. Bonus pickup effects are drawn inside bonus_apply, as in native, so each pickup's RNG draws precede the next pickup applied in the same tick. Projectile decals are queued in sim/presentation_step.py.

RNG Policy

The deterministic pipeline uses one authoritative RNG stream:

  • simulation + presentation RNG: state.rng

WorldState.step, the deterministic session hooks, and replay verification all consume that stream in a stable per-tick order.

Validation and tools

tests/modes/test_live_replay_invariant.py drives the live loop with real controller interpretation, ragged frame times and perk commands, then replays the recording and compares complete session state along the way; tests/replay/test_live_run_start.py compares full session state through actual mode startup and recording. Compact checkpoints support native comparison but omit state: use session_digest in tests/support/state_digest.py for same-build port regression checks.

Replay play, verify, info, benchmark and render all use this simulation contract. Use uv run crimson replay --help and command-specific help for options. Trace comparisons use the CDT contract.