Skip to content

Latest commit

 

History

History
393 lines (327 loc) · 22 KB

File metadata and controls

393 lines (327 loc) · 22 KB

Writing a Game Engine plugin

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 0 when your Editor + MCP are ready, you can write an engine plugin — no in-process code required.

How an engine plugin is loaded

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.

Mapping the Editor lifecycle onto hook stages

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.

The editor_mode[scm] isolation coupling

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:

  • shared requires [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_worktree requires [scm] isolation = "worktree" — one managed Editor per worktree, run serially, each launched by your pre_worktree_setup hook.

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.

The environment a hook script reads

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.

Worked example: a minimal shared-mode Godot plugin

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 = 600

godot_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 deferred

Then 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.

Reference: the bundled Unity plugin

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 knobs install_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_worktree Editor setup/teardown, MCP agent routing, Library priming, the editor_modescm.isolation coupling 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 on BMAD_LOOP_ENGINE_MCP).
  • unity_setup.pyper_worktree Library priming, .mcp.json write, 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 the SceneAutoSaveGuard editor script (with fixed-GUID .meta files) into the project.
  • unity_quiesce.py — save + close open scenes before a rollback's git 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 limits limits.dev_stall_grace_s (idle grace before an awaiting session is nudged/stalled) and limits.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.

Preventing Editor modal-dialog stalls (Unity)

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 two dialogs, and why they open

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:

  1. "Scene '…' has been changed on disk. Reload the scene?" — pops when git or an agent rewrites the open scene's .unity file underneath the Editor. A failed-attempt rollback does exactly this: git reset --hard rewrites the tracked scene file the Editor still holds dirty in memory.
  2. "Do you want to save the changes you made…?" — pops on Editor quit with a dirty scene, e.g. when a per_worktree Editor 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.

1. SceneAutoSaveGuard (seeded editor script)

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.

2. Rollback quiesce (pre_rollback / post_rollback)

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.

3. Prompt-fact injection (pre_session)

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.

4. Detect-only dialog probe (opt-in, dialog_probe, default off)

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 Unity window 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. Override BMAD_LOOP_UNITY_DIALOG_PROBE_CLASS or 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.

Platform behavior (Linux fast paths, Windows fallbacks)

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 /proc scan to find the worktree-bound Editor/MCP-server; non-Linux falls back to the same scan over psutil, imported lazily — a core dependency on Windows, the optional non-linux extra on macOS (pip install 'bmad-loop[non-linux]') — with a clear error if missing. The hard-kill uses signal.SIGKILL where present, degrading to SIGTERM/taskkill on Windows. Liveness uses os.kill(pid, 0) on POSIX but psutil.pid_exists on Windows (where os.kill(pid, 0) would terminate the process).
  • unity_setup.py — Library priming + launch. The warm-Library copy keeps the cp -a --reflink CoW fast path on POSIX (near-free on btrfs/xfs) and falls back to shutil.copytree where cp is absent. The empty-cache symlink fallback wraps symlink_to in try/except OSError, dropping to a real per-worktree dir (cold, no cross-run cache) where symlinks need privilege (Windows). Editor detach uses start_new_session on POSIX, CREATE_NEW_PROCESS_GROUP on Windows.
  • unity_cleanup.py — temp-cache scrub. Unity's temporaryCachePath base is exactly /tmp on Linux (kept byte-for-byte); other platforms derive it from tempfile.gettempdir(). Caveat: native Windows Unity actually uses %USERPROFILE%\AppData\Local\Temp\<company>\<product>, which gettempdir() 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.