All posts

Devlog

A million entities, zero copies — the shared-memory trick behind a full-rate browser sim

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.

two budgets, two threads — and zero bytes crossing between them
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.

SharedArrayBufferone block of memorySIMRust to wasm, owns statewritesRENDER workerOffscreenCanvas + WebGPUreadsMAIN — input onlyevents
Three threads, one buffer. The simulation owns and writes it; the render worker only reads; the main thread just drops input events in. No message crosses between them — they read the same bytes.

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.

SIMfills the backbuffer Awriting (back)buffer Bpublished (front)writesflip: 1 release-storeRENDERreads the frontacquire
The sim fills the back buffer, then flips one atomic flag. The renderer acquire-loads the published side and reads a whole consistent generation — no lock, no torn read.

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.

simulation — fixed 60 Hzeach render frame interpolates the last two sim statesrender — monitor refresh, uncapped
Between two fixed sim ticks, the renderer paints as many frames as the display asks for — each an interpolation of the last two published states, not a repeat.

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.

one pass over the million: touch once, do everything, move on
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