Agent guide

Agents are first-class authors on omibit. The same deterministic core runs the browser editor, the player, the CLI, and an MCP server — so a cart an agent builds and verifies headlessly is already a finished, shareable artifact. Nothing about the workflow is a mock: run it, hash it, prove it.

This page is written for the agent doing the work. The human-facing material is the user guide; the complete function inventory is the API reference (docs/API.md) — one table, small enough to hold in context while you work.

Remote (hosted)

The zero-install path. No checkout, no npm ci — the site hosts the MCP server for you:

  1. Open the editor and click agent in the header (or the

"build with an agent" card in the game library).

  1. Copy the connect URL it shows — https://<site>/mcp/<key>, where

<key> is a 32-hex agent key generated and kept in your browser.

  1. Hand the URL to any MCP client. Claude Code, for example:
sh
claude mcp add --transport http omibit https://<site>/mcp/<key>

The transport is streamable HTTP. Everything the key builds lands in the normal game library, tagged with that key (the library page's "agent builds" shelf lists them), so the human on that browser can play every cart the moment it verifies. Rotating the key in the same modal issues a fresh one; carts built under the old key stay listed.

Four tools, same core, remote signatures — carts are addressed by the hash build_cart returns, not by a filesystem path:

toolargumentsreturns
build_cartlua, tier? (8/16/32, default 8), name?hash, url, name, tier, created — the cart is built and published under your key
load_carthashtier, code length, section sizes
runhash, frames, input (base64), perFrameframes, final stateHash, error, a base64 PNG screenshot of the final frame
state_hashhash, framesstateHash after the last frame

The verification contract is identical to the local server: same deterministic core, same (cart bytes, input log) -> state hash map. The input log crosses the wire as base64, the same bytes as the CLI's --input file. A name collision within your key gets an auto suffix; the sanitized name is what the library shelf shows.

Quotas: the hosted endpoint is a shared service metered per agent key — run executes one run at a time per key under the same 216000-frame cap and 60-second verification deadline as the replay service. The per-key quotas bind honest use; sustained heavy traffic is additionally rate-limited per IP. If you are iterating hard, verify locally and publish once.

The local CLI below remains the tool for offline and repo workflows: no network, no key, no quotas, and screenshot access. Both paths run the same machine — a cart verified one way verifies the other.

The headless CLI

From the repo, the CLI is packages/cli (omibit on PATH when linked):

commandwhat it does
omibit run cart.png --frames N --input input.binrun headlessly, print final state hash and frame count
omibit run cart.png --fps-jsonone JSON object per frame: hash, instructions, error
omibit hash cart.png --frames Nfirst and last state hash of the sequence
omibit screenshot cart.png --frame N --out f.pngframebuffer at frame N as a PNG
omibit serveMCP server over stdio

Exit codes: 0 ok, 1 cart or runtime error, 2 usage. A runtime error in the cart stops the run and prints frame <n>: <message> — the frame number is the exact moment the machine caught it.

The input log is one byte per frame, bits the same as btn(): 1 left, 2 right, 4 up, 8 down, 16 A, 32 B. Held buttons keep their bits set in consecutive bytes. Missing bytes read as zero — a short log means "no input after this point".

Every command is tier-aware: the cart's tier byte selects the machine, no flags needed. An O16 cart (256x224 screen, 32 colors, 16x16 sprites, two map layers, tline, X/Y buttons on input bits 6-7 — 64 and 128 in the log) runs, hashes, and screenshots natively; its screenshot is 256x224 and its state hash covers 256KB of RAM. Building one headlessly is the O8 loop with O16 constructors: emptyCart16 + buildCart16 + encodeCartPng (label is 512x512 RGBA). The dialect deltas are one table in docs/API.md ("The O16 machine"); the walkthrough is docs/guide/o16.md.

An O32 cart (320x240 screen, 256 colors, ttri, a mesh bank instead of map layers — mget/mset/map error deterministically) runs, hashes, and screenshots natively the same way; its screenshot is 320x240 and its state hash covers 1MB of RAM. Build one with the O32 constructors: emptyCart32 + buildCart32 + encodeCartPng (label is 1024x1024 RGBA). The dialect deltas and the format/RAM/mesh tables are in docs/API.md ("The O32 machine"); the walkthrough is docs/guide/o32.md.

Connect an agent

Install the repository dependencies with npm ci using Node 22. Then configure your agent's local stdio MCP connection with this command:

json
{
  "mcpServers": {
    "omibit": {
      "command": "node",
      "args": [
        "--experimental-strip-types",
        "/absolute/path/to/omibit/packages/cli/src/main.ts",
        "serve"
      ]
    }
  }
}

Replace the absolute path with your checkout. Clients use different configuration containers; the command and arguments above are the connection itself. Use absolute cartridge paths in tool calls too.

The server runs locally and needs no Omibit account or API key. It provides execution and verification tools, not file editing or publishing tools. Give your agent access to the checkout through its normal filesystem/code tools: it writes Lua and assets, builds the PNG with the core constructors, then uses MCP to run, inspect screenshots, and check hashes. The worked example below demonstrates the build loop.

To publish the result, open the PNG in the web player and choose Share. Supply a name to list it in the public game library. Publishing requires the web app's publishing service; local MCP tests and PNG/HTML exports do not.

The MCP server

omibit serve speaks JSON-RPC 2.0, newline-delimited, over stdio. One message per line: batch arrays are rejected with -32600, and every request must carry "jsonrpc": "2.0" plus a string or number id. Send initialize, then tools/list, then tools/call. Four tools:

toolargumentsreturns
load_cartpathtier, code length, section sizes
runpath, frames, input (base64), perFrameframes, final stateHash, error
screenshotpath, framethe framebuffer as base64 PNG (128x128 O8, 256x224 O16, 320x240 O32)
state_hashpath, framesstateHash after the last frame

The input log crosses the wire as base64 — the same bytes as the CLI's --input file.

json
{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"run","arguments":{
  "path":"catch.png","frames":600,"input":"AAAA"
}}}

The reply carries frames, stateHash, and error as JSON. A null error with a full frame count is a clean run.

Replays and verified leaderboards

An input log IS a replay (DECISIONS O). The publish service turns that into player-facing infrastructure — every accepted replay is re-run headlessly by the server before it is stored, so unverified submissions are impossible and the state hash in the listing is the receipt:

endpointwhat it does
POST /replaysbody {cart, input (base64), frames, name?, stateHash?, seed?} — the server loads the cart, runs exactly frames frames with the log (injecting seed at the seed word if given), and stores the run only if it completes clean; a claimed stateHash that does not match is rejected
GET /replays/<carthash>the leaderboard: name, frames, stateHash, score, seed, date, id, sorted by score descending
GET /replays/<carthash>/<id>the input log bytes (base64) for playback
GET /daily{date, cart, name, seed} — the day's cart and the date-derived seed
GET /jams (or /jams/index.json on the app origin)the jam list, active first; each jam carries id, title, theme, startsAt, endsAt, seedCart, seed, rules, state
GET /jams/<id>/entriesthe ranked board: entries joined with cart meta, each with its best verified replay played with the jam seed
POST /jams/<id>/entriesbody {cart, creator?} — enter a published cart into an open jam (idempotent per cart)
  • score is the cart-defined convention documented in docs/API.md

(System): the u32le at the score word (O8 0x9500) when the run ends.

  • seed is the daily-challenge convention: the host writes the u16le

dayseed at the seed word (O8 0x9510) before frame 0; carts read peek2(0x9510) and srand it. Default 0 — corpus and plain runs are unaffected.

  • Jams use the same word: a jam definition carries its own seed, and a

jam entry ranks by its cart's best verified replay run with that seed injected. jamrun (seed cart of the maiden-voyage jam) is the reference: srand(peek2(0x9510)) in _init, score poked u32le at 0x9500 every frame. Remix it, publish, POST /jams/<id>/entries, then submit runs with the jam seed — the loop is the replay loop.

  • A run that errors is rejected, not ranked. Missing log bytes read as

zero, so a short log is "no input after this point" — the same rule as the CLI.

  • frames is capped at 216000, but the effective maximum for a verified

run is the server's 60-second verification deadline: a typical cart (~1ms/frame) verifies up to roughly 60k frames. Longer runs fail closed with a clean 400 "verification exceeded 60s".

  • For agents the loop is unchanged: build the cart, run it headlessly

with --input, then POST the exact log and frames you ran; the receipt the service returns is the same hash= the CLI printed.

The verification loop

The workflow that matters, as a loop:- Write the cart as Lua source.

  • Build it into a cart PNG with @omibit/core (buildCart +

encodeCartPng). The build is deterministic: same source in, same cart bytes out.

  • Run it: omibit run cart.png --frames N --input input.bin.
  • Read the state hash. Same cart bytes plus same input log equals the

same hash, on every machine, forever. The hash is the receipt.

  • Change the code, rebuild, rerun. The hash moving is proof the change

altered behavior; the hash staying put is proof it did not.

  • Screenshot frames you need to see. The hash proves behavior; pixels

are for the author's eye.

Treat the hash the way you treat a test assertion: an iteration is not done until you can name the hash it produces and why.

The O32 loop: mesh bank, ttri, hash receipts

The O32 machine is the same loop with one new surface — geometry lives in the mesh bank, not the map. Two patterns cover it:

  • Building: emptyCart32(code) gives zeroed sections; write faces as

24-byte records into the mesh section, or poke them from Lua at load. Face n lives at RAM 0x40000 + n * 24: three model-space vertices of i16le x, y, z (bytes 0-17), then six u8 texel coords u0..v2 (bytes 18-23). peek2 reads the i16s — unwrap with v - 0x10000 when >= 0x8000. The bank holds 21,845 faces. Textures live in the 256x1024 sheet — four 256x256 pages; page p starts at sheet byte p * 0x10000, ttri's tex argument (0-3) selects it.

  • Drawing: the console has no transform or matrix state — project in

Lua (integer, mat-free), sort far-to-near yourself, and issue one ttri per face. The guide's drawmesh listing is the whole pattern: rotate, add camera distance, divide once, snap with flr, painter-sort, draw.

The receipt is unchanged: same cart bytes plus input log equals the same hash, over the full 1MB of RAM. The o32 guide's closing paragraph pins the state hashes of its two listings, and a guard test in packages/cli re-runs both listings against the machine on every npm test — the recipe cannot silently drift from the machine.

Determinism guarantees

What the machine promises, and what you may therefore rely on:

  • (cart bytes, input log) -> frames + state hash is bit-identical on

every host, forever. Browser, Node, CLI, MCP — one core.

  • The state hash is FNV-1a-32 over the full 64KB RAM plus the frame

counter. Audio state and the input-edged draw state are inside it.

  • rnd is a seeded xorshift over RAM state. Identical carts roll

identical dice. srand(seed) makes a randomized cart reproducible.

  • The instruction budget (4M per frame) is part of the contract: an

over-budget frame fails identically everywhere, and the error is part of the state.

  • The API dialect is frozen at 1.0. A cart written today runs unchanged,

bit-exact, for the life of the machine.

The corpus is the contract

The machine's behavior is pinned by a golden corpus — cart code, input logs, expected hashes and frames — under packages/core/corpus/, run by CI on every push, in both Node and a real browser. Every API surface has an entry; coverage is listed in packages/core/corpus/COVERAGE.md.

Two rules follow for agents:

  • Corpus hashes are ground truth. If your cart disagrees with the

machine, the corpus wins; your mental model is what gets updated. The API reference documents the dialect, but the corpus pins it.

  • Never modify corpus expectations to make something pass. Post-freeze,

corpus entries are immutable by policy; pre-freeze, a behavior change requires a new entry and a regen commit, not an edit.

Worked example: an agent builds and verifies a cart

A complete session, reproducible from the repo. The cart is catch — a paddle, a falling ball, a score. Two iterations: the first has a bug the machine catches; the second verifies clean.

Step 1: write the cart

catch.lua, first attempt:

lua
-- catch: move the paddle, catch the ball
px, bx, by, bvy = 60, 64, 8, 1

function _update()
  if btn(0) then px = px - 2 end
  if btn(1) then px = px + 2 end
  px = mid(0, px, 120)
  by = by + bvy
  if by > 112 then
    if bx + 2 > px and bx < px + 8 then
      bvy = -bvy
      score = score + 10
    elseif by > 127 then
      by = 8 bvy = 1
    end
  end
  if by < 4 then bvy = 1 end
end

function _draw()
  cls(0)
  rectfill(px, 120, px + 7, 123, 11)
  circfill(bx, by, 2, 4)
  if score then print(score, 2, 2, 13) end
end

Step 2: build it

build.mjs — run everything in this example from the repo's carts/ directory, which can resolve @omibit/core:

js
import { readFile, writeFile } from 'node:fs/promises'
import { buildCart, encodeCartPng } from '@omibit/core'

const code = await readFile('catch.lua', 'utf8')
const z = (n) => new Uint8Array(n)
const cart = { code, gfx: z(8192), map: z(8192), sfx: z(4096), flags: z(128), music: z(256) }

// flat dark label; the top band is cosmetic, the data lives below it
const label = new Uint8Array(160 * 205 * 4)
for (let i = 0; i < label.length; i += 4) {
  label[i] = 0x1a; label[i + 1] = 0x1c; label[i + 2] = 0x2c; label[i + 3] = 0xff
}

const png = await encodeCartPng(buildCart(cart), label)
await writeFile('catch.png', png)
console.log('built catch.png', png.length, 'bytes')

Run it, then run the cart with no input — the ball falls straight onto the waiting paddle:

sh
node build.mjs
node ../packages/cli/src/main.ts run catch.png --frames 600

The machine catches the bug at the first catch, 104 frames in:

text
omibit: frame 104: [string "cart"]:12: attempt to perform arithmetic on a nil value (global 'score')

score is never initialized; the first bounce is the first read. The message carries the Lua line number (12) — the frame count is the machine's. Note what the error is not: not a crash, not host-dependent — a deterministic event at a deterministic frame, identical on every machine.

Step 3: fix and re-verify

One line changes:

lua
px, bx, by, bvy, score = 60, 64, 8, 1, 0

Rebuild, rerun:

text
hash=d11a2c3a frames=600

A clean 600 frames and a final state hash. That hash is now the cart's name for this input: any host that runs these cart bytes with no input for 600 frames must produce d11a2c3a or something is wrong with the host.

Step 4: exercise it with an input log

No-input runs only cover the rally path. Drive the paddle away and prove the miss path — input.mjs:

js
import { writeFile } from 'node:fs/promises'

const log = new Uint8Array(600)
for (let f = 30; f < 90; f++) log[f] = 0x01  // hold left
await writeFile('input.bin', log)
sh
node input.mjs
node ../packages/cli/src/main.ts run catch.png --frames 600 --input input.bin
text
hash=26261db0 frames=600

A different hash from the no-input run — proof the input log changed the outcome, as intended. Pin both: cart bytes plus input log plus frames plus hash is a complete behavioral specification.

Step 5: look at it

sh
node ../packages/cli/src/main.ts screenshot catch.png --frame 600 --out catch_600.png

The screenshot runs the cart without input, so it shows the rally path: paddle centered, ball mid-fall, the score counter in the corner — the pixels agree with the hash's story. (The screenshot tool takes no input log; what you are seeing is the no-input run of the same cart bytes.) Iterating further — ball speed per catch, a miss counter, sound — is the same loop: edit, build, run, compare hash, screenshot when seeing helps.

The same session over MCP

Every step above is one MCP call — for an agent that lives next to a omibit serve process, the loop never touches a shell:

json
{"method":"initialize","id":0,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"agent","version":"1"}}}
{"method":"tools/call","id":1,"params":{"name":"run","arguments":{"path":"catch.png","frames":600}}}
{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"screenshot","arguments":{"path":"catch.png","frame":600}}}

The run reply's stateHash is the same d11a2c3a — one core, every surface.

When a cart misbehaves

  • A runtime error names the frame and the Lua message. --fps-json

gives the per-frame hashes leading up to it — the divergence point is visible if you diff two runs.

  • A hash you did not expect means behavior you did not expect. Bisect:

halve the frame count until the first diverging frame, screenshot around it, read your _update.

  • Suspicious nondeterminism is always a cart bug — time-dependent logic

cannot exist (time() is frame count), and rnd is seeded. If two runs of the same cart bytes and input differ, file it against the machine, not the cart; the corpus decides who is right.

Checksums and execution failures

stateHash is the legacy name for the RAM/frame checksum, not a serialization of the Lua heap or a cryptographic proof. The service verifies a replay by re-execution. See API.md for deterministic table iteration and unsupported address-based reflection. Hosted runs are isolated with a hard 60-second deadline; local MCP calls can be cancelled even inside a slow frame. A cancelled local tool returns zero reported frames when its subprocess is terminated; no partial checksum is claimed. Browser commands have a two-second watchdog.

Persistent progress: carts may activate cartdata() and use dget(0..63) / dset(index, uint32). Browser players restore local saves before Lua starts; CLI/MCP, editor previews and verified competition start with zero save RAM. Do not compare a casual saved browser run with a clean headless run and infer a determinism bug. See guide/saving-progress.md for save identity and backups.