scenario-unity-architecture
Unity architecture (gameplay programmer / architect)
Expert architecture in Unity is judged by what survives the edges: the second Play session, a destroyed object mid-await, a relaunch of the built player, a class rename, a merge of two branches, a prefab dropped into an empty scene. Data lives in assets, state lives in plain C# and files, systems talk through events they do not own, and every claim is pinned by a test the agent can run without a mouse. Target: Unity 6000.3.21f1, Input System 1.20.0, Test Framework 1.6.0, macOS. If a sibling skill named here is missing from your available skills, ask the user to install it (npx skills add scenario-labs/skills --skill <name>); unattended, proceed from tool schemas and flag the gap.
REQUIRED BACKGROUND: scenario-unity-expert (channels, review loop, 6.3 traps). Toolkit: ut_env, ut_run, ut_live plus this skill's scripts/ut_arch.py, scripts/AgentKit/Architecture/ jobs and scripts/templates/ (a tested Game.Core / Game.Runtime / Game.Editor / tests layout).
Status (2026-09-24): every procedure ran in Unity 6000.3.21f1 on this Mac: tests/code/unity-architecture/run_all.sh --live (results in archive/tests/unity-architecture/live_results.jsonl).
Stance (the expert delta)
- ScriptableObjects are definitions, never saves. Play-mode writes to an SO persist in the Editor and reset on every launch of a build (Code Monkey, 5a-ztc5gcFw [00:04:17]). Observed sharper: the write lives only in Editor memory (
IsDirtyfalse, the.assetunchanged even afterSaveAssets), so a file hash never sees it; only an in-memory guard in the process that plays does (so_guard, the prefab SO test). A macOS player run twice started from the baked value while the save FILE carried over. Runtime modifiers apply to a struct copy (config.Stats, Tarodev, tE1qH8OxO2Y [00:11:18]); live registries are runtime sets, never scene searches (Hipple, raQ3iHhE_Kk [00:40:06]). Saves: versioned JSON of ids, atomic write, backup, migrations. - Read Enter Play Mode before trusting any Play test. With domain reload off (an option in 6.3, the default for new 6.6 projects) a static counter read 2 and a static event had 2 subscribers on the second Play (observed). Unregister static handlers on
ExitingPlayMode(the Manual's best practice; observed clean on its own) and reset values atSubsystemRegistration;DoublePlayproves both. Scene reload off makes Editor startup unrepresentative, and a script change still reloads the domain (6.3 Manual). - Every await has an owner. Awaits survive Destroy and Play exit (Unity, eXkFPoEp3BA [00:13:28]): a token-less loop ticked 13 to 14 more times in the 0.3 s after
Destroy(observed). MonoBehaviours passdestroyCancellationToken(Lifetime.Linked(this)when IO must also stop at exit); plain C# services passApplication.exitCancellationToken. Never await an Awaitable twice (pooled:AsTask()), never block on a Task. Release players skip the main-thread check: off the main thread a development player threw onpersistentDataPath,Time.time,transformandnew GameObject, the release player threw nothing (observed). Smoke-test threading in a development build. - Designers wire, code decides. Serialized SO references are the injector designers can see (Hipple [00:14:39]). Channels decouple sender and receivers, iterate listeners backwards ([00:33:23]) and ship a debug plan: counts, a log toggle,
RaiseDebug()from a job, an inspector button ([00:36:07]). UnityEvent is a serialized function call ([00:29:26]): rare designer-wired responses only (0 B per invoke measured on 6.3, but targets are hard references resolved by method name). One composition root runs first ([DefaultExecutionOrder(-1000)], git-amend, PJcBJ60C970 [00:12:01]; without it a consumer found nothing, observed) and fails loudly. Each prefab runs alone in an empty scene ([00:16:29]). Statics suit a small solo project only (Weimann, hQE8lQk9ikE [00:01:28]; Tarodev [00:03:00]), with resets. Enums only for sets fixed "by some law"; categories designers extend become SO assets ([00:46:39]). - Rebinding is a state machine with six traps. The action must be disabled (the package throws otherwise); Escape does not cancel a Button rebind by default; a composite head cannot be rebound; duplicates are judged on
effectivePath, composite parts only against earlier parts, modifier combos pershortcutKeysConsumeInput(off by default: Shift+B also fires B); Reset All deletes the persisted overrides;LoadBindingOverridesFromJsonwipes existing overrides unless passedfalse(samyam, csqVa2Vimao [00:12:51], [00:27:44], [00:33:22]; 1.20 manual; all tested). For the UI, start from the Rebinding UI sample: in 1.20 it already disables and disposes but lacks four fixes (ut_arch.rebind_audit). Eachnewof a generated input class is a separate asset: share one. - Assemblies are a contract. A Core with
noEngineReferencesholds rules testable in milliseconds; runtime asmdefs never reference editor ones (the Editor compiles it, the player build fails, observed). Test asmdefs referenceUnity.InputSystem.TestFramework;testableswas not needed on 6000.3.21f1 (observed). - Serialized data survives refactors and merges only on purpose.
[FormerlySerializedAs]for fields,[MovedFrom]for renamed[SerializeReference]classes (without it the entry loaded as null, observed),[Serializable]on every[SerializeReference]type (6.3 tolerated its absence; 6.4 requires it per the version notes). UnityYAMLMerge exits 2 on any conflicted field even with-l/-r, and without them writes the BASE value (observed): check the exit code, then open the result in Unity.