Determinism
Given the same build and the same inputs and commands, Tide's simulation gives bit-identical results on every supported platform: Windows, Linux, macOS, Android and the web, on Intel, AMD and ARM. That's what lets desktop, phone and web players share one match.
It uses ordinary 32-bit floats, not fixed point. Tide code gets this for free: there's nothing to avoid, and no special types to use. This page is about how the engine keeps it, for the curious, and for anyone writing C that touches the simulation.
Floats
Only the IEEE 754 basic operations, + - * / and square root, give the same result on every machine. Everything else is built from them:
- No fused multiply-add. The compiler never fuses
a * b + cinto one instruction, which rounds differently. - No fast-math. Float operations are never reordered or reassociated.
- The engine's own math library. Vectors, matrices and quaternions are written in-house.
Sin,Exp,Powand the other transcendental functions are computed from the basic operations, never with the platform's math library, which differs between systems. - No approximate instructions, like
rsqrtps, whose results differ between Intel and AMD. - Denormals stay on, on every thread and platform, since WebAssembly can't turn them off.
- SIMD on the web, but not relaxed SIMD. Web builds use WebAssembly's SIMD, which gives the same results on every machine, so loops the compiler turns into SIMD run that way on the web too. Relaxed SIMD's fused multiply-add differs between machines, so it stays off, and so does x87.
The engine compiles everything, your game included, with the flags that guarantee this.
NaN
NaN in the simulation is a bug: NaN's bit patterns aren't the same everywhere. The engine keeps it out where it can:
- Inputs come from other machines, so NaN and infinite floats in them become the field's default before the game sees them (see Input).
Math.Clampalways returns a value in range,Math.MinandMath.Maxwith one NaN return the other argument, andMath.Normalizegives zero for a zero vector, where dividing by its length would give NaN.
Random numbers
A match that rolls dice still has to roll the same ones on every machine. Random does, both ways it rolls:
-
A roll with nothing kept (
Random.NextInt(6)in a system or a handler) is worked out from the match's seed, the tick, the entity and which code rolls. Every machine has those the same, a tick run again has them the same, and no system writes anything to roll, so nothing waits and nothing depends on a thread. -
A generator of the game's own (a
Randomfield) is data, and its state is in the world like any other field:- The same numbers everywhere. The generator is integer arithmetic (PCG32), and its floats are made from integers with exact operations, so every platform gives the same bits.
- In every snapshot. A generator in a component or a singleton is part of the world's state and of its hash. A client that predicted a tick wrong goes back to the last world the server confirmed and runs the ticks again: the generators are back where they were then, and roll the same numbers.
- One for each entity. A system that rolls with its entities' own generators can run them on several threads, and no thread's rolls depend on another's.
- Seeded by the match. The machine that starts a match picks its seed,
Random.seed, from the system's random number generator. The seed is in the world: a player who joins gets it with everything else, snapshots and host migration carry it, and nothing sends it separately. An entity's generator is seeded as the entity spawns, from the seed, the tick and the entity, which every machine has the same.
Where nothing picks a seed (tests, benchmarks, a world a C host starts itself, a game run without a window) it's 0, so a run repeats exactly. tide run --seed 7 picks the seed, with or without a window: the same seed and the same input are the same match.
Order
Everything that could run in a different order does so in a fixed one:
- Expressions run left to right, so spawns and entity IDs come out the same everywhere, and so does what a function changes through a
mutparameter, or makes through anoutone, and what's read beside it. A generator's rolls are such calls: infloat2(random.NextFloat(), random.NextFloat()), x is rolled first on every platform. - Systems run in one order (see Systems), and structural changes and events apply in the order they were made.
- Running systems in parallel will never change results: thread timing never decides the order of anything that can be seen.
Integers
Integer arithmetic wraps on overflow, and dividing by zero gives 0, so no input can crash the simulation or reach undefined behavior. Float to int conversion saturates and turns NaN into 0.
How it's tested
The engine's tests hash the exact bits of math results and of whole simulations, and the hashes must match on every platform and in every build configuration: native and WebAssembly, debug and release. The demo's smoke test plays a scripted session through the real platform layer and checks its hash too.
Writing C
Code in C that changes the simulation, like a custom host, follows the same rules: use the engine's tide/math.h, never <math.h>, and don't depend on the order C evaluates function arguments in, which differs between platforms. Draw inputs into locals first.