Every game arcade serves, as a cabinet in one hall: the screen is the game's own poster image (Familiar, with no poster yet, gets a title card) and the marquee its title, the islands nearest the door have the most plays, and Roam has the stage at the back. Drawn with Strata 0.7.0 by tools/make_hall_media.py from the public registry; click for a walk from the door towards the stage. arcade-hall.sources.json lists which image is on which screen.
Walk the hall: the same hall in the browser, in real time. Walk up to a cabinet and play its game (see The hall).
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 and /hall/ the walkable hall (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 |
ARCADE_INTERNAL_HOST (arcade) |
/traefik/dynamic.json (see Traefik routers); everything else but /healthz is 404 |
| 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 exceptions are a static
game with isolation off (see Bundle kinds) and the hall's page at plain
/hall/ (see The hall), which get none of the three.
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. A game's files do not answer Range requests; the whole file
is sent. Only the hall's own files do (an uncompressed file, one range at a time; see Live screens in
The hall).
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
added: 2026-10-01 # optional: the day it joined the arcade
tags: [story, puzzle] # optional: theme tags, for the filters
engine: tak # optional: strata, tak, sdl or othertags are short lowercase theme tags (halloween, story, puzzle, sim, ...): at most 6, each
matching ^[a-z][a-z0-9-]{1,23}$, none twice, as a [flow, list]. engine is what the build runs
on: strata (Strata's WebGL 2 backend), tak (tak's console runtime), sdl (SDL through
Emscripten) or other. A static bundle does not say which engine built it (a Strata build and an SDL
build are both an Emscripten index.html), so it is stated here; a tak game is always tak and may
not say otherwise, and a static game without engine is other. The organisation a game is
filtered by is the part of repo before the slash. See Filters.
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.
added is when the game joined the arcade: a day (YYYY-MM-DD, read as midnight UTC) or a UTC time
(YYYY-MM-DDTHH:MM:SSZ, e.g. the time the commit that first registered the game landed).
/api/games reports it as added (ISO 8601 UTC, "2026-10-01T00:00:00Z", or null when unknown);
the landing page orders by it (?sort=newest) and badges a game "New", and the hall puts a NEW ribbon on
the marquee, for a game added in the last 14 days. Without it, arcade uses the game's first upload,
which it records in <data>/<slug>/.added from now on; a game uploaded before that record existed
uses its oldest stored version while none has been pruned, and is otherwise unknown (never NEW) until
added: is given.
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), a canonical that is not an https URL, an added that is not a real day or UTC time,
a malformed or repeated tag, more than 6 tags, and an unknown engine (or one other than tak for a tak game), 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 makes no external request (its Content-Security-Policy is default-src 'none'; style-src 'unsafe-inline'; script-src 'sha256-…'; form-action 'self'; …: one inline script, allowed by its hash),
follows the visitor's light/dark setting, is laid out for a phone first (no horizontal scroll at 320 px,
44 px touch targets, nothing that needs a hover), and is sent with Cache-Control: public, max-age=60.
Finding a game, all of it working without script:
- Search. A search box (
/?q=cat, a GET form the server answers) finds games by title, slug, theme tag and engine; every word must match. With script the list narrows as you type and the address follows (history.replaceState). Games the search hides stay in the page, hidden, so typing can widen it again. - Random game. A link to
/random, a302(Cache-Control: no-store) to one deployed game picked at random among those the chips in force allow (/random?tag=halloween). With script it picks among the games shown, the search included. - Most played this week. Up to 5 deployed games, most played first, over the seven complete UTC
days before today (the same window and the same k = 10 floor as
/api/activity: a game with fewer plays is not listed, and no count is shown). Shown when no chip is applied. - Continue playing. With script only: the games this browser opened last (from this page or the
hall), newest first, at most 6. Only their slugs are kept, in this browser's
localStorage(arcade.recent), and never sent anywhere; when storage is blocked the row stays hidden. - What's new. A deployed game whose current version is a release (
0.4.0+g1a2b3c4is0.4.0; a-SNAPSHOTor untagged build is not) shows What's new in 0.4.0, folded, with the start of that GitHub release's notes and a link to them (Looked up elsewhere). - New. A game added in the last 14 days carries a New badge. Set
ARCADE_LANDING_MODE=redirectto answer/with the old 302 toARCADE_LANDING_URLinstead.
The landing page has chips for each theme tag, organisation and engine, and an order (A–Z, most played, newest). They are plain links that put the choice in the query string, so the server renders the filtered list (no script) and any combination is a link to share:
/?tag=halloween
/?org=Preponderous-Software&engine=strata&sort=plays
?q= (the search box) narrows all three the same way: /api/games?q=strata, /hall/?q=strata.
One value per facet; a game shows when it matches every facet given (case does not matter), and an
unknown value matches nothing. The chip in force is marked aria-current="true" and links back to the
list without it. The same query works on /api/games (?tag=halloween lists only those games) and
on the hall (/hall/?tag=halloween, below); src/arcade/filters.py is the one place that decides
what matches.
/halloween is the page for the halloween tag: the same list as /?tag=halloween, under its own
heading and in the colours of the hall's Halloween season (seasons.py, Season.page), with a link
into the hall with only the Halloween cabinets lit.
https://play.<base>/hall/ is the README picture made walkable, drawn live by
Strata's web kit. It is opt-in: the landing page and
the portal keep their lists, and link here with "Walk the hall".
- The layout is shared with the picture.
src/arcade/hall.pylays out the floor like a real arcade: a roughly square room with islands of back-to-back cabinets either side of a centre walkway (walkways are at least 2.5 m), a change and prize counter by the door, and Roam alone on a raised, lit stage at the back wall, in sight from the door. It orders the cabinets (the most played in the island nearest the door, nearest the centre first), places them, lists the room's props, signs and warm lights, colours their side panels by GitHub organisation and describes a cabinet's parts.tools/make_hall_media.pydraws the README picture from it, andGET /hall/layout.jsonhands the same data, built from the live registry and play counts, to the page. - Walking. WASD or the arrow keys (Shift runs), drag to look, or Mouse look (pointer lock; Esc stops). Click or tap a cabinet, or the floor, to walk there. On a phone: a stick under the left thumb, drag anywhere else to look, tap to walk. Cabinets, the stage, the counter and the walls are solid; a place saved under an older layout that is now inside something moves to the nearest walkway.
- Playing. At a cabinet the hall shows its title and "Press E / tap to play". With
ARCADE_HALL_EMBED=onthe game opens in a full-screen frame over the hall, with Back to the hall, which returns to the same spot. A game that needs cross-origin isolation (tak games, Roam) can only be framed by an isolated page, and a game without COOP/COEP cannot be framed by one, so the hall serves itself in either mode (/hall/?isolated=1sends COOP/COEP; plain/hall/does not) and, when a game needs the other mode, reloads itself in it with the game open. WithARCADE_HALL_EMBED=off(the default) a game opens as its own page and the browser's Back button returns to the hall. Either way the visitor's place is kept in the URL hash (#p=x,z,yaw,pitch) and insessionStorage, and/hall/?at=<slug>starts in front of a cabinet. Turning framing on needs each game'sframe-ancestors(the gateway'sgame-headers) to namehttps://play.<base>as well as the portal. - Cabinets.
src/arcade/hall_model.pybuilds one cabinet model, shared by the page and the picture: side panels with the classic profile and bevelled edges, edged with a T-molding strip in the organisation's colour; a sloped control deck with a joystick and buttons; a coin door whose two coin slots glow; a bevelled bezel; a speaker grille; the marquee box. The side panels carry side art,posters/<slug>.side.jpg, whichtools/make_hall_posters.pymakes from the game's poster (a tall crop of it fading into stripes of its dominant colours;--sidesremakes them offline). Beyond about 8 m (less in economy) a cabinet is drawn as a few-faced stand-in:hall3d/main.cppswaps the two as the visitor walks, so only the nearest cabinets cost their detail. - The room. A patterned arcade carpet (hall.py's
carpet_pattern(): neon squiggles, rings, triangles, stars and confetti in one 256 px tile), a suspended ceiling with framed light panels, a neon ARCADE sign over the door, red stools tucked under every third cabinet's control deck (each with its own collider; the walkways stay at least 2.5 m) and plush prizes along the top of the prize shelves. - Screens. Each screen starts as a title card and gets its poster when it loads, nearest first.
Posters are
src/arcade/hall_assets/posters/<slug>.jpg(512x288), served same-origin because danielstephenson.dev sends no CORS header and WebGL needs one;tools/make_hall_posters.pyrefreshes them from the same sources as the picture, and draws a title-card poster (title, blurb, address) for a game with no public picture (Familiar). - Live screens. The screen of the cabinet nearest the visitor (two on a desktop, one on a phone or in
economy), when it is in front of them and faces them, plays the game's trailer, silent and looping,
in place of its poster, and goes back to the poster when they walk away. Clips are
src/arcade/hall_assets/clips/<slug>.mp4(384x216, 15 fps, H.264, no sound), re-encoded from danielstephenson.dev's trailers bytools/make_hall_clips.py(needs ffmpeg) and served same-origin for the same CORS reason as the posters, with byte ranges (Safari plays a video only from a server that answers them). Frames reach the screen asynchronously where the browser hasVideoFrame, else through a small canvas, at the clip's 15 fps (10 on a phone or in economy). No trailer plays under reduced motion, while a game is open or the page is hidden, or with?trailers=0. - The map. A "you are here" plan of the floor (top right; Map or
Mtoggles it, remembered; open by default on a desktop, closed on a phone, where it steps aside while the cabinet card shows): the room, stage and counter, every cabinet in its org colour, and you with the way you look. Clicking or tapping a cabinet on it walks there, without turning the view. - Attract mode and sound. Every few seconds one screen shows its game's title card instead of its poster, the nearest screens shimmer faintly (with baked-in scanlines), and the marquees' glow breathes; nothing moves under reduced motion. Sound (off by default, remembered) plays a quiet arcade hum, made with WebAudio (no audio files), and pauses while a game is open. With it on, the nearest cabinets (three, two on a phone or in economy) each play a soft attract loop of their own: a short chiptune phrase made up from the game's slug, the same every visit, flavoured by the game (a tak text adventure a slow minor triangle-wave figure, a Halloween game a minor one, the rest a brighter pulse, bell or bass arpeggio), two bars on and two off. Each is placed where the cabinet stands: louder near (silent past about 11 m), panned to the side it is on as the visitor turns, and quieter and muffled from behind it.
- Popularity in the room. The marquees of the most played games (and, once there are enough likes,
the most liked: likes weigh up to a third, in full from 30 likes in all) glow a little warmer: redder
and brighter, with an amber glow above them. Only the top end warms; the middle of the field is as it
was. A Hall of Fame board on the wall right of the door, across from the counter, lists the best score
on each game's leaderboard (player, game, board, value; at most six, most played games first) and the
games whose boards nobody has topped yet. Both come from arcade-social's public reads
(
GET /v1/likes/countsandGET /v1/boards/top, every board's #1 in one request), at the URL in<meta name="arcade-social">, sent without credentials, a moment after the hall loads. Against an arcade-social without/v1/boards/top(a 404, which reaches the page as a network error since it has no CORS headers) the board falls back toGET /v1/games/<slug>/boardsandGET /v1/boards/<slug>/<board>?limit=1per game, a few at a time; a 429 or 5xx does not fall back. The board's data is kept insessionStoragefor ten minutes (public data only: the names are the display names the boards already show), so walking round never asks twice. Without arcade-social, or when it is down, the board says the scores are resting and the marquees go by plays. - Seasons.
src/arcade/seasons.pydresses the hall by the date in America/Denver: from October 20 to November 1, Halloween (jack-o'-lanterns that flicker by the door and on the counter, cobwebs in the ceiling corners, warmer, more orange light, and a banner for Familiar over the walkway in from the door)./hall/?season=halloweenshows a season on any date and?season=noneturns decor off. Decor never moves a cabinet or narrows a walkway; the lanterns by the door are solid. Winter is a stub (ready=False: shown only when asked for by name) for the next one. - Filtered.
/hall/?tag=halloween(and?org=,?engine=, as on the landing page) lights only the cabinets that match: the rest go dark (screen and marquee turned down, their glow and light nearly out, their body in shadow). Nothing moves, no walkway narrows and the camera never turns. A pill in the bar says what is lit ("Halloween: 6 of 37") and lights every cabinet again from the same spot; Tab walks only to the lit cabinets; the map shows the dark ones faint; "All games" and "Share" keep the filter. It works with?at=<slug>(/hall/?at=familiar&tag=halloween).layout.jsontakes the same query and marks each cabinetlit, withfilter: {query, summary, lit, of}(ornull). - The card at a cabinet. Walking up to a cabinet shows a small card: the game's title, its GitHub
organisation, one line about it and its play count (live from
/api/games, refreshed every minute). The lines are the public repositories' descriptions, kept insrc/arcade/hall_assets/blurbs.jsonbytools/make_hall_blurbs.py(unauthenticated GitHub API, so only public repositories are read). - Likes, boards and achievements on the card (
hall-social.js). Under the play count the card shows the game's likes, how many achievements it declares and the top three of its first leaderboard, from arcade-social athttps://api.<domain>(the page names it in<meta name="arcade-social">; the CSP'sconnect-srcallows it and nothing else). These reads are public. When arcade-social answers the hall's origin with credentials, the count becomes a heart button (and L likes): signed in it likes or unlikes the game, signed out it goes to the service's sign-in page and comes back to the same cabinet. Until then, or when the service is down, the card shows the count alone, or nothing. - People in the hall (
hall-people.js, RFC 0030). Other modules hand it figures by layer (HallPeople.set("presence" | "replays" | "ghosts", people), positions in hall metres); it eases each one between updates, turns it to face the way it walks, stands it beside a cabinet it is "at" (to one side, so the visitor's view of the screen stays clear) and fades it in and out. Live players are solid little people (the engine's capsule and sphere primitives) in a colour of their id, with their display name above them; ghosts and replays are translucent arcade ghosts (a mesh from a pool of 12, built once and then only moved byhall_pose_meshesinhall3d/main.cpp). At most 12 are drawn (6 on a phone or in economy): live players first, then replays, then ghosts, nearest first; names for the nearest 6 (3) within 14 m; nothing beyond 30 m. Figures are never obstacles, and under reduced motion they neither bob nor sway. A tap or a click on a figure (or, on a desktop, the mouse over it) shows its caption for a few seconds, past those limits: a live player's display name, "A recent visitor" for a ghost, "A walk from today" for a replay; a tap anywhere else walks or plays as before. The map shows them too: a dot in their colour for each live player, a faint one for each ghost or replay, only for the layers the settings show. - Seeing each other, opt-in (
hall-presence.js). Watching needs no account: while the hall is visible and See other people (in the settings, on by default) is on, it asks arcade-social who is here (GET /v1/presence?layout=<key>, without credentials): once a second while anyone is, every 10 s while nobody is, at least every 3 s when the browser asks to save data, and never while the tab is hidden or the setting is off. Being seen is Show me in the hall: off by default, per account (kept by arcade-social), and only for a signed-in player; signed out, the setting links to the sign-in page, which comes back to the same spot (and turns it on, if that is what was asked within 10 minutes). Only while it is on and the hall is visible does the hall sendPOST /v1/presenceonce a second with{x, z, yaw, cabinet, layout}: the position rounded to 0.25 m, the facing to 10 degrees, the cabinet stood at or played (elsenull) and the layout key (a hash of the hall's cabinet order, so poses are shared only between halls whose cabinets stand in the same places). arcade-social holds it in memory for 10 s and shows it to others under the player's display name; nothing else about the visitor is sent, and the hall stores nothing about other people (the only things kept in the browser are the "See other people" choice inlocalStorageand, for 10 minutes around a sign-in, the wish to be shown insessionStorage). A visitor never sees their own figure. When arcade-social is down the hall is exactly as it was, with nobody in it. While Show me in the hall is on, a pill in the bar says so ("Visible as "; a green dot alone on a narrow screen, with the full sentence for a screen reader), whatever is open, and a tap on it opens the People settings; the name is the oneGET /v1/sessionalready returns, and nothing more is fetched or kept for it. - Ghosts of recent visitors. Anonymous, translucent figures drift in through the door, walk the
aisles (routed round the cabinets, the stage and the counter on
hall-walk.js's floor plan), stand at a cabinet for 20 to 90 seconds as if playing (a busy cabinet can also have one watching beside it), then walk to another or leave. They stand for no particular person:hall-ghosts.jsmakes them up fromGET /api/activity, a 0 to 4 level per game (below), one ghost per level point, more for busier games, never more than 12 at once (6 on a phone or in economy), and none for a dark cabinet in a filtered hall. Their paths come from a seeded generator (seeded by the UTC day), so tests are reproducible. Under reduced motion they stand at their cabinets and do not walk. Settings has Show ghosts of recent visitors (on by default, remembered).hall-people.jsdraws them (HallPeople.set("ghosts", [...])); without it the hall is as it was. RFC 0030 §2. - The hall today (opt-in walk replays, RFC 0030 §3). Share my walk anonymously, in Settings, is
off by default and kept per browser (
localStorage). While it is on,hall-replays.jsrecords a coarse outline of the visit: a point every 0.5 s of walking (standing still adds none), rounded to 0.25 m, at most 60 s (120 points), the cabinets walked up to (at most 10), and the layout key. It sends it once, withsendBeacon, when the page is hidden or left or a game opens; turning the option off throws away what was recorded. The hall today, in the help panel (shown only when arcade has enough walks), or/hall/?replay=today, plays a sample of them back as translucent grey figures throughHallPeople(hall-people.js), time-compressed, standing a moment at each cabinet, staggered so they do not move in step, and standing still under reduced motion.layout.jsoncarrieslayoutKey, a hash of the cabinets' slugs in order: a walk made in a hall whose cabinets have since moved is re-anchored, walking round the cabinets from where it began to each cabinet it visited, by slug, in front of where it stands now (a cabinet that is gone is dropped). See Privacy for what is kept. - Speed. The kit's adaptive quality lowers the pixel density first and then the light budget
(24, 12, 6 point lights; the nearest cabinets keep theirs). The room's carpet, walls and ceiling are
drawn after the cabinets, so a software renderer never shades the floor a cabinet hides.
?quality=0..2pins a tier,?fps=1shows frame time and counts.
The page is src/arcade/hall_assets/ (HTML, CSS and the page scripts; hall-walk.js holds the walking
maths, with no DOM). The engine is hall3d/main.cpp, built by hall3d/build.sh (emsdk 6.0.11) against
Strata v0.7.0's wasm release, whose tarball carries the web kit (1.1); the build output is committed, so
the image still needs no build step. python3 tools/hall_dev_server.py serves the hall locally against
a snapshot of the live registry.
| 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 usable Content-Length; 413 over the size cap (the upload, its unpacked contents, or tak's four assets unpacked from game.zip), 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 (including one damaged partway through, or with an encrypted or unsupported zip member), 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, lacks tak's four assets, or has one that cannot be read. |
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, added, tags, org, engine (see The registry), plays (page loads by people; crawlers and scripts are excluded by User-Agent, and one visitor counts once per game per UTC day: see below), notes ({"version", "text", "url"}: the current release's notes once arcade has looked them up, else null; Looked up elsewhere). Needs no token, never shows hashes, and is readable cross-origin (Access-Control-Allow-Origin: *) so the portal can show play counts. /api/games takes the landing page's ?tag=, ?org=, ?engine=, ?q= and ?sort= (Filters). 404 for an unknown slug. |
GET |
/api/activity |
{"window_days": 7, "k": 10, "levels": {"<slug>": 0..4}}: how busy each game has been lately, for the hall's ghosts (RFC 0030 §2.2). The level comes from the plays over the seven complete UTC days before today: 0 below k = 10 plays (RFC 0022 §4.2's k: nothing is shown for a game played a handful of times), 1 for 10 to 24, 2 for 25 to 99, 3 for 100 to 399, 4 for 400 or more. Only the level is served: never a count, a date or a time. Today is left out, so a level changes only at midnight UTC and never at the moment someone plays. Cache-Control: public, max-age= up to 3600, never past the next midnight UTC; readable cross-origin. Needs no token. |
POST |
/api/walks |
One walk for "the hall today" (opt-in in the hall), {"layout": "<layoutKey>", "dt": 0.5, "points": [[x, z], ...], "visits": [{"slug", "at"}]}, at most 16 KiB. Refused (400, with the reason) unless it has no other fields, the layout key is one this process has served (the last 32), dt is 0.5, there are at least 4 points, every point is two finite numbers inside the room (1 m slack), no two points are further apart than a run covers in 0.5 s (plus rounding), and visits, at most 40 of them, name cabinets in that layout with point indices in walking order. More than 120 points or 10 visits are truncated, not refused. Every point is rounded to 0.25 m and pushed out of the cabinets and walls, then kept (202). 413 over the size, 411 without a usable Content-Length, 429 for a second walk from the same client within 10 minutes, 503 if the walk could not be written. Needs no token. |
GET |
/api/walks/today |
{"dt": 0.5, "walks": [...]}: up to 12 of today's walks (UTC) at random, with yesterday's pooled in when today has fewer than 10, and none at all when the pool is under 10. Cache-Control: public, max-age=60. |
GET |
/random |
A 302 to one deployed game, at random among those the query's ?tag=, ?org=, ?engine= and ?q= allow, with Cache-Control: no-store; to the list (/?…) when none is deployed. The landing page's Random game. |
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. |
What is kept about plays. For each game, <data>/<slug>/plays holds one all-time count and
<data>/<slug>/.plays-daily holds a count per UTC day, {"YYYY-MM-DD": n}, for the last 35 days
(older days are dropped on each write). Both are updated together, each written atomically, when a
game's page is served to a person (crawlers and scripts are excluded by User-Agent, and so is any
User-Agent containing arcade-watch, the nightly health check's marker) for the first time that UTC
day: a reload, a second tab or a later visit the same day by the same visitor is not another play.
"The same visitor" is the client address (the last X-Forwarded-For hop) with its User-Agent, reduced
in memory to a keyed hash (HMAC-SHA256 under a random key that is replaced, with every hash, at
midnight UTC and never stored); at most 100,000 a day are held, and past that a day's new visitors are
not counted. Nothing of it is written anywhere, and a restart forgets it (src/arcade/visits.py).
Counts from before this change are kept as they were. They hold dates
and counts only: no IP address, user agent, time of day or anything else about the visitor. The
per-day counts are never served; /api/activity publishes only the coarse level above. Upgrading
loses nothing: plays is unchanged, and per-day counts start on the day this ships (there is no
back-fill).
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, the
middlewares in ARCADE_TRAEFIK_MIDDLEWARES (default 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)These two and check-registry take --domain play.<base> for an arcade on another domain (default
play.danielstephenson.dev; the command line does not read ARCADE_DOMAIN).
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_REMOTE |
on: look up trailer stills and release notes in the background (Looked up elsewhere); off never does (what is cached is still shown) |
ARCADE_HALL_EMBED |
off (games open as their own page) or on (in a frame over the hall; needs the games' frame-ancestors to allow play.<base>) |
ARCADE_TRAEFIK_MIDDLEWARES |
secure-headers@file (comma-separated; the gateway sets a set that lets the portal frame games) |
With ARCADE_REMOTE=on (the default), arcade fetches two kinds of decoration in one background thread
and keeps them under <data>/.remote/ (written atomically; they survive restarts). A request never waits
for either, and a failure (no network, a 404, a timeout, something that is not what was asked for) is
silent and retried after six hours at the earliest (src/arcade/remote.py):
- Trailer stills. A cabinet with no poster in
hall_assets/posters/(a game registered sincetools/make_hall_posters.pylast ran) getshttps://danielstephenson.dev/trailers/<repo-name>.jpg(or<slug>.jpg) once it answers 200 with a JPEG, served at/hall/fetched/<slug>.jpgand refreshed weekly. Until then (or when there is none) its screen is a title card lettered with its title, its organisation and its tags, and its card's line says what it is from its tags and engine. - Release notes. For a deployed game whose current version is a release, one unauthenticated
request to
https://api.github.com/repos/<repo>/releasesper new version (never for a-SNAPSHOTor untagged build): the release tagged<version>orv<version>, its body cut to what a player reads (up to the release tooling's own Version / Verification sections, links reduced to their text, at most 600 characters). GitHub allows 60 such requests an hour per address; arcade makes at most 30 an hour, and a 403 or 429 pauses them for an hour. A version with no release is looked up once. The notes are shown on the landing page (What's new), in/api/games(notes), and the first sentence is the hall card's line for a game with no blurb inhall_assets/blurbs.json.
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.
arcade keeps no access log (log_message is a no-op) and stores no IP address or user agent.
- Plays.
<data>/<slug>/playsis one integer per game: first page loads of the UTC day by people (crawlers and scripts are told apart by User-Agent, which is read and not kept; whether a visitor has already played a game today is a keyed hash in memory only, forgotten at midnight UTC).<data>/<slug>/.plays-dailyis the same count per UTC day for the last 35 days, never served (What is kept about plays). - Walk replays (opt-in only). When a visitor turns on Share my walk anonymously in the hall, one
walk per visit is kept in
<data>/.hall-walks/<YYYY-MM-DD>.json(UTC day):{"seen": n, "walks": [{"layout", "points", "visits"}]}. A walk is the layout key, its points (rounded to 0.25 m and clamped to the floor by the server) and the slugs of the cabinets walked up to, with the index of the point where each was reached. Nothing else: no time of day, no session or device identifier, no IP address, no user agent, no referrer, no account. The day's walks are stored shuffled, so their order says nothing about when each was left. At most 200 are kept a day, a uniform random sample of theseenwalks accepted that day; only the last 7 UTC days' files, today's included, are kept, and older ones are deleted on the next write. They are shown to everyone through/api/walks/today, and only once there are at least 10 in the pool, so one visitor on a quiet day is never on show alone. A walk carries no identifier, so it cannot be found and deleted per visitor; the operator can delete a day by removing its file. - Rate limit memory. To allow one walk per client per 10 minutes, arcade holds a keyed hash of the client's address (the key is random per process) in memory only, until its 10-minute window ends. It is never written down, and a restart forgets it.
- Layouts. The last 32 hall layouts served are held in memory (cabinet positions only), to clamp a walk against the hall it was made in.
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/The hall picture at the top of this README is regenerated with tools/make_hall_media.py (see its
docstring for the one-off venv; it needs Pillow and the Strata Python binding, and reads only
public URLs). It writes docs/media/arcade-hall.png, docs/media/arcade-hall.gif and
docs/media/arcade-hall.sources.json.
Stephenson Software Non-Commercial License (Stephenson-NC). The vendored
src/arcade/trace_client.py is MIT, as its header says.




