Making of King Tide

Chapter 05 — Sim vs. Render

One rule, and the freedom it buys.

Every system in the last four chapters obeys a single architectural rule. It sounds like bookkeeping, but it's the most important line in the codebase — the thing that makes multiplayer, replays, automated tests, and a smooth 60 fps all possible instead of heroic. The rule is boring. What it buys is not.

The rule

The game is split in two. The simulation — physics, hover, drift, AI, the wave field — lives under src/engine/sim/ and src/game/systems/. The renderer — Three.js scenes, materials, shaders, the camera — lives under src/engine/render/. And:

The sim may never import Three.js. The renderer reads from the simulation, one way, and never writes back.

That's it. It's so simple you can enforce it with a grep — grep -r "from 'three'" src/game/systems should always come back empty. The renderer reads the bike's position out of the ECS world and pushes it into a Three.js object every frame; nothing ever flows the other way. Why be so strict? Four reasons, and they compound.

1 — Determinism, which is multiplayer and replays

Because the sim never touches the renderer, it's a pure function of its inputs: a starting seed, plus the stream of button presses, fully determines every position forever. Run the same inputs on two machines and you get bit-for-bit identical results. The game leans on this hard — there's a determinism harness that hashes the entire sim into a string each tick, with the contract that "two sims advanced from the same seed and inputs MUST produce the same snapshot string."

Determinism is a property you have to defend. It decays the moment a new system stashes state the snapshot doesn't hash, or two entities are processed in an order that isn't stable. A recent hardening pass widened the snapshot to cover every sim-carrying store and made the tie-breaks entity-id-stable — so a draw between two bikes resolves the same way on every machine. Each is the kind of bug that would surface only as a rare multiplayer desync, which is exactly why the hash runs in the test suite, not just in production.

That single property is what makes the hard features cheap. Multiplayer is lockstep: peers exchange button presses, not positions, and each simulates the same race in parallel. Replays and Time-Trial ghosts are just a recorded input stream replayed through the same sim. Now imagine the renderer could write back into the sim — say, the smoothed camera nudged a bike's position. Two players whose cameras differ by a frame would instantly desync. The one-way rule is what keeps the simulation trustworthy.

2 — The whole game tests without a browser

Three.js is a megabyte of code that wants a GPU and a DOM. If a hover or drift system imported it, every unit test would have to fake a graphics context. Because they don't, the entire simulation runs headless in plain Node — so the suite is ~1,300 tests in seconds, no browser required. (Every demo on this site was sanity-checked the same way: the sim math is just importable functions.) Fast, honest tests are a direct dividend of the boundary.

3 — A stable sim under a smooth render (two clocks)

The split lets the two halves run on different clocks. The sim advances in fixed steps — a constant 60 ticks per second — so gravity, buoyancy, and drift always integrate over the exact same slice of time and behave identically whether your machine renders at 30 fps or 144. The renderer, meanwhile, draws as often as your display allows and interpolates between the sim's most recent states to stay smooth.

The demo below makes the two clocks visible. The cyan dots are discrete sim states; the orange bike is what you'd actually render. Drop the sim rate to a crawl and turn interpolation off — the bike lurches from tick to tick. Turn interpolation back on and it glides, even though the sim is still ticking just a handful of times a second.

Live demo — lower the sim tick rate, then toggle interpolation. Cyan dots are the sim's discrete states; the orange bike is the interpolated render.

That's exactly how networked bikes move in the real game: position snapshots arrive only 20 times a second, and the renderer interpolates between the two most recent ones (running ~100 ms behind, so there are always two to blend) instead of teleporting on each arrival. A coarse, cheap, deterministic stream of truth; a smooth picture on top. You can only pull that trick if the two layers are cleanly separated.

4 — One-way flow deletes a whole class of bugs

The nastiest bugs in game code come from feedback loops between rendering and simulation: a value the renderer computed (an interpolated pose, a camera-relative offset) sneaks back into the physics, and now the simulation depends on framerate, screen size, or which way the camera was pointing. Those desyncs are miserable to track down. The "render never writes back" half of the rule makes them impossible by construction.

When something genuinely does need to flow from the player into the sim — a key press, a click in the track editor — it goes in as input intent through a narrow, explicit channel, not by a render system poking an ECS component. Shared math (vector helpers, the wave-field sampler) lives on the sim side, and the renderer imports down into it; never the reverse.

You've been relying on it this whole time

Look back at Chapter 01. The reason this site can drop the actual wave-field sampler into a Three.js demo is that the sampler is pure sim — it has no idea the renderer exists. The CPU buoyancy reads it; the water shader reads the same formula; neither owns the other. Every honest demo on this site is a small proof that the boundary holds.

Read the code

The rule and its rationale are in docs/adr/0002-sim-render-separation.md; the ECS choice that makes the sim pure data in 0001-ecs-bitecs.md. The determinism hash lives in snapshot.ts, the fixed-step accumulator in src/boot/game-loop.ts, and the snapshot interpolation in remote-interp.ts.