Every Machine Agrees on the Same Frame
Every earlier co-op post (one simulation, many cursors and the server stopped repeating itself) leaned on a dedicated Node server that ran the whole simulation and shipped the result to thin browser clients. It worked, but it was the wrong shape for where the game is going. This week I tore out the foundation and poured a new one: a deterministic fixed-timestep simulation where the same seed, stage and inputs produce a bit-for-bit identical run on any machine. Then I rebuilt co-op on top of it as a peer relay instead of a server sim. This is the least visual week of the whole project and the one I am most happy about.
The problem with "run it every frame"
Until now the simulation advanced by however much wall-clock time had passed since the last frame. A 144Hz monitor stepped the world in tiny slices; a 30Hz laptop stepped it in fat ones. That is fine for a single player who never compares notes with anyone. It is fatal the moment two machines are supposed to agree on where an enemy is, because floating-point math depends on the exact sequence of operations, and "advance by 6.94ms" versus "advance by 16.66ms" is a different sequence. The two worlds drift apart within seconds.
The fix is the trick every serious deterministic game uses, from Trackmania to fighting games: separate the simulation clock from the display clock.
The sim ticks; the screen interpolates
The simulation now advances in exact fixed ticks of 1/20 of a second, FIXED_DT. It never sees a variable delta. Each frame the render loop banks the real elapsed time into an accumulator and drains it in whole ticks: if 33ms have piled up, that is two ticks of FIXED_DT with a bit left over. A GameState.tick integer is the master clock now, incremented once per step, the single source of truth for "which frame is this."
Speed-up falls out for free. The 2x and 3x buttons do not change the tick; they just fill the accumulator faster, so more ticks drain per second. The tick sequence is identical whether you play at 1x or 3x, which is exactly the property you want: fast-forwarding must not change the outcome.
The catch is that a 20Hz sim looks choppy if you draw it raw. So the presentation layer interpolates. Before every tick I snapshot each entity's position, and the renderer blends the previous position toward the current one by a sub-tick alpha (accumulator / FIXED_DT). The world simulates at 20Hz and draws at 144Hz, buttery smooth, and the cosmetic wiggle (enemy waddle, projectile spin, glows) still reads the display clock directly so it never touches the sim. A little observe(onRemove) hook invalidates recycled entity slots so a reused id never interpolates from a corpse.
The part that actually bites: transcendentals
Here is the subtle killer. JavaScript's + - * / and Math.sqrt are IEEE-754 correctly-rounded, meaning every engine on every platform returns the identical bit pattern. But sin, cos, atan2, exp, log, pow and the ** operator are not standardized to the last bit. V8, SpiderMonkey and a phone's JS engine can each return a slightly different Math.sin(x), and in a deterministic sim "slightly different" compounds into "totally desynced" over a few thousand ticks.
So every transcendental reachable from the simulation now routes through a tiny deterministicMath.ts module: dhypot, dsin, dcos, datan2, dexp, dln, dpow, all built only from the safe operations, all TDD'd. Squared-distance checks that used ** 2 got rewritten as plain multiplication. And because I will absolutely forget this rule in six months, there is a guard test that scans every sim file and fails the build if a native transcendental or a ** sneaks back in. Presentation and input files are exempt, since they never feed the sim.
To prove the whole thing actually holds, stateHash.ts fingerprints the entire world into a single number, dbg.hash() prints it in the browser console, and npm run determinism runs the same seed twice headless and asserts the fingerprints match tick for tick. When that command goes green, the sim is genuinely deterministic, not just deterministic-looking.
Co-op, rebuilt as a relay
With a deterministic core, the dedicated server stopped earning its keep. Why run the sim on a Node process when every browser can run the exact same sim from the same seed and stay in sync on its own? So co-op got rewritten as a host-authoritative deterministic relay.
The room creator's browser is the authority and runs the real sim. Every other player runs their own copy from the same seed and tick. The server is now dumb: it relays intents (stamping each with the sender's slot), relays cursors, and relays the authority's periodic corrections. It no longer simulates anything. If the creator leaves, authority migrates to another player via a pure election helper, and their sim just keeps going.
Entities are reconciled by a deterministic network id, not the bitecs entity id (bitecs recycles those across entity types, so they are useless as a shared key). A nextNid counter is stamped at every spawn and the same spawn on every peer gets the same nid. Snapshots carry tick, seed, nextNid and key everything by nid.
Card rolling moved out of the shared sim entirely, because it is per-player: each player rolls their own cards off their own RNG from their own unlock pool. Only the resolved pick gets broadcast, so every peer applies the same shared effect without ever seeing your card screen.
Don't snap; wait for your turn
The first version of the relay had guests apply each authority correction the instant it arrived, roughly four times a second. That rubber-banded horribly: enemies you were watching would teleport back to where they were 250ms ago, every 250ms. Ugly.
The fix is a jitter buffer keyed by tick. Every peer keeps running its own deterministic sim. The authority sends corrections stamped with the tick they describe. A guest does not apply a correction on arrival; it buffers it and applies it exactly when its own clock reaches that tick, deliberately running a fixed lag behind (10 ticks, 500ms, adaptive up to 20 if the connection is rough). Because a like-tick state reconciles against a like-tick state, the correction is tiny, usually invisible, instead of a snap into the past.
The pure coopSync.ts handles the clock control: planFrame returns an ordered list of step / apply / snap actions the main loop executes. If a correction arrives describing a tick already in the past (overrun), it freezes for a few ticks and drops the stale data. If a guest falls badly behind, it hard-snaps forward to the newest buffered full snapshot. The lag adapts: it grows when the connection stutters and relaxes when things are calm.
To keep bandwidth down, corrections come in two flavors. A full snapshot goes out every 20 ticks (once a second) and whenever something structural happened. On the other ticks the authority sends a delta: only the entities that changed, plus lists of removed network ids, all as absolute values. A structural intent (a tower placed, a card picked) flips a pendingFullSync flag so the very next tick flushes an immediate full, reconciling every guest to the exact post-event state. The server caches only fulls, so a mid-run joiner can be booted straight into the live sim.
Where it landed
The result is a game that runs one clock everyone agrees on. Single player and co-op share the identical fixed-timestep loop, the identical interpolation, the identical effect derivation. The server went from "runs the game" to "forwards mail." And the whole thing is provably deterministic, which is the unglamorous property that unlocks everything I want next: replays, spectating, and eventually a leaderboard you can actually trust. Back to the board.