Skip to content
Work in progress. These docs describe Spawnite at launch, and some parts are still being built.

Dev server

A game’s Vite dev server answers a few routes of its own beside the page, under /__, from @spawnite/dev-server: one Hono app that a game’s Vite config adds as a plugin beside the engine’s. The page posts to them. Everything they save lands in the game’s .spawnite/ folder, which is git-ignored, where the agent’s tools and hooks read it, except /__map, which writes the map editor’s strokes into the game’s own map file.

Roblox Studio, Unity and Unreal keep the editor and the game in one process, so a panel that wants a file writes it, and Godot’s editor talks to a running game over its own debugger protocol. A Spawnite game runs in a browser with no editor, and its agent runs in a terminal, so the dev server is the one process both can reach: the page over HTTP, the agent over the folder.

A game’s Vite config mounts the routes with devServer(), beside the engine’s spawnite():

// vite.config.mts
import { defineConfig } from "vite";
import { spawnite } from "@spawnite/engine/vite";
import { devServer } from "@spawnite/dev-server/vite";
export default defineConfig({ plugins: [spawnite(), devServer()] });

spawnite create writes both into a new game’s config and installs @spawnite/dev-server. A game under the repository’s games/ imports the plugin by its source path, ../../packages/dev-server/src/vite.ts, and resolves the server’s modules through the @spawnite/source condition under ssr.resolve.conditions, as games/example does. Without the plugin, Vite answers each /__ request with the game’s page or an empty 404: the devtools’ map editor says the server has no map endpoint and keeps its edits, and the agent’s tools find no server.

Route Body Reply
POST /__shots { png }: a PNG data URL, up to 16 MB { file }: its path under .spawnite/shots/
POST /__chat { text, shots, attachments }: markdown, the files /__shots answered, and the rows { id, label } it carries { file }: its path under .spawnite/chat/
GET /__tab none, with the server’s token { tabs }: each open tab of the game
POST /__tab { expression, timeoutMilliseconds }, with the server’s token { tab, value }: the value, awaited
GET /__clips none, from the dev server’s own page { clips, maxSeconds, refusal }: each clip the game keeps, newest first, as { name, folder, meta }, meta being its clip.json; the clip limit in seconds; and why a new clip would be refused, once the game keeps its keep clips
POST /__clips a form, from the dev server’s own page: meta, the measured clip as JSON; video, the MP4; sheet; and frames, the stills the clip’s clip.json, named for the local time it started, with the replay window’s events and its saved name, or a 409 once the game keeps its keep clips
DELETE /__clips/<name> none, from the dev server’s own page { removed }: the clip’s folder and its saved replay window gone
GET /__map?map=<name> none, from the dev server’s own page { terrain, revision }: the map file’s terrain block, null where no brush edited it, and the map’s revision, 0 before its first write, which every POST is made against
POST /__map { map, revision } with one of stroke, fill, restore, settle, material, restoreMaterial, props or sea, from the dev server’s own page { file, revision, change, terrain }, revision being the map’s after the write: the stroke, the fill, the undo or the settled base written under terrain in src/maps/<map>.json; for material, one entry of the map’s materials written over the file’s field by field, and for restoreMaterial an undo’s entry written back whole, null taking it out, each answering { file, revision, materials }; for props, each named prop written into the map’s props and each null one taken out, answering { file, revision, removed } with the names it took out; for sea, the map’s sea height in metres written, answering { file, revision, sea }

A refused body answers { error } with a 400 that names the field, and an unknown /__ route a 404. /__clips and /__map answer only a request whose Sec-Fetch-Site is same-origin, which a browser sends from the page itself and no other site can forge, and a 409 to any write made against another revision than the map’s, which every write counts; the map editor page describes the writer. /__chat takes a shot only from the game’s own .spawnite/shots/, since the agent reads each one back as an image. Vite’s own routes start with /@, so nothing overlaps.

<game>/.spawnite/
shots/ every screenshot any tool saved: the page's, the playtest's
chat/ one JSON file a message, unread
chat/read/ the same file once an agent has read it
servers/ <port>.json for each dev server that listens: { url, token, pid }
clips/<name>/ a recorded video clip, named by the local time it started
clip.mp4 H.264, written once the clip stops
sheet.webp the stills as one contact sheet, 6 to a row, each labelled
frames/ up to 30 stills, 768 px wide, evenly spaced: 00.webp, 01.webp
clip.json the clip's size and length, each still's moment, the replay's
events in its window, the saved replay window and warnings

A clip’s video is served by Vite as any file under the game’s folder, /.spawnite/clips/<name>/clip.mp4, with range requests, so the Clips section plays it with no route of its own. The page holds a clip in memory and saves it whole as it stops, writing clip.json last, so a folder without one is no clip, and a page closed mid-clip saves nothing. Every time in clip.json is milliseconds from the clip’s first frame.

A file is written under a temporary name and renamed into place, so a reader never sees half of one. A file’s name is the moment it was written, so a listing sorts oldest first, and a message and its screenshot carry names a few milliseconds apart.

A message on disk is an MCP tool result, the shape every MCP client already reads, validated with the SDK’s own schema: a text block holding the markdown, a resource_link block per screenshot with a file:// URI and image/png, and structuredContent with the game’s folder name, when it was sent and the rows it carries. The agent’s read_chat returns it almost as it is, with each linked screenshot read into an image block.

Four ways, from the cheapest up:

  • Hooks, with no setup. In this repository, .claude/settings.json runs spawnite chat pending on three Claude Code events; the Spawnite plugin’s own hooks do the same in any game folder. On Stop, when the agent finishes a turn, an unread message blocks the stop and becomes what the agent works on next. On UserPromptSubmit it rides beside what the person typed. On PostToolUse it rides beside a tool’s result, so a message sent mid-task lands within seconds. With nothing unread the hook prints nothing and costs a shell glob. The hook reads the games under the session’s own folder, so each worktree’s dev server talks to its own session, and it skips a subagent’s call, so a worker never takes a message meant for the session.
  • read_chat, for an agent without the plugin: the unread messages of every game in the session, marked read, and wait: true holds the call until one lands, up to 110 seconds or the timeoutSeconds given. An agent told to listen calls it that way over and over, so a message sent while it would otherwise sit idle lands at once.
  • spawnite chat list, for a person: what is pending, with --all for what was read.
  • A channel push, the moment a message lands, for a Claude Code session started with --dangerously-load-development-channels server:spawnite. The flag is Claude Code’s own gate on every custom channel; without it the message waits for the hooks.

Unread and read share one cursor, the folder a file sits in, so the hooks, read_chat and the channel never hand the agent the same message twice.

start_playtest attaches the agent’s playtest tools to the tab the creator already has open, so the agent sees and plays what the creator sees, and starts a headless page of its own only when no tab is open. The dev server writes the client that carries those tools into each page it serves.

The client joins over Vite’s own hot-reload socket and says the tab is there, where it points, whether it shows, and whether an automation drives it, and says so again on a focus, a change of visibility and a reconnect. Each tab has an id of its own, which a reload keeps, so an attached agent stays on its tab through an edit’s reload; a tab the browser duplicates gets a new one. POST /__tab runs a JavaScript expression in the tab the creator looked at last, a shown one before a hidden one, and answers its value, awaited; the playtest tools send the same functions they run in the headless page. A page an automation drives, such as the headless playtest’s, is never picked.

The creator plays live, so a call that steps the world pauses it for its steps and hands it back running, and a world the creator paused stays paused. A hidden tab draws no frames, so a call that steps or shoots refuses one and asks for it to be brought to the front; a read, such as the tree or the dump, reaches it anyway. A shot of a tab is its canvas, without the page’s HTML, and a tab keeps its size and its page, so a shot there takes no size and no map.

The creator sees the agent at work: the devtools’ Spawnite toggle, top left, lights green while it is attached, its tooltip names each call it runs, and the console keeps a line for each call that acts, as the devtools page says.

Any page the browser opens can reach localhost, so /__tab answers only a caller that sends the token the server wrote to .spawnite/servers/<port>.json, which only its owner can read, in x-spawnite-token. The file goes when the server closes, and a reader removes one whose process is gone.

A route module is a function of the server’s context, the game’s root folder and a way to warn, that returns a Hono app, with its own body limit and its own validator. The app in src/app.ts mounts each module under its path in one line, and the tests under test/routes/ run each module against a temp folder with no server. The engine’s replay routes still live in its own plugin and move here next.