M12: runners, the web Studio's heavy work on your own computer
The web Studio (docs/design/web-studio.md) runs every game's core on the host: builds, playtests, the Director's previews and turns. The host is one Fly machine (performance-2x, 4 GB), stopped when idle. A playtest of Saltwell takes about 6 minutes there, and each Director turn is paid on the person's API key. A runner moves that work to a computer of the person's own. The host becomes a front door: it signs people in, lists games and relays.
Adapted from Reelwright's M15 (~/Projects/Reelwright/docs/design/m15-runners.md, read only).
Gamewright needs no voicing, render farm or encoding, so most of M15's job machinery is not
copied. What came over is listed in core/UPSTREAM.md.
Modes
- Mode b, games on the runner's disk (first).
gamewright runner --games DIRoffers the games in DIR. The web Studio lists them beside the host's own; opening one, the host relays the browser's session over the runner's socket to agamewright servethe runner starts there. A file the Studio asks for (a build's bundle, its media) comes from the runner, from inside that game's folder only. Nothing of the game rests on the host. - Director turns on the owner's Claude plan (second, M15 phase 5). A runner with Claude Code
signed in on a plan takes the Director's turns for its person, through
claude -pwith the game's tools over MCP. The plan is never used for anyone else. - Mode a, jobs for games held on the host (later). Builds and playtests of a game kept on the host, run on the runner, files moved by content hash. Only if mode b is not enough.
Who connects to whom
Everything dials the host; the runner listens on nothing.
browser ──wss /ws──▶ host ◀──wss /runner/ws── runner ──ws 127.0.0.1──▶ gamewright serve
browser ──GET /api/file──▶ host ──file.get──▶ runner (bytes back on its socket)
Pairing. Signed in, the person opens
<base>/computersand asks for a code: 8 letters from an alphabet with no look-alikes, shownABCD-EFGH, good once for 10 minutes; the host keeps only its SHA-256. On the computer:gamewright runner pair ABCD-EFGH --host https://gamewright.diy/app. The host answers a token,gwr_and 32 random bytes; it keeps only the token's SHA-256, and the runner writes it to$XDG_CONFIG_HOME/gamewright/runner.json, mode 0600. Ten wrong codes a minute from one address and that address waits.Discovery.
GET <base>/runnerwithAuthorization: Bearer gwr_…answers{ws, runnerId, name}, or 401 with why (revoked,inactive) and the runner stops for good.The socket. JSON text frames, 8 MiB at most, binary frames only for a file's bytes. The runner's first frame is
hello; the host answerswelcomeor closes:Close Why 4000 another connection of the same runner replaced this one 4002 no hello within 15 s, or a hello the host cannot take 1008 the runner was removed (revoked); it stops 1013 runners are off on this host, or maintenance The host pings every 30 s and drops a runner that missed the last pong. The runner reconnects after 1 s, doubling to 30 s, and back to 1 s on
welcome.
runner → host {"type":"hello","protocol":1,"version","name","games":[{"id","title","dir","modifiedAt"}]}
host → runner {"type":"welcome","name"}
runner → host {"type":"games","games":[…]} the list changed
host → runner {"type":"stream.open","stream","game"}
{"type":"stream.frame","stream","data"} a browser's text frame
{"type":"stream.close","stream","code"?,"reason"?}
{"type":"file.get","req","game","path","range"?}
runner → host {"type":"stream.opened","stream"}
{"type":"stream.frame","stream","data"} the core's text frame
{"type":"stream.closed","stream","code","reason"}
{"type":"file.head","req","status","size"?,"start"?,"end"?}
binary: req as 4 bytes big-endian, then up to 1 MiB of the file
{"type":"file.end","req"}
How the web Studio sees it
GET /api/gamesadds each game of the person's runners that are online:{id: "<runnerId>.<gameId>", title, modifiedAt, computer: "<runner name>", open}. A runner's id isrnand 16 hex digits. The dot never appears in a host game's id (GAME_ID), so the two cannot collide. The welcome says "On <computer>" under such a game.POST /api/games/<runnerId>.<gameId>/openmakes it the person's current game and answers the usual session body. Itswsis the host's/ws(or a ticket onWS_ORIGIN); the host relays that socket to the runner: the browser's first frame must still besession.hello, and the runner puts its core's own token in it.GET /api/sessionnames the current runner game while its runner is online. While it is offline the answer names it with nows, so the Studio's reconnect (GwStudioSession.renew) opens it again, which answers 503computer_offline("<name> is offline"), and the Studio waits and asks again. When the runner returns, the game comes back by itself.GET /api/file?path=for a path inside a runner game's folder (as its hello said it) is asked of that runner. The host sets the content type from the file name, never from the runner, addsnosniffand, for anything but pictures and sound,Content-Security-Policy: sandbox. A runner that has not answered in 60 s gets 504.- The Studio needs no change to play a runner game: it already opens games by id, renews its
socket from
/api/session, and fetches builds by the paths its core names.
Fallback. With the runner off, its games are not listed and cannot be opened; the games kept on the host work as before. A session on a runner that drops closes with 1012, and comes back by itself when the runner does.
The runner
gamewright runner --games DIR [--games DIR]… [--name NAME]. A folder with a game.json is
one game, else each folder in it that has one. A game's id is its folder's name as a slug, made
unique with -2, -3. It starts gamewright serve --pack <dir> on its loopback the first
time a game is opened, as the desktop Studio does, and stops it after 30 minutes with no
session. It runs as the person, with their environment: their key (key-store.ts), their
GAMEWRIGHT_HEAVY and the box's renderer. Spending on a runner is the person's own and the
host does not count it.
What a runner serves is closed:
- a session on a game it listed, never a command;
- files whose real path (links resolved) is inside a listed game's folder, of a kind the
Studio loads (
.json, pictures, sound,.glb/.gltf/.bin, fonts,.txt/.md); never a page, script,.pckor.wasm.
The host can do to a runner game what the person's browser can: read and change it through the session. That is the trust mode b asks for, and the reason pairing is per person.
On this box: CPU priority (proposed default)
This box is shared: 24 cores, load 40 to 200 from other projects' agents. Saltwell's playtest
took about 3 minutes here at moderate load and did not finish in 8 minutes at load 150, slower
than the host. Every Claude session here runs in its own scope under
user@1000.service/app.slice, each at the default CPUWeight=100.
Default: run the runner as a systemd user service (tools/runner/install.sh) with
CPUWeight=400. Under full load it gets about four sessions' share of the CPU, and its games'
Godot jobs inherit it. When it is idle it takes nothing. No AllowedCPUs pinning: that would
only fence the runner in, not reserve cores for it. The weight is one line in the unit
(--weight N), so the owner can lower it, or set 100 for no priority at all.
Phases
- Mode b (this design): store, pairing, discovery, socket, relay, files,
runnerCLI, the/computerspage, the user unit. Proven by a test that pairs a runner with a stand-in host and relays a realgamewright servesession and a file, then live: pair this box with gamewright.diy, open Saltwell from it instudio-check.mjs --live, play and playtest. - Director turns on the plan (landed:
core/src/director/plan.ts):GAMEWRIGHT_DIRECTOR=plan(a runner's--plansets it for its games' cores) makes each Director turn runclaude -psigned in on a Claude plan. Its only tools are the game's owngamewright mcp, less the person's decisions (approve, reject, undo, redo, lock, unlock); no shell, files, web, settings, hooks or CLAUDE.md; the Director's instructions in place of Claude Code's; noANTHROPIC_API_KEYin its environment. The stream becomes the Director's events, proposals reach the Studio through the pack's watcher, and the next turn resumes Claude Code's session. Never on the host: its people's turns stay on their own keys. - The Studio's Computers panel (Settings in the desktop and web Studio), replacing the
page; a runner inside the desktop Studio ("Let the web Studio use this computer"), so a person
without a checkout or the
gamewrightcommand on their PATH can offer their games. - Mode a, if needed.
Not yet
- No end-to-end encryption: the host can read a relayed session, as it can a hosted one.
- No backpressure on a file's bytes beyond the socket's own (a build is a few MB).
- A runner's own spending is not shown in the web Studio's status bar.