Skip to content
Stephenson-SoftwarePublic

About

One static host for every browser game: <slug>.play.danielstephenson.dev (RFC 0006)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

arcade

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.

How it answers

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.

Compression

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.

The registry

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   # optional

canonical 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.yaml

A token for a new game

python3 -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.txt

Only the hash is committed. Rotating a token is a registry PR plus a secret update.

Bundle kinds (RFC 0012)

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.

The landing page

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.

The API (on play.<base>)

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/current

Traefik routers (TLS option B)

TLS-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.

Configuration

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)

Usage reporting

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.

Development

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/

License

Stephenson Software Non-Commercial License (Stephenson-NC). The vendored src/arcade/trace_client.py is MIT, as its header says.

About

One static host for every browser game: <slug>.play.danielstephenson.dev (RFC 0006)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages