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
selectrequest 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 |