Skip to content

Embed the games and demos in another website

You have another website and want one of this project's games, a recorded match, or the parameter learner to run inside a page there, the way it runs on the showcase site. This page shows how to build the embeddable elements, put them on your own server, and add them to a page with one script tag and one HTML element. It also covers the security policy entries the page needs, theming, and the iframe option for pages that may not run scripts.

Who this is for: the engineer who maintains one of our other websites and its web server.

What you need: this repository with make web-setup done (Node and pnpm, see web/README.md), and a web server you control. The configuration below is for nginx; any server that can set response headers works the same way.

What you get

Four HTML elements, each a standard web component with its own Shadow DOM, so the host page's styles never leak in and theirs never leak out:

Element What it shows Needs Python in the browser
<gd-replay> a recorded game, with play, pause, step, a scrubber and the gate's verdict on each move no
<gd-game> a game played live: watch the agents, or play a seat yourself, optionally guarded by the gate on the first press of Play live
<gd-demo> the parameter learner (parameters), the risk monitor (monitor) or the decision runtime (runtime) on the first change of a setting
<gd-ambient> recorded games one after another, as a background, with what the agent is "thinking"; a kiosk mode for a screen that runs all day (below) no

Everything runs in the visitor's browser. Live games and demos run this very Python package in a Web Worker (Pyodide), downloaded from your server the first time and then cached; nothing is sent anywhere else.

Build the files

make dist

This builds the site, the elements and the docs into one directory, dist/web/, ready to copy as it is. (make web-elements builds the elements alone, into web/packages/elements/dist/elements/.)

Path in dist/web/ What it is Cache
elements/0.1.0/ the elements, version 0.1.0: gd.js, its chunks, the engine's worker (assets/), the engine's files (engine/), the gallery's replays (replays/), the game catalogue (catalogue.json), the demos' recorded answers (demos/) and integrity.json forever: a versioned directory never changes
elements/0/ the moving alias for the newest 0.x release: the same scripts, which load their files from the versioned directory beside them a few minutes
embed/replay/NAME/, embed/play/GAME/ one small page per replay and per game, for iframes short
oembed/replay/NAME.json the oEmbed answer for each replay short
ambient/ the ambient screens' replay library (make ambient-replays, run by make dist) and its index.json a day for the replays (each name carries its seed); the manifest revalidated
everything else the showcase site, and the docs in docs/ as the site's _headers file says

integrity.json lists the SHA-384 hash of every file in the versioned directory (the engine's own files are hashed in engine/manifest.json) and the gzipped size each element loads before its first frame. The oEmbed files carry absolute URLs built from the deploy settings' host name and URL prefix (Release to our nginx server).

Put them on your server

Copy dist/web/ to the server's document root (or copy only elements/ next to the pages that use them):

rsync -a --delete dist/web/ www@games.example.org:/srv/games/

Then give nginx the headers the files need. web/apps/site/static/_headers is the source of truth: the end-to-end tests run under exactly those rules, and this configuration says the same thing:

types {
    text/html                html;
    text/css                 css;
    text/javascript          js mjs;
    application/json         json;
    application/wasm         wasm;
    application/zip          zip whl;
    application/octet-stream gdr;
    image/svg+xml            svg;
}

server {
    root /srv/games;

    gzip on;
    gzip_types text/css text/javascript application/json application/wasm application/zip image/svg+xml;

    # nginx drops server-level add_header lines in any location that sets
    # its own, so every location repeats the ones it needs.
    add_header X-Content-Type-Options nosniff always;
    add_header Content-Security-Policy "frame-ancestors 'self'" always;

    # The elements: versioned directories never change.
    location /elements/ {
        add_header X-Content-Type-Options nosniff always;
        add_header Cache-Control "public, max-age=31536000, immutable" always;
        # Only when pages on another of your origins load the elements:
        add_header Access-Control-Allow-Origin "*" always;
        add_header Cross-Origin-Resource-Policy cross-origin always;
    }
    # The moving alias: cached briefly, so a new release reaches pages soon.
    location /elements/0/ {
        add_header X-Content-Type-Options nosniff always;
        add_header Cache-Control "public, max-age=300" always;
        add_header Access-Control-Allow-Origin "*" always;
    }
    # The engine's worker, when a page on this origin starts it directly.
    location ~ ^/elements/[^/]+/assets/ {
        add_header X-Content-Type-Options nosniff always;
        add_header Cache-Control "public, max-age=31536000, immutable" always;
        add_header Access-Control-Allow-Origin "*" always;
        add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self'" always;
    }
    # The iframe pages may be framed by any page.
    location /embed/ {
        add_header X-Content-Type-Options nosniff always;
        add_header Content-Security-Policy "frame-ancestors *" always;
    }
    location /oembed/ {
        add_header Access-Control-Allow-Origin "*" always;
    }
}

Access-Control-Allow-Origin is needed only if a page on a different origin (another of your sites) loads the elements: module scripts, the engine's worker and its files are then cross-origin requests. If every page that uses them is on the same origin, leave those lines out. To allow only your own sites, name them instead of * (nginx's map on $http_origin can pick one of several).

Add an element to a page

Load the script once per page, from the versioned directory, with its integrity hash, then use any element anywhere in the page:

<script type="module"
        src="https://games.example.org/elements/0.1.0/gd.js"
        integrity="sha384-6ngHS4QbtmYZXBAXeEROtUlaC1J1bwWVBGVmla2OSTmegUQl9LUs9I5alme3H9xF"
        crossorigin="anonymous"></script>

<gd-replay src="checkers" controls autoplay loop>
  <p>A recorded game of checkers (it needs JavaScript).</p>
</gd-replay>

The hash is gd.js's entry in that version's integrity.json. If the file on the server is changed in any way, the browser refuses to run it and the element shows its fallback text. crossorigin="anonymous" is needed for the check on a file from another origin, and harmless on the same one.

gd.js loads the rest of what an element needs (the board, the replay player, the engine) from its own directory when the element first comes near the viewport. Those chunks are not covered by the script tag's hash; to pin them too, add an import map with an integrity entry per chunk, from the same integrity.json (supported by current Chromium, Firefox and Safari). An inline import map needs its hash in your policy's script-src.

Whatever is inside an element is its fallback: the browser shows it as it is when JavaScript is off or the script cannot load, and hides it once the element runs. Put a sentence and a link there.

The elements' attributes

Each attribute is also a property of the same name in camel case (engine-base is engineBase); setting the property sets the attribute, and changing either takes effect at once.

Every element: theme (light, dark, or absent to follow the page), palette (colour-blind for the colour-blind-safe colours) and glyphs (shapes and letters on the pieces, so nothing depends on colour alone).

<gd-replay>

Attribute Meaning Default
src a gallery replay's name (checkers, snake-adversarial, go-9x9 …: the names in replays/index.json) or the URL of any .gdr file none
autoplay start playing (not when the visitor asks for reduced motion: it then shows the first move, paused) off
loop start again two seconds after the last move off
speed moves per second, 0.25 to 1,000 4
frame the position to open on (setting it later seeks) 0
controls show play, pause, step, the scrubber and the speed off
label the caption and the board's accessible name the replay's title

Read-only properties: currentFrame, length. Methods: play(), pause().

<gd-game> takes <gd-replay>'s speed, autoplay, loop and label (for the recorded game it shows first), and grounded-decision play's flags:

Attribute Meaning Default
game tictactoe, snake, checkers, chinese_checkers, go, tetris, hexxagon, minesweeper, 2048, connect4 required
agents one agent per seat, comma-separated; you is the visitor the game's default line-up
world, size, players, until-length as the command's flags the game's defaults
seed fixes every random choice a fresh seed each time
guarded the gate checks the visitor's moves off
rules the game's own options as a query string (board=donut&mines=12) none
engine-base where the engine's files are the bundle's own engine/

Until the visitor presses Play live (or clicks or presses a key on the board), it plays the game's recorded example; then it downloads the engine once and plays live. The visitor moves by clicking or tapping a cell, with the arrow keys in Snake and 2048, or from the list of legal moves. Method: goLive(). Property: isLive.

<gd-demo>

Attribute Meaning Default
name parameters, monitor or runtime required
max-risk, confidence the risk target (parameters, monitor) 0.05, 0.95
procedure fixed_sequence or bonferroni (parameters) fixed_sequence
scenario authorisations or outcomes (monitor) authorisations
shadow shadow mode: decided and recorded, nothing executed (runtime) off
engine-base where the engine's files are the bundle's own engine/

It shows the documented example's answer at once (recorded by the engine); the visitor's first change computes the new answer in the browser.

<gd-ambient>:

Attribute What it sets Default
library a manifest's URL (/ambient/index.json); when it cannot be read, the bundle's gallery the bundle's gallery
games replay names or games, comma-separated (snake,go, go-9x9) every replay of the manifest
seed the order (reshuffled by the same seed at every pass) 0
pace how long each game stays: slow (3 min), normal (90 s), fast (40 s) or seconds; replays play at 1 to 8 moves a second, and a longer game cuts to its end normal
speed moves per second, instead of the pace none
captions the agent's verdict and the deciding check, at most every 6 s: on, large (readable across a room) or off off
transition fade (a cross-fade) or cut; reduced motion always cuts fade
kiosk fill the screen, fullscreen on the first click or key, the cursor hidden at rest, the screen kept awake off

Properties order, current, step, power and fps say what it is doing; skip() moves to the next game.

Events (all bubble and cross the Shadow DOM; read event.detail):

Event When detail
ready the first position or answer is on show source (replay, preview or live), length
frame a new position is on show index, length (null while a live game goes on)
move a move was played index, player, agent, move
verdict the gate decided on a move index, tone (act, override, abstain), move, proposal
error something failed (a missing replay, the engine) message
game <gd-ambient>: the next game is on show step, name, game, title, length
power <gd-ambient>: the low-power mode changed mode (full, low, still, paused), fps, reason
document.querySelector('gd-replay').addEventListener('verdict', (event) => {
  console.log(event.detail.tone, event.detail.move);
});

In React 19, render the elements as JSX tags and pass attributes as props; listen for events with a ref and addEventListener.

Theming

By default an element follows the page: a page whose color-scheme is dark (MkDocs Material's dark palette sets it) gets the dark theme, a page that declares light gets the light one, and otherwise the visitor's system setting decides. theme="light" or theme="dark" overrides that. The host page can style the scheme in use with gd-replay:state(dark).

Colours are custom properties, set on the element or any ancestor; each applies to both schemes unless you set it under :state(dark):

Property Light Dark
--gd-background #ffffff #12141a
--gd-surface #f5f6f9 #1b1e27
--gd-text #1b1d24 #e8eaf0
--gd-muted #4a4f5c #b4b9c6
--gd-accent, --gd-on-accent #2f3fa8, #ffffff #a5b4ff, #10131c
--gd-focus #0b7a75 #5fd3c9
--gd-border #d5d8e0 #343947
--gd-act, --gd-override, --gd-failed #1e7a34, #8a5a00, #b42318 #6fd38a, #f0b44c, #ff8a80

Also --gd-font, --gd-font-mono, --gd-font-size, --gd-radius and --gd-padding. For anything else, style the parts:

gd-replay { --gd-accent: #7a2a8a; --gd-radius: 0; }
gd-replay::part(controls) { justify-content: center; }
gd-replay::part(verdict) { font-size: 1rem; }

Parts: root, caption, title, verdict, board, status, controls, button (with play, step-back, step, start, play-move, send, compute), scrubber, speed, moves, move-list, progress, output, lines, chart, data, steps, setting, error.

The Content Security Policy your page needs

The elements set no inline script or style: their styles are constructed style sheets, which style-src does not restrict. A page with a strict policy adds these entries, with your server's origin in place of https://games.example.org:

Directive Entry Why Needed by
script-src https://games.example.org gd.js and its chunks; inside the engine's worker, Pyodide's scripts every element
connect-src https://games.example.org the replays, the catalogue, the recorded demo answers, and the engine's files every element
worker-src blob: and https://games.example.org (another origin), or 'self' (same origin) the engine's worker: a page may not start a worker from another origin, so the element starts a one-line blob: module that imports the worker script, and a module worker's imports are checked against worker-src gd-game, gd-demo
script-src 'wasm-unsafe-eval' Pyodide compiles its WebAssembly gd-game, gd-demo

On the same origin, 'self' covers the origin entries. A page with only <gd-replay> or <gd-ambient> needs just the first two. For example:

Content-Security-Policy: default-src 'self'; script-src 'self' https://games.example.org 'wasm-unsafe-eval'; connect-src 'self' https://games.example.org; worker-src 'self' blob: https://games.example.org; object-src 'none'; base-uri 'self'

The end-to-end tests load every element under exactly this policy, with the elements on another origin.

Hosting the elements yourself, elsewhere

The versioned directory is self-contained: copy dist/web/elements/0.1.0/ to any server and directory and load its gd.js. Keep the directory's name (the version): the moving alias finds its files through it. To keep the engine somewhere else (another disk, or one copy shared by several versions), copy the engine/ directory there and set engine-base on each <gd-game> and <gd-demo>:

<gd-game game="go" engine-base="https://games.example.org/engine/"></gd-game>

The iframe option

For a page that may not run scripts (some content systems strip them), frame an embed page instead. It holds one element and the script tag, with its own strict policy:

<iframe src="https://games.example.org/embed/replay/checkers/"
        width="640" height="600" title="Checkers: a recorded game"
        loading="lazy"></iframe>
<iframe src="https://games.example.org/embed/play/connect4/"
        width="640" height="640" title="Connect Four in your browser"
        loading="lazy"></iframe>

There is one page per replay in the gallery (/embed/replay/NAME/) and per game (/embed/play/GAME/). Only those pages may be framed by other sites: the server sends Content-Security-Policy: frame-ancestors * for /embed/ and frame-ancestors 'self' for everything else. To allow only your own sites, list them instead of *. A content system that reads oEmbed finds each replay page's answer from its discovery link (/oembed/replay/NAME.json, type rich, with the iframe as its html).

What each element downloads

Measured on the 0.1.0 build, gzipped, as the server sends it (the build fails if the replay elements pass their budget; the numbers are in integrity.json):

Element Before its first frame Budget Later, on demand
<gd-replay> 55.0 KB of script, then the replay file (2 to 170 KB) 64 KB nothing
<gd-ambient> 60.0 KB of script, then each replay 64 KB in kiosk mode, 1 KB
<gd-game> 10.5 KB, then the replay player and board (44 KB) and the recorded game none the engine, once: 7.0 MB (Python, lark, this package)
<gd-demo> 10.5 KB, then the drawing code, the protocol's validator and the recorded answer (30 to 300 KB before compression) none the engine with numpy, once: about 9.8 MB

Several elements on one page share everything: the script loads once, and so does the engine.

Ambient and kiosk screens

A screen in a hallway, or a television during a talk's break, can show the games one after another: /ambient on the site, or <gd-ambient> on any page. It plays recorded games only, so no Python is downloaded, and shows what the gated agent decided and why.

<gd-ambient library="/ambient/index.json" pace="normal" captions="large" kiosk></gd-ambient>

The site's page takes the same settings in its URL:

https://games.example.org/ambient?kiosk=1&games=snake,go,tetris&pace=fast&captions=large&seed=7
Parameter Values Default
kiosk 1: no header, no controls, the games fill the screen off
games replay names or games: snake, chinese_checkers, go, tetris, hexxagon, minesweeper, 2048, connect4 all eight
pace slow, normal, fast or seconds a game normal
captions on, large, off on
seed the order the day's (a new order every day)
transition fade, cut fade

Fresh games. make ambient-replays records a new library from the day's seed, three games of each kind (AMBIENT_SEED and AMBIENT_COUNT change that), into build/ambient/. Each replay is read back and checked as grounded-decision replay-info --verify does before index.json lists it, with its content hash and outcome. make dist runs it and puts the library in dist/web/ambient/; copy dist/web/ as above. Run it again (from cron on the build machine, for example) and copy dist/web/ambient/ for new games: each replay's name carries its seed, so old and new never collide. Without a library the page plays the gallery's replays.

Serving it. The site's pages are files named after their route (ambient.html), and the library sits in a directory of the same name, so nginx needs to look for both:

location = /ambient {
    add_header X-Content-Type-Options nosniff always;
    try_files /ambient.html =404;
}
location /ambient/ {
    add_header X-Content-Type-Options nosniff always;
    add_header Cache-Control "public, max-age=86400" always;
}
location = /ambient/index.json {
    add_header X-Content-Type-Options nosniff always;
    add_header Cache-Control "no-cache" always;
}

Kiosk mode. Browsers allow fullscreen only after a click, a tap or a key, so in kiosk mode the first one makes the screen fullscreen; the cursor hides after 3 seconds at rest, and the page asks the browser to keep the screen awake (the Screen Wake Lock API; where there is none, or it is refused, the system's own screensaver may start). For a screen that starts by itself, start the browser in its kiosk mode, which is fullscreen from the start, with no gesture:

# Chromium or Chrome (Linux; the same flags work on macOS and Windows)
chromium --kiosk --noerrdialogs --disable-session-crashed-bubble \
  --disable-infobars --check-for-update-interval=31536000 \
  'https://games.example.org/ambient?kiosk=1&captions=large'

# Firefox
firefox --kiosk 'https://games.example.org/ambient?kiosk=1&captions=large'

Turn off the operating system's screen blanking too (xset s off -dpms on an X11 kiosk), since a browser's wake lock covers only its own visible page.

Power. The screen pauses while its page is hidden; draws at most 30 frames a second, and 15 when the browser reports a discharging battery (Chromium's Battery API) or prefers-reduced-data; and under prefers-reduced-motion shows still positions that change every 8 seconds or more. On an Apple-silicon laptop in headless Chromium, a 1920 × 1080 kiosk screen with large captions used 2.9% of one core on its page's main thread (3.5% for the whole renderer process), 2.6% on battery, and 0.1% under reduced motion.

A day-long check. make web-soak plays the screen for 30 minutes and checks that memory stays flat with no errors; the 24-hour check before a screen goes up is make web-soak SOAK_MINUTES=1440.

Versions

The elements have their own version, independent of the Python package's (see Stability and versioning). A version's directory never changes once it is on the server: pin it with its integrity hash for anything that must not move. The major alias (elements/0/) always holds the newest release of that major version; use it without an integrity hash where picking up fixes matters more than pinning. Patch releases fix bugs, minor releases add attributes, events or elements, and a major release may remove or rename one; the attributes, properties, events and parts on this page are the stable surface, while the chunk files' names change with every build.

What can go wrong

What you see Why What to do
Only the fallback text the script did not load: a wrong path, a changed file (integrity), or the policy's script-src open the browser console; check the path and the hash against integrity.json
"Failed to fetch" on a page on another origin no Access-Control-Allow-Origin on the server add the CORS lines for /elements/
The live game stops at "Loading the engine" worker-src lacks blob: or the server's origin, script-src lacks 'wasm-unsafe-eval', or .wasm is not served as application/wasm add the entries; check the types block
An iframe stays blank the page is not under /embed/, or the server sends X-Frame-Options frame /embed/… pages only; remove X-Frame-Options there
The theme does not match the page the page declares no color-scheme set color-scheme on the page, or theme on the element
A kiosk screen is not fullscreen browsers need a click or a key first; an iframe needs allow="fullscreen" click once, or start the browser with --kiosk
A kiosk screen goes dark the system's screen blanking, or a browser without a wake lock turn blanking off on the machine