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

ResolvedTick carries a tick index, delta, a tuple of PlayerInput values in slot order, and commands. Live input comes from LocalInputProvider; replay input comes from PlaybackDriver. Replay preludes and postludes retain their own between-tick timing contract.

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.

TickResult holds that result in payload, alongside the source input and optional replay index. Presentation profiling time lives on the session and is collected by the outer loop, outside the deterministic result.

flowchart LR
    Local[LocalInputProvider] --> Runner[TickRunner]
    Replay[PlaybackDriver] --> Session[DeterministicSession]
    Runner --> Session
    Session --> Result[TickResult]
    Result --> Bookkeeping[Record and checkpoint current tick]
    Bookkeeping --> Stop[Evaluate mode stop]
    Result --> Present[Apply immutable presentation outputs]

Step and application order

For each live tick, the runner steps the session, records the replay input, applies metadata, records the checkpoint, and evaluates the mode callback before advancing another tick. A terminal callback stops the batch immediately. The final 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 demo attenuation and 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.

Frame orchestration is in src/crimson/sim/frame_pump.py and src/crimson/replay/driver/playback_pump.py. These preserve distinct live and replay source timing while sharing result application.

Input and timer ownership

Local input keeps unconsumed button edges across zero-tick render frames, uses the latest held controls and aim, and clears true 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 explicit 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 SimWorldState.presentation_elapsed_ms cache.

Custom network play has been removed; see Netplay for the deferred scope and requirements for any future implementation.

Phase ownership

Perk timing and death effects are direct calls in WorldState; per-player and global perk effects have explicit ordered calls in perks/runtime/player_ticks.py and perks/runtime/effects.py. See Perks architecture. Bonus pickup effects live in bonuses/pickup_fx.py, and projectile decals live in features/presentation/projectile_decals.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/sim/test_step_pipeline_parity.py covers live batching and playback behavior; 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 src/crimson/dbg/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. Native capture comparisons use the CDT contract and differential playbook.