Quick answer: Non-determinism comes from four places: variable timestep, unseeded RNGs, floating-point variance, and ordering of iteration over hash sets. Fix each one in order and replays will match the live run. If they still diverge, log the physics world hash every frame and bisect to the first frame that differs.

The replay system was fine last week. Today, a tester sends you a save file where the boss dies at 1:47 in the live run, but dies at 1:51 in the replay. You load it yourself and see the boss drift five meters to the left over the course of the fight. The inputs are correct. The seed is the same. Somewhere, the simulation is running differently — and until you find where, every replay is a lie.

The Four Sources of Non-Determinism

1. Variable timestep. If physics advances by deltaTime, and deltaTime depends on frame rate, two runs at different frame rates produce different simulations. The fix is a fixed timestep: physics runs in discrete 16.67 ms slices regardless of how fast or slow the renderer is going. Unity’s FixedUpdate, Unreal’s SubstepTicking, and Godot’s _physics_process all implement this. Make sure every gameplay system that moves a rigidbody or queries physics runs inside the fixed step, not the variable one.

2. Unseeded RNGs. Every call to Random.value or rand() that isn’t seeded from the replay file is a source of divergence. Particles, enemy AI, loot drops, even UI animations — anything that calls a shared RNG affects the stream of values that subsequent callers see. The discipline is: one project-wide RNG, seeded from the replay, and every gameplay system pulls from it in a deterministic order.

3. Floating-point variance. Two x86 CPUs with different microarchitectures can produce different results for the same float multiply. An ARM Mac and an Intel PC will definitely differ. Worse, compilers can reorder floating-point operations unless you explicitly forbid it. For tight determinism across hardware, use a fixed-point math library (FixMath, Photon Quantum) or restrict replays to the same architecture.

4. Iteration order over unordered collections. Iterating a Dictionary<K, V> in C# visits entries in insertion order in recent runtimes, but HashSet<T> does not guarantee order across runs. If your physics update iterates a hash set of active colliders, the order — and therefore the order of contact resolution — is non-deterministic. Sort on a stable key (entity ID) before iterating.

Lock the Timestep First

Before you chase floating-point ghosts, verify the timestep. Log fixedDeltaTime and the step count at the start and end of each simulation. They must match between the live run and the replay. If the replay ran 1,800 steps and the live run ran 1,803, you have three extra steps of divergence built in before a single float goes wrong.

Common causes of mismatched step counts: pausing the game pauses physics on one path but not another; the replay loads while the simulation is still running one last step from the previous level; and Time.maximumDeltaTime clamping the catch-up loop differently depending on frame rate.

// Deterministic fixed-step loop
public void Tick(float realDelta) {
    accumulator += realDelta;
    while (accumulator >= FIXED_DT) {
        StepSimulation(FIXED_DT);
        accumulator -= FIXED_DT;
        stepCount++;
    }
    // Render with interpolation between last two states
    Render(accumulator / FIXED_DT);
}

Record Inputs, Not State

A replay that stores state (positions, velocities, health) is easy to implement and cheap to debug, but it’s huge on disk and drifts on any engine update. A replay that stores inputs (button presses per tick, plus the RNG seed) is small, self-correcting if you replay on the same build, and reveals divergence instantly.

The trade-off: an input-based replay from v1.2 will not play back correctly on v1.3 if any gameplay code changed. Stamp the build hash on every replay file and refuse to play back mismatched versions. Offer a “legacy” mode that runs the old build in a sandbox if you care about historical replays.

Solver Iteration Count

Most physics engines (PhysX, Box2D, Bullet) resolve constraints iteratively. The velocity iteration count and position iteration count directly affect the outcome. If your replay is set to 8 velocity iterations and the live run used 6 (because the frame-rate scaler reduced them under load), the results diverge. Hardcode the iteration counts for replay mode. Do not let dynamic-quality systems touch physics parameters.

The same applies to continuous collision detection (CCD) thresholds, sleep thresholds, and broadphase bounds. Any parameter that affects simulation outcome must be constant across the live run and the replay.

Hash the World Every Frame

The fastest way to find the frame where divergence starts is to log a hash of the physics world every tick. For each active rigidbody, hash position, rotation, and velocity into a running CRC. Write it to a sidecar file during the live run. On replay, compare hashes per tick. The first mismatched frame is your ground zero.

From there, narrow down: which body differs? By how much? Is the delta symmetric (floating-point noise) or asymmetric (a missed contact, a different AI branch)? Usually the delta tells you which system to suspect. A 0.0001-meter drift on every body is float variance. A 2-meter drift on one body is a missed input or an unseeded RNG.

Double Precision as a Tool, Not a Fix

Switching the engine to doubles doubles your memory footprint and halves SIMD throughput. It doesn’t make the simulation deterministic across architectures — a double-precision multiply on AVX can still differ from one without FMA. Doubles are useful when the bug is accumulated error in long simulations (orbital mechanics, long-running sandbox worlds) or when your world is enormous and float precision runs out at the edges. For replay parity, fixed-point or strict float mode is the real answer.

“Determinism is a property of the entire simulation, not the physics engine alone. One unseeded RNG upstream poisons everything downstream.”

Related Issues

For rollback netcode that depends on the same guarantees, see rollback netcode for indie fighters. For capturing the state needed to reproduce these bugs, see capturing game state for bug reports.

Every replay divergence is a simulation bug in disguise — the replay just exposes it sooner than production would.