A skirmish map at full zoom out with several hundred units in motion, drawn while the tick runs elsewhere

Running an RTS simulation off the main thread

Engine & Performance10 min readUpdated
ClaudeBuilt the thing
Adam SturrockDecided what mattered

Written up later from the commit history. Dated to when the work landed.

When both armies are on screen, the game has to do two expensive things in the same instant: work out what four hundred units have decided to do, and draw them. On one thread those two jobs take turns, and a decision step that runs long arrives at the player as a camera that lags behind the mouse.

Dwarf shield lines and a brass field cannon meeting a ratmen horde in a green-lit crystal cavern, dozens of bodies engaged at once

The brief was not a profiler trace. It was that the picture goes sticky in the middle of a fight. The answer is that the part of the game which decides what happens does not have to live on the thread that draws, and this post is about what had to be true before it could move.

JavaScript in a browser runs on one thread. Everything — your game logic, the layout of the page, the click handlers and the rendering — takes turns on the same one, so anything slow blocks everything else. That is the constraint this post exists to work around.

A Web Worker is the escape hatch: a second JavaScript thread the browser will run genuinely in parallel, on another processor core. The price is isolation. A worker gets no access to the page at all — no screen, no canvas, no DOM, no keyboard, no audio — and it shares no variables with the main thread. The only thing that crosses between them is messages. That boundary is not the plumbing of this design, it is the design problem.

The snapshot is the design, not the plumbing

A worker boundary is only as good as what crosses it. Copying an object graph of several thousand units twenty times a second costs more than the work it is meant to relieve, so nothing object-shaped crosses at all. Both sides agree on one binary layout that packs every drawable thing into a fixed number of slots in a flat array of numbers, and posts that.

The layout is a hybrid. A hot core carries the fields every renderer reads for every visible thing: identity, position on screen, position on the grid, facing, health, animation state, owner, type, flags. Anything that only some things have goes into a side table addressed by identity, so a goblin grunt does not carry an unused construction-progress field and a renderer drawing no buildings never touches the buildings table.

The header is sixteen numbers, twelve of which are side-table row counts, and that decision is load-bearing. Every change to the layout since has been additive because of it. The core started at 24 numbers per unit, grew to 26 for status effects, and sits at 27 today after unit rank was added. Two more side tables arrived once the header ran out of slots, and both carry their own row count inline rather than claiming a header slot, so code written against the very first version still reads today's snapshot correctly.

Three rules buy you that, and they are cheap enough to follow from the first day. Put the stride in the header, so no reader ever hardcodes it. Append fields, never insert them, so every position an old reader computed is still the field it expects. Give each side table its own inline row count, so adding one does not resize the header and invalidate every position at once.

const HEADER = 4;      // [version, count, stride, sideTableOffset]
 
function encode(units, stride) {
  const buf = new Float32Array(HEADER + units.length * stride);
  buf[0] = stride === 6 ? 1 : 2;
  buf[1] = units.length;
  buf[2] = stride;     // readers take the stride from HERE, never from a constant
  units.forEach((u, i) => {
    const o = HEADER + i * stride;
    buf[o] = u.id; buf[o + 1] = u.x; buf[o + 2] = u.y;
    buf[o + 3] = u.rotation; buf[o + 4] = u.hp; buf[o + 5] = u.type;
    if (stride > 6) buf[o + 6] = u.rank ?? 0;   // APPENDED, never inserted
  });
  return buf;
}
 
// A reader written before "rank" existed. It still works, because it reads the
// stride out of the header instead of assuming it.
function decodeOldVersion(buf) {
  const count = buf[1], stride = buf[2], out = [];
  for (let i = 0; i < count; i++) {
    const o = HEADER + i * stride;
    out.push({ id: buf[o], x: buf[o + 1], y: buf[o + 2], hp: buf[o + 4] });
  }
  return out;
}

That is the property the whole design exists to hold: changing the layout is not a coordinated release of both sides. The side tables are the other half of it. One wide record per unit, with a construction-progress slot on every goblin grunt, would waste roughly 80% of the buffer and would fetch a line of memory per unit only to find nothing in it.

Handing the data over runs in one of two ways, chosen when the game starts. Where the browser allows it, both sides share the same block of memory: the worker writes one buffer while the main thread reads the other, with a single number flipped atomically to say which is which. Otherwise the buffer is posted across and handed back afterwards, which moves it without copying it. Renderers never see either mechanism. They read a view, and the game blends position, facing and health between the last two snapshots, which is the only reason twenty updates a second still looks like sixty frames.

Twelve steps, none of which broke the game

The move was done as a numbered plan, and the order is the interesting part, because at no point was the game unplayable.

StepWhat moved
2 and 3Every 3D renderer, then the 2D one, onto the snapshot
4The worker becomes the authority for everything visible
5 and 6Clicking on things, issuing orders, saving and loading
7, 8 and 9Starting units, then the computer opponents
10 and 11Main-thread AI switched off, wildlife re-synced
12The last few places still reading the old live world

Steps 2 and 3 shipped together specifically so the game never broke, and they landed behind a temporary bridge: the main thread encoded a fresh snapshot from its own world every frame and handed it to the renderers. Renderers were reading the new interface a full step before anything else was producing it. Step 4 deleted the bridge.

Not everything went across. Selection, voice lines and drawing never migrate, because they need the page, the audio or immediate feedback to a click, and none of them changes anything the simulation reads. Orders run on both sides: the main thread turns a click into an order and places buildings, and every order also fires an event the worker's copy executes against the authoritative world.

Events cross through a list that names every event allowed over the boundary and which direction it may travel. In most projects the equivalent is the big switch inside the message handler, which is worth promoting to a table for exactly this reason. It holds 130 entries today, 104 going one way and 26 the other. Anything forwarded is tagged so the forwarder skips its own message, and the tag is removed before anything else sees it, which is the entire scheme for stopping messages looping forever.

The day the map came up black

Step 5 switched the main thread's own copy of the world off. That was correct for the parts step 5 had already moved and wrong for the game, because switching it off depended on step 7 creating the starting units inside the worker, and step 7 had not landed yet.

The worker started with an empty world, the renderers dutifully drew an empty snapshot, and the result was a black map: no vision, no buildings, no units, no opponents. No profiler found this and no test caught it. It was found by starting the game and looking at the screen, which is how most of the interesting failures on this project get found. It cost a day, and the fix was to put one line back.

Dwarf and goblin lines colliding at sunset under a lightning-lit sky, a burning fortress on the ridge behind them

That ordering mistake set the shape of everything after it. Every step since has been written to be safe whichever side is in charge, which is what turned a half-finished migration from a cliff into a setting.

A check that fails the build if the worker touches the page

A worker has no window, no document, no local storage, no canvas element, no audio and no animation frame callback. A single import somewhere that touches any of those while the module is loading kills the worker the moment it starts, and a worker that died during startup looks exactly like a worker that is running and has nothing to say. That is a genuinely horrible thing to debug.

So there is a check that walks every import reachable from the worker's entry points, follows them recursively, and searches each file it reaches for a list of forbidden names, split into ones that are guaranteed to crash and ones that are merely available sometimes. It skips type-only imports, because those disappear before the code runs, and it forgives files that already guard themselves by checking whether they are on a page at all.

It is deliberately not a proper parser. It is a fast smoke test, it needs no dependencies, and two things make it worth running on every build anyway. It follows imports, so it catches the one four levels down that I was not thinking about. And it fails on a forbidden name being present, not on it being reached, which is the correct bias: creating an audio context at the top level of a module runs on import whether or not you ever call the function around it. Pointed at this game's two entry points it reports 259 files reachable and 16 things worth looking at.

Both of the real blockers were genuine. Input types its canvas as a canvas element and attaches keyboard listeners to the page, which is why input is translated rather than imported: the main thread resolves a click or a key into a small plain description of what the player did, and sends that. That is the general shape for anything a worker needs and cannot import. Resolve it where you can, send the result, never the object that produced it. Sound creates an audio context and was reachable by accident through the wildlife code, so inside the worker it becomes a stub that simply re-emits a play-this-sound event for the bridge to carry back to the real audio.

What the split actually bought

The pipe is the deliverable. Every renderer in the game now reads one thing, a decoded snapshot plus how far through the current tick we are, and none of them can reach into a live unit any more. That single property is what turned "which thread does the simulation run on" into a setting rather than a rewrite, and it pays off whichever answer you pick.

It also changed what the next measurement was allowed to say. Once the computer opponents stopped being 90% of the work, the whole simulation settled at roughly 12% of one processor core, which moved the interesting cost firmly onto the drawing side. The worker can also be run alongside the main thread, ticking its own copy and posting snapshots for the main thread to decode and compare, because a second world computing the same answers is the only independent cross-check available.

Each instrument answers a different question. Replaying a match and comparing the results proves the rules agree with themselves, exactly the job it is built to do, and the boundary timing is what the worker's own parallel run is for. If you are reading this for what the stack is, the accurate line is that this game's simulation is plain TypeScript with no dependency on the page at all, a binary format between it and the renderer, and a choice about which thread to put it on.

Questions

Can you run a game simulation in a Web Worker?

Yes, as long as the simulation never touches the page itself. The worker owns the units and the AI, and posts a compact binary snapshot of everything worth drawing to the main thread twenty times a second. The main thread smooths between the last two snapshots on its own frame loop, so the picture stays at sixty frames per second.

Why send a binary snapshot instead of the objects themselves?

Because copying an object graph of thousands of units across a thread boundary twenty times a second costs more than the work it is meant to relieve. A snapshot packs each unit into a fixed number of slots in one flat array of numbers, which can be handed over without copying at all.

How does a twenty times a second simulation look smooth at sixty frames?

The renderer never draws a snapshot directly. It keeps the last two and blends position, rotation and health between them using how far through the current tick it is, so a walking unit moves a third of the way each frame instead of jumping once every fifty milliseconds. That blending is the only reason the tick rate can stay low.

← All posts