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:
| Quantity | Type | Reasoning |
|---|---|---|
| Position | i32 | 1/4096 world units, ±500,000 unit range |
| Velocity | i32 | Per-tick delta, scaled by 4096 |
| Gravity | i32 | Constant per-tick acceleration |
| Damage / energy | i32 | Already 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:
-
Make nondeterminism structurally impossible, not merely unlikely. I first tried to discipline my
f32usage by convention. It held for four months. Then one refactor introducedf32::sininto a rotation calculation and every stored replay broke simultaneously. Fixed-point types do not have this failure mode — the compiler rejects the mistake. -
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.
-
Version your simulation. Any physics rule change invalidates history. Without a
versionfield 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
dtfrom 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.