The Game Engine layer adapts the bmad-loop dev/sweep cycle to projects whose work needs a live engine Editor — e.g. a Unity project the agent drives through an Editor MCP. It is niche and opt-in: a normal project enables no engine plugin and the orchestrator behaves exactly as before.
As of the plugin-system migration, the game engine is just a plugin on the
general plugin system. There is no separate engine
machinery: an engine plugin uses the same plugin.toml manifest, the same
lifecycle hooks, and the same trust
model as any other plugin. This guide covers the engine-specific slice —
which stages an Editor binds, the editor_mode ↔ isolation coupling, and the env
a readiness/setup script reads. Read the plugin authoring
guide first for the manifest, settings, hook, and
trust fundamentals.
Unity ships bundled as the reference engine plugin
(src/bmad_loop/data/plugins/unity/). This guide is for adding another engine
(Godot, Unreal, …) — or reshaping the Unity one for your project. For wiring a
specific Editor MCP (IvanMurzak vs CoplayDev, readiness probing, the full env-var
reference), see the companion Game Engine MCP guide.
If you can write a shell/Python command that exits
0when your Editor + MCP are ready, you can write an engine plugin — no in-process code required.
Like any plugin, it's a directory with a plugin.toml (plus helper scripts),
discovered and overlaid from:
| Source | Path | Wins |
|---|---|---|
| Bundled | bmad_loop/data/plugins/<name>/plugin.toml |
base |
| Project-local | <project>/.bmad-loop/plugins/<name>/plugin.toml |
override |
A project-local plugin with the same name overrides the bundled one. The
plugin's directory is its {scripts} dir, so its manifest and helper scripts sit
together.
Enable it in .bmad-loop/policy.toml:
[plugins]
enabled = ["unity"] # or your engine's name
[plugins.unity]
editor_mode = "shared"
mcp = "ivanmurzak"Legacy
[engine]still works. A pre-migration[engine] name = "unity"block loads with a deprecation warning, folded into the[plugins]allowlist plus a[plugins.unity]table. The policy block is the only thing folded, though — project-local plugin overrides are now discovered under.bmad-loop/plugins/<name>/, so move an old.bmad-loop/engines/unity/override dir to.bmad-loop/plugins/unity/. Migrate to[plugins]when convenient.
An engine binds the orchestrator's per-story stages that surround a unit's worktree and sessions. The relevant ones (full list in the stage reference):
| Stage | shared mode | per_worktree mode |
|---|---|---|
pre_worktree_setup |
not run | per unit, right after the worktree is cut — make it a usable project + launch its Editor |
pre_ready_gate |
once, before the first session | per unit, after setup, before the agent runs — block until Editor + MCP are ready |
| (agent dev/review) | drives the operator's live Editor | drives the worktree's managed Editor |
pre_worktree_teardown |
not run | per unit, on completion and on pause/escalation — quit the Editor + clean up |
pre_rollback / post_rollback |
per failed attempt, around the rollback's git reset --hard |
same — quiesce the Editor (save + close open scenes) before the reset rewrites tracked files under it, refresh assets after |
post_run |
once, on clean finish | once, on clean finish — reclaim per-run scratch (the Unity plugin clears the MCP server's /tmp zips + truncates its editor log) |
A blocking hook at pre_ready_gate or pre_worktree_setup whose command
exits non-zero defers the unit — bmad-loop never starts a session against a
half-open Editor. pre_worktree_teardown, pre_rollback, and post_rollback are
observe-only for veto purposes (a veto can't un-tear-down or un-reset) but the
command still runs — best-effort, even when a unit pauses or escalates, so a
managed Editor never outlives its worktree and a rollback is never stalled by a
wedged Editor. The Unity plugin uses pre_rollback to save + close open scenes
before the reset, so a shared Editor holding a dirty scene never raises the
run-freezing "scene changed on disk" modal.
You can implement these as declarative [hooks.<stage>] shell commands (the
smallest thing that works), or as an in-process [python] module when you need
richer logic. The bundled Unity plugin is in-process because it also does MCP
agent routing, editor_mode↔isolation validation, and Library priming — but a
simple engine needs none of that.
A live Editor MCP can only act on the folder its Editor has open, and most engines
bind one Editor per folder and can't be repointed live. So an engine's
editor_mode setting is coupled to [scm] isolation:
sharedrequires[scm] isolation = "none"— the agent works in place on the project your warm Editor already has open. Zero relaunches, full live MCP, the Editor stays open across stories. The recommended starting point.per_worktreerequires[scm] isolation = "worktree"— one managed Editor per worktree, run serially, each launched by yourpre_worktree_setuphook.
The bundled Unity plugin enforces this coupling in its validate(policy)
(raising at startup on a mismatch — e.g. editor_mode = "per_worktree" with
isolation = "none"), and the TUI surfaces it on save. An engine plugin you write
should validate the same way (see
Plugin.validate).
Start with shared only. A new engine plugin can support just shared and a
single pre_ready_gate hook — skip setup/teardown entirely. Add per_worktree
once the in-place flow is solid.
A declarative hook receives the generic bus environment (full table in the
authoring guide) — the run/unit
identity plus BMAD_LOOP_SETTING_<KEY> for each of your [[settings]]. So a
readiness script reads its knobs from its own settings:
| Variable | Source |
|---|---|
BMAD_LOOP_WORKTREE |
the workspace/worktree the Editor opens |
BMAD_LOOP_REPO_ROOT |
main repo root |
BMAD_LOOP_STORY_KEY |
the current story key |
BMAD_LOOP_SETTING_<KEY> |
each of your plugin's settings (resolved) |
The bundled Unity plugin's in-process module additionally exports
BMAD_LOOP_ENGINE_MCP, BMAD_LOOP_ENGINE_EDITOR_MODE,
BMAD_LOOP_ENGINE_READY_TIMEOUT, BMAD_LOOP_ENGINE_READY_GRACE, and
BMAD_LOOP_UNITY_PATH for its bundled scripts (derived from its settings) — a
plugin-internal contract, not part of the generic env. The
Game Engine MCP guide tables every knob the Unity
scripts read.
The smallest useful engine plugin is a single readiness gate. Drop two files under
<project>/.bmad-loop/plugins/godot/:
plugin.toml:
[plugin]
name = "godot"
version = "1.0.0"
api_version = 1
description = "Drive a Godot project that needs a live Editor + MCP."
[[settings]]
key = "mcp_url"
type = "str"
default = "http://localhost:9000"
label = "Godot MCP URL"
help = "Where the readiness probe connects."
[[settings]]
key = "ready_timeout_sec"
type = "int"
default = 600
label = "Readiness timeout (sec)"
# Readiness gate: block until the Editor + MCP answer. A non-zero exit defers
# the unit, so a session never starts against a half-open Editor.
[hooks.pre_ready_gate]
cmd = 'python3 "{scripts}/godot_ready.py"'
blocking = true
timeout_sec = 600godot_ready.py (exit 0 when the Editor + MCP answer, non-zero otherwise):
#!/usr/bin/env python3
import os, sys, time, socket
from urllib.parse import urlparse
url = os.environ.get("BMAD_LOOP_SETTING_MCP_URL", "http://localhost:9000")
deadline = time.time() + int(os.environ.get("BMAD_LOOP_SETTING_READY_TIMEOUT_SEC", "600"))
host, port = urlparse(url).hostname, urlparse(url).port or 80
while time.time() < deadline:
try:
with socket.create_connection((host, port), timeout=2):
sys.exit(0) # ready
except OSError:
time.sleep(2)
sys.exit(1) # never came up → unit deferredThen enable it — and keep [scm] isolation = "none" (the default) for shared:
[plugins]
enabled = ["godot"]That's a complete engine plugin. To give each unit its own Editor, add
[hooks.pre_worktree_setup] + [hooks.pre_worktree_teardown] and switch
[scm] isolation = "worktree" — see the MCP guide for the per-worktree
port-isolation and seeding mechanics. If you need the editor_mode↔isolation
validation or MCP agent routing the Unity plugin does, reach for a [python]
module (see the authoring guide).
A declarative engine plugin activates as soon as its folder is present (the declarative trust tier). For an engine that's usually what you want. If you'd rather require explicit opt-in via
[plugins] enabled, give the plugin a[python]module — that's trust-gated and won't run until listed. The bundled Unity plugin is in-process for exactly this reason.
The canonical example lives at src/bmad_loop/data/plugins/unity/:
plugin.toml— a[python]module + twelve[[settings]](editor_mode,mcp,unity_path,ready_timeout_sec,ready_grace_sec, plus the modal-dialog knobsinstall_scene_guard,scene_guard_dir,quiesce_on_rollback,quiesce_timeout_sec,dialog_probe,dialog_probe_interval_sec,dialog_probe_notify) +seed_globs = [".claude/skills/*"].unity_plugin.py— the in-process brain: the readiness gate (on_pre_ready_gate),per_worktreeEditor setup/teardown, MCP agent routing, Library priming, theeditor_mode↔scm.isolationcoupling validation, and the modal-dialog defense (scene-guard seeding, rollback quiesce, prompt-fact injection, and the detached detect-only probe — see the next section).unity_ready.py— readiness gate script (branches onBMAD_LOOP_ENGINE_MCP).unity_setup.py—per_worktreeLibrary priming,.mcp.jsonwrite, Custom-mode pin, and Editor launch.unity_teardown.py— Editor quit + MCP-server reap + symlink-Library cleanup + dialog-probe reap.unity_seed_assets.py+unity_assets/— seed theSceneAutoSaveGuardeditor script (with fixed-GUID.metafiles) into the project.unity_quiesce.py— save + close open scenes before a rollback'sgit reset --hard, refresh assets after.unity_facts.md— the scene-save discipline appended to every dev/review prompt.unity_dialog_probe.py— the detached, detect-only modal-dialog watcher (X11 only, opt-in).
Tuning long PlayMode dev sessions. The readiness knobs above (
ready_timeout_sec/ready_grace_sec) gate Editor startup, not dev-session completion. A story whose dev session waits on a long PlayMode run or a slow test is kept alive instead by the core limitslimits.dev_stall_grace_s(idle grace before an awaiting session is nudged/stalled) andlimits.dev_stall_nudges(wake-nudges spent on grace expiry before it is called stalled). The grace window measures genuine inactivity — pane output re-arms it — so raise these (not the readiness knobs) if networked/PlayMode-heavy stories are being mis-stalled.limits.dev_stall_nudges_cap(default 6) additionally bounds the total nudges a session may ever receive — a very long wait needing more wake cycles than that should raise the cap too (a stalled-but-finished session is still rescued post-kill when its terminal artifact is on disk).
Each script's module docstring documents every env knob it reads — the authoritative source if a default ever changes. The Game Engine MCP guide distills those into a single reference table and explains the IvanMurzak vs CoplayDev differences.
A live Unity Editor can raise a modal dialog — a window that blocks
EditorApplication.update, the pump the Unity-MCP plugin uses to dispatch every
tool call. While it is up, all MCP calls time out and the run wedges. The bundled
Unity plugin defends against the two that a bmad-loop run can trip, in depth,
so no single failure lets one appear. All four layers are best-effort and none can
block or veto the run.
The root cause is that Unity-MCP GameObject tools
(com.ivanmurzak.unity.mcp) call MarkSceneDirty but never save, so a
project driven by the shared Editor accumulates a chronically dirty open scene.
That dirty state is what makes the Editor raise:
- "Scene '…' has been changed on disk. Reload the scene?" — pops when git or
an agent rewrites the open scene's
.unityfile underneath the Editor. A failed-attempt rollback does exactly this:git reset --hardrewrites the tracked scene file the Editor still holds dirty in memory. - "Do you want to save the changes you made…?" — pops on Editor quit with a
dirty scene, e.g. when a
per_worktreeEditor is torn down.
Clean scenes are not safe from dialog 1. Live verification on Unity 6.5.2 showed the "changed on disk" dialog can pop for a clean open scene too — and even after dismissal the Editor could stay hard-wedged. Keeping scenes saved (layer 1) is therefore necessary but not sufficient on the rollback path; the quiesce's scene-close (layer 2), which leaves no tracked scene open during the reset, is the real protection there.
unity_seed_assets.py copies an editor-only C# guard —
Assets/BmadLoop/Editor/SceneAutoSaveGuard.cs plus its asmdef, with
pre-generated fixed-GUID .meta files — into the project, so the Editor's very
first import already sees it (seeded at pre_worktree_setup in per_worktree
mode, at pre_ready_gate in shared mode where setup never runs). The guard fixes
the root cause: it debounce-saves any loaded, on-disk scene ~5 s after it goes
dirty, and on quit it saves pathed scenes (discarding an unsaveable untitled
scene by swapping in a fresh empty one, since saving it would itself pop a "Save
As" modal). The quit hook is EditorApplication.wantsToQuit; a hard
EditorApplication.Exit bypasses it (along with every other quit handler). An operator can toggle it live from the Editor's BmadLoop menu
("Scene Auto-Save Guard" on/off, "Save Open Scenes Now"); the toggle persists in
EditorPrefs, default on.
Seeding is idempotent + version-aware (a bmad-loop-scene-guard-version header
gates reinstall), never rewrites a file it didn't ship, and a missing Assets/
tree is a graceful skip. Because it happens pre-baseline, the run's rollback
never treats the guard as a created-this-unit file and never reclaims it — and
story-finalize's git add -A commits the seeded guard into the consumer
project. That is intended: the guard travels with the repo, so any Editor that
later opens the project is protected. Disable with install_scene_guard = false;
relocate with scene_guard_dir.
The seeded guard cannot help a scene that goes dirty during a failed attempt
that is about to be rolled back, so the plugin also quiesces the Editor around
the reset. At pre_rollback (before verify.safe_rollback runs git reset --hard) unity_quiesce.py saves every open scene, then opens a fresh empty
untitled scene so no tracked .unity file is open when the reset rewrites it —
closing the "changed on disk" dialog's window before it can open. At
post_rollback it refreshes assets so the Editor drops its stale in-memory copies
and re-imports the reverted tree. The first call doubles as a wedge probe: if
the Editor is already unresponsive it fails fast and the quiesce is skipped, at the
cost of one call timeout rather than the whole budget. Every call carries both the
CLI's own --timeout and a subprocess-level kill, and the plugin hard-kills the
whole helper past quiesce_timeout_sec — so a wedged Editor can never stall the
rollback. The quiesce logs to stderr and writes no journal events of its own —
the rollback's journal marker remains rollback-auto. Disable with
quiesce_on_rollback = false.
At pre_session the plugin appends the scene-save discipline (shipped as
unity_facts.md) to every non-empty dev/review prompt, so the agent itself saves
dirty scenes at the boundaries that would otherwise trip a modal — a prompt-only
nudge that complements the automatic guard. A project-local plugin-dir copy of
unity_facts.md overrides the shipped text.
The last-resort observability net for a modal the first three layers didn't
prevent. When dialog_probe = true, the plugin launches a detached
unity_dialog_probe.py (shared mode: at pre_run; per_worktree: per unit at
pre_worktree_setup) that polls xdotool for a visible Unity-owned window whose
title matches a known dialog phrase and, on a fresh detection, reports it and
nothing more — a JSONL record (<run_dir>/unity-dialog-probe.jsonl), an
ATTENTION line, and a best-effort notify-send. It never clicks, keys, or
closes any window; clicking a Unity dialog blind could discard work. It is
X11/Linux only (a no-op where DISPLAY is unset or xdotool is absent, so
Windows/macOS fall straight through), self-reaps when the engine pid dies, and is
reaped at post_run / worktree teardown via a pid-file handle plus a /proc
argv-scan backstop. Tune with dialog_probe_interval_sec and
dialog_probe_notify.
The exact dialog titles and the
Unitywindow class are unverified against a live Linux Editor build and kept broad on purpose; detection is advisory, so a miss degrades to the pre-existing behavior (a human notices the frozen run), never worse. OverrideBMAD_LOOP_UNITY_DIALOG_PROBE_CLASSor the phrase list in a project-local plugin copy if your build differs. Detection is verified on XWayland — but synthetic input (xdotool/ydotool) is unreliable there, one more reason the probe only reports: dismissing a detected dialog must stay a human action.
The Unity plugin's helper scripts are stdlib-only and run identically on Linux,
macOS, and WSL (which is Linux — it takes every fast path unchanged). Each
POSIX-only primitive is guarded behind a sys.platform branch so a future
native-Windows multiplexer backend can slot in; those Windows branches are
best-effort and not yet exercised (no Windows backend ships today). The
guards, by script:
unity_teardown.py— process discovery. Linux uses a zero-dependency/procscan to find the worktree-bound Editor/MCP-server; non-Linux falls back to the same scan overpsutil, imported lazily — a core dependency on Windows, the optionalnon-linuxextra on macOS (pip install 'bmad-loop[non-linux]') — with a clear error if missing. The hard-kill usessignal.SIGKILLwhere present, degrading toSIGTERM/taskkillon Windows. Liveness usesos.kill(pid, 0)on POSIX butpsutil.pid_existson Windows (whereos.kill(pid, 0)would terminate the process).unity_setup.py— Library priming + launch. The warm-Library copy keeps thecp -a --reflinkCoW fast path on POSIX (near-free on btrfs/xfs) and falls back toshutil.copytreewherecpis absent. The empty-cache symlink fallback wrapssymlink_tointry/except OSError, dropping to a real per-worktree dir (cold, no cross-run cache) where symlinks need privilege (Windows). Editor detach usesstart_new_sessionon POSIX,CREATE_NEW_PROCESS_GROUPon Windows.unity_cleanup.py— temp-cache scrub. Unity'stemporaryCachePathbase is exactly/tmpon Linux (kept byte-for-byte); other platforms derive it fromtempfile.gettempdir(). Caveat: native Windows Unity actually uses%USERPROFILE%\AppData\Local\Temp\<company>\<product>, whichgettempdir()does not always resolve to — getting that cache root exactly right is a documented follow-up for when a Windows backend lands.
When authoring your own engine plugin, mirror this discipline: stdlib-only scripts,
optional extras imported lazily, and every POSIX-ism behind a sys.platform branch
with a # portability: comment. See the plugin authoring guide
for the general rule.