One static host for every browser game. arcade serves each game's page, bundle and kit assets at
<slug>.play.danielstephenson.dev, with the cross-origin isolation headers the
tak kit needs. A game deploys by uploading a versioned
bundle from its own CI, and rollback is a repoint, not a rebuild.
arcade is designed in RFC 0006, "A Static Hosting Service for Browser Games" (Stephenson-Software RFCs; Accepted, v1.1). The portal that lists the games is https://danielstephenson.dev/play.
arcade serves files and runs nothing. Every game runs in the player's own tab, with its saves in that browser's IndexedDB. The service is standard-library Python with one data volume and no database.
| Host | What it serves |
|---|---|
play.<base> |
the upload API (below); / is a landing page listing every game (see below) |
<slug>.play.<base> |
the game registered under that slug |
| an alias from the registry | the same game, at an older hostname, so that origin's saves are kept |
| anything else | 404 |
Game routes mirror tak.web.serve route for route, so a page built for a per-game container works
unchanged:
| Path | Response |
|---|---|
/, /play, /play/, /index.html |
the current version's index.html |
/web/game.zip, /web/version.txt |
the current version's files |
/tak/<asset> |
that asset from the game's own game.zip (src/tak/web/assets/), so the kit's Python and JavaScript halves always match (RFC 0006 §5) |
| anything else | 404 |
A game's host is read-only: a PUT or POST to it gets 405. A game with nothing deployed answers
404.
Every response, errors included, carries Cross-Origin-Opener-Policy: same-origin,
Cross-Origin-Embedder-Policy: require-corp and Cross-Origin-Resource-Policy: cross-origin
(cross-origin so that the portal can embed a game in an iframe). The one exception is a static
game with isolation off, which gets none of the three (see Bundle kinds).
Files are sent with an ETag and Cache-Control: no-cache, so a returning player revalidates
instead of downloading again. For a game with a canonical: URL, every HTML response also carries
Link: <url>; rel="canonical". /healthz answers ok on any host.
Files are sent in 64 KiB chunks, never read whole, so a large .data or .wasm costs arcade no
more memory than a small one. Compressible files (application/wasm, HTML, JavaScript, CSS, plain
text, JSON, web manifests and SVG, 1 KiB or more) get a gzip copy, <name>.gz at level 9, written
once when the version is uploaded. A version stored before this gets its copies lazily: the first
request for a file is answered uncompressed and queues its .gz, which one background thread
writes, one file at a time, atomically. A bundle that ships its own <name>.br or <name>.gz (as
dotnet and Emscripten builds can) has those used as they are; brotli is not in the standard library,
so arcade serves .br only when the bundle brings one.
The variant is chosen by Accept-Encoding (q-values honoured; br before gzip when both are
equally acceptable) and sent with Content-Encoding, Vary: Accept-Encoding and its own ETag
("<version>-<file>-gz" or -br; the uncompressed file keeps its old tag). Content-Type is that
of the original file, so a .wasm is still application/wasm and compiles while it streams. A
response that already carries Content-Encoding is passed through untouched by a compressing proxy
such as Traefik's compress middleware. Range requests are not supported; the whole file is sent.
games.yaml lives in the gateway repository and is mounted read-only at /config/games.yaml.
Reserving a slug is a gateway pull request that adds one entry:
games:
- slug: tidewater # ^[a-z][a-z0-9-]{1,30}$
title: Tidewater
repo: Stephenson-Software/Tidewater
owner: dmccoystephenson # optional
token_sha256: "<64 hex>" # sha256 of the game's upload token
aliases: [tidewater.danielstephenson.dev] # optional
kind: tak # optional: tak (default) or static
isolation: on # optional: on/off (see Bundle kinds)
canonical: https://danielstephenson.dev/play/tidewater # optionalcanonical is the game's main public page, as an https URL. A game can be reached at its slug
host, its aliases and the portal page that frames it; when canonical is set, every HTML response
for the game names this one address with a Link: <url>; rel="canonical" header, so search engines
index it once. Leave it out and no Link header is sent.
The parser is deliberately strict: it accepts only this subset of YAML, and it refuses unknown keys,
duplicate slugs, reserved slugs (play, www, api, arcade, admin, static), malformed
hashes, aliases under the arcade's own domain or claimed twice, an unknown kind, an isolation
other than on/off (or off for a tak game), and a canonical that is not an https URL, each with its
line number. arcade picks up changes to the file without a restart. A file that stops parsing is
logged and ignored, and the last good registry stays in force.
python -m arcade check-registry games.yamlpython3 -c "import secrets; print(secrets.token_urlsafe(32))" > token.txt
python -m arcade hash-token < token.txt # → token_sha256 for games.yaml
gh secret set ARCADE_TOKEN -R <owner>/<game> < token.txt
rm token.txtOnly the hash is committed. Rotating a token is a registry PR plus a secret update.
kind |
Upload | Served | isolation |
|---|---|---|---|
tak (default) |
exactly index.html, game.zip, version.txt |
tak's routes; /tak/ from the game's own game.zip |
always on; cannot be turned off |
static |
any tree with index.html and version.txt at its root (a pygbag, Emscripten or plain HTML/JS build) |
as files; / and …/ serve index.html; .wasm is application/wasm |
off by default, on to opt in |
The static kind defaults to no Cross-Origin headers because builds that fetch their runtime from a
CDN without Cross-Origin-Resource-Policy (pygbag loads from pygame-web.github.io) cannot run
under require-corp. Set isolation: on for a build that needs SharedArrayBuffer, such as
Emscripten with pthreads. A static upload refuses links, absolute paths, .. and empty segments,
a top-level .uploaded (arcade's own upload-time marker) and a path that is both a file and a
directory, caps the file count at 10,000, and is subject to the same byte cap as a tak upload.
https://play.<base>/ (and /index.html) is a small HTML page generated from the registry on each
request: every game by title, linked to https://<slug>.play.<base>/ (a game with nothing deployed is
listed as "Coming soon", unlinked), and a prominent "Browse all games" link to ARCADE_LANDING_URL.
It has no script and makes no external request (its Content-Security-Policy is
default-src 'none'; style-src 'unsafe-inline'; …), follows the visitor's light/dark setting, is laid
out for a phone first, and is sent with Cache-Control: public, max-age=60. Set
ARCADE_LANDING_MODE=redirect to answer / with the old 302 to ARCADE_LANDING_URL instead.
| Method | Path | Does |
|---|---|---|
PUT |
/api/games/<slug>/versions/<version> |
Body: a .tar (optionally compressed) or .zip holding the game's bundle: for a tak game exactly index.html, game.zip, version.txt; for a static game its tree (see Bundle kinds). It is validated before anything is written, then stored and made current unless ?activate=false. Responses: 201; 409 if the version exists (versions are immutable); 401/403 on a missing or wrong token; 411 without a Content-Length; 413 over the size cap (the upload or its unpacked contents), or a static tree of more than 10,000 files; 400 for a malformed <version>, an upload that is neither a readable zip nor a readable tar, a missing, extra or repeated file, a link or other non-regular file, a refused path (see Bundle kinds), a version.txt that is not UTF-8 or disagrees with <version>, or a game.zip that is unreadable or lacks tak's four assets. |
POST |
/api/games/<slug>/current |
{"version": "…"}: repoints the game to a stored version. This is rollback. Responses: 200; 401/403 on a missing or wrong token; 400 for a body that is not that JSON; 413 for a body over 4 KiB; 404 for a version not on disk. |
GET |
/api/games, /api/games/<slug> |
slug, title, repo, url, aliases, kind, isolation, current, versions, plays (page loads by people; crawlers and scripts are excluded by User-Agent). Needs no token, never shows hashes, and is readable cross-origin (Access-Control-Allow-Origin: *) so the portal can show play counts. 404 for an unknown slug. |
GET |
/version.json |
{"version": "<x.y.z>"}: the version of arcade itself that is running (arcade.__version__), with Cache-Control: no-store, so a deploy can be verified by the version it reports. Needs no token. Answered on play.<base> only: on a game's host /version.json is the game's own path. |
GET |
/og.png |
The landing page's share-preview image (og:image, twitter:image): a 1200x630 PNG, src/arcade/og.png, with Cache-Control: public, max-age=86400. The picture is arcade cabinets rendered in 3D with Strata, drawn from og-card/card.json (see og-card/README.md). Needs no token. Answered on play.<base> only: on a game's host /og.png is the game's own path. |
GET |
/robots.txt |
User-agent: * / Allow: / as text/plain, with Cache-Control: public, max-age=3600. Answered on play.<base> only: on a game's host /robots.txt is the game's own path. |
The token is Authorization: Bearer <token>, and each game's token deploys only that game. An
unknown slug and a wrong token both get 403. The last ARCADE_KEEP_VERSIONS (5) versions per game
are kept; older ones are pruned on upload, never the current one. A version is written to a
temporary directory and renamed into place, and current is replaced atomically, so a crash
mid-upload leaves the previous version live.
# deploy by hand (CI does this with the arcade-deploy action)
tar -cf bundle.tar -C web index.html game.zip -C .. version.txt
curl -fsS -X PUT -H "Authorization: Bearer $ARCADE_TOKEN" --data-binary @bundle.tar \
https://play.danielstephenson.dev/api/games/tidewater/versions/$(cat version.txt)
# roll back
curl -fsS -X POST -H "Authorization: Bearer $ARCADE_TOKEN" -d '{"version":"0.1.0"}' \
https://play.danielstephenson.dev/api/games/tidewater/currentTLS-ALPN-01 cannot issue a wildcard certificate, so every host arcade answers gets its own
Host() router and certificate. The gateway routes play.<base> with ordinary compose labels
(service arcade). arcade itself hands Traefik every game's routers through Traefik's
HTTP provider:
# traefik.yml (static)
providers:
http:
endpoint: "http://arcade:8080/traefik/dynamic.json"
pollInterval: "10s"/traefik/dynamic.json answers only on the internal hostname (ARCADE_INTERNAL_HOST, default
arcade). No public router sends that Host, so the document is never served to the internet. It
contains one router per <slug>.play.<base> and per alias. Each router uses websecure,
secure-headers@file and the letsencrypt resolver, and points at ARCADE_TRAEFIK_SERVICE
(default arcade@docker). Adding a game to games.yaml therefore becomes a router and a
certificate within one poll, with no Traefik or compose edit. If arcade is down, Traefik keeps the
routers it last read; this was verified against Traefik v3.5.3.
The same routers are available offline:
python -m arcade routers --registry games.yaml > arcade.yml # a file-provider document
python -m arcade routers --registry games.yaml --check arcade.yml # exit 1 when stale
python -m arcade hosts --registry games.yaml [--games-only] # one host per line (cert-check)When migrating a game that already has its own container, add its alias in the same gateway change that removes that container's router, or Traefik will see two routers for one host.
| Variable | Default |
|---|---|
ARCADE_DOMAIN |
play.danielstephenson.dev |
ARCADE_DATA |
/data (back this up; losing it is recoverable by each game re-running its deploy) |
ARCADE_REGISTRY |
/config/games.yaml |
ARCADE_LANDING_URL |
https://danielstephenson.dev/play (the portal: the landing page's "Browse all games" link, or the redirect target) |
ARCADE_LANDING_MODE |
page (serve the landing page at /) or redirect (a 302 to ARCADE_LANDING_URL, as before 0.6.0) |
ARCADE_MAX_UPLOAD_BYTES |
67108864 (64 MiB, compressed and unpacked) |
ARCADE_KEEP_VERSIONS |
5 |
ARCADE_HOST / ARCADE_PORT |
0.0.0.0 / 8080 in the image |
ARCADE_TRACE_KEY |
unset (no reporting) |
ARCADE_TRACE_URL |
https://trace.danielstephenson.dev |
ARCADE_TRACE_PROGRAM |
arcade |
ARCADE_INTERNAL_HOST |
arcade (the only host that may read /traefik/dynamic.json) |
ARCADE_TRAEFIK_SERVICE |
arcade@docker |
ARCADE_TRAEFIK_MIDDLEWARES |
secure-headers@file (comma-separated; the gateway sets a set that lets the portal frame games) |
When ARCADE_TRACE_KEY is set, arcade reports to trace as the
program arcade (override with ARCADE_TRACE_PROGRAM) using the vendored
trace-client-python:
startup once, and one page-served event tagged with the game's slug per game page served to
a person (the same User-Agent rule as plays: crawlers and scripts are not reported). That is a
count of page loads, not of players. Nothing about the visitor is sent: no IP address,
user agent or path beyond the slug. TRACE_USAGE_REPORTING=off or DO_NOT_TRACK=1 turns it off,
as for every trace client.
python3 -m pytest # Python 3.8+, standard library only (pytest to run the tests)Play a real game locally. *.localhost resolves to loopback and counts as a secure context, so the
page is cross-origin isolated over plain HTTP:
ARCADE_DOMAIN=play.localhost ARCADE_DATA=/tmp/arcade ARCADE_REGISTRY=games.yaml \
ARCADE_HOST=127.0.0.1 PYTHONPATH=src python3 -m arcade serve
# upload as above to http://127.0.0.1:8080 with -H "Host: play.localhost",
# then open http://<slug>.play.localhost:8080/Stephenson Software Non-Commercial License (Stephenson-NC). The vendored
src/arcade/trace_client.py is MIT, as its header says.