Game guide · source of truth
Tech

Unity development toolkit

Everything that helps agents build the Unity client — batchmode build/test commands, the project skills in zoen/.claude/skills, and the reviewed GitHub tools (Unity MCP, GameCI, Unity samples, Sentry, OpenTelemetry, audio and asset validators) with licence, use and adoption gate.

The owner accepted ADR-024 in round 6: the client is Unity 6 LTS + URP (C#) for Windows, Android, iOS and the Web (Web capped at Low–Mid). This page lists what helps agents work on it, and the rules for adopting each tool. Nothing here is installed yet. No tool is installed without the gate below.

◆How agents drive Unity (no GUI needed)

The pin is Unity 6.3 LTS (newest 6000.3 patch; supported until December 2027, checked 2026-10-06). The owner installs Unity Hub and the editor, or an agent runs the Hub command line (allowed by the owner on 2026-10-06). The owner signs in once with their Unity ID; agents never create accounts or sign in. After that, agents use the editor binary headless:

UNITY=~/Unity/Hub/Editor/6000.3.25f1/Unity.app/Contents/MacOS/Unity   # pinned in stack.json
P=apps/client-unity

# tests (do not combine -runTests with -quit)
"$UNITY" -batchmode -nographics -projectPath $P -runTests -testPlatform EditMode -testResults logs/test-output/editmode.xml -logFile -
"$UNITY" -batchmode -projectPath $P -runTests -testPlatform PlayMode -testResults logs/test-output/playmode.xml -logFile -

# builds through one static build script (Assets/Zoen/Editor/Build.cs)
"$UNITY" -batchmode -nographics -projectPath $P -buildTarget WebGL   -executeMethod Zoen.Build.Web     -quit -logFile -
"$UNITY" -batchmode -nographics -projectPath $P -buildTarget Android -executeMethod Zoen.Build.Android -quit -logFile -

Every run goes through node tools/task-log.mjs test <ID> "<name>" -- <command> so it lands in the ledger.

TargetBuilt from this Mac?Notes
macOS (test app)yespaused since 2026-10-11 (ADR-032): not built or tested in milestone work; code and scripts kept
Web (WebGL2)yesLow/Mid profiles only; Playwright opens it for smoke and perf
Android (APK now, AAB for a store later; IL2CPP ARM64)yesAndroid module (OpenJDK, SDK, NDK, Gradle) installed; first build ~19 min
iOSXcode project yes; Xcode 26.6 is installed (device build needs an Apple ID)paused since 2026-10-11 (ADR-032)
WindowsMono from this Mac; IL2CPP needs a Windows machine or CI runnertest build (ADR-032): the owner runs it on his Windows PC; not yet implemented in unity-run (WIN-01). Release builds use IL2CPP on a Windows runner (GameCI)

◆The Zoen client (apps/client-unity)

Pinned editor: Unity 6000.3.25f1 (6.3 LTS, Apple silicon, in ~/Unity/Hub/Editor; stack.json). Code lives in Assets/Zoen/Runtime (boot, quality tiers, FPS overlay, launch capture), Assets/Zoen/Editor (ProjectSetup.cs, Build.cs) and Assets/Zoen/Tests/EditMode. Engine-free rules (the tier policy, the feel-quality reader) live in packages/core-cs/Zoen.Core, which the client loads as the local package com.zoen.core (assembly Zoen.Core, noEngineReferences; M0-09). Edit them there and run pnpm core:test (seconds, no editor); Unity writes the package's .meta files, which are committed. Builds go to apps/client-unity/Builds/ (ignored by git). One Unity process per project at a time; on a loaded Mac a Web build takes about 11 minutes, so run builds in the background.

Setup. ProjectSetup.Run generates the URP assets for Low/Mid/High/Ultra, the quality levels, the per-platform caps and defaults, the player settings and the Boot scene from feel-quality.json. Never hand-edit the generated assets in Assets/Zoen/Settings or the quality settings: change the data or ProjectSetup.cs and run it again.

"$UNITY" -batchmode -nographics -projectPath $P -executeMethod Zoen.ProjectSetup.Run -quit -logFile -
"$UNITY" -batchmode -nographics -projectPath $P -runTests -testPlatform EditMode -testResults "$PWD/logs/test-output/<ID>-editmode.xml" -logFile -
"$UNITY" -batchmode -nographics -projectPath $P -buildTarget WebGL         -executeMethod Zoen.Build.Web     -quit -logFile -
"$UNITY" -batchmode -nographics -projectPath $P -buildTarget StandaloneOSX -executeMethod Zoen.Build.MacOS   -quit -logFile -   # paused (ADR-032)
"$UNITY" -batchmode -nographics -projectPath $P -buildTarget Android       -executeMethod Zoen.Build.Android -quit -logFile -

-testResults needs an absolute path. Add -zoen-development to a build for a development player. Each build writes Builds/<Target>-build-summary.json and logs ZOEN_BUILD target=… result=… sizeBytes=….

The runner is the default way to run these (M0-10, ADR-029). node tools/unity-run.mjs editmode | playmode | build web|macos|android | setup (through task-log.mjs test) uses the editor pinned in ProjectVersion.txt, refuses to start while another Unity holds the project (it prints that process's pid, kind and start time), runs quietly (--verbose streams the log) and prints one report: PASS/FAIL, test counts or the build size on disk, duration, the ZOEN_* lines and the first five errors with file:line (compiler errors, failed tests, exceptions, licence and lock errors; harmless licensing/usbmuxd lines are ignored). Full log and NUnit XML: logs/test-output/unity/<stamp>-<kind>.log|.xml (git-ignored); the summary .json is tracked. Exit code 0 pass, 1 fail, 2 refused, 3 not tested: build windows|ios only records "not tested" (ADR-032; the Windows build is not yet implemented in unity-run, task WIN-01; iOS is paused). unity-run.mjs report <log> re-reads a saved log. Parser tests: packages/game-data/tests/unity-run.test.mjs (part of pnpm data:test).

TargetOutputSettings
WebBuilds/Web/WebGL2, Brotli (no fallback), ships Low+Mid only
macOS (paused)Builds/macOS/Zoen.appuniversal, Mono, 1280×720 resizable window, ad-hoc signed
Windows (WIN-01)not yet implemented in unity-runMono x86_64 player for the owner's Windows PC
AndroidBuilds/Android/zoen.apkAPK for sideloading (ADR-032), IL2CPP ARM64, landscape, package com.zoen.client

All players run in the background (runInBackground): an online client keeps ticking when its window or tab loses focus.

Smoke tests (evidence to logs/evidence/<ID>/, --out to change it):

CommandWhat it checks
node tools/unity/serve-web.mjsserves Builds/Web on http://localhost:4350 with the Brotli headers
node tools/unity/web-smoke.mjs --label mac-chromePlaywright + Chrome: graphics API, tier per ?tier=, 0 console errors, Ultra refused
node tools/unity/web-smoke.mjs --label phone-chrome --cdp http://127.0.0.1:9222the same in the phone's Chrome (after adb reverse tcp:4350 tcp:4350 and adb forward tcp:9222 localabstract:chrome_devtools_remote)
node tools/unity/macos-smoke.mjspaused (ADR-032): native app per tier (windows open briefly): boot, tier, 0 errors, own screenshot
Windows smoke kit (WIN-01, not yet built)the owner runs the player per tier on his Windows PC with -logFile; a checker reads the ZOEN_* lines and the results come back as a file in logs/evidence/
node tools/unity/android-smoke.mjsUSB phone: install, per-tier launch, logcat (E/F lines from the app's pid), screenshots, an 8 s recording
node tools/unity/probe.mjs macos-app|mac-chrome|phone-chrome|android-apkreads the ZOEN_PROBE line on one owner-device target, checks the contract, adds the host facts (sysctl/system_profiler, adb getprop/dumpsys, userAgentData) → logs/evidence/M0-05C/probe-<target>.json and the merged probe-summary.txt
node tools/unity/probe-contract.mjs <probe-<target>.json>…re-checks saved probe records against the current contract (exit 0 only when every run passed and still holds it); the data test device-probes cross-checks ownerTestDevices[].probes against the same files
node tools/unity/android-overlay-cycle.mjsUSB phone, installed APK: from cleared app data, taps the FPS overlay and checks every ZOEN_TIER against the data's cycle (Low/Mid/High while the mobile Ultra gate is closed); a screenshot per tap

web-smoke.mjs serves the build itself on port 4350, so stop any serve-web.mjs first. The phone's Chrome must be running for the DevTools socket: adb shell monkey -p com.android.chrome -c android.intent.category.LAUNCHER 1. adb is ~/Unity/Hub/Editor/6000.3.25f1/PlaybackEngines/AndroidPlayer/SDK/platform-tools/adb (or $ADB; use the binary whose server is running, e.g. ADB=~/Library/Android/sdk/platform-tools/adb). Android and the GPU vendor write a few E lines into every app's process (gralloc format probes, the Swappy frame-pacing fallback, framework window/audio notices). android-smoke.mjs lists them by exact tag and message with a reason (PLATFORM_NOISE) and counts them as platformNoise in the report; any other E/F line from the app's pid fails.

Starting tier and frame cap (M0-04C, feel-quality.json → tierSelection, proposed). At boot TierPolicy picks the tier in this order: a test launch option; the player's saved choice (an overlay pick, PlayerPrefs zoen.tier; it always wins); a phone at or below lowMemoryPhone.maxSystemMemoryMb → Low; a browser at or below webLowEnd (deviceMemory or cores) → Low; on native builds the cached or a new startup benchmark (the Boot scene at the platform default, p95 frame time against the tier's budget, stepping down; result in PlayerPrefs zoen.benchmark, logged as ZOEN_PICK); else the platform default. Phones reach Ultra only through the tier launch option until mobileUltraGate.passed (the M1 thermal/frame gate): the overlay cycle stops at High. Every tier sets its frame cap from frameRate.fps (Low 30, Mid 30, High 60, Ultra 60): phones and the Web through targetFrameRate (vSync 0), desktop through vSync when the cap reaches the display rate. So Android runs its default Mid at 30 fps and High at 60 fps. ?fps= / -zoen-fps / --es zoen-fps override the cap for tests. The rules reach the player as the generated Assets/Zoen/Settings/Resources/Zoen_TierRules.asset (ProjectSetup.Run).

Launch options (BootOptions.cs):

OptionWebmacOS / desktopAndroid
quality tier?tier=low-zoen-tier lowam start -n <activity> --es zoen-tier low
frame-rate cap?fps=60-zoen-fps 60--es zoen-fps 60
own screenshot—-zoen-screenshot <png> [-zoen-capture-at 8]—
quit after N s—-zoen-quit-at 13—

A tier the platform does not ship (Ultra on the Web) is refused and the platform default is kept. On phones the tier option is the only way to Ultra while the M1 gate is closed.

Log lines the smokes parse (browser console, Player.log, logcat):

  • ZOEN_BOOT {json} – once before the first scene: product, version, Unity, platform, graphics API/version, GPU, device, OS, memory, screen, chosen tier, shipped tiers, requested tier, tierSource, tierRefused, capKey, the overlay cycle, ultraGate, benchmark (run/cached/off), browser deviceMemoryGb/logicalCores, displayHz, fpsTarget, frame-rate target, vSync.
  • ZOEN_PROBE {json} – once after the first scene (M0-05B, Runtime/Diagnostics/CapabilityProbe.cs; on the Web the browser block comes from ZoenWebProbe in ZoenDevice.jslib): the capability probe whose contract is reference-devices.json → probe. On Android it arrives as ZOEN_PROBE_PART i/n <chunk> lines of at most 800 bytes (Unity's Android player cuts a logcat entry at 1023 bytes; M0-05E), joined by number in tools/unity/probe-contract.mjs (ProbeJson.Lines/Join in the core).
  • ZOEN_PICK {json} – the startup benchmark's pick: tier, source, steps (tier:p95 ms), frame cap.
  • ZOEN_FPS tier=… api=… target=… fps=… avgMs p50Ms p95Ms p99Ms maxMs frames=… logErrors=… logWarnings=… – every 5 s.
  • ZOEN_TIER <name> – the overlay button switched the tier. ZOEN_CAPTURE <png> – the launch screenshot was written.
  • ZOEN_BUILD … (builds) and ZOEN_SETUP done … (setup).
  • ZOEN_SIM {json} – once after the first scene (M0-11 spike, Runtime/Sim/SimBootCheck.cs): the zoen-sim plugin's ABI version, every golden scenario replayed through it (match/allMatch, one scenario's final hash) and a step microbench (median ns per tick for 1 and 5,000 movers); loaded:false with a reason where the plugin is not linked yet. macos-smoke.mjs --sim requires a match; android-smoke.mjs --sim and web-smoke.mjs --sim also check the final hash against packages/sim-golden/vectors.json and the bench for 1 and 5,000 movers (tools/unity/sim-expect.mjs). The plugins come from node tools/sim/build-plugin.mjs macos|android|web|ios|windows (crates/zoen-sim-ffi, C header include/zoen_sim.h; import settings in Editor/SimPluginImport.cs). Android (M0-11B): aarch64-linux-android linked by the clang of the NDK bundled with the pinned editor (r27c, API = AndroidMinSdkVersion), 16 KB LOAD alignment, llvm-strip, installed as Plugins/Android/arm64-v8a/libzoen_sim_ffi.so (Android player, ARM64 only, not the editor); --device also runs the C smoke on the adb phone. Web (M0-11C): wasm32-unknown-emscripten staticlib repacked for the editor's bundled Emscripten 3.1.39 (LLVM 17), installed as Plugins/WebGL/libzoen_sim_ffi.a (Web player only, not the editor or Android); Zoen.Build adds ZOEN_SIM_STATIC to a Web build when it exists, otherwise the Web calls stay stubs. iOS (M0-11D, built, not tested): staticlib at iOSTargetOSVersionString, C smoke linked with Xcode's iPhoneOS SDK, installed as Plugins/iOS/libzoen_sim_ffi.a (iOS player only; ZOEN_SIM_STATIC for iOS too). Windows (M0-11D): the staticlib builds; the DLL needs the MSVC CRT and Windows SDK libraries, so it is built on the owner's Windows PC (Rust + Visual Studio Build Tools, installed by the owner; steps in WIN-01; no SDK/CRT download on this Mac, ADR-032), then installs as Plugins/Windows/x86_64/zoen_sim_ffi.dll (64-bit Windows player only). Until it exists the Windows player runs without the native sim (the boot check reports it absent; not an error).

Tier memory. The player's pick and the benchmark result live in PlayerPrefs (Web: IndexedDB; macOS: the com.zoen.client defaults domain; Android: app data); the boot sets the level itself, so Unity's own remembered level does not decide. A launch option wins and is never saved. The smokes clear app data before their default run (defaults delete com.zoen.client, pm clear com.zoen.client), so that run shows the benchmark pick. On the Web every page load logs one Unity warning, "Manual synchronization of Unity Application.persistentDataPath via JS_FileSystem_Sync() is deprecated": Unity's own framework prints it once per page when the engine flushes PlayerPrefs to IndexedDB, which happens on each boot because the boot applies its tier with QualitySettings.SetQualityLevel (Unity stores the level in PlayerPrefs). It is harmless; the opt-in config.autoSyncPersistentDataPath = true needs a custom WebGL template (M0-04D finding, not done).

Packages. Opening the project folder without ProjectSettings merges Unity's new-project defaults (IAP, Analytics, Version Control) into Packages/manifest.json. They were removed (M0-04). Re-adding any of them, or any other package, goes through the gate below. The local package com.zoen.core (M0-09) is the repo's own code, not a third-party package, so it needs no gate.

◆Project skills (zoen/.claude/skills/)

SkillUse it when
unity-batchmodebuilding or testing the Unity project from the CLI; the exact commands, log parsing and the per-target table above
unity-toolchain-gatesomeone proposes installing a Unity package, MCP server, CI action or third-party skill

They are written for Zoen. The Unity-era skills in the workspace root .claude/skills/ stay archived.

◆Reviewed GitHub tools

Licences and adoption status as reviewed in October 2026 (re-check the pinned release before installing; stars and recent pushes are not security evidence). "Gate" = what must happen first.

ToolWhat it gives usLicenceZoen statusGate / when
CoplayDev/unity-mcpLets an agent read/edit scenes, prefabs and assets in a running editorMITDeferred — no MCP (owner direction, M0-07, 2026-10-08): the smoke test was skipped; agents drive Unity through batchmode and tools/unity-run.mjsre-review a pinned release (e.g. the v10.2.0 line) only if the owner asks: disposable project, telemetry off, loopback only, one editor writer
IvanMurzak/Unity-MCPAlternative Unity MCPApache-2.0Deferred — no MCP (M0-07)only if the owner asks for an MCP
game-ci/unity-test-runnerRuns EditMode/PlayMode tests in CIMITPlannedneeds a git remote + a Unity licence secret from the owner
game-ci/unity-builderCI builds for every target, incl. Windows IL2CPP on Windows runnersMITPlannedsame as above; required for release Windows builds
Unity-Technologies/CharacterControllerSamplesCharacter controller patternsUnity Companion LicenceReference onlyread, don't copy without checking third-party exceptions
Unity-Technologies/EntityComponentSystemSamplesData-oriented crowd/projectile patternsUnity Companion LicenceReference onlyM1 crowd spike
Unity-Technologies/GraphicsURP source and renderer-feature examplesUnity Companion LicenceReference onlyVFX distortion, decals
Unity-Technologies/VisualEffectGraph-SamplesVFX Graph examples for High/Ultra effectsUnity Companion LicenceReference onlyM2 Combat Lab
Unity-Technologies/audio-examplesAudioMixer / voice management patternsUnity Companion LicenceReference onlyM2-05
getsentry/sentry-unityCrash and error reports from playersMIT (service has paid tiers)DeferredM11 alpha; a Sentry account is an owner decision (cost cap)
open-telemetry/opentelemetry-dotnetClient/server traces and metricsApache-2.0DeferredM6 online core
KhronosGroup/glTF-ValidatorValidates Blender exports before importApache-2.0DeferredM3 character pipeline
git-lfs/git-lfsLarge binary assets in gitMITDeferredwhen git is initialised (owner)
csteinmetz1/pyloudnormLUFS loudness checks for ambience/musicMITReference / optionalaudio delivery step
audacity/audacityTrim and layer SFXGPLv3 (tool only)Optional tooloutputs are ours
audiokinetic/waapi-client-pythonWwise automationApache-2.0 (Wwise itself is commercial)Not plannedwe use Unity's AudioMixer
ahujasid/mcp-for-blenderAgent control of BlenderMITDeferredheadless blender -b -P scripts first
Nice-Wolf-Studio/unity-claude-skillsSmall Unity skill collectionMITReference onlyour own skills above replace it
anthropics/skillsSkill authoring examples (skill-creator is Apache-2.0)per folderReference only—
devdavv/unity-ai-workflowGame-feel ideasMITReference onlyuses a global timeScale hitstop, which Zoen forbids
alttester/AltTester-Unity-SDKUI test automationGPLRejectedGPL in the shipped client
denfry/claude-skillsGeneric skillsMITRejectedno advantage over our own

Blender headless check (M0-02): pnpm blender:smoke runs tools/blender/smoke.py (Blender 4.5.9 LTS) — a 1 m cube exported to art/build/smoke/smoke_cube.glb with the glTF settings the Unity import expects (GLB, +Y up, metres, embedded PNG, transforms applied, no compression) — and validates the GLB and its 512 px Eevee render (logs/evidence/M0-02/).

◆Unity packages we expect to use (all first-party, free)

URP · Input System · Addressables · UI Toolkit · TextMeshPro · Cinemachine · VFX Graph · Shader Graph · Burst + Collections + Mathematics (hot paths) · Unity Test Framework · Performance Testing · Profiler/Memory Profiler. Paid Asset Store packages need owner approval and count toward the PHP 5,000 cap.

◆Test packages: gate record (M0-07, 2026-10-08) — adopted

PackagePinnedSourceLicenceNotes
Unity Test Framework com.unity.test-framework1.6.0built-in core package of 6000.3.25f1Unity Companion Licenseruns EditMode/PlayMode in batchmode; brings Custom NUnit com.unity.ext.nunit 2.0.5 (Unity Package Distribution License)
Performance Testing com.unity.test-framework.performance3.5.0packages.unity.com (was already resolved at depth 3 through Collections; now a direct pin)Unity Companion License (bundled Perfolizer: MIT)Measure.Method/Frames, GC.Alloc samples; its runtime assembly is filtered out of player builds unless test assemblies are included

No network listeners, telemetry or post-install scripts. Files it writes: -perfTestResults <file> (the runner passes logs/test-output/unity/<stamp>-<kind>-perf.json), PerformanceTestResults.json in the editor's Application.persistentDataPath, and during every player build Assets/Resources/PerformanceTestRunInfo.json + PerformanceTestRunSettings.json (build metadata and the editor command line; git-ignored, the package's post-build cleanup removes them). They ship inside player builds: strip them before release builds (M11 release checklist). Proof: Assets/Zoen/Tests/PlayMode/SkyPerformanceTests.cs — SkyModel.Sample + SkyLook.Evaluate over a whole day and SkyController.Tick (rain, frozen cycle) record 0 GC allocations, checked against an allocating control; Boot-scene frame times with the sky active are recorded (no budget: editor batchmode frames are not device numbers). unity-run.mjs prints median/p95 per sample group. Evidence: logs/evidence/M0-07/.

◆The gate (any new tool)

  1. Read the exact release/tag, licence, install script and the last commits. Never run curl-pipe installers.
  2. Install into a disposable copy of the project first; telemetry off; servers bound to loopback; one editor writer.
  3. Log the smoke test with task-log.mjs. Remove it if it fails or writes outside the project.
  4. Commercial services, accounts, runtime AI or remote access need the owner's explicit approval.

Source: zoen/docs/tech/UNITY_TOOLKIT.md · 3,163 words · edit the Markdown, not this page.