Mushroom Wars 2 has run the same multiplayer simulation on six platforms for about ten years, and the simulation runs on plain float. There is no fixed-point math in it. That claim usually starts an argument, so the game's CTO, Alex Silaev, wrote down exactly how the netcode works, what actually breaks determinism in practice, and the one bug that taught his team the most.
The game: Mushroom Wars 2, a real-time strategy game with thousands of units on screen, live for about ten years.
The platforms: Switch, PlayStation, Xbox, PC, iOS and Android, at times all playing against each other on the same servers.
The netcode: deterministic lockstep with rollback, running on plain float, with no fixed-point math.
The tick: 33 ms, so 30 ticks per second; only the logic is tied to ticks, rendering runs at the device frame rate.
The safety net: clients hash confirmed ticks with FNV-1a and the mismatch rate is a permanent metric.
The lesson: in ten years, floats were never the main problem. Hand-written copy code was.
The game in one paragraph
Mushroom Wars 2 is a real-time strategy game with thousands of units on screen. It has been live for about ten years, on Switch, PlayStation, Xbox, PC, iOS and Android, and at various points all of those platforms played against each other on the same servers. The netcode is deterministic lockstep with rollback, and the simulation runs on plain float. Silaev, CTO at Zillion Whales, the studio behind the game, has been building games in Unity for more than 15 years, and this case comes from the networking chapter of his book on Unity performance, memory and architecture.
The two source articles for this dissection were both published on 2026-10-31 on dev.to: the main piece, Ten Years of Rollback Netcode in a Cross-Platform RTS, Without Fixed-Point, and its companion, One Forgotten Field Out of a Hundred, which goes deeper on the desync hunt. Everything below is traced to one of those two articles, and anything the author presents as his own report or opinion is labelled as such.
Thin server, fat clients
There are a few classic multiplayer architectures: the server simulates and clients render, one client is the master, or every client simulates the same thing. RTS games and fighting games use the last one. Every client runs the full simulation, and only player inputs travel over the network. The server does not run game logic at all. It relays packets, and does a few small things like deciding the winner.
That makes the server absurdly cheap. In this setup, Silaev writes, a 50 dollar server holds around 10,000 connected players. Traffic does not depend on the number of units either, which is the only reason thousands of units are possible at all. Logic runs in fixed ticks of 33 ms, so 30 ticks per second. Classic PC RTS games used much longer network turns, around 200 ms in Age of Empires. Rendering and animation run at whatever frame rate the device can do; only the logic is tied to ticks.
Why rollback, and why StarCraft does not need it
Pure lockstep waits: a client does not compute tick N until it has every player's input for tick N. On PC that is fine. PC RTS games simply show waiting for player when someone lags, and the simulation only ever moves forward. On mobile that does not work. Pings are high and jumpy, matches last three to five minutes, and nobody wants to stare at a waiting screen. So Mushroom Wars 2 does what fighting games do.
A client applies its own command immediately, so the game feels offline. The packet goes to the server, which forwards it to everyone else. When another client receives player 1 did X at tick 1 while it is already at tick 5, it restores a saved snapshot from before tick 1, applies the input, and re-simulates ticks 1 to
5. Visual jumps are smoothed by interpolation, or not smoothed at all if units are slow enough that nobody notices. Snapshots are taken every 10 to 20 ticks. Fighting games snapshot every tick, because they roll back two or three ticks constantly. Mushroom Wars 2 rolls back further but less often.
The skeleton: snapshots, sorting, and a memory copy
The skeleton Silaev published is an illustration of the design, not the game's production code, and it is worth reading as a design, not copying as a file. The state of the world is a set of flat arrays of structs, copied wholesale. Snapshots are kept for 600 ticks, which is 20 seconds at 30 ticks per second, and any input older than that is already a desync. When an input arrives, it is filed under its tick and the inputs for that tick are sorted by playerId, then by sequence number. If the input belongs to a tick in the past, the simulation marks that tick as dirty, restores the nearest snapshot at or before it, and re-simulates forward.
Two details in there matter more than they look. The sort inside a tick: if two players send commands for the same tick, every client must apply them in the same order, and packet arrival order is different on every client, so it cannot be used. The cost of the copy: re-simulating 10 to 20 ticks inside one frame is routine, so copying the world has to be close to a memcpy. If a snapshot means walking a thousand objects and allocating, rollback will kill any phone. On the weakest supported devices a logic tick takes about 6 ms out of the 33 ms budget, which leaves room to catch up two or three ticks per frame after a spike. A free bonus: replays cost nothing, because a match is just its inputs.
Who wins when two players want the same spot
Say there is one free spot left to build on, and two players click it 33 ms apart. Each one sees their own building first. Then player 2 receives player 1's packet, rolls back to tick 1, applies player 1 builds here, reaches its own command at tick 2, and the command is now invalid. It stays in the history, it just does not do anything. Player 1 does the mirror image: rolls back, keeps their building, skips player 2's command. Packet arrival order does not matter, only the ticks written in the packets. Once all packets have arrived, every client has exactly the same world.
Floats are deterministic. Mostly.
Here is the part people object to. IEEE 754 defines rounding for basic operations, so the same sequence of additions, multiplications and divisions produces the same bits on compliant hardware. If 10 divided by 100 comes out as 0.0999999 instead of 0.1, that is fine. What matters is that every client gets the same 0.0999999. In practice, Silaev writes, divergence comes from three places. First, the compiler: clients built by the same toolchain with the same settings agree, but build two clients differently and they can diverge. Fused multiply-add is the classic culprit, because a times b plus c with one rounding instead of two gives different low bits.
Second, math libraries: sin, cos, pow and friends are implemented differently on different platforms, so do not call engine or operating system transcendental functions in simulation code. Write your own from basic operations. Third, a small tail of weird hardware: the team saw divergence on roughly 1 percent of some Android devices from a few vendors. There are tens of thousands of device models out there, and you cannot fix all of them. You can put those players in rooms with each other.
The real modern problem, Silaev writes, is vectorizing compilers. Unity's Burst, for example, may fuse operations into FMA, reorder them in fast-math modes, or use approximate instructions like rsqrt depending on the target CPU. On Burst's deterministic float mode, Silaev says only this, in his own words as far as he knows: it was announced but never actually shipped, so treat that as the author's report, not as a verified fact. If you use Burst for simulation, he writes, you will probably end up needing something other than float. On a recent project the team went the textbook route and built fixed-point math so everything could go through Burst, then took it out again, and performance went up by about 40 percent. That 40 percent figure is the author's own report from his project, not an independent benchmark. Fixed-point is not free: every multiply needs a widening and a shift, and Sqrt, Sin and Atan2 become hand-written tables or iterations that eat exactly the time you were trying to save. What he recommends today is neither general fixed-point nor float, but plain integers with a unit chosen per quantity. His example: if speed never exceeds 100 metres per second and a tick never exceeds a second, millimeters and milliseconds are enough, so a position in millimeters plus a speed in millimeters per second times a delta in milliseconds, divided by 1000, stays inside a 32-bit int, because 100,000 times 1000 is 10 to the power of
8. Division truncates, which can lose precision for very slow objects, but it truncates the same way everywhere, so determinism is safe.
What actually causes desyncs
System.Random or UnityEngine.Random in logic. Randomness must be part of the world state, seeded, and rolled back with it.
Iterating a Dictionary or HashSet. Order is not guaranteed.
GetHashCode on strings and objects. In .NET Core it is randomized per process.
The engine physics. PhysX is not deterministic across machines.
Unstable sorts with equal keys. Always add a secondary key like the entity id.
Anything that touches deltaTime. Logic must run on fixed ticks, not on frame time.
And one more category that no rule catches: a field someone forgot to copy in a hand-written snapshot.
The forgotten field
Snapshots used to be taken by CopyFrom methods written by hand, one per state class. A unit had about a hundred fields. Someone added a new one and forgot to copy it. Nothing broke. Without a rollback, the field lives its normal life and the game is correct. It only breaks when a rollback lands exactly on a tick where that field matters: one client restores the snapshot and keeps the stale value from the discarded prediction, another client has the correct one. In the team's case, a building got captured on one client and stayed neutral on the other. Reproducing that by hand is close to impossible, because you need a rollback on exactly the tick where the forgotten field changes.
The team caught it with hashes. Clients periodically hash the world state for a tick that is already confirmed, meaning all inputs have been received, and send the hash to the server. The share of matches with mismatched hashes is a permanent metric. There is always some background noise from the hardware tail described above, but one day the rate went up, which meant a new source of divergence, somewhere in code that touches simulation state. The fix came in two steps. First, a reflection test that finds every type with a CopyFrom, fills an instance with random values, copies it, and compares every field, including private ones in base classes. Silaev writes in the companion piece that he asked an AI model to write this test, because it is a mechanical task that is easy to verify by eye. Second, all blittable fields of a unit moved into one struct, so the copy became a single assignment, and a new field cannot be forgotten anymore, because the compiler copies it. The field names in the published struct example are illustrative, and the few reference fields that are still copied by hand are guarded by the test.
Hashes, cheaters, and the 1v1 problem
The same hashes handle cheating. If three clients report hash A and one reports hash B, the odd one out is told to roll back further and re-simulate. A transient glitch fixes itself. A modified client keeps producing B and eventually gets removed from the room. Nobody else notices anything. In one-on-one games you cannot tell who is right with two votes. For that, the server can run a headless client fed with all the match inputs. It renders nothing and re-simulates the whole match almost instantly; whoever it agrees with wins. It is also the best debugging tool the team has for real desyncs.
Two practical notes on the hash itself. Do not use GetHashCode. Walk the raw bytes of the state in a fixed order with something simple like FNV-1a, and zero your structs on creation, because padding bytes can contain garbage and break the hash for identical data. And only hash ticks for which all inputs are confirmed, or you will chase desyncs that disappear after the next rollback.
A self-check worth stealing
The cheapest determinism test the team has does not need a network. In the editor, a debug mode rolls back one tick on every tick, re-simulates it, and compares the hash. Any state that is not fully captured by snapshots, or any hidden dependency on time or order, shows up immediately, long before a real match. That is the single practice Silaev recommends stealing first: if your snapshots cannot survive a rollback on every tick in the editor, they will not survive one in a real match either.
Takeaways: what to steal, and what not to copy
Rollback lets a lockstep game feel offline on bad mobile connections, at the cost of re-simulating ticks. Make snapshots a memory copy, or do not do it.
Floats can be deterministic in production across consoles, PC and phones, if you control the compiler and stay away from platform math libraries and vectorizing fast-math.
Most desyncs come from randomness, iteration order, hashing and hand-written copy code, not from floating point.
Hash confirmed ticks, track the desync rate as a metric, and keep a headless client on the server for disputes.
Keep simulation state in blittable structs, and test the copy mechanically: a forgotten field is a time bomb no gameplay test will catch.
Do not copy the published skeleton as production code. It is an illustration of the design; the constants and the sort rule are the parts to keep.
Do not treat the Burst deterministic float claim as verified. Silaev presents it as far as he knows, and this article keeps that attribution.
Do not treat the 40 percent fixed-point figure as a benchmark. It is the author's own report from one project.
Do not hash unconfirmed ticks. You will chase desyncs that disappear after the next rollback.
How Slippi Gave a 2001 GameCube Game Rollback Netcode It Never Had
The Light Moved on Its Own: How Project Ghost Fixed Its Photon Fusion Flashlight
Multiplayer Not Laggy but Keeps Kicking You Out? How Factorio Desyncs Really Happen

