Docs Design

Contents

The web Studio: gamewright host and the Studio in a browser

The web Studio is Gamewright Studio's own Godot web export, the same UI as on the desktop (ADR-0012), run in a browser as the client of gamewright host (ADR-0013). People reach it at gamewright.diy/app/studio/: Vercel passes /app on to the host on Fly.io.

gamewright host is the core half: sign-in by an emailed code, each person's games on the host's volume, a session daemon (gamewright serve) on the game they have open, the Studio's web export, and an admin console for the host's owners. It is Reelwright's host, ported and trimmed; core/UPSTREAM.md lists every file, its Reelwright commit and what changed. The client half (the Studio's web mode and export) and deploying both to Fly.io come after the host's sections.

What came over, and what did not

Ported: the account store (host/store.ts, one SQLite file, node:sqlite), sign-in and invites (accounts.ts), mail through Resend (mail.ts), the owners' settings (settings.ts), WebSocket tickets (tickets.ts), each person's sealed Anthropic key (keys.ts, claude.ts), the daemon pool and its grant pipe (daemons.ts, grant.ts), the HTTP server and its pages (server.ts, files.ts, html.ts, pages.ts), the workspace (workspace.ts), disk and machine figures (usage.ts, system.ts), the admin console (console.ts, console-pages.ts) and the command (cli/host.ts).

Left in Reelwright: sharing a game with other people, invitations to it, the collaboration API, devices for the installed Studio, Claude over MCP and the plan bridge, render watching and render quotas, notifications and digests, uploads, and release downloads (releases go to a public repository of builds, ADR-0013). A game is its owner's alone, so a daemon works for one person.

Games are made by Gamewright's own new (core/src/cli/new.ts), in process, into <data>/users/<user id>/games/<game id>/, the same three ways in as the desktop Studio's New game.

Routes

Every route lives under BASE_PATH (/app in production, where Vercel passes gamewright.diy/app/* on to the host); /healthz also answers at the root.

Route What it does
GET /healthz {ok, version, daemons}
GET /login the sign-in page (?invite=, ?email=, ?next=)
POST /api/auth/request {email} mails a six-digit code to an address that may sign in: {ok, email}, else 400/403/429/502/503 {error, message}
POST /api/auth/verify {email, code} sets the gw_session cookie (HttpOnly, Secure, SameSite=Lax, path the base, 30 days): {ok, user}
POST /api/logout ends the session (a form gets a redirect to /login)
GET /api/me {user: {id, email, name, role, owner?}, announcement}
GET /api/starts exactly gamewright new --list --json: {samples: [{name, title, template, description}], templates: [{id, name, description, starter}]}
GET /api/games {games: [{id, title, template, modifiedAt, open}]}, the latest changed first
POST /api/games {title, start} start is "empty", "starter:<template folder or tpl. id>" or "sample:<name>"; title may be "" for a sample (its own title). 201 {game: {id, title}}; 400 bad_start/workspace; 403 quota_games/quota_storage
POST /api/games/<id>/open starts or reuses the game's daemon and makes it current: the session body below
GET /api/session the session body for the game opened last, or {user, game: null, announcement} with no game yet
GET /api/file?path=<absolute path> a file inside the person's own folder, with ranges and ETags; anything but a raster picture, a sound or a video goes out with Content-Security-Policy: sandbox (files.ts sandboxed), so a page or SVG a game holds never runs with the person's session on this origin
GET /api/claude {claude: {available, connected, key?: "sk-ant-…WXYZ", connectedAt?, problem?, spentThisMonth, monthlyCap?}}
POST /api/claude/key {key} checks the key with Anthropic (a free model listing) and keeps it sealed; the person's daemons get it at once and their sockets close (1012)
DELETE /api/claude/key forgets it, the same way
GET /studio/… the Studio's web export (WEB_STUDIO_DIR, default build/studio/web), cross-origin isolated, never framed by another site (frame-ancestors 'self'); a stand-in page while none is installed
GET/POST /admin/… the admin console, for ADMIN_EMAILS only (a 404 for anyone else)
WS /ws the WebSocket to the current game's daemon (cookie), or /ws?ticket=… on WS_ORIGIN

The session body: {user, game: {id, title}, ws, token: "service", announcement}. ws is <base>/ws on the page's own origin, or with WS_ORIGIN an absolute wss://…/app/ws?ticket=… that opens once, within a minute, for this user and this daemon: the client asks GET /api/session again before every reconnect. The browser's first frame must be session.hello with token: "service"; the host puts the daemon's own token in its place and changes nothing else (HelloParams stays {token, client, protocol}). Anything else first closes the socket with 4001.

Writes to /api/claude/key must carry X-Gamewright-Studio: 1, JSON, and no other site's Origin or Sec-Fetch-Site. Every other state-changing request is refused from another site's Origin, and the console's forms take POST alone, from its own pages (Sec-Fetch-Site). Signing out closes the person's open sockets (1008). The next of the sign-in page is a plain path or the Studio, and every value a page puts in an inline script is escaped for it.

Sign-in limits

A code works once, for ten minutes, and dies after five wrong guesses. Wrong codes also count against the address across codes: the tenth in an hour locks it for an hour (address_locked, in the audit log), so a new code does not wipe the slate. An address may ask for five codes in 15 minutes and an IP for 30 an hour; both are the owners' settings, since everyone behind one network (or the proxy) shares an IP. Behind Fly.io the client's address is Fly's own Fly-Client-IP, never X-Forwarded-For.

Quotas

Every account has 20 games and 2048 MB unless an owner sets otherwise (the console's settings and each account's page; empty is no limit). One person's creates run one at a time, so many at once cannot all pass the games quota. Their folder is measured before their socket opens and again when that is a minute old; while it is over their storage, the frames that write (pack.bake, pack.verify, director.send, tools.call, docs.edit) are answered by the host with quota.storage and plain words, and the daemon never hears them. The host reads a frame's method as the daemon's JSON parser does (a name with an escaped letter is the same name), on every text frame while the folder is over; a frame that does not parse goes on, and the daemon refuses it.

Backpressure

The host pipes each socket to its daemon frame for frame. Past 4 MB held for the other side, the sending side is not read until what is held drains under 1 MB, both ways; a side past 32 MB is closed with 1013. A person flooding frames waits on their own connection.

The person's key, on the daemon

A daemon the host starts runs gamewright serve --pack <game> --grant-fd 3. When its owner's socket joins, the host writes one line on fd 3, {"userId", "anthropicKey"?, "room"?: {"usd", "what"}}, and again whenever what they may spend changes (a call counted, their cap, the ceiling). The daemon keeps the key in memory (core/src/director/hosted.ts); the Director's key is that one and nothing else, never ANTHROPIC_API_KEY, the keychain or a key file. A hello waits up to 3 s for the first grant, so its capabilities say director exactly when a key is there. key.set and key.forget answer key.hosted: on the web the key is connected in the Studio's Claude settings.

A turn's budget is the least of what it asked and the grant's room: what is left of the person's monthly cap (set in the console) and the host's ceiling. With nothing left, director.send answers director.over_cap. Each priced call is a stdout line of the daemon's, {"spend": {"session", "model", "usd"}}; the host checks it and counts it in <data>/host/accounts/<user id>/spend/<YYYY-MM>.jsonl with its own time, then writes a fresh grant. It counts at most 5 lines a second from one daemon and 200 for one turn (a line's session; a real turn makes at most 24 calls, each a second or more); the rest of a flood is dropped, with one log line for each stretch of it. Past 256 KB a month's ledger folds into one line holding its total, and the host keeps each account's month in memory once read, so a grant reads no ledger. It holds at most 64 KB of any one line a daemon writes on stdout or stderr. Accepted: the room is read when a turn starts, so a turn may pass a limit by its last call, and two daemons of one person (or many people under the ceiling) turning at once may each spend up to a turn's budget past it. That is bounded by the per-turn budget (default $1). A daemon that lies about what it spent only loosens its own owner's cap.

Isolation

A daemon runs a person's own pack and the Godot that checks it, so a bug fed by their files could run their code. It reaches nothing of the host's or anyone else's:

  • Environment: an allowlist (host/daemons.ts daemonEnv): PATH, LANG, LANGUAGE, TZ, LC_*, ANTHROPIC_BASE_URL, the GAMEWRIGHT_* that find and run Godot, and a software renderer's VK_ICD_FILENAMES, __EGL_VENDOR_LIBRARY_FILENAMES, LIBGL_ALWAYS_SOFTWARE, GALLIUM_DRIVER, MESA_*; HOME, TMPDIR and the XDG folders inside the person's folder; GAMEWRIGHT_HOSTED=1 and GAMEWRIGHT_JUDGE=none. A secret added to the host later never reaches a daemon.
  • As root (the Fly.io container runs the host as root): each person's daemons run as a uid and gid of their own (users.uid, from 20000, given once and kept), and so does everything that writes in their folder: a game made (POST /api/games runs gamewright new as them) and the folder emptied when their account is deleted (host clear-folder, then root removes the empty folder). Each is a child process of the core's with a daemon's environment, its home and working folder theirs (host/isolation.ts personWriter), so what it makes is born theirs. Root makes their folder in users/ (0700, theirs) and changes the owner or mode of nothing in it, so no link they plant there (a hard link to a file of root's, a folder swapped for a link while a walk is under way) can turn root's hand on what is not theirs. A daemon also starts in its owner's folder, not the game's: root changes to the working folder before it becomes them, and a game folder could be swapped for a link. The data folder and users/ are only traversable (0711); host/ (the database, the settings in it, and accounts/) is root's alone (0700), so a daemon cannot even name a path to anything of the host's, and no hard link to it is possible whatever fs.protected_hardlinks says. A daemon cannot list another's folder, read the database, the accounts or the host's /proc/<pid>/environ (where KEY_ENCRYPTION_KEY and RESEND_API_KEY still are). The image must keep the host as root and give daemons no shell or setuid helper beyond what Godot and the core need.
  • Files the host reads in a person's folder (/api/file, a game's game.json and brief.json for its title): opened, then the descriptor checked, since another process of theirs can swap a link in between (host/files.ts openContained): its real path first, then where /proc/self/fd says it points (elsewhere, its device and inode against the path resolved again), never a link at the last step (O_NOFOLLOW), never a FIFO (O_NONBLOCK, so nothing waits on one), and, as root, a file of their own uid only (a hard link to another's is inside by every path). A game folder that is a link, or a games/ that leads out, is no game.
  • Not root (a laptop, the tests): daemons run as the host's user, with the allowlisted environment; a game is made and a folder removed in the host itself; and the host says once that daemons are not isolated.

The self-check

gamewright host check-isolation --data <the host's data folder>, run as root on the host's own machine (on Fly.io, over fly ssh console, with the image's core), proves the above where it matters. It makes two throwaway people in the host's database and folders, runs a probe as the first exactly as the host runs their work (host probe, a child process as their uid), and checks that the probe ran as that uid; could write and read its own folder; got EACCES on host/, the database and the other person's folder, and could not hard-link the database into its own; got EACCES (or ENOENT under hidepid) on the check's own /proc/<pid>/environ (a root process, as the host is) and PID 1's; and had exactly the allowlisted environment. Then, as the host, it checks the file route in the probe's folder: their file served, a link to the database swapped in between the check and the open refused, a file of root's refused. It removes both people, folders and all, and prints one JSON line, {"ok", "root", "uid", "checks": {name: what it got}, "failed": [names]}: exit 0 when everything held, 1 when anything did not (or the probe could not run, say the image's core is not readable by others), 2 when it is not root. It needs nothing but the core, so it runs from the production image.

Accepted: root still reads in a person's folder, never writes. It measures its size for the storage quota (host/usage.ts walks it without following links; a folder swapped for a link mid- walk can make it count, never read or change, what lies beyond), reads a game's title and serves its files (the descriptor checks above), and lists games/.

Data

<data>/host/gamewright.db          users (and the uid each one's daemons run as), codes (hashed),
                                    wrong codes and locks by address, sessions (hashed), invites,
                                    settings, quotas, the audit log, sealed Claude keys
<data>/host/accounts/<user id>/    cap.json and spend/<YYYY-MM>.jsonl, written by the host alone
<data>/host/accounts/ceiling.json  everyone's monthly ceiling
<data>/users/<user id>/            the person's own: games/<game id>/ (their Game Packs), and the
                                    daemon's HOME, .tmp (it makes that itself) and XDG folders

A host that finds gamewright.db (and its -wal and -shm) or accounts/ in the data folder itself, where an older one kept them, moves them into host/ before it opens the database; with both there it stops and says so.

Tables change through MIGRATIONS in host/store.ts, counted by PRAGMA user_version: a new database is made whole at the latest version; an older one runs the steps it lacks, each once.

Running it on this machine

ADMIN_EMAILS=you@example.com HOST_INSECURE_COOKIES=1 BASE_PATH=/app \
KEY_ENCRYPTION_KEY="$(openssl rand -base64 32)" \
node core/src/cli/main.ts host --data /tmp/gw-host --port 8457 --listen 127.0.0.1 --dev-mail

--dev-mail writes every email to stderr (host (dev mail) to …: 123456 is your Gamewright code); without it the host needs RESEND_API_KEY and refuses to start. Then sign in with curl:

B=http://127.0.0.1:8457/app
curl -s -X POST -H 'content-type: application/json' -d '{"email":"you@example.com"}' $B/api/auth/request
curl -s -c jar -X POST -H 'content-type: application/json' \
  -d '{"email":"you@example.com","code":"<the code from stderr>"}' $B/api/auth/verify
curl -s -b jar $B/api/me
curl -s -b jar -X POST -H 'content-type: application/json' \
  -d '{"title":"Moon Bakery","start":"starter:adventure"}' $B/api/games
curl -s -b jar -X POST $B/api/games/moon-bakery/open

gamewright host users list|add|remove --data DIR and gamewright host session EMAIL --data DIR manage accounts from the machine. The environment the host reads is in .env.example and at the top of core/src/cli/host.ts. .claude/skills/verify/features/web-studio.md has the checks.

The Studio in a browser: the page and the export

tools/heavy.sh tools/web/build-studio.sh writes build/studio/web/ (the host's default WEB_STUDIO_DIR): tools/export.sh studio-web exports the "Studio web" preset (engine/export_presets.cfg), then each page, script, wasm and pck gets a .br and a .gz twin for a server that sends them precompressed.

  • Single-threaded, like Reelwright's: the page needs no SharedArrayBuffer. The host still sends COOP same-origin, COEP require-corp and CORP same-origin, which a threaded build would need.
  • The Compatibility renderer (ADR-0007), WebGL 2.
  • The Studio, not the web player: the preset carries the gamewright_studio feature, whose run/main_scene override (res://studio/studio.tscn) comes before the web player's in engine/project.godot. Godot takes the first override that matches, so the order matters: with the web player's first, the export booted the player.
  • The page is engine/studio/web/shell.html: a loading screen in the Studio's colours with the download's progress, kept until the Studio says it has drawn itself (GwStudioWeb.say_ready calls window.gamewrightStudioReady; after 30 s it goes anyway), a notice when the browser lacks WebGL 2 or WebAssembly, and a question before the tab closes on what the person typed to the Director and has not sent (window.gamewrightUnsaved).

Where the Studio finds the host

The page is <base>/studio/, so the host lives at the page's path with its last /studio segment taken off: /app/studio/ gives /app (GwStudioWeb.base_of). Every call goes to the page's origin under that base with the header X-Gamewright-Studio: 1: /api/me (who is signed in), /api/games and /api/starts (the welcome and New game), POST /api/games and /api/games/<id>/open, /api/session before every reconnect, /api/claude and /api/claude/key (the key), /api/logout (the Routes above). A 401 sends the page to <base>/login?next=…; a 503 maintenance loads the page again, which the host then answers with its own notice.

What web mode changes

GwStudioWeb.is_web() is true in a browser (OS.has_feature("web"), the origin from window.location); a test forces it at a stand-in. The Studio then makes a GwStudioHosted, and the few places that differ ask it:

  • The welcome lists the person's games on the host, says who is signed in with Sign out, and has no Open a folder, no recent folders and no update check (the host serves the newest Studio). Its sample cards are the host's (/api/starts), and a card makes its copy there (POST /api/games with sample:<name>).
  • New game has no folder to choose; the host makes the game with gamewright new and the Studio opens it.
  • Opening a game: GwStudioSession.open_hosted asks the host to open it and connects GwSessionClient.connect_to_url to the socket it names: wss://gamewright-app.fly.dev/app/ws?ticket=… in production (Vercel carries no WebSocket), or /app/ws with the cookie where the host has no WS_ORIGIN. The hello is {token: "service", client, protocol}; the host puts the daemon's own token in its place. A ticket works once, for a minute, so GwSessionClient.renew asks /api/session again before each reconnect (and opens the game again when the session names another, opened in another tab, or none).
  • Reading the game: there is no folder in the browser, so GwStudioWebPack asks the core for docs.list (the game's own documents, not its template's) into the same GwStudioPack the desktop reads from disk. The manifest's and the brief's files come with it (the overview's name, what the game is, whether it was played); any other document's file is read with docs.get when it is shown (GwStudioHosted.show_text): a docs.get takes a moment on a busy core, and a game has dozens of documents. A file that changes on the host (doc.event) reads the game again.
  • The Director's key is kept by the host, sealed, and offered as "your Gamewright account" (/api/claude/key, never key.set, which a hosted daemon answers key.hosted); there is no claude mcp add command on the key card or in the "Your key" menu, since the core is not on the person's computer. Keeping one closes the person's sockets (1012): the Studio comes back through a fresh ticket, and the hello then says director.
  • Playing (2026-10-07): Play builds the game on the host, as on the desktop, and the web Studio fetches that build (GwStudioWebBundle: its bundle.json, then each file it lists, through /api/file, only inside the person's own folder) into the browser's storage, kept by the build's name so the same build is fetched once, and plays it in the middle with the same player the desktop hosts. F5 plays (the page keeps the key from reloading). The play origin (ADR-0009) cannot serve a person's private builds: its pages fetch only from themselves.
  • What only the desktop does (GwStudioShortcuts.DESKTOP_ONLY): open a folder or a recent one, export a zip, quit, open a game in a window of its own, check for an update, connect the person's own Claude Code. The web Studio offers none of them: no menu item, palette entry, sheet row or key.
  • Exporting: nor does it export: the zip goes in a folder on the person's computer. No Export button or step, and Ctrl+E (or the palette) says to export in the desktop Studio.

Everything that starts a process (GwDaemonSupervisor, gamewright new and --list run by GwStudioStart), the file dialogs, and use_this_engine stay on the desktop. Every desktop Studio scenario passes unchanged (tools/studio/check.sh).

Proving the Studio in a browser

tools/web/studio-dev-server.ts is an account-less stand-in for the host: the export at /app/studio/ with the isolation headers, the API above for one person, a real gamewright new and gamewright serve --pack per game, one-time tickets to a relay at /app/ws that swaps the hello's token (--cookie for /app/ws alone), and a key kept in memory, unchecked, that starts the cores again.

  • engine/tests/studio/net/gw_studio_hosted_test.gd: the Studio forced into web mode on the desktop against the stand-in: the welcome's games, a game opened through a ticket and read through its core (the same documents the desktop reads from the folder, a document's file read when shown, the key card with no Claude Code), a sample's card copied on the host, a key kept bringing the core back through a fresh ticket, New game made on the host, and sign-in when nobody is signed in.
  • tools/heavy.sh node tools/web/studio-check.mjs: the export in headless Chromium against the stand-in; with --real, against gamewright host itself on this machine (mail to its log, a cookie from host session, WS_ORIGIN tickets, the game's core killed to force a reconnect). Shots to read in build/web-studio-proof/.

Deploying

Nothing deploys until the owner has a Fly.io account and token, and says so (ADR-0013).

  • node tools/host/build.ts writes build/host/context/: the core bundled by esbuild into app/gamewright.cjs (run by Node, its resources under GAMEWRIGHT_ROOT=/app: schemas, library, examples, the playbook, the kits and the engine project; the host starts its daemons and probes from the same script), BUILD.json (the console's Operations page shows its commit), the web export with its twins, and a Dockerfile on Ubuntu 24.04 with Node and Godot at tools/toolchain.json's pins (checked by checksum) and Mesa and Xvfb for jobs that draw, the engine imported while the image builds (GAMEWRIGHT_ENGINE=godot, GAMEWRIGHT_ENGINE_ARGS=["--path","/app/engine"]). A job that draws (the Director's previews, L3's shots) runs through gw-draw (GAMEWRIGHT_HEAVY): Godot under a private Xvfb with llvmpipe and the Compatibility renderer, as tools/lib/render-env.sh's offscreen; the image fails to build unless a drawing job reports llvmpipe (without a display Godot draws nothing, silently, and every preview failed). The image's command is node /app/gamewright.cjs host --data /data, on PORT 8080.
  • Who runs what in the image. The host runs as root, so it can run each person's work under a uid of their own (Isolation). The app (/app: the core, schemas, library, examples, engine and web Studio), Node and Godot are root's, readable and runnable by every uid and writable by root alone (chmod -R u=rwX,go=rX, whatever modes the build context had), and no file in the image keeps a setuid or setgid bit. Daemons start Node, Godot and git themselves, and no shell but gw-draw (root's, run as the daemon's own uid; /bin/sh stays for apt and for it). Root's HOME is /root, off the volume; /data holds only host/ and users/, which the host makes with their modes. Built here, the context's core and engine verify a sample to L1 with every file of the app read-only, and its host runs a person's daemon from it.
  • tools/host/fly.toml: the app gamewright-app in iad, PUBLIC_URL=https://gamewright.diy/app, BASE_PATH=/app, WS_ORIGIN=wss://gamewright-app.fly.dev, a /healthz check, the volume gamewright_data at /data, and a performance-2x with 4 GB, as Reelwright runs (about $65 a month): the web Studio's Playtest runs there, and on shared-cpu-2x, throttled under sustained load, Saltwell's ran out of L2's 600 s budget; it passes in 342 s here.
  • tools/host/deploy.sh builds the web Studio when there is none or an engine file is newer than it, the context, then fly deploy --remote-only --ha=false.
  • The site: web/next.config.ts rewrites /app and /app/<anything> to https://gamewright-app.fly.dev/app/…, cookies, query strings and POST bodies as they are (a build may point it elsewhere with GAMEWRIGHT_APP_ORIGIN). Next would redirect /app/studio/ to /app/studio, where the page's relative index.js is no longer the Studio's, so skipTrailingSlashRedirect is on and a redirect of its own strips trailing slashes everywhere but under /app.
  • The site's link to the web Studio ("Sign in" in the header and the menu, "The Studio in a browser" in the footer, both to /app, which signs a person in and then opens the Studio) is in a site built with GAMEWRIGHT_WEB_STUDIO=live only, so no page links to a host that is not there. Set it in the Vercel project's production environment once the checks below pass, then deploy the site (tools/site/deploy.sh); remove it and deploy again to take the link down.

Once, before the first deploy: fly apps create gamewright-app, fly volumes create gamewright_data --region iad --size 10, and fly secrets set RESEND_API_KEY=… ADMIN_EMAILS=… KEY_ENCRYPTION_KEY=… (with RESEND_FROM); a Resend domain for the sign-in mail. No DNS change: gamewright.diy stays on Vercel, and the socket's address is the Fly domain.

Accepted, for now: behind the rewrite, the host's Fly-Client-IP is Vercel's address, not the person's, so the per-IP limit on sign-in codes counts everyone Vercel sends from one address together. The per-address limits and lockouts hold as they are.

After the first deploy

Before anyone is invited (all three with the owner's Fly token):

curl -s https://gamewright-app.fly.dev/healthz
fly ssh console -a gamewright-app -C "node /app/gamewright.cjs host check-isolation --data /data"
fly ssh console -a gamewright-app -C "node /app/gamewright.cjs host users list --data /data"
  • /healthz: {"ok":true,"version":"<VERSION>","daemons":0}.
  • check-isolation must print one line with "ok":true,"root":true, a "uid" of 20000 or more, and "failed":[], and exit 0. Its checks: "uid" that same number; "own folder: write" and "own folder: read" "ok"; "host folder", "database" and "another person's folder" "EACCES"; "database hard-linked" anything but "ok"; "the host's environ" and "PID 1's environ" "EACCES" or "ENOENT"; "environment" the allowlisted names only (no RESEND_API_KEY, KEY_ENCRYPTION_KEY or other secret); "file route: own file" "served"; "file route: link swapped in" and "file route: not theirs" "refused". Exit 1, or any name in failed, is a hole: let nobody in until it is fixed and the check passes. A message saying the probe did not report means a person's uid could not run the core: the image's /app or Node is not readable or runnable by others.
  • users list shows no isolation-check-… account: the check removes both of its own.

Then sign in at https://gamewright.diy/app with an address in ADMIN_EMAILS, make a game, open it in the Studio, and only then set GAMEWRIGHT_WEB_STUDIO=live for the site.

Backups and the restore drill

Everything a person has on the host lives on one volume, gamewright_data (10 GB, iad, mounted at /data): accounts, games, keys (encrypted). Fly snapshots it daily and keeps each snapshot 5 days (checked 2026-10-09: two snapshots, 286 MiB and 98 MiB stored).

fly volumes list -a gamewright-app
fly volumes snapshots list <volume id>

A longer retention (fly volumes update <volume id> --snapshot-retention 14) costs more: the owner's call.

The drill, to run with the owner before anyone depends on the host (it touches the live app):

  1. Pick a snapshot: fly volumes snapshots list <volume id>.
  2. Make a volume from it, beside the live one: fly volumes create gamewright_drill --snapshot-id <snapshot id> --region iad --size 10 -a gamewright-app.
  3. Look inside without touching the live machine: fly machine run <the app's image> --volume gamewright_drill:/data --region iad -a gamewright-app --rm --command "node /app/gamewright.cjs host users list --data /data". It must list the accounts the live host has (compare with the same command over fly ssh console).
  4. Destroy the drill's volume: fly volumes destroy <drill volume id> -a gamewright-app.
  5. A real restore swaps the volume under the machine: stop it (fly machine stop), make the volume from the snapshot as in 2 under the name gamewright_data, point the machine at it (fly machine update <machine> --mount source=<new volume id>,destination=/data), start it, check /healthz and a sign-in, and only then destroy the old volume.

From docs/design/web-studio.md in the Gamewright repository.