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:
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."
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.
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.