Unity architecture (gameplay programmer / architect)

SkillFiles & storage

Guides your agent through structuring Unity game projects covering data design, saves, input handling, and version control.

Available today. Use it from your connected AI after setup.

Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Then ask your AI: use the Unity architecture (gameplay programmer / architect) skill

About this skill

Use when structuring Unity 6.3 C# code: ScriptableObject data vs runtime state, events and services, inventory, health, save and load, Input System actions and key rebinding, async with Awaitable, assembly definitions, Enter Play Mode and domain reload, .meta files, Git, Smart Merge and LFS. Also wh

What this skill tells your AI

The instructions your AI receives, as published by scenario-labs/skills in skills/game-engines/unity/scenario-unity-architecture/SKILL.md and read by ahel’s review.

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)

  1. 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 (IsDirty false, the .asset unchanged even after SaveAssets), 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.
  2. 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 at SubsystemRegistration; DoublePlay proves both. Scene reload off makes Editor startup unrepresentative, and a script change still reloads the domain (6.3 Manual).
  3. 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 pass destroyCancellationToken (Lifetime.Linked(this) when IO must also stop at exit); plain C# services pass Application.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 on persistentDataPath, Time.time, transform and new GameObject, the release player threw nothing (observed). Smoke-test threading in a development build.
  4. 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]).
  5. 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 per shortcutKeysConsumeInput (off by default: Shift+B also fires B); Reset All deletes the persisted overrides; LoadBindingOverridesFromJson wipes existing overrides unless passed false (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). Each new of a generated input class is a separate asset: share one.
  6. Assemblies are a contract. A Core with noEngineReferences holds rules testable in milliseconds; runtime asmdefs never reference editor ones (the Editor compiles it, the player build fails, observed). Test asmdefs reference Unity.InputSystem.TestFramework; testables was not needed on 6000.3.21f1 (observed).
  7. 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.

Establish first

Team shape (who wires behavior: designers or programmers), project lifetime, target platforms (Web has no managed threads), Enter Play Mode setting, input devices (keyboard-mouse, gamepad, touch, local multiplayer) and modifier shortcuts, save needs (slots, cloud, migrations), VCS (Git + LFS by default). Defaults: project-wide actions, Game.Core/Runtime/Editor/Tests asmdefs, SO definitions + plain C# state, event channels between systems, runtime sets for registries, one GameRoot, JSON saves under persistentDataPath, Git with the UnityYAMLMerge driver. Run ArchJobs.ProjectFacts and write its answers into the plan.

Workflow

  1. Scaffold and compile. Goal: rules in pure C#. How: ut_arch.install(P), ut_arch.scaffold(P) (A1), then ArchJobs.ProjectFacts (Unity -batchmode -nographics -quit -executeMethod AgentKit.Architecture.ArchJobs.ProjectFacts). GATE: zero compile errors and warnings; ArchJobs.AssemblyGraph: Game.Core engine_refs == 0, violations == [].
  2. Data as assets. Goal: definitions designers edit. How: ArchJobs.CreateAssets with ut_arch.item_specs() (SerializedObject edits, managed_refs for [SerializeReference] lists, SetDirty only on change) (A2, A13). GATE: second run all unchanged; ItemDatabaseValidator clean.
  3. Core systems with tests. Goal: inventory, health, events, sets. How: templates; ut_run.run_tests(P, "EditMode", assemblies="Game.Tests.EditMode"). GATE: all pass; ut_arch.code_lint no warn or error in runtime code (A10).
  4. Save and load. Goal: state that survives relaunch. How: SaveService (A3). GATE: EditMode round trip, corrupt-file fallback, v1 migration; CrossProcessSave across two editor processes; a player run twice for persistence claims (A4); a development player when saves touch threads (A12).
  5. Input. Goal: every device, rebinding that persists and resets. How: project-wide actions, InputContext, RebindFlow, BindingStore, .inputactions edited as JSON; ArchInputAudit.Audit (A5, A15). GATE: InputTestFixture tests per trap; audit without missing_scheme or duplicate_binding; rebind_audit missing nothing.
  6. Async and lifecycle. Goal: no orphan work. How: async Awaitable Start() with a token, AsTask, PlayMode suite; async tests as a [UnityTest] returning the inner Awaitable, or async Task (A6, A7, A14). GATE: no tick after Destroy; prefab isolation and prefab SO guard pass.
  7. Play-mode hardening. Goal: session 2 equals session 1, events reach responses. How: ArchJobs.StaticStateAudit, then ArchPlayMode.DoublePlay with so_guard and invoke (RaiseDebug on each channel) in the project's mode (A8, A11). GATE: no arch.static_without_reset in own assemblies; watched values equal across sessions; so_changed_in_memory == []; every invoke ok with its response counted.
  8. Version control. Goal: merges and moves never break references. How: ArchJobs.SetVcsSettings, ut_arch.write_vcs_files, git_setup_unity_merge, meta_audit, yaml_merge, then ArchJobs.InspectAsset (A9). GATE: meta_audit.ok; .meta not in LFS; clean merges exit 0 and deserialize; conflicts stay unmerged with a report.
  9. Hand off with evidence (scenario-unity-expert report format): Unity call per step, Verified, Assumed.

Numbers

ValueRelative toSource
11 to 13 sone batch job on this project (editor boot included)observed
about 11 s / 16 sEditMode suite (33 tests) / PlayMode suite (17 tests), final runobserved
13 to 14 tickstoken-less 0.02 s Awaitable loop in the 0.3 s after Destroyobserved
2 / 2static counter / static event subscribers in Play session 2, domain reload offobserved
4 of 6 / 0 of 6Unity calls that threw off the main thread in a development / release macOS playerobserved
0 Bheap growth over 10,000 channel raises or persistent UnityEvent invokes after warm-upobserved
about 37,000 to 61,000 fpsbatch -nographics Play mode (uncapped): wait on time, not framesobserved
0.05 s; falsePerformInteractiveRebinding OnMatchWaitForAnother; shortcutKeysConsumeInput defaultpackage source 1.20; observed
next Update (33 ms at 30 fps)Task continuation started on the main thread6.3 Manual
about 500 KBbinary size above which Git LFS paysPettersen 2016
exit 0 / 2UnityYAMLMerge clean / any conflicted field (also with -l, -r)observed
114.7 MB / 311.2 MBmacOS Mono release / development player of a probe sceneobserved

Quality gates

  • Measurable: zero compile errors and warnings; AssemblyGraph.violations == []; EditMode and PlayMode failed == 0 in the full suite, not only single tests; code_lint no warn or error in runtime code; so_guard and the prefab SO test clean (SnapshotAssets changed_on_disk == [] only proves editor tools saved nothing); StaticStateAudit clean; DoublePlay sessions equal; ArchInputAudit warn 0; rebind_audit complete; meta_audit.ok; merge exit codes checked; player relaunch report (A4) and development-player report (A12) when persistence or threads are claimed.
  • Visual (few): after a scene merge, a camera capture before and after (scenario-unity-expert AgentCapture); a rebinding screen shows the new binding text (scenario-unity-ui).

Common mistakes

MistakeWhat it looks likeFix
save stored in an SOworks in the Editor, lost after relaunch of the buildSaveService file; SO read-only (A3, A4)
SO guard by file hashpasses while Play writes an SOso_guard / prefab SO test in the playing process (A11)
session state in an unreferenced SOvalue back to baked after a scene load, in the buildkeep it referenced or hold it in C#
statics with domain reload offsecond Play starts at 2, handler runs twiceExitingPlayMode unregister + SubsystemRegistration reset
await without tokenlogic runs after the object died or Play endeddestroyCancellationToken, Lifetime.App for services
awaiting an Awaitable twiceexception or hangAsTask() once
threads checked in a release build onlyfine now, undefined laterdevelopment player smoke test (A12)
listeners iterated forwarda self-removing response skips the nextiterate backwards
composition root without execution ordera consumer's Awake finds no service[DefaultExecutionOrder(-1000)]
rebinding an enabled actionInvalidOperationExceptionRebindFlow disables, re-enables on complete and cancel
Button rebind without WithCancelingThroughEscape becomes the binding.WithCancelingThrough("<Keyboard>/escape")
Reset All keeps the file; loads not layeredold rebinds return; earlier overrides vanishBindingStore.ResetAll; Load(..., removeExisting: false)
InputSystem.actions in tests or with PlayerInputnull after fixture tests; pairing breaksEditorBuildSettings.TryGetConfigObject(...); playerInput.actions
a generated input class per consumera rebind shows on one screen onlyone shared instance
runtime asmdef references editor asmdefEditor fine, player build failsAssemblyGraph explicit-reference check
class renamed behind [SerializeReference]entries load as null[MovedFrom] in the same change
trusting a merge outputconflicted field silently back to basecheck exit 2, resolve, InspectAsset

Handoffs

  • Receives from scenario-unity-expert: brief, channel, project facts. From scenario-unity-gameplay: which systems need health, damage, state machines (the FSM pattern lives there).
  • Delivers to scenario-unity-ui: RebindFlow, BindingStore, InputContext, channels (with RaiseDebug) for HUD and settings screens, with tests. To scenario-unity-gameplay: IDamageable, Health, Services, GameRoot, runtime sets. To scenario-unity-pipeline-automation: asmdef layout, run_tests(..., assemblies=...), development builds, .gitignore/.gitattributes, merge driver. To scenario-unity-performance: GC per frame (scenario-unity-expert AgentProfile), per-object Awaitable loops. To scenario-unity-web: no managed threads (SaveAsync stays synchronous, no Task.Delay). To scenario-unity-mobile: touch schemes, persistentDataPath. Packet: files written, tests with totals, audits with counts, Verified and Assumed.

Unity 6.3 notes

  • Enter Play Mode is a dropdown ("When entering Play Mode"); 6.6 new projects default to "Reload Scene only"; 6.5 adds code lifecycle attributes and AutoStaticsCleanup.
  • FindObjectsByType<T>(FindObjectsSortMode.None) (the sort mode is obsolete from 6.4); [SerializeField] only on fields; GetInstanceID becomes a compile error in 6.5 (EntityId); [SerializeReference] types must be [Serializable] from 6.4 (version notes, unverified there).
  • Templates run the Input System only (legacy Input.* throws); Input System 1.20 bundled; OnMouseDown works with it only from 6.4.
  • Object.Destroy: verified live on 6000.3.21f1 (LifecycleTests, 2026-09-24) that OnDisable reaches every descendant (four levels); the 6.4 upgrade guide and the version notes describe pre-6.4 editors as "only direct children", so keep the idempotent OnDestroy unregistration for older editors.
  • Force Text and Visible Meta Files are the 6.3 defaults (observed); 6.6 turns YAML word wrap off and removes "Reduce Version Control Noise".

References

Signals

GitHub stars
681
Forks
82
Last commit
Sep 2026
Advanced
Item type
skill
Key
scenario-unity-architecture
Source
github.com/scenario-labs/skills