Skip to content
Deterministic fixed-step physics in Unity without fighting the engine
article2 min read3.1K viewsAug 6, 2026

Deterministic fixed-step physics in Unity without fighting the engine

Rollback netcode does not need a custom physics engine. It needs you to stop reading Time.deltaTime.

Priya Raman
Priya Raman@priyaraman

August 6, 2026 · 2 min read

3.1K views6 comments

Every determinism bug we shipped last year had the same shape: something in gameplay read Time.deltaTime instead of Time.fixedDeltaTime, and two clients running at 60 and 120 fps disagreed about where the player was four seconds later. Not by much. By enough.

The rule that fixed 90% of it

Simulation code lives in FixedUpdate and may only read fixed-step time. Presentation code lives in Update and may never write simulation state. We enforce it with an assembly definition: the Gameplay.Sim assembly does not reference UnityEngine.Input or the renderer at all, so the compiler catches the mistake instead of QA catching it three builds later.

// Sim assembly. No Update(), no deltaTime, no Random.value.
void FixedUpdate() {
    var input = _inputBuffer.ForTick(_tick);
    _state = Step(_state, input, FixedDt); // FixedDt is a const, not a property
    _tick++;
}

Floats are fine, actually

The usual advice is to move to fixed-point maths. We did not, and we did not need to. IEEE 754 is deterministic for the same operations in the same order on the same instruction set — the danger is not the float, it is the ordering. Our real culprits were, in order of how often they bit us:

  • Iterating a Dictionary in the simulation. Insertion order is not guaranteed across runs. Replaced with a sorted entity id array.
  • Physics.autoSimulation left on, so PhysX stepped on the render frame instead of our tick.
  • Random.value instead of a seeded xorshift carried in the simulation state.
  • Coroutines. A coroutine resumes on a frame boundary, which is exactly the thing we are trying not to depend on.

How we test it

A headless test runs the same 1,800 ticks of recorded input twice, at different frame pacing, and hashes the simulation state every 60 ticks. If the hash streams diverge, the test prints the first differing tick and the entity whose position moved. That test has caught four regressions this year, all of them within a day of the commit that caused them.

The value of determinism is not netcode. It is that a bug report becomes a seed and a tick number.

Set Physics.simulationMode to Script, step it yourself, and hash your state. That is most of it.

Written by

Priya Raman

Priya Raman

Gameplay engineer, seven years in Unity. I own the character controller and the frame budget on a mobile action title, which mostly means I argue about fixed timestep determinism and profile animation rebinds. Burst and the Job System are the two tools I reach for before I reach for a bigger phone.

6 Comments

Sign in to join the discussion

Marcus Okafor
Marcus Okafor@marcusokafor1mo ago

The Dictionary iteration one is the same bug as relying on row order without an ORDER BY. It works for two years and then the storage layer reorganises and it 'randomly' breaks in production. Sorted entity ids is the right fix.

0
Priya Raman
Priya Raman@priyaraman1mo ago

That is the analogy I should have used in the post. The hash-every-60-ticks check is borrowed from exactly that world too — it is a checksum comparison on a sample, not a full state diff, because the full diff is too slow to run per tick.

0
Diego Alvarez
Diego Alvarez@diegoalvarez1mo ago

How does the 1,800-tick replay run in CI? Anything needing a GPU runner blows our budget, so I am curious whether this is a headless player job or something lighter.

0
Priya Raman
Priya Raman@priyaraman1mo ago

Test runner in batch mode: -batchmode -nographics -runTests. The sim assembly does not reference the renderer at all, which is the whole point of the assembly split, so it never needs a device. About 40 seconds on a standard runner.

0
Sana Qureshi
Sana Qureshi@sanaqureshi1mo ago

One caveat on 'IEEE 754 is deterministic': that holds for the same operations in the same order on the same instruction set, and stops holding the moment FMA contraction is enabled on one target and not the other. Our training-versus-serving skew bug was exactly this, x86 with FMA against ARM without.

0
Priya Raman
Priya Raman@priyaraman1mo ago

We ship arm64 and x86_64 clients in the same match, so yes — Burst is pinned to FloatMode.Strict and FloatPrecision.High on the sim assembly. Should have been a paragraph in the post rather than a footnote in my head.

0