Skip to content

The engine in the browser

The web showcase's play pages run this Python package itself, in the visitor's browser: CPython compiled to WebAssembly (Pyodide) runs in a Web Worker, installs the project's own wheel, and answers the session protocol the command line also runs on. This page explains how the browser build is put together, where every file comes from, how a page talks to the engine, how the build is checked to play the same games as the command line, and what it costs. The plan behind it is the web plan, phase 3; the choices are D43 to D50 in Design decisions.

What runs where

 play page (main thread)                       Web Worker
 ───────────────────────                       ──────────────────────────────────
 PlayView (Svelte)                              worker.ts
   LiveSession: frames, speed, selection          EngineHost
   Engine: typed requests, validated replies        Pyodide 314.0.6 (Python 3.14)
     WorkerTransport ── postMessage(JSON text) ──►    grounded_decision wheel + lark
                     ◄── reply, progress, ready ──    Endpoint().handle_text
 BoardRenderer, DecisionPanel (phase 2)

Nothing game-specific runs in JavaScript. The page sends describe, open, step, submit and select requests as JSON text; the worker passes each string to Endpoint.handle_text in Python and posts the reply string back, with the time the engine spent on it. A request that arrives while Python is still loading waits for it.

The files and where they come from

make web-engine writes the engine's files into the site (web/apps/site/static/engine/, build output that is never committed) and lists them, with sizes and SHA-256 hashes, in manifest.json:

File From Checked by
pyodide/314.0.6/pyodide.mjs, pyodide.asm.mjs, pyodide.asm.wasm, python_stdlib.zip, pyodide-lock.json the pyodide npm package, pinned to 314.0.6 its integrity hash in web/pnpm-lock.yaml
pyodide/314.0.6/numpy-2.4.6-…whl the Pyodide release's own wheel, downloaded at build time the SHA-256 in that release's pyodide-lock.json
wheels/<hash>/lark-1.3.1-py3-none-any.whl PyPI, the exact file uv.lock pins the SHA-256 in uv.lock
wheels/<hash>/grounded_decision-0.1.0-py3-none-any.whl make build its own SHA-256, recorded in the manifest

Downloads happen only when the site is built, and are cached in web/packages/engine/.cache/, so a rebuild works offline. At run time the browser fetches nothing from any other host. Wheels sit under a directory named by the first twelve hex digits of their hash, so a new build is never served from a stale cache, and every file except the manifest is served with a year-long immutable cache lifetime (static/_headers).

Pyodide 314.0.6 is CPython 3.14 with numpy 2.4.6. The package needs only Python 3.12 or later, and the session layer (everything a game needs) imports neither numpy nor anything that imports it: the package's __init__ and grounded_decision.learn import their names on first use (PEP 562), and a test plays every game and checks that numpy was never imported. So the engine starts without numpy; its wheel is hosted beside the runtime and loaded on demand by the pages that need it: the demos beyond games (/parameters, /monitor, /runtime) create their engine with new WorkerTransport(url, { packages: ['numpy'] }), the boot message carries the list, and EngineHost.boot loads numpy with lark and the wheel, in the progress bar's packages phase ("Loading numpy, lark and grounded-decision"; design decision D65). A package the manifest does not host is refused before anything loads. lark is installed at start, because some games use it.

Starting the engine

Python loads lazily: a play page first shows the game's recorded replay (no Python), and the engine starts when a person first touches the settings or presses Start live game. Loading reports progress in three phases, shown as a progress bar: the runtime (download and start of the interpreter, about three quarters of the wait), the packages (lark and the project's wheel), and the import of the session layer. EngineHost.boot in web/packages/engine/src/host.ts does all three; the worker and the Node scripts below run the same class on the same files.

Talking to the engine

web/packages/engine has one class per concern:

Concept Class (file)
The page's client: typed requests (validated by the protocol's validator before they are sent), replies validated on the way in, lazy start, progress and readiness events, timings Engine (engine.ts)
Every failure as one kind of error, carrying a protocol ErrorReply and a reason (engine, invalid, load, transport, cancelled) EngineError (engine.ts)
How messages travel; Pyodide in a dedicated worker Transport, WorkerTransport (transport.ts)
Our own service over a WebSocket, falling back to the worker when the service is off or full WebSocketTransport (socket.ts)
Which of the two a page uses, by one setting EngineChoice (choice.ts)
The messages around the protocol (boot, progress, ready, reply with its time), checked at the boundary parseWorkerMessage, parsePageMessage (messages.ts)
What is downloaded EngineManifest, EngineFile (manifest.ts)
Pyodide answering requests EngineHost (host.ts), run by worker.ts
import { Engine, WorkerTransport } from '@grounded-decision/engine';

const engine = new Engine({
  transport: () => new WorkerTransport('/engine/manifest.json'),
});
engine.subscribe((event) => console.log(event)); // state, progress, ready
const opened = await engine.open({ game: 'tictactoe', agents: ['you', 'grounded'] });
const after = await engine.submit('5'); // a FramesReply
await engine.step(1); // the gated O answers
engine.cancel(); // terminates the worker; pending requests fail with reason 'cancelled'

Stopping a long computation means terminating the worker (design decision D24: no SharedArrayBuffer, so no cross-origin isolation is needed); the next request starts a fresh engine.

The engine on our own service

The same pages can run their engine on our own service instead (Run our own service, web plan phase 9): WebSocketTransport is a second Transport, one WebSocket to /ws/ on the page's own origin per engine, and nothing else changes. The service runs the same wheel's Endpoint with one session per connection, so its replies are the worker's, byte for byte.

One setting picks the transport, EngineChoice: the site's build setting VITE_ENGINE (worker, the default, or service; anything else fails the build), overridden by a page's ?engine=worker or ?engine=service for testing (apps/site/src/lib/engine.ts, siteTransport). With service, the worker is the fallback: a socket that cannot open, or that the service turns away before its first reply (at capacity), hands every request sent so far to a worker, which answers them instead, so the site still plays with the service switched off. Stopping a computation closes the socket; the service drops the session. The parameter learner always uses the worker, because a visitor's file never leaves the browser (D68).

import { Engine, EngineChoice, serviceSocketUrl } from '@grounded-decision/engine';

const choice = EngineChoice.of(import.meta.env.VITE_ENGINE, location.search);
const engine = new Engine({
  transport: () =>
    choice.transport({
      manifestUrl: '/engine/manifest.json',
      serviceUrl: serviceSocketUrl(location.href), // wss://this-origin/ws/
    }),
});

web/tests/e2e/service.spec.ts runs the built site against a locally started service behind a proxy that does what our nginx does (the site, /api/ and /ws/ on one origin): every golden conversation through the socket, the snake played live and /runtime sent an event with no Python downloaded, and a page whose socket is refused falling back to the worker.

The play pages

/play/GAME is prerendered for all ten games from the engine's own catalogue (the describe fixture), so the settings are there before Python loads: an agent per seat (with each agent's description and information regime), the world, the seed (a fresh random one, shown and put in the URL), the size and the game's rule options. The URL holds the whole spec, its parameters are play's flags, and Copy CLI command gives the identical terminal run, for example grounded-decision play hexxagon --agents you,gated --seed 42 --board donut --guarded.

Watching: play, pause, step and a speed of 1 to 1,000 moves a second (several moves per request at high speeds, the last one drawn). Playing: every legal move lists the cells it touches, so the page needs no game-specific code to turn input into moves ($lib/moves.ts):

  • click or tap a highlighted cell (a button over each target cell, so the board also works from the keyboard and with a screen reader);
  • a piece whose moves start on it is selected first: the select request asks the engine for its targets, and Hexxagon's own hook marks clones green and jumps yellow, as the terminal prompt does;
  • a Connect Four drop is played from any cell of its column; moves that share a cell (Minesweeper's reveal and flag) offer a choice;
  • Tetris places a piece with the keyboard: ← and → choose the column, ↑ rotates, ↓ or Enter drops, with the landing cells highlighted;
  • Snake and 2048 take the arrow keys and W A S D;
  • a list of every legal move works for every game.

With guarded on, the game's gate checks each of the person's moves; when a check fails it plays its safe fallback, and the page says which check failed and its evidence.

Content Security Policy

Pages carry their policy in a <meta> tag that SvelteKit writes with the hashes of its own inline script: scripts only from the site itself plus 'wasm-unsafe-eval', which Pyodide needs to compile its WebAssembly. Styles also allow inline style attributes, which SvelteKit's route announcer sets. The worker's script gets the same script policy as a response header listed in static/_headers, a plain list of paths and headers for your own web server to send (the end-to-end tests' static server applies it too, so Pyodide is tested under it).

The embeddable elements run the same engine from their own versioned directory (elements/VERSION/engine/, the same files). A page on another origin cannot start a worker from the elements' server directly, so the elements start it through a one-line blob: module that imports the worker script. That page's policy then needs worker-src blob: and the server's origin, script-src and connect-src for the server, and 'wasm-unsafe-eval'. The server sends Access-Control-Allow-Origin for /elements/. See Embed the games and demos in another website.

The same games as the command line

The determinism check, make web-pyodide-golden, plays every game on the golden seeds of the phase-1 tests (seeds 0, 1 and 2; 40 moves, 10 for Go) through one Endpoint in CPython, records each request and reply (scripts/golden_frames.py), then sends the same requests to the engine in Pyodide through EngineHost (the browser's own code path) and compares every reply byte for byte (scripts/golden.ts). Today: 91 replies, 944 frames and 10.9 MB of JSON, identical for all ten games.

The check found one real difference on its first run. Checkers, Connect Four and Go agents that randomise per position seeded random.Random from hash() of an integer tuple, which depends on the platform's word size: 64-bit CPython reduces integers modulo 2^61 − 1, 32-bit WebAssembly modulo 2^31 − 1, so the browser played other games. grounded_decision.examples.games.hashing.portable_hash computes what 64-bit CPython's hash() gives on any platform, so every existing seed keeps its game and the browser now plays the same one.

The test suite in Pyodide

make web-pyodide-test runs the pure-library tests inside Pyodide in Node: the session layer (test_session*.py), every game's rules and solvers (test_games*.py, except the drawing benchmark) and parameter learning (test_learn.py), against the wheel the browser loads, with Pyodide's own pytest. Tests that need a terminal, subprocesses, threads or the wall clock are marked needs_native and skipped there (today one: the purity test, which starts a Python subprocess); tests marked slow are skipped as in make test. Today 1,153 tests pass in Pyodide in about seven and a half minutes (four deselected: the one marked needs_native and three slow). CI runs both checks in the pyodide job.

What it costs

Measured in headless Chromium on an Apple-silicon laptop against the built site (web/tests/e2e/engine.spec.ts and the unlisted /bench/engine page; CI checks generous bounds and prints the numbers). The first figure of each pair is the test run alone, the second the whole end-to-end suite, whose other tests share the machine:

Measurement Result Plan's target
First live move, cold cache, 40 Mbit/s with 20 ms latency 2.5 s; 2.7 s 4 s or less
First live move, warm cache 1.0 s; 1.2 s 1 s or less
Python start in the worker (runtime, packages, imports) 0.89 s (0.72, 0.08, 0.09); 1.24 s
One move, page to worker and back 0.6 ms median
Snake 20 × 20, every frame encoded as JSON in the worker 4,100; 3,700 moves a second 3,000
Snake 20 × 20, frames received and checked by the page 4,000; 3,600 moves a second
Worker WebAssembly memory after 2,000 snake moves 75 MB; 90 MB
Download to start (runtime, lark, the wheel) 13.8 MB, 6.9 MB gzipped
numpy, loaded only by the demo pages 2.8 MB gzipped
Play page scripts before any interaction 101 KB gzipped
The worker's own script 2.9 KB gzipped

The snake throughput was below the plan's 3,000 (2,700 and 2,570 moves a second) until protocol 1.2: building each frame's board picture gave every body cell its own note, with an order that changes on every move, so a frame cost O(length) in Python on top of the rules and the agent. The body now travels as one ordered path (Board.paths, see Session protocol), built at C speed, and the web board derives the notes; the wire encoder also writes nested records inline (design decision D51). In Pyodide under Node the same 2,000 moves went from about 2,900 to 4,400 moves a second (make web-pyodide-golden builds the engine those runs use).

Commands

Command What it does
make web-engine make build, then the engine's files into the site
make web-pyodide-golden the golden frames, CPython against Pyodide, byte for byte
make web-pyodide-test the pure-library tests inside Pyodide
make web-build, make web-e2e build the site (engine included) and run Playwright against it