playcal
Integrates with the Unity Editor via Unity's official CLI to run editor commands, wait for compile and domain reload settling, verify scene changes, inspect hierarchies, run Play Mode checks, capture logs and screenshots, and support Unity SDK workflows such as frame recording and scene diffs.
playcal
One open-source toolkit for AI agents that build games. It gives your agent a loop it can trust (edit → wait until the editor has really settled → check the change actually landed → look only at what changed → assert), and it checks bot playtests against real players: "the bot finds level 12 easy, but people will pass it about 41% of the time (CI 30–53%)."
Unity first, built on top of Unity's official CLI. Godot works too (headless, through a probe script). Unreal is postponed, see the table below.
AI agents: read llms.txt first.
Status: pre-alpha (v0.1.0). Today the event schema,
ingest,report,calibrate,logs,loop,query,snap,observe,replay,baseline(perf and memory),visual,tests,affected,lock,meta,checkpoint,doctor, the MCP server and the Unity SDK work;loop,query,snap,observe,replayand the SDK frame recorder were checked on a live Unity 6000.3 Editor. Everything below marked planned isn't built yet. I'd rather show a short honest list than a long fake one. Progress is tracked in the roadmap.

A real run on a live Unity 6000.3 Editor: the text is what playcal printed, and the waits are sped up (their real length is shown).
Why
Agents working in a game editor fail in boring, repeatable ways:
they run a tool before compile or domain reload has finished, then reason about stale state;
the editor isn't focused, so Play Mode sits on frame 1 while reporting "playing";
a modal dialog blocks everything and nobody notices;
they report "done" when the prefab was never saved;
a single console read or hierarchy dump eats 10k+ tokens.
Unity's official CLI and the community MCP servers give you the building blocks: play, test, capture, console. Nothing ties them into a loop that handles these failures. playcal is that loop, plus scene diffs that survive an Editor restart, perf and screenshot gates, flaky-test quarantine, and calibration against human players. See the benchmark for what the loop does and doesn't buy you on a live Editor.
Related MCP server: Godot Safe Change MCP
Install
Requires Python 3.10+.
pipx install playcal
playcal modulesFor the latest main, use pipx install git+https://github.com/RizgarOzan/playcal. The Unity SDK installs from a git URL, see Unity SDK.
Install with your AI agent
Paste this into Claude Code, Cursor, Codex or any agent with a shell:
Install playcal for this project:
1. Make sure pipx exists (python -m pip install --user pipx && python -m pipx ensurepath).
2. Run: pipx install playcal
3. Run: playcal modules --json and show me the module list.
4. Read https://github.com/RizgarOzan/playcal#usage and tell me which commands fit this project.
Do not change project files yet.MCP server and skill
playcal mcp is a stdio MCP server. Every module becomes a tool (playcal_ingest, playcal_logs, playcal_loop, ...), built from the same argument definitions as the CLI, so the two can't drift apart.
The server's initialize reply carries a short "which command when" guide as MCP instructions, the same lines that open the skill file, so an agent knows to run doctor first and to put Editor writes through loop.
# or pick it from the MCP Registry as io.github.RizgarOzan/playcal
claude mcp add --transport stdio playcal -- playcal mcp
code --add-mcp '{"name":"playcal","type":"stdio","command":"playcal","args":["mcp"]}'If your agent has a shell, a skill file is cheaper than MCP (no tool list in every prompt):
mkdir -p .claude/skills/playcal && playcal skill > .claude/skills/playcal/SKILL.mdOr get both at once. In Claude Code, the plugin adds the skill and the MCP server (it starts the server with pipx run playcal mcp):
/plugin marketplace add RizgarOzan/playcal
/plugin install playcal@playcalFor other agents, skills.sh installs the same skill file:
npx skills add RizgarOzan/playcalplaycal skill --project . writes .claude/skills/playcal/SKILL.md into a game project. It spots the engine (Unity: Assets/ + ProjectSettings/, Godot: project.godot) and puts the red gate first: the settle and observe commands that must pass before a change counts as done. In a test, a fresh Claude Code session given only "add a print to main.gd" in the Godot sample loaded the skill and ran loop settle --engine godot without being asked (it checked the print with plain Godot instead of observe).
Usage
Every command can print --json, so agents can read the result without scraping text.
playcal ingest (works today)
This command validates gameplay events against schema/event-v0.json. It accepts JSONL files, or free text that contains PC0: blobs. The in-game "send result" button produces those blobs, and players can paste them into any chat.
playcal ingest sessions.jsonl --json
playcal ingest whatsapp-export.txt --pasteValid events go to accepted.jsonl. Every line that fails goes to rejected.jsonl with the reason.
The schema doesn't accept unknown fields, so names, emails, device IDs, location and IP can't slip in. See PRIVACY.md.
playcal report (works today)
Per-level pass rates, counted two ways: per player and per attempt. Each rate comes with a 95% Wilson interval, because with 10 players a single "60%" means very little.
playcal report accepted.jsonl
playcal report accepted.jsonl --jsonDifferent game_versions stay separate, and levels sort naturally (1, 2, 10).
playcal calibrate (works today)
Bots can play every level on every build, but you want to know how people will do. calibrate takes the bot runs plus a small human panel that played some of the levels, learns how bot pass rates map to human ones, and predicts the human rate for every level the bots played, each with a 95% interval.
playcal calibrate --human panel.jsonl --bot bots.jsonl
# version level bot predicted human human seen
# 1 03 50% 35% [21%-52%] 36% n=25 *
# 1 06 67% 50% [33%-66%] -
# leave-one-out: 5/5 human rates inside their interval, median error 4 points.Both files use the same event schema. It fits human logits to bot logits per level with a random-effects regression, so levels that the bots get wrong in their own way make every interval wider instead of quietly pulling the line. You need humans on at least 3 levels (5+ attempts each, --min-attempts). From 4 levels on it also leaves each one out in turn and checks whether its real rate lands in the predicted interval; that line tells you how far to trust the rest. With a handful of levels the intervals are wide, and that's the honest answer.
Give it the difficulty you designed for and it becomes a gate. --targets targets.json holds a pass-rate band per level, like {"4": [40, 60]}. Any level whose predicted rate falls outside its band prints level 4: predicted 25%, target 40%-60% and the command exits 1, so CI can stop a build that made a level too hard. playcal report --targets does the same with the measured attempt pass rate.
playcal logs (works today)
Boils a Unity Editor.log (or Player.log, or pasted console text) down to what an agent needs. The same error a thousand times becomes one entry with count: 1000, and the first cause is pulled out on its own, since later errors are usually fallout.
playcal logs --json # this machine's Editor.log
playcal logs Editor.log --since "Reloading assemblies"
cat Player.log | playcal logs -
adb logcat -d | playcal logs - # Android: only the Unity tag is readExit code 1 means it found errors. In CI it reads a batch-mode build log the same way, so the job fails on the first cause instead of a wall of fallout:
- run: unity -batchmode -quit -projectPath . -executeMethod Build.Run -logFile build.log || true
- run: pipx run playcal logs build.log --jsonplaycal loop (works on a live Editor)
loop settle waits until the Editor is really ready: two "ready" polls in a row, because a domain reload can flicker. While Unity compiles or reloads, unity status shows no Editor at all, so settle asks unity pipeline list why: Safe Mode, Unity running but stuck on a dialog (editor_blocked), or really no Editor. Two open Editors are reported too, instead of timing out on all of them.
loop run checks the Editor is ready, runs one unity command, checks again, and can check its own work. Each check is one editor status call, which says outright whether the Editor is compiling or reloading; settle's polls are the fallback for a busy or silent Editor and for a write that starts a reload:
playcal loop run "gameobject create" --verify "scene get_hierarchy" --expect "result.roots.*.name=Probe" --diff --json -- --name Probe--verify runs a read command after the write, --expect asserts on its JSON (path=value, path!=value, path~text, or a bare path for "exists"; * matches any item), and --diff shows what changed between the read before and after. The digest says fault: "infra" when the Editor or CLI broke and fault: "task" when the command itself was wrong, so an agent knows whether to retry or rethink. A write that reported success but didn't land fails with verify_failed (exit 5).
Two traps we hit on a live Editor (Unity 6000.3, Pipeline 0.10.0-exp.1):
An Editor with compile errors still says "ready".
--compilealso runsunity recompileand fails withcompile_error(exit 6) and the errors asfile,line,code,message.Some commands start a reload a few seconds after they return (
package resolvesaysrequiresRecompile). loop sees that and waits for the reload to start before it trusts "ready" again;--reload-wait SECONDSforces the same wait for anything else, like a script written to disk.
Read, write and --expect passed against that Editor. The Safe Mode branch itself hasn't been seen live yet.
playcal query (works today)
A full hierarchy dump can be tens of thousands of tokens when the agent needs three names. query keeps only what you ask for and says how much it cut:
playcal query --unity "scene get_hierarchy" --from "result.roots.**" --where "name~Enemy" --select name --select activeSelf --json
playcal query scene.json --from data.objects --where "tag=Enemy" --select name--where uses the same syntax as loop --expect. * is any item of a list, ** is anything at any depth.
playcal checkpoint (works today)
Saves the project's files before an agent step and brings any save back. Saves are git commits on a private ref (refs/playcal/checkpoints), so your branch, HEAD and staging area don't move and nothing is pushed. .gitignore is respected.
playcal checkpoint save "before level 4 tweak"
playcal checkpoint list
playcal checkpoint diff latest
playcal checkpoint restore ~1 # one save back; prints how to undo the restoreRestore only rewrites files that differ, so Unity doesn't reimport everything. With loop run --checkpoint, every write is preceded by a save and a failed digest carries an undo command. Unity only writes scenes and prefabs to disk when they're saved, so unsaved Editor changes aren't in a checkpoint.
playcal snap (works today)
Saves a read under a name and later tells you what changed, so "what did this run do to the scene" is a few lines instead of two dumps:
playcal snap save before --unity "scene get_hierarchy"
# ... the agent works, or you play a round ...
playcal snap diff before --unity "scene get_hierarchy" # exit 1 when something changed
playcal snap diff run-a run-b --jsonUnity's instanceIds only hold within one Editor session, so a plain diff across a restart or into play mode says everything was removed and re-added. snap leaves instanceId out and matches objects by hierarchyPath, then path, guid, name; two siblings with the same name become Enemy and Enemy#2. isDirty is left out too, since it flips on every edit. Any JSON file works, not only Unity reads. Saves go to .playcal/snaps/.
On a live Editor we saved a scene, added an object, and the diff said added: result.roots[hierarchyPath=/SnapProbe]. Then we saved, restarted Unity and diffed again: no changes, even though every instanceId was new and Unity had handed one object's old id to another object.
playcal observe (works on a live Editor)
Presses Play, lets the game run a few seconds, and says what happened in one digest: the console errors logged during the run (collapsed and with the first cause, like logs), what changed in the scene (with snap's rules), and any --expect checks on the scene while it plays. Only a red run takes a screenshot.
playcal observe --seconds 5 --expect "result.roots.*.name=Player"
# errors (task)
# LogError: boss has no target at Assets/Boss.cs:13
# scene: 1 added, 0 removed, 0 changed
# played: 5.0-19.4 s before the second read
# shot: C:\MyGame\.playcal\observe\observe.pngExit 0 is clean, 1 is red (console errors, a failed check, a read that failed, Play Mode that never started), and 3 or 4 is the Editor itself (same codes as loop). Exit 3 with reason console_unread means the console couldn't be read, or was cleared or overflowed during the run, so errors could have slipped past; observe won't call that clean. The console mark is taken before Play starts, so errors from earlier don't count. The scene diff is only reported, since a running game is supposed to change; gate on it with --expect. --read CMD diffs another read instead of scene get_hierarchy, --no-play diffs two reads --seconds apart without touching Play Mode, and an Editor that's already playing or paused is watched and left as it was. Play Mode that observe started is always stopped again, even when the run fails halfway. played is how long the game ran before the second read: at least the first number (counted from when the Editor said it was playing), at most the second (counted from the start request).
On a live Editor (Unity 6000.3, Pipeline 0.10.0-exp.1) a clean observe --seconds 3 takes about 33 s. The reload isn't what costs: editor.playmode start only comes back once it's done. What costs is the CLI itself, 3–5 s for every unity call, whatever it does. So observe makes 7 calls where it used to make 16: the reads before and after Play go through one job batch call each, and it asks editor status, which says "playing" right away, instead of polling unity status, which can list no Editor for several seconds after that. The first version took about 70 s and let the game run ~26 s when asked for 3. Then I added a script that spawns from a missing prefab: observe reported the ArgumentException with its file and line, 1 added in the scene, and saved a real Game view screenshot, in 45 s. Not seen live yet: a project with "Enter Play Mode Options" set to skip the reload, and a console that overflows during the run (the digest says dropped when it does).
playcal playtest (works on a live Editor or Godot)
One green run doesn't tell you a bug is gone; a flaky null ref passes one run in three. playtest runs observe several times (default 3) and prints a checklist: each --expect row and "no console errors", with pass, fail and skip counts. Every run red is red (exit 1), some red is inconclusive (exit 7), all green is clean. Runs that broke for infra reasons (no Editor) count as skip. --events bot.jsonl --level 4 writes each run as one PC0 attempt, so playcal calibrate --bot bot.jsonl and playcal report read it. playcal observe --repeat N does the same thing.
playcal playtest --engine godot --project-path . --expect "Player/hp=3" --events .playcal/bot.jsonl --level 4playcal audio check (offline, Unity or Godot)
A streamed UI click starts late and a 3-minute track set to Decompress On Load eats memory. audio check reads each clip's import settings (.meta in Unity, .import in Godot) and checks them against a folder policy, first matching rule wins. WAV files are also measured with the stdlib only: integrated loudness in LUFS (BS.1770), true peak in dBTP and clipped samples. Any problem is exit 1 with the file and the rule. The default policy keeps */UI/* and */SFX/* in memory (UI also mono), streams */Music/* and wants peaks under -1 dBTP; --policy FILE replaces it and can add a "lufs": [lo, hi] band per rule.
playcal audio check --project-path .
# Assets/UI/click.wav: load type Streaming, */UI/* wants DecompressOnLoad (short UI sounds should sit in memory; a streamed click starts late)playcal assets check (offline, Unity or Godot)
The texture side of the same idea. It reads each image's import settings (.meta or .import) and its real size from the PNG or JPEG header, then checks them against a folder policy with the same shape as audio check: texture type, mipmaps, Read/Write, compression and max size. It reads the default platform settings, not per-platform overrides. The default policy wants */UI/* as sprites without mipmaps, and everything else compressed, not readable and capped at 4096.
playcal assets check --project-path .
# Assets/UI/button.png: mipmaps on, */UI/* wants mipmaps off (UI draws at screen size; mipmaps add a third more memory and blur it)playcal replay (works on a live Editor)
Record a session's input once, then play it back after every change and see whether the game still does the same thing. The game reads input through the SDK and marks what should come out the same:
float move = PlaycalInput.Axis("move.x", Input.GetAxis("Horizontal")); // live value, or the recorded one during a replay
if (PlaycalInput.Button("jump", Input.GetButton("Jump"))) Jump();
PlaycalReplay.Mark("score", score); // compared frame by frameplaycal replay record jump-test --frames 600 # press Play, record 600 frames, leave Play
playcal replay run jump-test # replay with the same frame rate and seed, compare the marks
# diverged
# frame 150: expected x=0.181579992 at 150, got x=0.150087655 at 150
playcal replay run jump-test --stop-at 120 # pause on frame 120 and stay in Play Mode
playcal replay step --frames 5 # advance the paused Editor 5 frames
playcal replay check jump-test # compare the last result again, e.g. with --toleranceThe SDK picks the request up from .playcal/replays/request when Play starts, so nothing in the scene has to change. While recording or replaying it sets Time.captureFramerate to the recording's fps, so deltaTime is the same every frame, and seeds UnityEngine.Random (a System.Random you create yourself isn't touched). Exit 0 is a match, 1 is a divergence, a replay that ended early, console errors during the run, or no_marks (nothing was marked, so nothing was compared); 3 is console_unread, as in observe. no_sdk means the request was never read. Whole-number marks must match exactly; --tolerance only loosens decimal ones. Read input in Update, not FixedUpdate, since frames are what's recorded.
On a live Editor (Unity 6000.3, Pipeline 0.10.0-exp.1) I recorded 300 frames from a probe script whose "live" input came from the wall clock, so only the recording could reproduce it. Its marks were a moved value, Random.value and the position of a Rigidbody ball rolling on a box. Two replays matched the recording exactly, even with --tolerance 0, physics included. With one input changed at frame 150, the run said diverged at frame 150. --stop-at 120 left the Editor paused on replay frame 120, and step --frames 5 moved it to 125. With the same changes as observe, a 120-frame record takes about 42 s and a run about 37 s, most of it the 3–5 s each unity call costs. Not checked yet: a person playing on a keyboard, replays across an Editor restart or on another machine, a player build, scenes with many bodies or joints, and the new Input System (route its values through PlaycalInput the same way). Since deltaTime is fixed, a machine that can't keep up runs the recording in slow motion instead of dropping frames.
playcal baseline (works today on JSON)
Keeps a perf run as the baseline and fails a later run only when it is slower by more than noise. Feed it per-frame samples, {"frame_ms": [...], "gc_alloc_bytes": [...]} or a bare list of frame times:
playcal baseline save level4 run-main.json
playcal baseline check level4 run-branch.json # exit 1 only when slower
playcal baseline check level4 run.json --percentile 99 --tolerance 5 --floor frame_ms=0.5It compares p95 by default and bootstraps the difference. The check fails only when even the low end of the 95% interval is past the tolerance (10% by default). A short run with a couple of hitches comes out inconclusive, so one noisy CI machine doesn't turn the build red. --floor ignores slowdowns that are too small to matter in absolute units. Baselines live in .playcal/baselines/. The Unity SDK's PlaycalFrames.Begin("level4") records the samples, and Save() writes them where baseline can read them. Its used_memory_bytes series goes through the same check, so a run that needs more memory fails the same way; add --floor used_memory_bytes=1048576 to ignore growth under 1 MB. The visual gate is playcal visual, below.
playcal tests (works today)
Keeps a history of test results and tells a flaky failure from a real one. It reads what Unity's -testResults writes (NUnit3 XML) and JUnit XML:
playcal tests check TestResults.xml # exit 1 only on failures of tests that aren't flaky
playcal tests flaky # tests that passed and failed on the same commitA test counts as flaky once it has both passed and failed on one commit, so the first rerun that passes is what marks it. After that its failures show up as quarantined instead of turning the run red. They're still listed, so nobody forgets them. check judges the run against the history first and then adds it (--no-record skips that). The history is .playcal/tests/history.jsonl; keep it in CI's cache or commit it. A flaky test stays quarantined on later commits too, until you fix it and drop its lines from the history.
affected says which test assemblies a change can break, so a small change doesn't have to wait for the whole suite:
playcal tests affected # uncommitted work; --base main for a branch
# -runTests -assemblyNames "Game.UI.Tests"It reads the project's .asmdef and .asmref files (names and GUID: references), puts each changed script in the assembly of the nearest asmdef above it, and follows references outward to the test assemblies. Scripts with no asmdef go to Assembly-CSharp or Assembly-CSharp-Editor. A scene, prefab, setting or Packages/manifest.json change can't be traced through code, so it says so and asks for a full run. Packages referenced with file: from outside the project aren't scanned.
playcal visual (works today on PNG files)
A screenshot gate. Save a shot as the baseline, then check later shots against it:
playcal visual save level4-start shot.png
playcal visual check level4-start shot.png # exit 1 if more than 0.1% of pixels changed
# changed: 4096 pixels (0.1975%) differ, box [900, 300, 963, 363], see .playcal/visual/level4-start.diff.pngA pixel counts as changed when one of its channels moves by more than --tolerance (default 8 out of 255), so antialiasing and compression noise pass while a missing sprite doesn't. --max-changed sets how many percent of pixels may change. On a fail it writes a diff PNG with the changed pixels in red over a pale copy of the new shot, and the box tells an agent where to look. It reads 8- and 16-bit PNGs (grey, RGB, RGBA, no interlace) with the standard library only, and compares 16-bit shots at 8 bits (the high byte of each sample); a 1920x1080 shot takes about 2 s. Take shots at a fixed resolution, with animations paused, or the gate measures the animation. A part that changes on its own, like a clock or an FPS counter, can be skipped with --ignore x0,y0,x1,y1 (repeat it for more boxes).
playcal ui (works today)
Every loop, observe, replay and visual check run adds one line to .playcal/history.jsonl. playcal ui draws that file as one static HTML page, newest run first:
playcal ui --open # writes .playcal/ui.html and opens it
playcal ui --last 20 # only the newest 20 runsEach row shows what ran, the verdict, compile and console errors as file:line, failed checks, the scene diff, the checkpoint to undo to, and the screenshot or visual diff image. The page has no server and no scripts. Set PLAYCAL_HISTORY to write the history somewhere else, or to off to write nothing.
Godot (works today, headless)
No plugin goes into the project. playcal runs godot --headless with its own probe script, so each call is one short run. Put Godot on PATH or set PLAYCAL_GODOT to the binary.
playcal loop settle --engine godot --project-path my_game # loads every .gd; a parse error is compile_error (exit 6) with file:line
playcal observe --engine godot --project-path my_game # runs the main scene 3 s; push_error and script errors come back as file:line, exit 1
playcal snap save before --godot --project-path my_game # node tree of the main scene, keyed by node pathThere is no live editor to send commands to, so loop run is Unity only: edit the files, then settle. Headless runs draw nothing, so observe takes no screenshot on Godot. Checked on Godot 4.7.2 on Windows.
playcal meta (works today)
An agent that moves Player.cs with mv leaves Player.cs.meta behind. Unity gives the script a new GUID on the next import and every scene that used it shows "Missing script". meta reads the project files, no Editor needed, and catches that before the import:
playcal meta --project . # exit 1 on errors; --json for the full listErrors are things that will break: two .meta files with one GUID, an asset moved without its .meta, a .meta with no GUID, and a scene or prefab pointing at a script GUID nothing has. Warnings are new files without a .meta yet, leftover .meta files, case mismatches, and MonoBehaviour classes whose name isn't the file name. Script GUIDs from packages are read from Library/PackageCache; on a fresh clone without it, missing scripts are only warnings.
playcal lock (works today)
When two agents, or an agent and a person, work on one project, they shouldn't both edit Level4.unity. lock takes a lock file under .playcal/locks/:
playcal lock take Assets/Scenes/Level4.unity --owner agent-a --ttl 30m # exit 1 and who holds it, if taken
playcal lock check Assets/Scenes/Level4.unity --owner agent-b
playcal lock release Assets/Scenes/Level4.unity --owner agent-a
playcal lock listTaking several paths is all or nothing. Locks expire, so a crashed agent can't hold a scene forever, and taking your own lock again renews it. PLAYCAL_OWNER saves typing --owner. A lock only works if every agent asks before it writes; it doesn't stop Unity or git from writing the file. If you use Git Bash, pass Unity paths without a leading / (or set MSYS_NO_PATHCONV=1), since Git Bash rewrites /SnapProbe into a Windows path.
playcal doctor (works today)
Run it first when something won't start. It checks Python, git, the Unity CLI, each Editor the CLI can see (running, stuck on a dialog, in Safe Mode) and, with --project, whether the SDK is in Packages/manifest.json. It exits 1 if any check fails; --json prints the checks as a list.
playcal doctor --project C:/Games/MyGame
# ok unity cli: 1.0.0-beta.12
# fail editor C:\Games\MyGame: running but not answering; usually a modal dialog is open in the EditorUnity SDK (works today)
Install from git URL https://github.com/RizgarOzan/playcal.git?path=/unity/com.playcal.sdk, then call PlaycalLog.Init(), log.LevelStart("04"), log.LevelFail(...). The "send result" button calls PlaycalLog.CopyResult(). Usage is in the package README. A test checks that the C# output passes the same schema as the Python side.
For perf runs, PlaycalFrames.Begin("level4") records frame time, GC bytes allocated per frame and total used memory (through ProfilerRecorder) and skips the first 30 frames. Call Save() at the end and pass the file to playcal baseline. GC numbers measured in the Editor include the Editor's own allocations (about 15 KB per frame in our test scene), so take baselines from a player build.
For playcal replay, read input through PlaycalInput.Axis / PlaycalInput.Button and note results with PlaycalReplay.Mark; the replay section has the details.
For the visual gate, PlaycalShots.Capture("level4-start") saves a screenshot to persistentDataPath/playcal/shots/. In a batch-mode test run it renders Camera.main instead, since the screen capture never fires there.
Roadmap
Module | What it does | State |
| event schema, validation, paste blobs | ✅ works |
| per-level pass rate, attempts, Wilson CI | ✅ works |
Unity SDK | level events, PC0 "send result" line, delete-my-data, under-18 off | ✅ works |
| edit → settle (compile/reload, focus, modal) → verify write → diff → assert, one JSON digest | ✅ read, write and |
| bot metrics + small human panel → predicted human pass rate with CI | ✅ works on attempt pass rates; not yet checked against a real panel |
| pick, filter and project fields from any JSON result (file, stdin or a Unity read command) | ✅ works on JSON and on live |
| every agent step is a git-backed, revertible checkpoint | ✅ works ( |
| console/Editor.log summary: dedupe, first cause, error kinds | ✅ works |
| MCP server and SKILL.md generated from the module registry | ✅ works |
| save a read, diff two saves with session ids left out | ✅ works, checked across an Editor restart |
| whole-scene runtime diff, screenshots only on red | ✅ works, checked on a live Unity 6000.3 Editor (errors, scene diff, screenshot) |
| visual, perf (p95 frame time, GC) and memory gates per commit | ✅ perf and memory gates, the SDK recorder, and the visual gate as |
| frame stepping, recorded input, replay → regression test | ✅ works, checked on a live Unity 6000.3 Editor (record, match, divergence, stop and step); not yet with a person playing or across restarts |
| flaky history and quarantine, affected tests | ✅ history, flaky detection, quarantine (NUnit3, JUnit) and affected test assemblies from the .asmdef graph |
| scene/asset locks for several agents at once | ✅ works (advisory lock files with expiry) |
| .meta/GUID damage from files moved outside the Editor | ✅ works (offline, reads files only) |
| check the setup before a run | ✅ works |
| texture import settings against a folder policy | ✅ works (offline, Unity |
| audio import settings against a folder policy, WAV loudness and peaks | ✅ works (offline, Unity |
Godot | same core, new backend | ✅ |
Unreal | same core, through Epic's editor MCP server (127.0.0.1:8000/mcp) | postponed: I don't have an Unreal install to check it against, and I won't ship an adapter I haven't run. A PR from someone who has one is welcome |
Every row is one module. A module is a Python entry point in the playcal.modules group, with optional Editor C#, its own tests and its own benchmark scenario. New features arrive as new modules, not as new tools to install.
Benchmark
bench/live.py runs one write against a real, open Editor in four states, two ways: a naive agent that sends the plain unity command and trusts success, and playcal loop run with a read-back check. "Landed" is read back from the Editor afterwards, not taken from either side's word. Unity 6000.3.9f1, Pipeline 0.10.0-exp.1, Windows 11, 3 rounds:
Editor state | naive landed | playcal landed | false successes (either) | median time naive / playcal |
idle | 3/3 | 3/3 | 0 | 2.2 s / 7.4 s |
mid domain reload | 0/3 | 3/3 | 0 | 2.2 s / 33.6 s (19–41 s, the reload itself) |
in Play Mode (write refused) | 0/3 | 0/3 | 0 | 3.9 s / 28.0 s |
bad value (string into a float) | 0/3 | 0/3 | 0 | 4.3 s / 5.4 s |
What this says, plainly:
A write sent while Unity reloads is dropped by the plain CLI and lands through playcal. The reload window was really hit in 6 of 6 runs.
Unity's CLI is stricter than I expected. It refused Play Mode writes and bad values loudly, so neither side reported a single false success here. playcal's digest does add which kind of failure it was (
fault: taskfor both of those), so an agent knows not to just retry.playcal is still about 3x slower than a bare call on a quiet Editor. Every
unitycall costs about 3 s, and part of that is the CLI sending usage analytics onstatus. Each ready check is oneeditor statuscall, with the read-back in the same batch, so a quiet write is 3 calls instead of the 6 it started at: the idle case went from 24 s to 15 s to 7 s (#4). The mid-reload time is mostly the reload itself; it took 10 to 24 s on this run before the Pipeline server even went away, and 14 s on a quieter one.The refused and bad-value cases take as long as the Editor takes to say no. Play Mode held the write for about 25 s before refusing it; the bad value came back in 5 s.
Reading the names of a few objects: the full
scene get_hierarchywas 5216 bytes,playcal query682. The test scene is tiny; the gap grows with the scene.
Not measured yet: an MCP server as a third arm, agent token use over a whole task, and the unfocused-Editor and modal-dialog cases on a live Editor. bench/sim.py replays those against a scripted Editor in CI, plus a write that lands but makes the game throw once Play starts (loop alone calls that a success; observe catches it). It's a regression guard, not evidence about Unity.
Run it on your own project (it creates and deletes BenchLive* objects and one temporary script):
python bench/live.py --project-path C:/path/to/Project --repeat 3Predicting human difficulty with agents isn't a new idea. Roohi et al. (2021) did it for a Rovio game with a large player base. What playcal adds is doing it with a small panel (10–30 friends), honest confidence intervals, and open code.
Contributing
The most useful thing you can send is a real case where an agent and a Unity Editor got out of sync: what the agent ran, what it said, what was actually true. There's an issue template for it. After that: new modules, and a Godot or Unreal backend if you know those editors' APIs.
No time for a PR? Running four commands against your own project and posting what they printed in Testers wanted tells me more than another test I write myself.
CONTRIBUTING.md has the setup (pip install -e ".[dev]" and pytest, no Unity needed for most of it) and the few ground rules. Please also read the code of conduct. Security problems go through SECURITY.md, not public issues.
Small, well-described tasks are under good first issue. If playcal saved you a debugging session, a star helps other Unity people find it.
License
Apache-2.0. Use it in commercial games. The patent grant is there so studios don't have to ask.
Available Tools
20 toolsplaycal_assetsB
check texture import settings (.meta / .import) against a folder policy: type, mipmaps, Read/Write, compression, max size, offline
Inputs: action (required), paths, policy, project_path.
playcal_audioB
check audio import settings (.meta / .import) against a folder policy, and WAV loudness (LUFS), true peak and clipping, offline
Inputs: action (required), paths, policy, project_path.
playcal_baselineA
keep a perf baseline (p95 frame time, GC, memory) and fail only when a run is worse by more than noise
Inputs: name (required), action (required), dir, floor, +4 more.
playcal_calibrateB
predict each level's human pass rate from bot runs, calibrated on a small human panel, with 95% intervals
Inputs: bot, human, targets, min_attempts.
playcal_checkpointB
save project files to a private git ref before each step, and restore any save
Inputs: action (required), repo, limit, target.
playcal_doctorB
check Python, git, the Unity CLI, open Editors and the SDK before a run
Inputs: project.
playcal_ingestB
validate gameplay events (JSONL or pasted PC0 blobs) against the event schema
Inputs: files (required), out, paste, rejected.
playcal_lockB
lock scenes and assets so several agents don't edit the same file at once
Inputs: action (required), dir, ttl, force, +2 more.
playcal_logsB
summarize a Unity log: collapse repeats, show the first cause
Inputs: path, limit, since.
playcal_loopB
run a Unity Editor command safely: wait until ready, run, wait again, one digest
Inputs: action (required), diff, name, engine, +8 more.
playcal_metaA
find .meta and GUID damage (copied, moved or missing .meta files, missing scripts) before Unity imports it, without an Editor
Inputs: limit, project.
playcal_observeB
press Play, run the game a few seconds, diff the scene and collect console errors; screenshot only on red
Inputs: name, read, limit, scene, +10 more.
playcal_playtestC
run observe N times: a checklist of pass/fail/skip per run, red vs flaky (inconclusive), runs saved as PC0 events for calibrate
Inputs: game, name, read, level, +14 more.
playcal_queryC
pick a list, filter it and keep only the fields you need from a big JSON result
Inputs: frm, limit, unity, where, +3 more.
playcal_replayB
record input through the SDK, replay it as a regression test with fixed frame rate and seed, step frames
Inputs: action (required), fps, seed, wait, +7 more.
playcal_reportB
per-level pass rate, attempts and quit rate with 95% Wilson intervals
Inputs: files (required), targets.
playcal_snapC
save a JSON read under a name and diff two saves, with ids that change between sessions left out
Inputs: action (required), dir, refs, godot, +5 more.
playcal_testsB
test result history: spot flaky tests, fail CI only on real failures, run only affected test assemblies
Inputs: action (required), dir, base, files, +3 more.
playcal_uiC
draw the run history (loop, observe, replay, visual) as one static HTML page, newest first
Inputs: out, last, open, history.
playcal_visualB
screenshot gate: compare a PNG to a saved baseline with a per-pixel tolerance, write a diff PNG on a fail
Inputs: png (required), name (required), action (required), dir, +3 more.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 20 tools
Most tools target clearly distinct concerns (meta/asset/audio checks, perf baseline, visual diff, replay, report), and descriptions are explicit about scope. The main overlap risk is among the run-related tools (loop, observe, playtest, replay), plus report/calibrate/tests, but the descriptions differentiate them well enough for an agent to choose.
All 20 tools use a consistent `playcal_` prefix with a single short lowercase word, which is predictable and readable. There is minor inconsistency in part of speech (verbs like observe/ingest/lock mixed with nouns like logs/meta/assets), but the pattern itself is uniform.
20 tools is on the heavy side for a single server, but the domain is broad (environment setup, import checks, play runs, replay, visual/perf gating, reporting, calibration, collaboration). Each tool covers a distinct workflow step, so the count is justified rather than bloated, though it sits at the upper end of comfortable.
Coverage is broad: setup (doctor), safety (checkpoint, lock), import validation (meta, assets, audio), execution (loop, observe, playtest, replay), gating (visual, baseline, tests), and analysis (logs, report, calibrate, ingest, ui). Minor gaps exist around baseline lifecycle management (e.g., explicitly updating/replacing a saved baseline) and test/checklist authoring, but agents can work around these.
Maintenance
Related MCP Connectors
Enforces architectural invariants and code quality rules inside your AI agent's edit loop.
Change-aware CI validation and affected-test guidance for coding agents.
Source control for coding agents: declare an intent, settle overlaps before code, verify each push.
Parallel coding agents claim a file before editing, so none overwrites another.
Related MCP Servers
- AlicenseNot gradedqualityBmaintenanceEnables coding agents to see and act inside a live Godot 4 editor, inspecting scene trees and capturing visual evidence while performing undo-backed, approval-gated edits with runtime verification.MIT
- AlicenseNot gradedqualityBmaintenanceEnables AI agents to read Godot editor context, search project scenes/scripts/resources, and propose reviewable scene node edits that must be previewed, confirmed, then applied through Godot's UndoRedo, after which the current scene can be run and its diagnostics collected. All writes stay within fixed allowlisted routes and node types, with no arbitrary code execution and no fabricated success when the editor is unavailable.31 npmMIT
- AlicenseNot gradedqualityBmaintenanceEnables AI agents to verify their own work on real Godot 4 projects: edit scenes and scripts safely, launch the game, send real keyboard/mouse input, wait on conditions, read live runtime state, capture screenshots and errors, and run declarative scenario play-tests that return per-step pass/fail reports with the observed failing value.MIT
- AlicenseAqualityBmaintenanceEnables AI agents to perform exact-match, transactional multi-file edits with atomic writes, dry-run diffs, and self-correcting error diagnostics that reduce token usage.11MIT