Skip to content

Using threads ​

A browser game runs on one thread unless you move work off it. For heavy logic such as physics, big crowds, pathfinding or a particle fluid, a Web Worker can simulate while the page keeps drawing and reading input at full speed. Several workers can share one block of memory through SharedArrayBuffer, so they split the work without copying it back and forth.

SharedArrayBuffer only exists on a page that's cross-origin isolated, which takes two response headers from the server. debuff sets them for you in development and writes them for the hosts that read a headers file. Since @debuffjp/cli 0.23.0.

The spryte flow demo runs its river fluid this way, on eight workers.

Turn on isolation ​

ts
// debuff.config.ts
import { defineConfig } from "@debuffjp/cli";

export default defineConfig({
  isolation: true,
});
  • debuff dev sends Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Embedder-Policy and Cross-Origin-Resource-Policy: same-origin on every response.
  • debuff build writes them to dist/_headers. If you ship your own _headers in public/, the block is added to it, unless your file already sets Cross-Origin-Embedder-Policy.
  • debuff doctor warns about scripts, frames, styles and images in index.html that come from other sites.
  • debuff ship itch reminds you to tick itch.io's option (see below).

Check it in the game:

ts
const threads = globalThis.crossOriginIsolated ? Math.min(8, navigator.hardwareConcurrency - 1) : 1;

Always keep a single-thread path. Some browsers and hosts won't isolate the page, and the game should still run there, just with less headroom.

What isolation costs ​

Once a page is isolated, the browser refuses files from other sites unless they opt in with a Cross-Origin-Resource-Policy or CORS header. Fonts and scripts from a CDN, analytics, ads and embedded videos are the usual casualties. Host those files yourself where you can. Sign-in popups that need to talk back to the page (some OAuth flows) also stop working.

isolation: true uses embed: "require-corp", which works in every browser. embed: "credentialless" loads cross-origin files without cookies instead of blocking them, which is easier on third-party files, but Safari doesn't support it, so on Safari the page simply isn't isolated:

ts
export default defineConfig({ isolation: { embed: "credentialless" } });

What each host needs ​

HostWhat to do
WavedashNothing: Wavedash runs every game isolated. Every server your game calls must send the headers too.
Cloudflare Pages, NetlifyNothing: they read the dist/_headers that debuff build writes.
itch.ioUnder Embed options, tick SharedArrayBuffer support. itch.io marks it experimental: it moves the game to a new domain, so players start with fresh saves, and Safari players get a popup window.
VercelAdd the three headers to vercel.json in your project ("headers": [{ "source": "/(.*)", "headers": [...] }]).
GitHub PagesIt can't send custom headers. coi-serviceworker adds them from a service worker, at the cost of one reload on the first visit.
Your own serverSend the three headers on every response under the game.

Simulate in a worker ​

The pattern from the flow demo, in four parts.

The worker runs a fixed step. Step the simulation at a fixed rate, 60 times a second, like debuff's own game loop. When a step takes longer than its slot, drop the missed time rather than running extra steps to catch up: extra steps make the next frame slower still, and the game falls further behind. The simulation then runs in slow motion while the page stays smooth.

The page draws between the last two steps. Keep each particle's position at the start and end of the latest step, and draw it part way between them, by how far the current frame is into the next step. Drawing one step behind costs a few milliseconds of latency and buys smooth motion whatever rate the simulation manages. If the simulation reorders its particles each step (sorting by grid cell for speed, for example), send both positions per particle, since index i won't mean the same particle in the next step.

Pick a transport. Without isolation, post each step's arrays with postMessage and transfer them (postMessage(data, [data.buffer])), so nothing is copied. With isolation, the worker can write into a SharedArrayBuffer the page reads directly: double-buffer it and flip an Atomics counter after each step, so the page never reads a half-written step.

Pace the worker with a deadline. Atomics.wait with a timeout is only as precise as the operating system's timer, which on Windows can mean a 14 ms wait lasts about 30 ms and halves your step rate. Sleep in short waits while plenty of time is left, then spin for the last few milliseconds:

ts
deadline += 1000 / 60;
while (deadline - performance.now() > 3) Atomics.wait(pace, 0, 0, 1);
while (performance.now() < deadline) {}

Never call Atomics.wait on the page's main thread: browsers don't allow it there.

Splitting one simulation across workers ​

Several workers can step the same particles in lockstep: each takes a slice, and they meet at a barrier (Atomics.add, Atomics.wait and Atomics.notify on a shared counter) between phases. Any phase where one particle writes to another, such as pushing its neighbours apart, has to change shape first: each particle sums what it receives and writes only to itself. That form can overshoot, so damp it and settle over two or three rounds per step. The flow demo's river source has a complete example, scaling from 64 ms a step on two workers to 20 ms on eight for 30,000 particles.