The lockstep networking article covers how inputs are synchronised across clients. This one covers the other half: what makes the simulation that processes those inputs produce identical output on every machine.
Lockstep is only as reliable as the simulation's determinism. One client calling UnityEngine.Random.Range() inside a tick, one reading Time.deltaTime to influence game state, one iterating a Dictionary in a different order: any of these silently diverges state. The clients continue running, producing different worlds, with no indication anything is wrong until the inconsistency becomes visible.
Two contexts, one rule
The codebase is divided into two contexts. ForwardSimulation() is the simulation context: everything that affects game state runs here, once per tick, deterministically. Everything else (rendering, audio, UI layout, camera movement) is the Unity context. It runs per frame and can use any Unity API freely.
The rule is: if it mutates game state, it belongs in the simulation. If it only affects what the player sees or hears, it runs outside. This single rule eliminates the majority of determinism problems before they are written. The game state is only ever modified during a tick, and ticks process identically on every client because they consume the same input.
Unity is essentially a rendering layer in this architecture. It draws what the simulation tells it. The simulation does not know or care about frame rate, VSync, or display refresh rate.
Replacing UnityEngine.Random
UnityEngine.Random is seeded automatically by Unity at startup using system time. Two clients starting at different times produce different sequences. Even if you seed it manually, the state persists across calls and any code path that calls it (including Unity internals) advances the sequence. Using it inside the simulation is a reliable way to desync.
MasterRandom replaces it for all simulation code. It is a subtractive generator based on Knuth's algorithm, chosen because it produces identical output on Windows, macOS, and Linux for the same seed and call sequence. It is instantiated with an explicit seed and has no global state. The simulation never touches UnityEngine.Random or System.Random.
Per-event seeds, not a shared sequence
The less obvious design decision is how MasterRandom is used. The instinct is to have one simulation-wide instance whose state advances with each call, the same approach as a global System.Random, just deterministic. Warman does not do this.
Instead, each event that needs randomness creates a fresh instance from a seed derived from the simulation state at that moment:
var seed = WarmanServer.GetRandomSimulationSeed();
var random = new MasterRandom(seed);
The reason is ordering. If a global random is advanced by each event in sequence, the order in which events are processed must be identical across all clients. Damage events processed in order A, B, C produce a different random sequence than A, C, B, even if the individual events are identical. Most of the time the ordering is stable, but anywhere a Dictionary, HashSet, or unsorted collection is iterated, order is not guaranteed. In .NET, dictionary ordering can differ between builds or runtimes.
Per-event seeds eliminate the ordering dependency. Each event's random calls are isolated to a fresh instance seeded from deterministic state. Event A's rolls do not affect event B's rolls regardless of processing order. Two events that process in different sequences still produce the same individual outcomes.
Float arithmetic: the open problem
FixPointCS is in Warman's dependencies. The simulation does not use it yet. It runs on floats, and that is a known problem rather than a deliberate choice.
IEEE 754 single-precision arithmetic produces identical results on the same architecture with the same JIT compiler. The problem is that Warman targets Windows, macOS on Intel, and macOS on Apple Silicon. Apple Silicon is ARM, and ARM's NEON SIMD units have different rounding behaviour for some operations than x86 SSE2. A calculation that produces identical results on every x86 machine can produce a different value on ARM, and that difference compounds through a 30 tps simulation into a desync.
The long-term fix is to convert the simulation to fixed-point arithmetic using FixPointCS. Fixed-point is integer math under the hood: no floating-point rounding, identical results on any architecture. The conversion has not happened yet because it requires touching every piece of simulation math, which is a large migration.
The current stopgap is MathValidator. At startup it runs a battery of float operations (basic arithmetic, transcendental functions, rounding) and hashes all the results into a CRC32:
public static void AppendFloatMathToHash(Crc32 crc32)
{
float[] values = { 0.1f, 1.0f, 3.14159f, 2.71828f, -5.0f, 1e-10f, 1e10f };
// arithmetic, sqrt, exp, log, trig, rounding...
foreach (var operation in results)
{
var valueStr = operation.Value.ToString("R");
crc32.Append(Encoding.UTF8.GetBytes(valueStr));
}
}
The hash acts as a fingerprint for the machine's float behaviour. Clients with different hashes have detectably different float semantics and cannot play together without risking a desync. The approach catches the problem at connection time rather than mid-game. It does not fix the underlying issue: a x86 client and an ARM client will produce different hashes and be rejected from the same session even if their float divergence would only matter for a handful of calculations. The migration to fixed-point math is what actually solves it.
The non-obvious sources of non-determinism
Random number generators are the obvious hazard, and MasterRandom covers them. These are the sources that cause desyncs without any obvious mistake:
Dictionary and HashSet iteration order. In .NET, the order in which entries are iterated is not specified. It is typically consistent within a single run but can differ between builds, runtime versions, or platforms. Any simulation code that iterates a Dictionary or HashSet and produces game-state effects in iteration order is non-deterministic. The fix is either to use sorted collections or to gather results and sort before processing.
string.GetHashCode() randomisation. .NET randomises string hash codes per process by default. Code that puts strings in a Dictionary or HashSet and depends on their iteration order will see different results each run. The fix is the same: sort explicitly rather than relying on hash-based ordering.
Unity component query ordering. GetComponentsInChildren, FindObjectsOfType, and similar Unity APIs return results in scene graph order, which is stable but depends on the order objects were added to the scene. Anything that changes the scene graph at runtime can shift this ordering. Simulation code should not query scene structure for game logic.
Physics queries. Physics.OverlapSphere, Physics.Raycast, and every other Unity Physics API are non-deterministic. Warman does not use them inside the simulation. All collision detection and spatial queries use the custom A* grid and manual bounding-box checks.
UiInputCommand: deferring player actions
Player actions from the UI (buying items, equipping gear, selling loot, using the workbench) happen at the display frame rate, not at the simulation tick rate. Executing them immediately would mean one client's game state changes before other clients know about the action.
These actions are serialised as UiInputCommand values and added to the player's input for the current tick. The simulation processes them during the next ForwardSimulation call, at the same tick on every client, from the same serialised command. The client that clicked "Buy" sees no immediate response. The item appears on the next tick, simultaneously with all other clients.
This is the same principle as player movement input: nothing affects game state outside the tick. The UI is a write-only interface to the lockstep stream.
Testing for non-determinism
LockstepTests.cs covers SimulationTick serialisation, PlayerInputCommand round-trips, and LockstepHelper arithmetic. These catch protocol regressions but not simulation-level non-determinism.
The canonical test for simulation determinism is simpler than it sounds: run the simulation from the same starting state with the same input sequence twice, then compare the resulting game state. If any value differs, something inside the simulation is non-deterministic. This test is inexpensive to write and catches almost every regression. A PRNG call added in the wrong context, a dictionary iterated for side effects, a Time.time read that slipped into a skill calculation: all of them produce observable state divergence and show up in the diff.
The second run does not need to be on a different machine. Running sequentially in the same test is enough to catch most issues, because the state-dependent sources of non-determinism (dictionary ordering, hash randomisation) are often consistent within a single process but diverge across runs or builds. Any divergence between two sequential runs of the same input is a guaranteed bug.