Docs Design

Contents

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 DIR offers 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 a gamewright serve the 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 -p with 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)
  1. Pairing. Signed in, the person opens <base>/computers and asks for a code: 8 letters from an alphabet with no look-alikes, shown ABCD-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.

  2. Discovery. GET <base>/runner with Authorization: Bearer gwr_… answers {ws, runnerId, name}, or 401 with why (revoked, inactive) and the runner stops for good.

  3. 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 answers welcome or 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/games adds each game of the person's runners that are online: {id: "<runnerId>.<gameId>", title, modifiedAt, computer: "<runner name>", open}. A runner's id is rn and 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>/open makes it the person's current game and answers the usual session body. Its ws is the host's /ws (or a ticket on WS_ORIGIN); the host relays that socket to the runner: the browser's first frame must still be session.hello, and the runner puts its core's own token in it.
  • GET /api/session names the current runner game while its runner is online. While it is offline the answer names it with no ws, so the Studio's reconnect (GwStudioSession.renew) opens it again, which answers 503 computer_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, adds nosniff and, 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, .pck or .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

  1. Mode b (this design): store, pairing, discovery, socket, relay, files, runner CLI, the /computers page, the user unit. Proven by a test that pairs a runner with a stand-in host and relays a real gamewright serve session and a file, then live: pair this box with gamewright.diy, open Saltwell from it in studio-check.mjs --live, play and playtest.
  2. Director turns on the plan (landed: core/src/director/plan.ts): GAMEWRIGHT_DIRECTOR=plan (a runner's --plan sets it for its games' cores) makes each Director turn run claude -p signed in on a Claude plan. Its only tools are the game's own gamewright 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; no ANTHROPIC_API_KEY in 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.
  3. 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 gamewright command on their PATH can offer their games.
  4. 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.

From docs/design/m12-runner.md in the Gamewright repository.