Skip to Content
rustgame-enginesalgorithmsdeterminismstate

Deterministic Physics in a Tactical Deckbuilder: Fixed Timesteps, Integer Ticks, and Reproducible Runs

AUTHENTICATED_ACCESS_GRANTED

The Context: "The Run That Should Have Been Impossible"

It was late August 2026. GraviDeck is a tactical 2D roguelite deckbuilder — cards manipulate gravity and momentum on a grid battlefield. Runs are short, roughly fifteen minutes, and every single one is shareable.

Then a player reported a run that should not have existed. They played a sequence that our internal simulator, fed the exact same inputs, could not reproduce. Not a near-miss — a completely different board state. The complaint thread devolved into an argument about whether card-game physics "count" as deterministic.

They do. They simply are not by default, and I had been treating f32 as if it were a source of truth.

The Problem Statement

A deterministic simulation is the foundation for leaderboards, replays, anti-cheat, and shared seeds. Every one of those was quietly broken:

  • Reproducibility: Identical input sequences must yield bit-identical final states, across machines and across runs.
  • Replay Integrity: A replay must be verifiable, not merely recordable.
  • Speed Independence: Physics must behave identically at 30fps on a throttled laptop and 144fps on a gaming monitor.
  • Network Authority: A server must be able to re-simulate a submitted run and reject a fabricated score.

Visual Architecture

The Core Logic / Process

1. The Variable-Delta Trap

The naive loop advances physics by however long the last frame took:

// The classic bug. Do not ship this.
let dt = Instant::now().duration_since(last_frame).as_secs_f32();
world.step(dt);

This is the root of every determinism bug I hit, and it is worth understanding precisely. A player at 30fps steps physics with dt = 0.0333; a player at 144fps steps with dt = 0.0069. Integration results differ, but worse, floating-point rounding is not associative: x + 0.0333 + 0.0333 and x + 0.0069 * 5 produce different bits. Two machines at different frame rates cannot converge on the same state, even with identical inputs.

The fix is to decouple simulation rate from render rate entirely:

const TICK_HZ: i64 = 60;
const TICK_NS: i64 = 1_000_000_000 / TICK_HZ;

let mut accumulator: i64 = 0;
while accumulator >= TICK_NS {
    world.step();              // no dt argument at all
    accumulator -= TICK_NS;
    prng.next_u32();           // PRNG advances once per tick, never per frame
}
render(world.interpolate(alpha));

The simulation advances in identical discrete ticks regardless of frame rate. Rendering interpolates between the last two tick states for smoothness. Note what the signature now looks like: step() takes no delta. There is no f32 anywhere near the simulation, so there is nothing to be non-associative.

2. Eliminating Floating Point From State

Even with a fixed timestep, f32 physics is not portable. Different CPUs, compiler versions, and optimisation levels can produce different results from identical operations — x87 excess precision on old hardware, fused multiply-add contraction on modern ones.

I converted the entire authoritative simulation to fixed-point integers:

QuantityTypeReasoning
Positioni321/4096 world units, ±500,000 unit range
Velocityi32Per-tick delta, scaled by 4096
Gravityi32Constant per-tick acceleration
Damage / energyi32Already integral by domain

Multiplication is the one place overflow genuinely bites: (i32 as i64) * (i32 as i64) >> 12 requires widening to i64 before the multiply and shifting back after. Forgetting the widening is an overflow bug that manifests as a unit flying off the board roughly 2^31 ticks into a run — the kind of bug that survives months of testing and then ruins a tournament.

Rendering converts to f32 at the very last moment, which is safe because visual output is not part of the simulation's contract.

3. The Input Tape

Determinism requires that the only source of nondeterminism be eliminated, which means capturing every input decision:

pub struct RunRecord {
    pub seed: u64,
    pub version: u32,          // simulation version; bump on any rule change
    pub inputs: Vec<Input>,    // (tick, actor, action)
}

The server replays the tape against the authoritative build and compares the final hash. A mismatch means either a modified client or a physics bug, and both are worth rejecting. The version field matters more than it looks: a bug fix that changes float rounding or collision ordering silently invalidates every stored replay, so the version is what tells you which replays are still meaningful.

4. Seeding the PRNG

The PRNG is drawn from a single seeded stream (PCG32), seeded once per run from the run seed. There is deliberately no per-subsystem RNG. Multiple independent streams look tidier, but they create ordering dependencies — if the combat system draws before the physics system in one build and after in another, the streams diverge and replays break. One stream, drawn in a fixed order, is the only arrangement I would trust.

The "Baigan" Take (Expert Insights)

Look — every physics engine ships with deterministic stepping. Every one of them is still nondeterministic in practice.

baigan ka determinism is the belief that "the engine is deterministic, so my game is deterministic." The engine is deterministic given deterministic inputs and a deterministic arithmetic model. Shipping f32 across a networked leaderboard means your server is running a different simulation than your client, and neither of you will notice until the numbers diverge in public.

Three lessons I would hand to any engineer starting this:

  1. Make nondeterminism structurally impossible, not merely unlikely. I first tried to discipline my f32 usage by convention. It held for four months. Then one refactor introduced f32::sin into a rotation calculation and every stored replay broke simultaneously. Fixed-point types do not have this failure mode — the compiler rejects the mistake.

  2. Test determinism in CI or not at all. I added a test that runs 200 known input tapes, hashes each final state, and compares against a committed manifest. It has caught three regressions, including a collision-order change that only manifested on machines with different core counts. It costs 400ms.

  3. Version your simulation. Any physics rule change invalidates history. Without a version field you cannot tell "this replay is corrupt" from "this replay was recorded by an older build," and you will spend a week debugging ghost bugs.

The uncomfortable truth: for a single-player game, all of this is over-engineering. It became mandatory the moment I shipped a leaderboard. Determinism is not a physics property — it is a trust property, and you cannot add it after your players start recording results.

Performance Checklist

  • Drive simulation from a fixed-timestep accumulator; never from frame delta.
  • Remove dt from the step signature entirely so it cannot be misused.
  • Restrict authoritative state to i32/i64; widen before every multiply.
  • Convert to floating point only in the render step.
  • Use one seeded PRNG stream drawn in a fixed order.
  • Record {seed, version, input_tape} and hash the final state for server verification.
  • Keep a CI determinism suite comparing committed state hashes across builds.
  • Interpolate between ticks for rendering smoothness; never simulate at render rate.
© 2026 PRANABESH_VAULT_ENCRYPTION_V4CONFIDENTIAL_REPRODUCTION_PROHIBITED