Gamebrew is a Unity MCP that plugs AI agents into your game-dev loop as hands-on playtesters. They drive a running Unity Editor — walk the player, orbit in to inspect a prop, capture what they see, read live scene state, run tests — so bugs and rough edges surface as you develop. One loopback HTTP port. No cloud, no account, no SDK lock-in.
Unity Editor ⇄ MCP ⇄ your agent — brew a playtester out of any Unity 6 project.
| 🚶 Move & navigate | walk the player, NavMesh-path to any object by name |
| 👁 Look & orbit | aim the camera, orbit a free camera around a prop to inspect it |
| 📸 See | capture the game view to a PNG the agent then Reads |
| 🔍 Inspect | dump any live component's private fields to JSON; read the scene tree |
| ⌨️ Interact | send Input-System keyboard / mouse in Play Mode |
| 🧪 Test | run EditMode tests, gate on compilation |
| 🧩 Extend | add your own play.* verbs — auto-allowlisted, one case away |
| 🎟️ Share safely | a lease dispatcher lets several agents take turns on the one Editor |
Two halves: the Unity Editor bridge, and a Node MCP server your agent talks to.
1 · Unity — drop unity-package/ into your project, install the two
UPM packages (Input System + Newtonsoft JSON), then Tools → Unity Bridge → Start.
2 · MCP
cd mcp && npm installPoint your MCP client (Claude, Cursor, …) at mcp/src/index.mjs — config below.
3 · Drive it — enter Play Mode, then:
cd mcp
./browse.sh my-agent play.navTo '{"target":"Environment/Player","standoff":2}'
./browse.sh my-agent play.orbitView '{"target":"Environment/Player","pitch":18,"yaw":45,"distance":2.5}'
./browse.sh my-agent capture shot # → Read the printed PNG path to SEE itYour agent is now looking around your game.
- Self-hosted & tiny — ~20 Editor C# files + one Node server. No cloud service, no account, loopback only.
- Agent-native — speaks MCP, so it drops straight into Claude, Cursor, or any MCP client as
bridge_*tools. - Multi-agent by design — a cooperative lease lets several agents share one Editor without desyncing the world.
- Safe by default — a default-deny allowlist keeps browsing agents to gameplay + inspection; lifecycle stays with you.
- Yours to extend — game-specific verbs are one
caseaway, and the movement layer sits behind clean, subclass-able seams.
Driving Gamebrew yourself? Read AGENTS.md — the effective-use playbook, written
in agent-imperative style. The three rules that matter most:
- See by capturing and Reading the PNG; trust
dumpover screenshots for anything numeric — a dim frame lies, live component fields don't. - Orbit props to judge them. One head-on
capturehides the silhouette — andplay.orbitViewwritesLogs/orbit.png(a free camera), not what a plaincapturereads. Use theorbitverb. - Take turns and release. Acquire the lease, drive, release — never sit on it.
The full loop, copy-paste command patterns, and a symptom→fix table are in AGENTS.md.
Gamebrew is unopinionated about your agent stack — but here's a structure that works well for AI-assisted playtesting. It's how the tool was dogfooded: an orchestrator plus two browse specialists found real gameplay + visual bugs in a running game.
| Role | Drives the bridge? | Job |
|---|---|---|
| Orchestrator | directly, :8787 |
Owns lifecycle — enter/exit Play Mode, load scenes, run tests, apply fixes — and synthesizes + verifies findings. Never lets a browsing agent do lifecycle. |
| Playtester agent | lease window, :8788 |
Evaluates gameplay logic · legibility · edges: can a new player tell what to do? do interactions read? any soft-locks or exploits? |
| Visual / tech-art agent | lease window, :8788 |
Evaluates silhouettes · materials · lighting · HUD against your art bible — orbits props, dumps material state. |
| Implementer agent(s) | no | Fix what's confirmed (edit code); the orchestrator re-verifies with a fresh browse. |
The loop
- The orchestrator enters Play Mode and dispatches the browse specialists — one lease turn each (one game world = one driver at a time).
- Each agent runs the browse loop:
acquire → navigate / orbit / capture / dump → report → release. - The orchestrator verifies every finding against the code or capture before acting — agents can be wrong (in dogfooding, an orbit refuted a "plain table" verdict that came from a bad head-on angle). Confirm, don't trust.
- Implementers fix what's confirmed; the orchestrator recompiles and re-browses to prove the fix.
- Feel is the human's. Agents settle understandable and correct; "does it look / feel good" routes to you.
Start small — one orchestrator + one playtester is already useful; add the visual agent when art
matters, and scale to a full crew for a deep eval. Per-agent driving rules are in
AGENTS.md.
Two halves. The Unity Editor side hosts an HTTP listener; the Node side speaks MCP to your agent client and optionally serializes multiple agents through a lease dispatcher.
┌─────────────┐ MCP/stdio ┌───────────────────────┐
│ AI agent │ ─────────────▶ │ Gamebrew MCP server │ (mcp/src/index.mjs)
│ (Claude, │ │ bridge_* tools │
│ Cursor, …) │ ◀───────────── │ │
└─────────────┘ └───────────┬───────────┘
│ POST { command, args }
▼
http://127.0.0.1:8787/ ← Editor HTTP bridge
┌───────────────────────┐ (unity-package/…/BridgeServer.cs)
│ Unity Editor (Play) │
│ CommandRouter switch │ ── drives ──▶ your running game
└───────────────────────┘
── Multiple agents? put the lease DISPATCHER in front of 8787 ──────────────────────────
agent A ─┐ ┌──────────────────────┐
agent B ─┼── POST /lease, /cmd ─▶ │ dispatcher :8788 │ ─ only the lease HOLDER ─▶ :8787
agent C ─┘ (take turns) │ lease + queue + │
│ default-deny allow │
└──────────────────────┘
- Editor bridge (
:8787) — aHttpListenerbound to127.0.0.1. ACommandRouterdispatches{command, args}to coordinators (scene, gameobject, component, play-mode input, navigation, orbit camera, capture, component dump, tests). - MCP server (
mcp/src/index.mjs) — exposes the bridge asbridge_*MCP tools; POSTs a bare{command,args}body to:8787. This is the orchestrator / lifecycle path. - Lease dispatcher (
mcp/src/dispatcher.mjs,:8788) — optional, additive. Sits in front of:8787and serializes turns so parallel agents don't interleave and desync the one shared world. Enforces a default-deny browse allowlist. mcp/browse.sh— a thin shell wrapper an agent uses to acquire/drive/release a lease.
-
Copy the whole
unity-package/into your project (e.g.Assets/Gamebrew/), keeping theEditor/andRuntime/subfolders intact. Unity regenerates the.metafiles. Both assemblies (Gamebrew.Bridge.Editor,Gamebrew.Bridge.Runtime) are self-contained — the Editor asmdef references the Runtime asmdef by name, which resolves once both folders are present. -
Requirements (UPM packages) — install both via Window → Package Manager:
- Input System —
com.unity.inputsystem(assemblyUnity.InputSystem). Referenced by both assemblies; drivesplay.sendKey/play.mouse*and the input relay. Set Edit → Project Settings → Player → Active Input Handling to Input System (or Both). - Newtonsoft Json —
com.unity.nuget.newtonsoft-json(Newtonsoft.Json.dll). Used by the Editor assembly's JSON command envelope. (The Runtime assembly needs neither Newtonsoft nor any other package.)
The built-in AI (NavMesh) module (
UnityEngine.AIModule) is used byplay.navTofor path planning and needs no package. Baking a runtime NavMesh is your game's job — see theBridgeNavMeshBakerseam in Status. - Input System —
-
Wire the movement seams (optional, only if you use
play.move/play.navTo/play.aimAt). The play-driver verbs locate game-agnostic abstract components —BridgeLocomotor,BridgeCameraRig,BridgeNavMeshBaker(inRuntime/BridgeSeams.cs) — viaFindAnyObjectByType. Subclass each on your player / camera / navmesh baker and forward to your own controllers (a 3-line adapter each; example inBridgeSeams.cs). If a seam is absent, the matching verb returns a clean"No <seam> in the scene"error — it never throws, and every other verb still works. -
In Unity: Tools → Unity Bridge → Start. The listener comes up on
http://127.0.0.1:8787/. It survives domain reloads (remembers your Start/Stop intent inSessionState).
cd mcp
npm installRegister the server with your MCP client (Cursor .cursor/mcp.json, Claude Code MCP config, …):
{
"mcpServers": {
"gamebrew-bridge": {
"command": "node",
"args": ["/absolute/path/to/Gamebrew/mcp/src/index.mjs"]
}
}
}Restart / reconnect the client after config changes. If the client shows a stale tool count
after you add tools, regenerate descriptors with npm run export-mcp-tools and toggle the
server off/on.
| Variable | Default | Purpose |
|---|---|---|
UNITY_BRIDGE_URL |
http://127.0.0.1:8787/ |
Editor bridge endpoint (also read by the dispatcher). |
GAMEBREW_BRIDGE_URL |
— | Legacy alias for UNITY_BRIDGE_URL. |
UNITY_BRIDGE_CONTROL_FILE |
.unity-bridge/control.json |
Start/stop the bridge when HTTP is down (Editor polls this file). |
UNITY_BRIDGE_MENU |
Tools → Unity Bridge → Start |
Shown in error messages. |
DISPATCHER_PORT |
8788 |
Lease dispatcher port. |
DISPATCHER_LEASE_TTL |
90 (seconds) |
Idle auto-release timeout. |
GAMEBREW_BROWSE_RUN |
default |
Per-session nonce so two parallel runs of the same agent id don't fight over one lease. |
The bridge cannot stream video. An agent sees the world by capturing a still and Reading the PNG; it reads exact state with a component dump. A typical single-agent loop:
cd mcp
# 1. ACQUIRE the lease (only granted if free; otherwise you're queued)
./browse.sh my-agent acquire
# 2. NAVIGATE / LOOK (play.* verbs; require the Editor to already be in Play Mode)
./browse.sh my-agent play.navTo '{"target":"Environment/Player","standoff":2}'
./browse.sh my-agent play.orbitView '{"target":"Environment/Player","pitch":18,"yaw":45,"distance":2.5}'
# 3. CAPTURE → then Read the printed path to SEE the frame
./browse.sh my-agent capture before # → Logs/browse-my-agent-before.png
# 4. DUMP live state as JSON (beats squinting at a low-contrast capture)
./browse.sh my-agent dump Environment/Player Rigidbody
# 5. RELEASE promptly when done — do NOT sit on the lease
./browse.sh my-agent releaseThere are two different cameras and it trips people up:
play.orbitViewmoves a free inspection camera and writes its own frame toLogs/orbit.png(a fixed path). It does not move the player rig.capture(editor.captureGameView) reads whatever the player / main game camera currently sees, to the path you name.
So after an orbitView, Read Logs/orbit.png to see the orbit result — a plain
capture will show the player's view, which orbitView never moved. To judge a prop's
silhouette, orbit it from a few yaws (yaw:0 front, yaw:60 3/4, yaw:120 side) and Read
each Logs/orbit.png. One head-on navTo capture is not enough to say a prop "reads".
- Loopback only. The Editor bridge binds
127.0.0.1:8787; the dispatcher binds127.0.0.1:8788. Nothing listens on a routable interface. - No authentication. This is a local dev tool. Reflection can invoke component methods — run it only on projects you trust.
- Default-deny browse allowlist. The dispatcher's
/cmdforwards a command only if it is aplay.*verb (whole namespace) or an exact member of a small read/inspect/perceive set. Everything else — Play-Mode toggle, scene load/save, tests, structural mutation, killing the bridge — is rejected without reaching the Editor. (Seemcp/src/browseAllowlist.mjs.) - Orchestrator owns lifecycle. Doctrine: the orchestrator drives Play enter/exit, scene
I/O, tests, compiles, and structural mutation on the direct
:8787path. Agents get lease windows for gameplay only on:8788. An agent physically cannot enter Play Mode or mutate the scene graph — so ask the orchestrator to enter Play Mode before your window. - The lease is COOPERATIVE, not enforced. The dispatcher gives advisory turn-taking +
observability, not access control.
:8787stays open, so a client that talks to it directly bypasses the lease entirely. The lease works only because everyone agrees to go through:8788. The orchestrator mustGET :8788/statusand not drive while an agent holds the lease. Do not repoint the MCP client'sUNITY_BRIDGE_URLat:8788— the client POSTs a bare body to/, which the dispatcher doesn't route (it only knows/lease/*,/cmd,/status); keep the MCP path on:8787.
- Acquire before driving; you're queued if it's held.
- Drive, then release promptly — never hold more than 1–2 minutes.
- Idle leases auto-reclaim at
DISPATCHER_LEASE_TTL(90s) and pass to the next agent. - Two-phase promotion: on release the next queued agent is reserved and must re-
acquirewithin ~12s to confirm, so a dead agent can't block the queue for the full TTL. - Same agent id in two parallel runs → give each a distinct
GAMEBREW_BROWSE_RUNnonce.
Validate the whole lease/allowlist machine with the self-contained test (spins up the dispatcher + a stub Editor, asserts turn-taking + allowlist):
cd mcp && npm run test:dispatcher # → "37 passed, 0 failed"<target> is a scene path like Environment/Player. play.* verbs require Play Mode.
| Command | Args | Purpose | Browse-safe? |
|---|---|---|---|
ping |
{} |
liveness / Unity version | ✅ |
editor.getBridgeStatus |
{} |
bridge health | ✅ |
editor.getPlayMode / editor.setPlayMode |
{} / {playing} |
read / set Play Mode | read ✅ / set 🚫 |
editor.captureGameView |
{path} |
write a PNG of the game camera | ✅ |
editor.getConsoleLogs |
{level?, count?} |
recent console output | ✅ |
editor.executeMenuItem |
{path} |
run a menu item | 🚫 |
editor.stopBridge |
{} |
stop the listener | 🚫 |
scene.getHierarchy |
{} |
active scene tree | ✅ |
scene.open / scene.save |
{path} / {} |
scene I/O | 🚫 |
gameobject.find / gameobject.get |
{name} / {path} |
read a GameObject | ✅ |
gameobject.create / .rename / .reparent / .setActive |
varies | mutate the scene graph | 🚫 |
component.add / .setProperty / .getProperty / .callMethod |
varies | mutate / invoke components | 🚫 |
debug.dumpComponent |
{type} or {path, component} |
live component fields → JSON | ✅ |
play.move / play.moveTo |
varies | move the player | ✅ |
play.navTo / play.navTo.status |
{target, standoff?, …} / {} |
NavMesh-walk the player to an object | ✅ |
play.viewObject |
{target, standoff?} |
frame an object from the player's eye | ✅ |
play.orbitView |
{target, pitch, yaw, distance} |
orbit a free camera → Logs/orbit.png |
✅ |
play.aimAt / play.aimAtObject / play.setLook |
varies | aim / look | ✅ |
play.sendKey |
{key, action?, shift?, …} |
Input-System keyboard in Play Mode | ✅ |
play.mouseLook / play.mouseButton |
varies | mouse look / click | ✅ |
test.spawnWall / test.despawnWall |
varies | test obstacles | 🚫 |
editor.runTests / editor.getTestResults / editor.waitForCompile |
varies | EditMode tests / compile gate | 🚫 (orchestrator) |
New play.* verbs are auto-allowed by the dispatcher — you do not touch the allowlist.
To add one:
- Write a static coordinator in the Editor assembly (e.g.
MyGamePlayCoordinator.cs). - Add a
case "play.myVerb": return MyGamePlayCoordinator.Do(args);inCommandRouter.Execute(there is a clearly-marked extension point where the game verbs used to be). - Optionally add a matching
bridge_*MCP tool inmcp/src/index.mjs.
A minimal, self-contained worked example (a single play.example.* verb) lives in
examples/custom-verb/ — copy the pattern for adding your own play.* verbs.
Gamebrew/
├── README.md
├── LICENSE ← MIT
├── assets/gamebrew-logo.svg
├── unity-package/ ← drop this whole folder into your project (keep both subfolders)
│ ├── Editor/Bridge/ ← the Editor-only assembly (Gamebrew.Bridge.Editor)
│ │ ├── Gamebrew.Bridge.Editor.asmdef ← refs Gamebrew.Bridge.Runtime by name
│ │ ├── BridgeServer.cs ← HttpListener on 127.0.0.1:8787 + Tools menu
│ │ ├── CommandRouter.cs ← the command switch (+ game-verb extension point)
│ │ ├── MainThreadDispatcher.cs, CompileCoordinator.cs, ConsoleLogBuffer.cs …
│ │ ├── GameObjectResolver.cs, ComponentResolver.cs, JsonCoercion.cs
│ │ ├── DebugDumpCoordinator.cs (debug.dumpComponent), GameViewCapture.cs
│ │ ├── Play{Move,Nav,Orbit,View,Look}Coordinator.cs, PlayModeInputCoordinator.cs
│ │ └── EditorPlayModeCoordinator.cs, Test{Run,Obstacle}Coordinator.cs
│ └── Runtime/ ← the companion runtime assembly (Gamebrew.Bridge.Runtime)
│ ├── Gamebrew.Bridge.Runtime.asmdef ← refs Unity.InputSystem; all platforms
│ ├── BridgeInputRelay.cs ← generic Play-Mode keyboard injector (play.sendKey)
│ ├── BridgeSimTime.cs ← generic deterministic-dt seam (#if UNITY_EDITOR)
│ └── BridgeSeams.cs ← BridgeLocomotor / BridgeCameraRig / BridgeNavMeshBaker
├── mcp/
│ ├── package.json ← "gamebrew-mcp"
│ ├── browse.sh ← agent lease wrapper
│ ├── src/index.mjs ← MCP stdio server (bridge_* tools)
│ ├── src/dispatcher.mjs ← lease dispatcher on :8788
│ ├── src/browseAllowlist.mjs ← default-deny browse-safe command set
│ ├── src/playModeUtils.mjs ← enter/exit Play Mode helpers
│ └── scripts/{test-dispatcher,export-mcp-tool-descriptors}.mjs
└── examples/
└── custom-verb/ ← "how a game adds its own play verbs" (worked example)
├── README.md
└── ExampleVerbCoordinator.cs
Snapshot. Gamebrew was extracted from a real Unity game. The
unity-package/is now self-contained — an Editor assembly plus its bundledGamebrew.Bridge.Runtime— and every referenced symbol resolves to the package, UnityEngine/UnityEditor, Newtonsoft.Json,System.*, or the Input System, so it's expected to compile on drop-in once the two UPM packages above are present. The remaining game hooks below are optional and markedTODO(decouple)in-source.
Decoupled and generic: the whole transport (HttpListener, CommandRouter, main-thread
dispatch), scene/gameobject/component verbs, debug.dumpComponent, all play.* navigation /
orbit / view / look coordinators, capture, tests, the console buffer — and the entire Node
half (MCP server, lease dispatcher, allowlist, browse wrapper). The bundled runtime assembly
holds the three game-agnostic pieces the Editor coordinators need: BridgeInputRelay (keyboard
injection), BridgeSimTime (deterministic dt), and the BridgeLocomotor / BridgeCameraRig /
BridgeNavMeshBaker seams. The Node lease + allowlist logic passes its self-test 37/37.
| Marker / seam | What it is | What you do |
|---|---|---|
Runtime/BridgeSeams.cs |
Abstract MonoBehaviour seams standing in for a game's player controller / camera rig / navmesh baker. The play-driver verbs invoke them directly. | Subclass each on your player / camera / baker (3-line adapters) only if you use play.move / play.navTo / play.aimAt / test.spawnWall. Absent seam → clean error, no crash. |
CommandRouter.cs (extension point) |
The origin game's own play.* verb cases were removed; a marked extension point remains. |
Add your own play.* cases here (see examples/). |
CommandRouter.cs (time.advance) |
Drove a game clock. Body stubbed to return an error. | Wire to your own time system, or delete the case. |
CommandRouter.cs (perceive.aimHit / perceive.locate) |
Relied on a game perception/interaction helper. Bodies stubbed. | Port a small perception helper, or delete the cases. |
CommandRouter.cs (editor.getPlaytestReport, playtest.clear) |
Read a game acceptance-test log. Bodies stubbed. | Wire to your own test log, or delete the cases. |
PlayNavCoordinator.cs |
Default nav standoff was a game interaction range; hardcoded to 3.25f * 0.9f. |
Repoint at your own range if you have one. |
ComponentResolver.cs & DebugDumpCoordinator.cs |
Simple-name type resolution preferred a game namespace; replaced with a PreferredNamespacePrefix const defaulting to "" (off). |
Set it to your game's root namespace for the convenience, or leave empty. |