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¶
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):
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>:
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.
The site's page takes the same settings in its URL:
| 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 |