Yodacon Ship Pack · Tutorial Note 10 · October 2026
Drawing Off the Main Thread: How the OffscreenCanvas Example Uses the Pack
Abstract—The OffscreenCanvas example transfers its canvas to a module worker that loads pack ships with loadShipMeta() and createImageBitmap(), simulates a fleet of up to five thousand and draws it with the library's drawShip(). This note follows both source files and measures the result by blocking the page's own thread.
Index Terms—OffscreenCanvas, Web Workers, ImageBitmap, Canvas 2D, sprite sheets.
I. Introduction
Everything a game does each frame normally shares one thread with the page: layout, input, scripts,
garbage collection. OffscreenCanvas lets a page give its canvas to a Web Worker, which can then
draw on it from its own thread. This example moves the whole game there: the worker loads the pack's ships,
simulates a fleet and draws it; the page forwards keys and shows what the worker reports. Its source is two
files, examples/offscreen/index.html and worker.js.
II. Assets Used
| Asset | Read at | Used for |
|---|---|---|
ship.json ×6 | worker.js:12 | Frame grid, stats |
sheet-alpha.png ×6 | worker.js:13 | Decoded to an ImageBitmap |
loadShipMeta(), drawShip(), frameIndex() | worker.js:3 | Loading, drawing, frame choice |
The page itself loads no assets at all. The library imports into a module worker unchanged: it resolves
its pack root from import.meta.url and touches document only when there is one.
III. Handing Over the Canvas
The page calls transferControlToOffscreen() once and posts the result to a module worker in
the message's transfer list (Listing 1). After that the page can no longer draw on the canvas; it still lays
it out, scales it with CSS and shows it, while the pixels come from the worker. A browser without the API
gets a sentence instead of a broken page (index.html:54).
index.html:60–6560const offscreen = canvas.transferControlToOffscreen();61const worker = new Worker(new URL("worker.js", import.meta.url), { type: "module" });62worker.postMessage({63 type: "start", canvas: offscreen, fleet: Number(document.getElementById("fleet").value),64 edition: q.get("edition") || "conex", ship: q.get("ship") || "yodacon",65}, [offscreen]);
IV. Images Without the DOM
A worker has no Image element, so the library's loadShip() cannot be used.
The worker instead fetches ship.json with loadShipMeta(), fetches the sheet as a
blob and decodes it with createImageBitmap() (Listing 2). An ImageBitmap is a valid
drawImage source, so the result has the same shape as a loadShip() ship and the
library's drawShip() draws it unchanged (worker.js:52).
worker.js:11–1411async function loadShipBitmap(edition, slug) {12 const ship = await loadShipMeta(edition, slug);13 ship.sheet = await createImageBitmap(await (await fetch(ship.urls.sheet)).blob());14 return ship;
V. The Loop and the Messages
The worker runs the fixed-step loop of the Canvas example [2], with
requestAnimationFrame, which dedicated workers provide, and a timer where they do not
(worker.js:58). Each fleet ship turns slowly and moves along the frame it shows, using
the pack's convention that heading θ moves along (sin a, −cos a) with
a = 10 · frameIndex(θ)°. Messages go both ways (Listing 3): the page sends
start, key and fleet; the worker answers with ready and,
twice a second, stats (worker.js:68).
worker.js:75–9175onmessage = async ({ data: m }) => {76 if (m.type === "start") {77 ctx = m.canvas.getContext("2d");78 ({ width: W, height: H } = m.canvas);79 [flagship, ...kinds] = await Promise.all([80 loadShipBitmap(m.edition, m.ship),81 ...[["conex", "gryphon"], ["conex", "trident"], ["conex", "defender"], ["conex", "necromancer"], ["conex", "tomaquad"], ["conex", "dart"], ["override", "callisto"]].map(([ed, slug]) => loadShipBitmap(ed, slug)),82 ]);83 Object.assign(s, { x: W / 2, y: H / 2 });84 setFleet(m.fleet);85 postMessage({ type: "ready", name: flagship.name });86 last = lastReport = performance.now();87 nextFrame(tick);88 }89 if (m.type === "key") keys[m.key] = m.down;90 if (m.type === "fleet" && kinds.length) setFleet(m.n);91};
VI. Proving the Point
The page's button busy-waits the main thread for two seconds
(index.html:95). During that time the page cannot repaint its text or
answer a click, yet the ships keep moving: the worker's frame counter, read before and after, shows how many
frames it drew meanwhile, about 120 at 60 Hz. The same holds for any long main-thread task, such as a heavy
React render or a large JSON parse.
VII. Using It in Your Project
- Move your game loop and drawing into a module worker; keep DOM work on the page.
- Load sheets with
fetchandcreateImageBitmap; draw withdrawShip(). - Send input as small messages; send back only what the page must show.
Sound stays on the page: AudioContext is not available in workers, so the worker should post
events (a shot, a hit) for the page to play.
VIII. Conclusion
With one transfer and an ImageBitmap loader, the pack's Canvas code runs unchanged on its own
thread, and the game's frame rate no longer depends on what the page is doing.
References
- Yodacon Ship Pack, “From sprite sheet to moving ship,” Tutorial Note 1, examples/, 2026. examples/#tutorial
- Yodacon Ship Pack, “A game loop on Canvas 2D,” Tutorial Note 3, examples/canvas/.
- Yodacon Ship Pack, lib/yodacon-ship-pack.js, v2.0.0, 2026.
- WHATWG, HTML Living Standard, §4.12.5.3 “The OffscreenCanvas interface,” 2026.