Engineering
Making a simulation reproduce bit-for-bit on three platforms
Determinism is usually described as a property of the game loop. In practice the loop is the easy part. The hard part is that most of the guarantees you think you have stop precisely where you need them to hold.
A deterministic simulation is one where the same build, fed the same seed and the same inputs, produces the same state: not approximately, not visually, but byte for byte. Get that and replay, lockstep networking, reproducible bug reports and regression testing all become straightforward. Miss it, and each of those becomes a research project.
renew is built around that property, and the interesting part is not the design but the inventory of things that quietly break it.
Floating point: the guarantee ends at the operators
The usual advice is that floats are fine because IEEE 754 is a standard. Rust does
guarantee IEEE 754 semantics for f32 and f64 operators. The
problem is where that sentence stops:
Rust guarantees IEEE 754 semantics for
renew-fixed, crate documentationf32andf64operators, and that guarantee stops at the operators:sin,cosand their siblings are the platform's maths library, and are permitted to differ between targets. A simulation that calls them has no cross-target claim to make.
Addition and multiplication will agree across Windows, Linux and macOS.
sin will not necessarily, because it is not specified by IEEE 754 to the
last bit. It is whatever the platform's libm shipped. One transcendental call in a
physics step and cross-platform reproducibility is gone, silently, in a way no test on
a single machine will ever reveal.
So renew's simulation arithmetic is not floating point at all. It is Q47.16 fixed
point in an i64: sixteen fractional bits, giving a resolution of 2⁻¹⁶
over a range of ±2⁴⁷. Integer arithmetic is bit-identical everywhere, with nothing to
police. That is the entire argument.
Three details matter more than the representation:
- No float appears in any signature the crate exposes. Converting to a float is a presentation concern, written at the boundary that needs it rather than offered centrally, so each conversion carries its own precision-loss justification instead of one shared helper flattening them all into a reason that fits none of them.
- Overflow saturates, in every build profile, and is counted. It never wraps, and it never differs between debug and release. A simulation that behaves differently under optimisation is not deterministic in any useful sense.
- Float arithmetic is denied at the lint level inside the crate, rather than left to code review. The crate exists to make one guarantee, and a float operator in it would defeat the only reason it exists.
Time: the loop cannot read a clock
A fixed timestep is standard advice. What is less standard is making it structurally impossible to cheat.
renew's frame loop is a passive integer state machine over nanoseconds. It owns no loop, drives no application, knows nothing of rendering or windows, and never reads a clock. It cannot, having no dependency that offers one. Its whole job is a single total function: given the schedule so far and this instant, how many fixed steps are due, how many did the budget refuse, and how far between steps is the renderer. The caller reads the one clock in the program, executes the steps, and renders.
That inversion is what makes a headless run a pure function of its timestamp sequence, and therefore comparable across runs, processes and machines. It is also why the engine's proof-of-life binary can drive sixty frames of deliberately uneven frame times and emit the same numbers everywhere:
$ cargo run --bin hello-engine
hello-engine 0.1.1
fixed timestep: 16666667 ns
frames simulated: 60
time submitted: 1245000015 ns
ticks executed: 74
time pending: 11666657 ns
Iteration order, and other invisible state
Two more things leak nondeterminism into a simulation, and both are easy to miss because neither looks like arithmetic.
The first is container iteration order. If a system iterates a hash map, the order can depend on insertion history, on capacity, or on a per-process random seed, and the result is a simulation whose behaviour depends on the history of how entities were spawned and destroyed rather than on their current state. renew's component storage is a sparse set whose iteration order is defined by slot rather than by insertion history, so a query's result never depends on churn.
The second is randomness. Anything drawing from thread-local or entropy-seeded generators is out. The engine's generator is PCG32, explicitly seeded, with derived per-domain streams so two subsystems drawing numbers cannot perturb each other by changing how often they draw.
The part that is usually done wrong: proving it
This is where most claims of determinism quietly fail, and it has nothing to do with the simulation code.
The obvious test is to hash the simulation state after N steps and compare that digest to a constant committed in the repository. That test is worth having, because it catches accidental behaviour changes. But it is a regression guard, and it is not by itself evidence of anything cross-platform. It only ever proves that this machine still agrees with the machine that recorded the constant.
Cross-platform determinism is a claim about targets agreeing with each other, so the only thing that discharges it is running the simulation on every target and comparing those digests against one another. In renew's CI that is two stages: each platform emits its digests as an artifact, and a final job collects them and compares.
That final job has a failure mode worth naming, because it is the kind of thing that makes a green checkmark meaningless:
renew CI, determinism-compare
needson a failed job SKIPS this one, and GitHub reports a skipped required job as neutral rather than failed, which is how a comparison that never ran can read as a comparison that passed.
The job therefore runs unconditionally and turns a missing leg back into an explicit failure, on the principle that a missing target is an untested target rather than a passing one. It is a small piece of YAML, and it is the difference between a determinism claim that is checked and one that is merely displayed.
What is deliberately outside the guarantee
A determinism claim is only credible if it names what it excludes. In renew the boundary is simulation versus presentation.
Cameras, particles and UI rendering all interpolate between simulation ticks at display rate, because smooth motion on a 144 Hz monitor is not something a 60 Hz simulation should be distorted to provide. Those crates are presentation-side and sit outside the digest by design. What is inside is the state that can change future observable behaviour, and the digest names in the source what it leaves out rather than leaving the reader to infer it.
Simulation also runs single-threaded, and will until there is a written contract for
deterministic parallel scheduling with fixed partitioning and reduction order. A job
system exists and has a deterministic-chunk parallel_for. Pointing it at
simulation state is a decision that needs its own design rather than a performance
afterthought.
renew is in early development, pre-0.1, with no module yet at stable
maturity, and it is not ready to build a game on. The determinism machinery described
here is the part that exists first, because retrofitting it is not realistic. The
engine is Apache-2.0 and developed in the open at
github.com/renew-engine/renew.