A million entities move every tick. The screen redraws at your monitor's full refresh rate — 120 Hz, 144 Hz, as fast as the panel will go. Between those two facts, not one byte is copied. Three threads share a single block of memory: the simulation writes it, the renderer reads it, and nothing in the middle marshals a message from one side to the other.
Before any of the marketing gloss, I described the target to a model that had never seen our code — *simulate a million entities with real gameplay logic while rendering at the monitor's refresh rate, in a browser* — and asked it where that sits. Its verdict: *extraordinary. nowhere near an out-of-the-box or usual implementation — the bleeding edge of what web tech can do.* So here is the walkthrough of how that bleeding edge is actually shaped. It is less exotic than it sounds, and the trick at the center of it is a refusal, not an invention.
The budget says no
Start with the arithmetic, because the arithmetic is where most people stop. Sixty simulation ticks a second gives each tick 16.67 milliseconds. Spread that across a million entities and you have about sixteen *nanoseconds* per entity to do its gameplay logic. A single cache miss costs around a hundred. You do not get to miss on the way to an entity, and you certainly do not get to copy it across a thread boundary and back. Every decision below falls out of taking that number seriously.
simulation thread — fixed 60 Hz
16.67 ms per tick ÷ 1,000,000 entities = ~16 ns each
one cache miss ≈ 100 ns → about a sixth of a miss, per entity
render thread — monitor refresh, uncapped
144 Hz = 6.94 ms per frame — and it never waits on the simulation
the crossing between them: 0 bytes copied You cannot buy your way out of that with a faster CPU. What you can do is delete the two costs the naive design pays without noticing: copying state between threads, and making one thread wait on another. That is the whole post.
Three threads, one block of memory
The usual web-worker model passes messages. You do work on a worker, serialize the result, post it to the main thread, and it deserializes a copy. For a million entities sixty times a second, the serialize-and-copy alone would blow the budget many times over. So we don't do it. The simulation is a systems language — Rust — compiled to WebAssembly, and its linear memory *is* a SharedArrayBuffer. The render worker and the main thread hold views over that same buffer. When the sim writes an entity's position, the renderer sees the new bytes at the same address. There is no message. There is no copy. There is one block of memory with three threads looking at it.
Each thread has exactly one job. The simulation *owns* the state — it is the only writer. The render worker only ever reads, drawing the million through a standard WebGPU renderer onto an OffscreenCanvas off the main thread. The main thread does the least of all: it listens to your mouse and keyboard and drops input events into a ring buffer inside the shared memory, for the sim to pick up. The orthodoxy says workers don't share memory, they pass messages. The design starts by refusing that sentence.
Sharing memory is easy. Sharing it without tearing is the job.
The moment two threads touch the same bytes, you have a new problem. If the renderer reads while the simulation is halfway through writing a tick, it gets a torn frame — half of this tick's state, half of last tick's, a million entities caught in two different moments at once. The fix is old and cheap: a double buffer and a single atomic. The simulation writes into the *back* buffer while the renderer reads the *front*. When a tick is done, the sim flips a published index with one release-store. The renderer's next acquire-load sees the flip and reads the whole new generation, consistent to the byte. No lock. The reader never blocks the writer; the writer never waits on the reader.
The published-generation counter quietly earns a second job. The renderer watches it to know a fresh simulation state has landed — which is the same edge that tells it to slide its interpolation from the old pair of states onto the new one. One atomic integer is both the *safe to read now* flag and the heartbeat the render clock runs off. It never consults the main thread's game loop; it clocks itself off the bytes.
The simulation and the screen don't share a clock
Here is the refusal at the center. The simulation runs at a *fixed* 60 Hz — fixed because determinism demands it, which we'll get to. The renderer runs as fast as your display will draw, and it is never gated on a sim tick. On a 144 Hz panel it paints more than two frames for every simulation step, and each of those frames is a fresh interpolation between the last two published states — not a stutter, not a repeat, a smooth in-between. Fixed simulation over free-running render is the oldest trick in the game-loop book. The twist is running the two on *different threads through shared memory*, so the fast one never has to slow down to the pace of the slow one.
What a million entities are actually doing
None of this would matter if the million were just points drifting in a field. They aren't. The world is a whole universe — galaxies made of solar systems made of planets, well over a million bodies in all. Every one carries live gameplay state: who holds it, its population, its wealth, how stable it is, how much is happening there. And every one is *moving* — planets sweep around their star, bodies spin on their axis, every single tick. Battles and rebellions roll through whole regions and flip who owns them. It is a living strategic map at a scale you're used to seeing faked with a few hundred markers.
Every tick, that whole million gets advanced *and* summarized in a single sweep. One pass walks the entities in memory order, moves each one forward, and folds it into the handful of fixed-size totals the dashboard actually shows — all from the same read. The lesson the profiler beat into us: at this scale the enemy is not compute, it is memory bandwidth. The fastest arithmetic in the world sits idle if it is waiting on data to arrive from RAM. So the pass touches each entity's data once, does everything it needs while that data is hot in registers, and moves on — laid out as a structure of arrays, processed four lanes at a time on the CPU's vector unit, allocating nothing once the loop is running.
for each entity, in memory order:
load its columns once // straight from cache, 4 lanes at a time
advance them in registers // orbit, spin, economy, stability
fold them into the totals // reduce as we go — no second sweep
store, and step to the next
// no allocation. no re-reading the million. bandwidth is the ceiling. Nothing in that loop is clever on its own. It is four old disciplines held all at once:
- Structure of arrays — every field is its own tight array, so a pass over one property streams straight through cache instead of hopping through scattered objects.
- Vectorized — the CPU's SIMD unit advances four entities per instruction, so the arithmetic keeps pace with the memory instead of lagging behind it.
- Zero allocation — once the loop starts it never asks the allocator for anything; every buffer it needs was sized at startup. No allocation means no garbage-collector pause to blow a frame.
- One fused pass — advancing the entities and summarizing them share a single read of the data, not two. At a million rows, the second read is the thing you can't afford.
Built to run twice and agree
Why is the simulation locked to a fixed timestep instead of running flat out like the renderer? Because it has to be reproducible. Every number comes from that fixed step and from hashes — never the wall clock, never the machine's random-number generator. Feed the same inputs to two different computers and they land on bit-identical state, tick for tick. That isn't a party trick; it's the price of admission for rollback netcode and lockstep multiplayer, where every player's machine re-simulates the world and they all have to agree on the result. The camera and the current selection stay per-player and never touch the shared simulation — so two people can study opposite corners of the same universe without ever pulling it out of sync.
The costs we're not hiding
A post like this earns its credibility by naming what it costs, so here are the real edges:
- The sim is 60 Hz; the render is what's uncapped — a million entities get their gameplay logic sixty times a second, and the renderer paints at 144. Both numbers are true, and they live on different threads — collapsing them into 'a million entities at 144 Hz' would be the dishonest version of this brag.
- The pool is a fixed size, on purpose — capacity is set at startup and never grows. Zero-allocation steady state is the whole point, and the price is that there's no unbounded spawning — you design within the ceiling.
- Shared memory has a cost of entry — a browser won't even hand you a SharedArrayBuffer without specific cross-origin-isolation headers, and once you have it you owe an ordering discipline — release/acquire, generation counters — that a message-passing app never has to think about.
- The bandwidth wall is real — past a few million the single fused pass stops feeling free, and the parts that depend on each entity's own data don't vectorize. This isn't infinite headroom; it's a budget spent carefully.
Why fight the frame budget in a tab
You could ask why do any of this in a browser at all, when a native engine hands you threads and a GPU without the ceremony. The answer is the tab itself. *Open a link and you're playing* — no install, no store, no console, the same build on the laptop and the phone in your pocket — is worth more than the budget it costs, and the budget, it turns out, is payable. The browser stopped being the toy tier a while ago; most people just haven't updated the assumption. A million entities moving under a renderer that never drops to their pace is one way to make the update concrete.
We build small, real games in the open as a proving ground for AI-native tooling — where the same agents that write a system like this one also record why it's shaped this way, for the next agent and the next engineer who has to touch it.
Read next
The other half of *open a link and you're playing* is how the build gets there in the first place:
- Idea to playable in seconds — the pipeline that puts every change on every device the instant it's written — the delivery side of the same 'no install, just play' bet. /devlog/idea-to-playable-in-seconds