Topic 3 · Unity programming
Runtime references & object lifecycle
How objects get references to each other, pooling, subscriptions, and how references die.
How a runtime object gets a reference to another one, and how that reference dies. The [SerializeField]-not-a-lookup family, pooling, subscriptions, and where state lives.
-
GetComponent<T> ?? AddComponent<T>never adds (HARD). A missing component is Unity fake-null.??is C# null and does not use==. The left side is a live wrapper, Add is skipped, the next field write throws “no X attached”. Writevar c = GetComponent<T>; if (c == null) c = AddComponent<T>;. -
IS-PATTERN-BYPASSES-FAKE-NULL - a C#
ispattern on a Unity object is the same bug as??.if (go.GetComponent<T> is T found),obj is nullandobj is not nulltest the C# reference, not Unity's overloaded==, so a destroyed object or a missing-component wrapper passes as live. Writevar found = go.GetComponent<T>; if (found != null). Found in review of an editor tool that reused child components by name; rule-check greps??and?., notis, so review is the only gate. -
Logic ↔ presentation via events, paired by name. A logic owner (
Server_X) and its view (UI_X) share a name. The logic side exposespublic static event Action<…>and never reaches into the view (suffix the event by origin when useful —_Rpc,_Target). The view subscribes inOnEnable, unsubscribes inOnDisable(handlers namedHandleOn<Event>), and drives its child widgets through serialized refs. Data flows one way: logic → event → view; the view talks back through messages/commands, never by mutating logic directly. Seeexamples/system-ui-split.mdfor a full annotated chain. -
Re-expose designer-facing hooks as
UnityEvent. When animations / audio / several UI elements must react to one event, add[SerializeField] UnityEvent on<Thing>on the view and wire it in the inspector — no code change to add another listener. -
Data and visuals stay separate. A model/visual is a prefab reference the data/logic side owns and can swap — never bundled into the data type. Keeps full dynamic control of models (runtime swap, network-driven appearance) and keeps the prefab under your control independent of state.
-
State lives in plain data, not
MonoBehaviourfields. Hold game/domain state in explicit data objects (structs / state classes) and pass normalized payloads across boundaries — don't hard-wire rule assumptions into MB fields. Keeps logic editable as rules change and portable off the scene. -
Wire references at design time. Serialized
[SerializeField]fields for sibling/child/asset refs. No runtimeGetComponentfor things known at author time. -
No
Resources.Load*and noResources/folders in our own assets. The folder is the anti-pattern as much as the call: EVERYTHING under aResources/folder is force-built into every player (even if nothing references it) and is exempt from build stripping — andResources.Loadis a stringly-typed lookup that silently returnsnullon a wrong path/case (no compile error, the system just quietly does nothing). Wire a[SerializeField]ref instead — it's GUID-based, so it can't typo AND the asset can be moved freely without breaking the ref — and keep the asset in a normal folder. Exempt: (1) third-party packages that require their ownResources/(DOTween doesResources.Load("DOTweenSettings")internally, soAssets/Resources/DOTweenSettings.assetmust stay; likewise TMP, SRDebugger, UniStorm, …) — flag vendor, don't refactor it; (2) Editor-onlyResources.FindObjectsOfTypeAll<EditorWindow>(the standard way to enumerate open editor windows — notResources.Load, never in a build). An OWN asset sitting in aResources/folder is a bug: relocate it (the wired GUID ref keeps working). Audit:find Assets -type d -name Resources— every hit must be vendor orDOTweenSettings.asset. -
NEVER
FindObjectsByType/FindFirstObjectByType/FindAnyObjectByType(or legacyFindObjectOfType) in runtime code. It is the worst way to get a reference: a full O(n) scene scan on every call, and it silently excludes inactive GameObjects — a deactivated object reads as "doesn't exist," a brutal debugging trap (a panel toggled off looks un-spawned). It also couples logic to scene contents instead of explicit refs. Replace, in order of preference: (1) a wired[SerializeField]ref; (2) astaticinstance/event the owner publishes (set inOnEnable, cleared inOnDisable) — reference the type, not a scan; (3) an ECSEntityQueryfor entity data; (4) a small registry candidates opt into. Bootstrap "spawn one only if none exists" guards usestatic Instance != null, never a scan. Editor-only tooling is exempt (setup / diagnostic menus andEditorWindows underEditor/— scanning the scene they build or inspect is the tool's whole job); the ban is for code that runs in a build. (DOTS: the same ban inside ECS systems, see ECS core mechanics, NO-SCENE-SCAN-INSIDE-SYSTEMS.) -
Asmdef references are not transitive. If a file
usings a namespace, that file's own asmdef must reference the assembly that defines it — even if a referenced asmdef already does. -
Don't reimplement engine features in C#. Check for native support before scaffolding (object pooling, sound randomization, curves, etc.).
-
POOL-RENT-PURGES-DEAD — a GameObject pool must fake-null-check every entry it hands out, purging dead ones, because pooled instances die out from under the pool. Scene teardown, an in-play domain reload (compile while playing), or any external Destroy kills pooled GOs while the pool's C# list keeps the wrapper refs — the next rent then throws MissingReferenceException on
SetActiveEVERY time, forever (the dead entry goes back in the pool). Rent loopswhile (stack.Count > 0)popping until it findsinstance != null(Unity fake-null catches destroyed), falling through to a fresh Instantiate; return paths drop dead instances instead of re-pooling them. (a mobile strategy game: pooled projectile fliers died in a compile-during-play domain reload; every arrow rent after that threw.) -
COROUTINE-LIFETIME-IS-THE-STARTER —
sequencer.StartCoroutine(child.Routine)is stopped by the SEQUENCER dying, never by the child. Unity stops a coroutine when the MonoBehaviour it was started on is destroyed (or that GameObject is deactivated); the object whose method produced theIEnumeratoris only a closure overthisand has no say. The cross-object form is normal wherever a sequencer doesyield return StartCoroutine(child.Step)to order an animation — and it is exactly where the child's own objects get torn down mid-flight, because the sequencer that outlives them is often the thing that tears them down (a re-entered pass that clears the row it is about to refill). The iterator then resumes next frame holding destroyed instances and throwsMissingReferenceExceptionat the first.transform; the stack names the child, so the lifetime bug reads as a bug in the file that cannot fix it. Any teardown landing inside a per-item reveal loop reproduces it — a 0.15 s grow per item is 0.15 s of exposure per item, not a rare window. Fix: the iterator checks the objects it OWNS andyield breaks. Put the check on the statement immediately afteryield return— that is where execution resumes, and a check at the top of the loop body leaves the resume-side dereference unguarded. Test with== null(Unity fake-null catches destroyed), never?./??(UNITY-FAKENULL-COALESCE above). One check per suspension point covering the objects that can actually die is the whole job, not a null test per line. Where the throw finally lands is not this file's frame: on a networking host it can be a disconnect (HOST-RPC-THROW-IS-A-MIGRATION-LOOP in the mirror page), and a coroutine that latches a guard bool needs COROUTINE-GUARD-FINALLY in the scene flow page as well. -
PlayerPrefs is not the player account. Wallet, inventory, cosmetics, XP, energy, loadout, IAP entitlements persist through UGS first (protected Cloud Save + Cloud Code). Prefs are last-known HUD cache / unsigned fallback only. Full rule: UGS-PROFILE-SERVICE-FIRST in the ugs page. Graphics, volume, and hotkeys are device settings, and they do NOT go in PlayerPrefs either (next rule).
- EDITOR-PLAY-READS-ITS-OWN-PREFS (HARD; found) — PlayerPrefs in the Editor is a
different store from the built player's, so Editor Play never runs with the settings the player
chose.
On Windows the Editor reads
HKCU\Software\Unity\UnityEditor\<company>\<product>and the player readsHKCU\Software\<company>\<product>.persistentDataPathis one folder for both. Measured on one machine: the player ran TAA High, ultra shadows, 120 fps and vSync; Editor Play ran TAA, high shadows, 60 fps and no vSync. A "the build looks different" bug cannot be reproduced in the Editor in that state. - The rule. Every value the player chooses — graphics, audio, input rebinds, camera, gameplay
options — lives in ONE file under
persistentDataPaththat Editor Play and the player both read. Runtime code never callsPlayerPrefs.tools/rule-check.shgates it. - The shape. One store type opens the file at
SubsystemRegistration(player start and every Play entry, with or without domain reload). An EditorInitializeOnLoadMethodopens it for Edit Mode. Never open it lazily on first read. Save merges only this host's changed keys into the file as it is on disk, so an Editor and a player running side by side keep each other's edits. The project's settings tab shows the file path and a Reload button. - The only exception is a value that must differ per host: a local character id that lets the
Editor and a player join as two players, or a rectangle in that host's screen pixels. It carries
// rule-check:allow per-host: <why>on the line. - New project: the store exists before the first settings menu. Existing project: move the settings in one change, and say which values reset once (there is no migration from the two registry copies unless you write one).
- Tests write the real store. An EditMode test that calls the game's settings API writes the
Editor's live prefs. Measured: the Editor's preset label read the top tier while its values were
the tier below, because a test applied two presets and restored 5 of 20 keys. Point the store at a
scratch file for the whole test assembly (a namespace
[SetUpFixture]), never restore key by key. -
PERSISTED-INDEX-OUTLIVES-THE-ARRAY — a saved value that indexes an authored array must be an id, or be clamped on read.
TowerInfos[PlayerPrefs.GetInt("SelectedTowerIndex")]with no bounds check means shortening that array throws in the boot scene'sAwakeon every device that already saved a higher index — the game will not start, only there, and no tester catches it because a tester's prefs are fresh. Persist a stable id (SELECTION-PANEL-CATALOG in the content production page), or clamp / reset the value before it touches the array. (a tower-defense game,TowerSelection.LoadSelectedTower.) -
(HARD — ZERO EXCEPTIONS) Runtime code NEVER calls
GetComponent*,AddComponent,FindObjectsByType/FindFirstObjectByType/FindAnyObjectByType/FindObjectOfType, orGameObject.Find. Every reference is a[SerializeField]wired at design time on a PREFAB. This supersedes and hardens the softer wording elsewhere in this file — treat any such call in code that ships in a build as a review defect, not a style preference. The rule exists because each of these is a different flavour of the same mistake: resolving at runtime what should have been resolved at author time. - Need a component on an object you spawn? The object is a PREFAB with that component
already on it, referenced by a
[SerializeField]/SOfield andInstantiated. "I created the GameObject in code so I have to AddComponent" is the tell that the object should have been a prefab. Applies to bootstrap/DDOL service hosts, pools (author N children on the prefab, hold them in a serialized array), and singletons alike. - Need another system's object? A
static Instance/event the owner publishes, anEntityQuery, or an opt-in registry — never a scan. - Caching a scan result is NOT compliance.
if (_x == null) _x = FindObjectOfType<X>is still the banned call; it just runs less often. Wire the ref. - Editor-only tooling is the ONLY exemption (code under
Editor/or#if UNITY_EDITORthat inspects/builds the scene — that is the tool's job). -
Corollary — never
??or?.aGetComponent/UnityEngine.Objectresult — that is the fake-null rule at the top of this file (UNITY-FAKENULL-COALESCE). InsideOnEnable/Awakethe resultingMissingComponentExceptioncan be swallowed, so the feature just silently does nothing. -
UNITYEVENT-MULTICAST-AUDIT — changing what a wired method DOES means auditing every invoker's full persistent-call list, not just the method body. A UnityEvent (
Button.onClick, and every other inspector-wired event) is a multicast list, and its other entries are completely invisible from C#: reading the handler proves nothing about the sibling calls sitting next to it on the same control. So "I only touched code, the scene can't be involved" is a false premise — repointing a handler silently changes the meaning of a composite action. Before altering what any inspector-wired method does, dumpm_OnClick.m_PersistentCalls.m_Callson every control that invokes it and decide per sibling call whether it still belongs on that press or has moved to a later step in the new flow. The failure is silent and looks like an unrelated bug: on a mobile card game, a Quickmatch button carriedOnButtonPress_QuickmatchandUI_Animator.PlayClip("Open_Quickplay"); turning the first into "open a confirm popup" left the second still sliding the menu out to the Searching screen, so the popup was animated off-screen, no search ever started, and the Cancel button — which only activates when a search starts — never appeared, stranding the player on a dead screen. Corollary for duplicated UI: a duplicated control inherits the original's persistent calls, so clear or re-point them (Unity does not warn), and re-check the ORIGINAL afterwards to prove the duplication didn't disturb it.
GETCOMPONENT-IS-TWO-FAILURES - caching fixes one of them (HARD)
A runtime lookup fails in
two INDEPENDENT ways, and caching addresses exactly one. An agent that only holds the cost argument
"fixes" a violation by hoisting it into Awake and leaves the real defect in place - which is why the
ban says caching a scan is not compliance.
Failure 1 - COST (caching does fix this).
- GetComponents<T> / GetComponentsInChildren<T> return a NEW ARRAY per call - an allocation
on every invocation. The List<T> overloads exist to avoid exactly that.
- Documented: GameObject.TryGetComponent exists because it "doesn't allocate memory in the
Editor when the requested component does not exist" (Unity Scripting API, GameObject.TryGetComponent).
So a per-frame FAILED GetComponent allocates every frame in the editor - and that allocation is
absent from a player profile, which is how it hides.
- Same page, also documented: TryGetComponent "doesn't search the hierarchy of parent or child
GameObjects". The InChildren/InParent forms DO - their cost scales with the subtree, and they
are gated on active state in a way the direct form is not.
- The direct form crosses the managed->native boundary to resolve a type against that GameObject's
components. (Mechanism, not a documented figure - do not quote a number for it.)
- Per frame it re-derives a CONSTANT: a prefab's component set is fixed at author time.
Failure 2 - COUPLING (caching does NOT fix this; this is the one that becomes tech debt).
- The dependency is invisible. Nothing in the prefab, the inspector or the authored data records
that this type needs a MeshRenderer. You find out at runtime, when it is null.
- It breaks late and elsewhere. A renamed, removed or re-parented component is not a compile
error; the lookup yields Unity fake-null and the throw lands in another frame, in another system.
- It destroys blast radius - you cannot enumerate who depends on what by reading authored data,
the exact property the event topology protects (CHAIN-MAP-IS-A-DECOUPLING-AUDIT).
- It is a second acquisition path. Beside a [SerializeField] of the same type that is
WIRED-AND-FETCHED (the C# style page ONE-PATH-CODE-SHAPE): two sources for one value, and the day
the wire breaks the code keeps running WRONG instead of failing.
- ?? / ?. on the result is a correctness bug on top: Unity overloads == for fake-null, but the
null-coalescing operators use real CLR null and bypass the override silently.
[SerializeField] wired on the prefab fixes BOTH: no runtime cost, and the dependency becomes
authored, inspector-visible, GUID-stable across moves and renames, and loud at author time. That is
why the rule is "wire it", not "cache it".
A class can be dead by instantiation and alive by static event (HARD)
Before moving a script nobody instantiates out of the project, grep for invocations of its static events and
static fields, not just for Instance / GetComponent / scene GUID references. A static
Action fires perfectly well when the declaring class has never been constructed and is not in
any loaded scene — the event is a global slot on the type, and the type is loaded by the
assembly, not by a GameObject.
The shape that hides it:
// LegacyController.cs — not in any shipping scene, "obviously dead"
public static Action CardSliding;
public static int gameSpeed = 1;
// LiveThing.cs — in the shipping scene
LegacyController.CardSliding.Invoke; // runs, and a live subscriber HEARS it
Time.timeScale = LegacyController.gameSpeed; // reads a static field off a dead class
Removing LegacyController here breaks the build in two ways and silently removes a working
behaviour, even though every "is it used?" check based on instantiation says the file is dead.
Checklist before retiring a script (move it aside into a backup folder outside Assets/; never delete it):
- Scene/prefab attachment by GUID, not by name —
grep -rl "<guid>" --include=*.unity --include=*.prefab. Then check whether those scenes are in Build Settings; a script attached only to a non-shipping scene is not thereby dead (see 2). - Static event invokers and static field readers from files that ARE live.
- Type usage, including types the file declares. Two types with the same short name in
different namespaces is common in a half-migrated codebase — a global
class Cardand aMyGame.Cardstruct will each look "referenced everywhere" to a bare grep. - The compile closure. Obsolete clusters reference each other, so they move out as a set or not at all. Let the compiler close it: move the set aside, recompile, fix what falls out, repeat.
- Runtime-only attachment. A component added with
AddComponent<T>has no GUID reference in any scene and will look unattached. Grep for the type name too.
⚠ Removing a redundant trigger is still a behaviour change to state out loud. If the legacy path was a third call into the same effect, deleting it is correct — but say so in the report rather than claiming "no behaviour change", because the effect's timing/overlap changes.
DELETED-SCRIPT-TAKES-ITS-ATTACHMENTS (HARD)
The checklist above asks "is it dead?". This is the other half: the commit that deletes a script,
strips a template or vendor code folder, or removes a package also removes every component that
attaches it — in scenes, prefabs and .asset files (volume profiles, ScriptableObject sub-assets).
A missing script is never "debt to log later"; left behind, nobody owns it.
Measured: one template strip deleted 95 scripts in one commit and left 57 dead components across 6 scenes and 13 prefabs, 5 of those scenes still enabled in the global build list, for 7 weeks. A later render-pipeline package removal left its per-light, per-decal, per-camera and volume-override data as 100 more. Nobody noticed because the product never loaded them.
Sweep (both halves, same commit):
1. For every tracked first-party .unity, .prefab and .asset, collect m_Script GUIDs; follow
m_SourcePrefab into placed prefabs.
2. Resolve each GUID in the Editor: AssetDatabase.GUIDToAssetPath non-empty AND
MonoScript.GetClass != null. A GUID that resolves to a .cs whose assembly is excluded by a
defineConstraints symbol is still missing at runtime — a file grep calls it fine.
3. Per hit: nothing live references the asset (git grep <asset guid> outside the dead island) →
move the asset aside into the backup folder; still referenced → remove only the dead component (Editor:
GameObjectUtility.RemoveMonoBehavioursWithMissingScript; offline: drop the !u!114 document
and its - component: line, then confirm that fileID appears nowhere else).
4. Drop deleted scenes from EditorBuildSettings — the global list feeds every build profile that
does not override it, even when the project's own build menu filters it.
Vendor / local-only packs are reported, not gated. A release cut treats a missing script in a first-party asset as a blocker.
⚠ Resources paths are global across packs. Resources.LoadAll<T>("Managers") loads every
Resources/Managers/ folder in the project and deserialises each asset before filtering by T;
one pack's loader then logs "The referenced script (Unknown) on this Behaviour is missing!" for
another pack's dead assets. The stack trace names the loader, never the owner of the dead asset.
- NO-AUDIOLISTENER-IS-SILENT — a UI-only shell with zero
AudioListenerplays everyPlayOneShotinto nothing, with no warning. A UITK menu has no camera-with-listener by default; put the listener on the persistent audio host, in the boot scene, and assert one exists in the pre-ship check. Sibling importer trap: a clip imported withnormalize: 1is re-pumped to full peak, so a deliberately quiet click stays a loud tick after you swap the wav — authornormalize: 0and scale the clip. (a card game /.) - AUDIOMIXER-SETFLOAT-IN-AWAKE-IS-DROPPED —
AudioMixer.SetFloatfromAwake/OnEnableis silently lost. The mixer applies exposed-parameter writes only once the audio system is up, so a settings store that binds saved volumes inAwakereads back 0 dB at boot while its own state says 0.8 — the first slider drag "fixes" it and nobody sees the bug. Bind (and re-apply) fromStartor later, and probe it: after play entrymixer.GetFloat("MusicVol")must equal the stored linear → dB value, not 0. (a tower-defense game.) -
NATIVE-HANDLE-NEVER-IN-A-MONOBEHAVIOUR-FIELD-INITIALIZER —
new MaterialPropertyBlock(orMaterial,Mesh,Texture, any engine object with a native side) in aMonoBehaviourfield initializer throwsCreateImpl is not allowed to be called from a MonoBehaviour constructor, once, at deserialization. The exception is logged and swallowed, the field stays null, and the first use blows up every frame from a call site that never mentions the constructor. It appears when a one-path sweep flagsif (_block == null) _block = newas a lazy acquire and the fix is moved into the declaration: the allocation site for a native handle isAwake(paired withOnDestroyforMaterial/Mesh), never the initializer and never lazily. Plain managed state (List<T>,StringBuilder, structs) is fine in the initializer. (.) -
INACTIVE-STILL-COUNTS - deactivating a component does not take it out of a collector that scans with
GetComponentsInChildren<T>(true). A setup step that registers every child owner including inactive ones (so disabled LODs or pooled parts are still wired) keeps aSetActive(false)owner registered, and whatever reads the registry still sees its data. Measured: a hidden cloud shell copied from a template kept a 5,000-9,000 m cloud deck on an airless body, and that deck switched the weather view's clouds and fog on at runtime. To remove a feature, delete the owner (or its registration), then have the validator refuse the leftover owner AND the derived value (here: a non-zero deck). (0.5.8.)
⚠ Watch your own replacement text. A verification assert "OldClass" not in source will fail
on a comment you just wrote that mentions OldClass. Assert on the code shape
(OldClass\s*[.(]), not the bare identifier.
FORCE-TO-MONO-GATES-NORMALIZE . The AudioClip importer's Normalize applies only when Force To Mono mixes the clip down; a stereo clip keeps its source level. Stereo footsteps played about 20 dB louder than mono ones at the same 0.25 volume. Compare surfaces by measured loudness x volume, never by the volume number.
WRITE-THE-ASSET-THE-LIVE-OBJECT-LOADED . A profile value written to a weapon folder's profile pair changed
nothing live: the character rig loaded its own copy of that profile from another folder (0.50 against the 0 written).
Before writing a presentation value, read AssetDatabase.GetAssetPath of the object the live component holds and write
that asset through the code that owns it. Same-named assets in two folders are the usual cause.