Deterministic Lockstep in Browser Games: Keeping Simulations Identical Across Clients

Have you ever watched two players in the same browser match feed identical inputs into identical code and still end up with different health totals three minutes in? If you have shipped anything on rollback or lockstep netcode, you have, and you probably lost a week finding the one line responsible.
Deterministic lockstep is the oldest bandwidth trick in multiplayer games. You send inputs instead of world state, every client runs the same simulation, and the results are supposed to match forever.
Deterministic lockstep sends inputs, not state, and requires every client to produce bit-identical results from the same inputs. One divergent bit at tick 400 becomes a visible desync by tick 460.
Most of the writing on lockstep treats determinism as a solved prerequisite and spends its pages on latency hiding instead. In JavaScript, determinism is the hard part, and the failure modes are specific enough to enumerate and close one at a time.
What Deterministic Lockstep Actually Requires
A lockstep session is a shared function: given the same starting state and the same ordered input stream, every peer must produce the same output state. Nothing in that contract mentions networking, which is why the networking layer is almost never where desyncs originate.
Four conditions have to hold on every client, and all four are easy to break by accident. The conditions include but are not limited to:
- Identical starting state. Every entity, counter, and random seed must be byte-for-byte the same at tick zero. That means the match seed is negotiated once and replicated, never generated locally on each client.
- Identical input ordering. Inputs from all peers must be applied in a fixed order, usually sorted by player slot rather than by arrival time. Two inputs applied in a different order on two machines produce two different worlds.
- Identical arithmetic. Every operation must round the same way on every engine, browser version, and CPU architecture in your player base. This is where JavaScript stops cooperating.
- Identical iteration order. The order in which you visit entities changes the order of floating-point accumulation, and therefore changes the result.
Miss any one of those and the simulation still runs perfectly well. It simply runs two different games that agree for a few hundred ticks and then quietly stop agreeing.
Why JavaScript Makes Bit-Identical Simulation Harder Than The Literature Admits
The usual advice is to avoid floating point and move on. That advice is both too broad and too narrow, because some float behaviour in JavaScript is exactly specified while other parts of the standard library are deliberately left open.
Transcendental Math Functions Are Implementation-Defined
The ECMAScript specification pins basic arithmetic to IEEE 754 double precision and requires each operation to round on its own, which rules out fused multiply-add surprises across CPU architectures. It explicitly does not pin Math.sin, Math.cos, Math.tan, Math.exp, Math.pow, or Math.log, all of which are described as implementation-approximated.
In practice V8 ships an fdlibm port and has changed that implementation partway through its history, while SpiderMonkey and JavaScriptCore carry their own. A rotation computed with Math.cos on Chrome and on Safari can differ in the final bits, and a final bit is all a desync needs.
ECMAScript leaves Math.sin, Math.cos, Math.exp, Math.pow, and Math.log implementation-approximated. Addition, subtraction, multiplication, division, and Math.sqrt are exact IEEE 754 and safe to use.
The fix is to stop calling those functions from simulation code entirely. Either implement your own sine and cosine over a fixed-size lookup table indexed by an integer angle, or move to fixed-point math where the table is the only option anyway.
Keep in mind that Math.sqrt is safe, because IEEE 754 specifies its result exactly, and Math.fround is safe for the same reason. Math.pow(x, 0.5) is a different call with different guarantees and should never be treated as a substitute.
Iteration Order Quietly Reorders Your Float Math
Floating-point addition is not associative, so summing three forces as a plus b plus c can differ in the last bit from a plus c plus b. Any system that accumulates into a shared value is therefore order-sensitive, including gravity integration, collision impulse resolution, and damage stacking.
JavaScript's iteration orders are well specified: integer-like object keys ascend numerically, string keys follow insertion order, and Map and Set iterate in insertion order. The specification does not save you, though, because insertion order itself diverges the moment one client spawns a projectile before a pickup and another client does the reverse.
Floating-point addition is not associative, so iterating entities in a different order gives different last bits. Sort by a stable integer entity ID before every simulation pass.
The durable fix is to assign every entity a monotonic integer ID from inside the simulation, then iterate a dense array sorted by that ID instead of a hash map keyed by object reference. This is one of the practical reasons an entity component system layout for browser games earns its keep the moment netcode enters the picture.
Array sorting deserves its own warning. Array.prototype.sort has been required to be stable since ES2019, but stability only helps when your comparator defines a total order, so break ties on entity ID rather than returning zero.
Math.random Cannot Be Seeded Or Rewound
Math.random has no seeding API, its algorithm is engine-specific, and every realm gets its own independent state. Even if two clients somehow started from the same internal state, rollback would break it, because re-simulating a tick would consume random numbers a second time.
Simulation randomness therefore has to be an explicit part of your state. A 32-bit generator such as mulberry32 or sfc32, built from Math.imul and unsigned right shift so every intermediate stays inside int32 range, gives you a small integer state you can snapshot, restore, and hash.
Replace Math.random with a seeded 32-bit generator such as mulberry32 or sfc32, and store its state inside every snapshot. Rollback must rewind the random stream along with everything else.
Run at least two streams rather than one. The simulation stream advances only inside the fixed step, while a separate cosmetic stream drives muzzle flashes, hit sparks, and camera shake so that presentation variance never touches shared state.
Time Sources Do Not Belong Inside The Step
Date.now, performance.now, and the timestamp handed to requestAnimationFrame are all wall-clock values that differ per machine and per frame. A simulation that reads any of them is nondeterministic by construction, however careful the rest of the code is.
The only clock the simulation may see is the tick counter, and the only duration it may see is the constant step. This is why a fixed timestep simulation loop is a hard prerequisite for lockstep rather than a stylistic preference, and why the accumulator that decides how many steps to run must live strictly outside the step.
Fixed-Point, Floating-Point, Or WebAssembly
Once you accept that the standard library cannot be trusted wholesale, you are choosing between three arithmetic strategies. Each buys a different amount of determinism at a different engineering cost, and the right answer depends on how much of your simulation you are willing to rewrite.
| Approach | Determinism guarantee | Cost | Use when |
|---|---|---|---|
| JS doubles, hand-rolled trig | Strong for arithmetic, only as good as your own sin/cos tables | Lowest; you keep existing code and replace roughly a dozen call sites | The simulation is already written and the math surface is small |
| Fixed-point Q16.16 in Int32Array | Total, because no floating point is involved anywhere | High; every vector, physics constant, and division is rewritten | You are starting fresh and want determinism you never have to re-audit |
| WebAssembly simulation core | Specified by the Wasm standard for f32 and f64 operations | Moderate to high; a language boundary plus a state marshalling layer | The simulation can move wholesale to Rust, C++, or AssemblyScript |
| Server-authoritative, no lockstep | Not needed; one machine owns the truth | Bandwidth scales with state instead of inputs | Player counts are high or clients cannot be trusted at all |
WebAssembly is the pragmatic middle for most browser teams, because its arithmetic determinism is a specification guarantee rather than something you maintain by hand. Be aware that the two known escape hatches, NaN bit payloads and relaxed SIMD operations, still need care, so canonicalize NaN before hashing and avoid relaxed SIMD in simulation code.
Building A Random Number Generator You Can Roll Back
A rollback-safe generator has two properties: its entire state fits in your snapshot, and advancing it is a pure function of that state. Mulberry32 satisfies both with a single unsigned 32-bit integer, which is why it shows up in so many netcode-adjacent codebases.
The pattern is straightforward. Add a fixed odd constant to the state, run two multiply-and-xor mixing rounds using Math.imul so the multiplication truncates the way a 32-bit machine multiply would, then finish every intermediate with an unsigned right shift by zero to keep the value in range.
There is a stronger option worth knowing about, though, particularly for anything spawned or resolved in bulk. Instead of a stateful stream, hash the tuple of entity ID, tick number, and a per-system salt through a splitmix-style finalizer and use the result directly.
Stateless, hash-derived randomness removes ordering dependence entirely. Two clients that process the same entities in different orders still get the same random values, because each value depends only on the tuple that produced it and never on how many draws came before.
That property is worth real money during a rollback. A stateful stream forces you to re-draw in exactly the original order across every re-simulated tick, while a hash-derived draw is correct no matter how the re-simulation schedules its work.
How Do You Know Your Simulation Is Still In Sync?
Determinism you cannot observe is determinism you do not have. Every lockstep build needs a checksum channel from day one, because retrofitting one after a desync report is how a two-hour investigation becomes a two-week investigation.
Serialize the simulation state into a canonical byte layout each tick, then hash it with something cheap and stable such as FNV-1a over the resulting buffer. Canonical means fixed field order, fixed entity order by ID, and no maps or objects serialized by enumeration.
Hash the canonical simulation state every tick and exchange the digest every 15 to 30 ticks. The first mismatched tick number tells you exactly where to start bisecting a replay.
Two values need canonicalizing before they reach the hash. Negative zero must be normalized to positive zero by adding zero, and any NaN must be replaced with a single chosen bit pattern, because both compare equal in arithmetic while hashing to different digests.
Exchange the digest with its tick number roughly four times a second, which at 60Hz means every 15 ticks. Keep a ring buffer of the last two seconds of inputs and snapshots so that when a mismatch fires, you can replay from the last agreeing tick and bisect forward system by system.
A desync report should bundle the match seed, the full input log, and the first mismatching tick. That bundle is a complete reproduction case, and it is the same data that makes browser spectator mode replays possible from an input stream rather than a recorded video.
What Rollback Adds On Top Of Determinism
Lockstep waits for every peer's input before advancing, so its felt latency is the worst round trip in the session. Rollback predicts the missing inputs, advances immediately, and re-simulates when the real inputs arrive, which is strictly more demanding of the simulation than lockstep is.
The added requirement is cheap save and restore. Serialize state into a preallocated ArrayBuffer from a pool of eight to ten slots rather than allocating objects, because a rollback that triggers a garbage collection pause has traded a correctness problem for a frame-time problem.
Budget the worst case explicitly. Seven to eight rollback frames is the common ceiling, so a 60Hz game with a 16.67 millisecond frame needs a simulation step that runs in roughly one millisecond to fit eight re-simulated ticks plus rendering inside a single frame.
Prediction quality matters less than people expect. Repeating the last known input is usually enough for two to four frames, and the harder problem is the local input path feeding the predictor, which is covered in our piece on input buffering for browser games.
Where Determinism Breaks On Roblox
Roblox developers ask about lockstep regularly, usually after reading a fighting-game netcode article. The honest answer is that the engine does not offer the guarantee lockstep is built on.
Roblox does not guarantee bit-identical physics across machines, so lockstep is not available there. The equivalent pattern is a server-authoritative simulation with client-side prediction and reconciliation.
Roblox runs a server-authoritative physics solver with network ownership handed to individual clients for responsiveness, and it publishes no bit-identical guarantee across platforms. Two clients simulating the same parts on an ARM phone and an x86 desktop are not expected to match, so a lockstep design would desync on the first collision.
The parts of your game you write yourself can still be deterministic, and it is worth making them so. Random.new(seed) gives you an isolated, reproducible generator object rather than the shared global stream behind math.random, which makes loot rolls and procedural layouts reproducible for debugging and for audit.
Parallelism introduces its own hazard here. Work distributed across parallel Luau actors has no guaranteed completion order, so any accumulation performed across actors must be reduced in a defined sequence rather than in whatever order the scheduler finishes.
There is a security dimension too. Lockstep gives clients authority over the simulation, which is exactly the property you do not want in a game with trading, currency, or ranked progression, and it is why the Roblox anti-exploit patterns we recommend keep the server as the sole writer of consequential state. Checksums catch an accidental desync, but they do not catch an attacker who desyncs on purpose and then argues their state is the correct one.
A Determinism Checklist Before You Ship
Determinism holds until someone adds a feature that quietly breaks it, so this belongs in code review rather than in a one-time audit. Here is the list to run before a lockstep or rollback build goes to players:
- No implementation-defined math in the step. Grep the simulation directory for Math.sin, Math.cos, Math.tan, Math.exp, Math.log, and Math.pow, and confirm every hit is a lookup table or a Wasm call.
- No wall-clock reads in the step. Grep for Date.now, performance.now, and any dt parameter threaded past the accumulator boundary.
- No unseeded randomness anywhere. Ban Math.random from simulation code with a lint rule, since a single stray call takes hundreds of ticks to surface as a visible bug.
- Stable iteration everywhere. Confirm every entity loop walks a dense array ordered by integer ID, and that every comparator breaks ties rather than returning zero.
- Canonical hashing. Verify that negative zero and NaN are normalized before the digest, and that the serializer writes a fixed field order.
- A cross-engine soak test. Run the same input log through Chrome, Firefox, and Safari, plus at least one ARM device, and diff the per-tick digests for a full match length.
The cross-engine soak test is the one teams skip and the one that finds the most bugs. Running an hour of recorded inputs on four targets overnight costs nothing and catches the engine-specific divergences that no amount of local testing will reproduce.
Frequently Asked Questions
These are the questions that come up most often once a team commits to a deterministic simulation core.
Is JavaScript floating-point arithmetic deterministic?
Basic arithmetic is deterministic. Addition, subtraction, multiplication, division, and Math.sqrt are IEEE 754 double operations that the specification requires to round after every individual step, which means no engine may fuse them and every engine produces the same bits. The transcendental functions are the exception.
How many rollback frames should a browser game buffer?
Seven to eight frames at 60Hz covers most consumer connections and matches the long-standing GGPO default. The consequence is that a worst-case frame must re-simulate eight ticks and still render, so your simulation step needs to complete in roughly one millisecond.
How often should clients exchange state checksums?
Hash every tick, but transmit roughly four times a second, which is every 15 ticks at 60Hz. Send the 32-bit digest alongside its tick number so peers compare like for like, and keep enough history that the comparison can still reference a tick that is already several frames old.
Does WebAssembly solve determinism completely?
It solves the arithmetic, since Wasm specifies f32 and f64 behaviour exactly. It does not solve iteration order, unseeded randomness, or wall-clock reads, and it leaves two edges to handle yourself: NaN bit payloads and relaxed SIMD operations, both of which should be avoided or canonicalized in simulation code.
What should a desync bug report contain?
Three things make a report reproducible: the match seed, the complete ordered input log, and the tick number of the first digest mismatch. With those, any developer can replay the match locally, stop at the failing tick, and bisect forward through systems until the divergent value appears.
Getting A Second Set Of Eyes On Your Netcode
Determinism work is unglamorous, and it is also the difference between netcode that ships and netcode that gets rewritten twice. If you are scoping a rollback or lockstep build and want the failure modes named before you write the simulation core, that is the kind of review worth doing early.
Start with the pieces this one sits beside. Our guides on fixed timestep loops and input buffering cover the two systems that have to be correct before determinism is even testable.

