← Examples · Ship Pack · How this page works

OffscreenCanvas in a worker

The canvas is handed to a Web Worker, which loads the ships, runs the game and draws every frame. The page only passes on your keys (← → ↑). Press Block the main thread: the page freezes for two seconds and the ships fly on.

Starting the worker…

Try ?edition=override&ship=callisto as the flagship.

Ships from the Yodacon Ship Pack by Paul Richeson (ConEx, 1997), GPL-2.0-or-later — github.com/yodacon/yodacon-ship-pack

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

Table I. What the worker takes from the pack
AssetRead atUsed for
ship.json ×6worker.js:12Frame grid, stats
sheet-alpha.png ×6worker.js:13Decoded to an ImageBitmap
loadShipMeta(), drawShip(), frameIndex()worker.js:3Loading, 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).

Listing 1. The hand-over, index.html:60–65
60const 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).

Listing 2. Loading a ship in a worker, worker.js:11–14
11async 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).

Listing 3. The worker's message handler, worker.js:75–91
75onmessage = 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

  1. Move your game loop and drawing into a module worker; keep DOM work on the page.
  2. Load sheets with fetch and createImageBitmap; draw with drawShip().
  3. 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

  1. Yodacon Ship Pack, “From sprite sheet to moving ship,” Tutorial Note 1, examples/, 2026. examples/#tutorial
  2. Yodacon Ship Pack, “A game loop on Canvas 2D,” Tutorial Note 3, examples/canvas/.
  3. Yodacon Ship Pack, lib/yodacon-ship-pack.js, v2.0.0, 2026.
  4. WHATWG, HTML Living Standard, §4.12.5.3 “The OffscreenCanvas interface,” 2026.