---
title: "Runtime references & object lifecycle"
slug: /workflows/references-lifecycle/
markdown: /workflows/references-lifecycle.md
section: architecture-csharp
tier: 2
summary: "How objects get references to each other, pooling, subscriptions, and how references die."
sources: ['unity/references.md']
canonical: https://unityworkflowlibrary.dev/workflows/references-lifecycle/
read_first: /read-first.md
---

> Read first, on every prompt: [non-destructive rules](/read-first.md).

# 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”. Write `var c = GetComponent<T>; if (c == null) c = AddComponent<T>;`.

- **IS-PATTERN-BYPASSES-FAKE-NULL  - a C# `is` pattern on a Unity object is the same bug as `??`.**
  `if (go.GetComponent<T> is T found)`, `obj is null` and `obj is not null` test the C# reference, not
  Unity's overloaded `==`, so a destroyed object or a missing-component wrapper passes as live. Write
  `var found = go.GetComponent<T>; if (found != null)`. Found in review of an editor tool that reused child
  components by name; rule-check greps `??` and `?.`, not `is`, 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 exposes `public static event Action<…>` and never reaches
  into the view (suffix the event by origin when useful — `_Rpc`, `_Target`). The view subscribes in
  `OnEnable`, unsubscribes in `OnDisable` (handlers named `HandleOn<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. See `examples/system-ui-split.md` for
  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 `MonoBehaviour` fields.** 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 runtime `GetComponent` for things known at author time.

- **No `Resources.Load*` and no `Resources/` folders in our own assets.** The folder is the
  anti-pattern as much as the call: EVERYTHING under a `Resources/` folder is force-built into every
  player (even if nothing references it) and is exempt from build stripping — and `Resources.Load` is
  a stringly-typed lookup that silently returns `null` on 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 own `Resources/` (DOTween
  does `Resources.Load("DOTweenSettings")` internally, so `Assets/Resources/DOTweenSettings.asset`
  must stay; likewise TMP, SRDebugger, UniStorm, …) — flag vendor, don't refactor it; (2) Editor-only
  `Resources.FindObjectsOfTypeAll<EditorWindow>` (the standard way to enumerate open editor windows
  — not `Resources.Load`, never in a build). An OWN asset sitting in a `Resources/` folder is a bug:
  relocate it (the wired GUID ref keeps working). Audit: `find Assets -type d -name Resources` — every
  hit must be vendor or `DOTweenSettings.asset`.

- **NEVER `FindObjectsByType` / `FindFirstObjectByType` / `FindAnyObjectByType` (or legacy
  `FindObjectOfType`) 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) a `static` instance/event the owner publishes (set in `OnEnable`,
  cleared in `OnDisable`) — reference the type, not a scan; (3) an ECS `EntityQuery` for entity data;
  (4) a small registry candidates opt into. **Bootstrap "spawn one only if none exists" guards use
  `static Instance != null`, never a scan.** **Editor-only tooling is exempt** (setup / diagnostic
  menus and `EditorWindow`s under `Editor/` — 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 `using`s 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 `SetActive` EVERY time, forever (the dead entry goes back in the
  pool). Rent loops `while (stack.Count > 0)` popping until it finds `instance != 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
  the `IEnumerator` is only a closure over `this` and has no say. The cross-object form is normal
  wherever a sequencer does `yield 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 throws
  `MissingReferenceException` at 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 and `yield break`s. Put the check on the statement
  **immediately after `yield 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
  reads `HKCU\Software\<company>\<product>`. `persistentDataPath` is 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 `persistentDataPath` that Editor Play and the player both read.
    Runtime code never calls `PlayerPrefs`. `tools/rule-check.sh` gates it.
  - **The shape.** One store type opens the file at `SubsystemRegistration` (player start and every
    Play entry, with or without domain reload). An Editor `InitializeOnLoadMethod` opens 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's `Awake` on 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`, or
  `GameObject.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]`/`SO` field and `Instantiate`d. "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, an
    `EntityQuery`, 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_EDITOR`
    that inspects/builds the scene — that is the tool's job).
  - **Corollary — never `??` or `?.` a `GetComponent`/`UnityEngine.Object` result** — that is the
    fake-null rule at the top of this file (UNITY-FAKENULL-COALESCE). Inside `OnEnable`/`Awake` the
    resulting `MissingComponentException` can 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, dump `m_OnClick.m_PersistentCalls.m_Calls` on 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 carried `OnButtonPress_Quickmatch` **and** `UI_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](/workflows/csharp-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:

```csharp
// 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):

1. **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).
2. **Static event invokers and static field readers** from files that ARE live.
3. **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 Card` and a
   `MyGame.Card` struct will each look "referenced everywhere" to a bare grep.
4. **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.
5. **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 `AudioListener` plays every
  `PlayOneShot` into 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 with `normalize: 1` is re-pumped to
  full peak, so a deliberately quiet click stays a loud tick after you swap the wav — author
  `normalize: 0` and scale the clip. (a card game /.)
- **AUDIOMIXER-SETFLOAT-IN-AWAKE-IS-DROPPED — `AudioMixer.SetFloat` from `Awake`/`OnEnable` is
  silently lost.** The mixer applies exposed-parameter writes only once the audio system is up, so a
  settings store that binds saved volumes in `Awake` reads 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) from `Start`
  or later, and probe it: after play entry `mixer.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` (or `Material`,
  `Mesh`, `Texture`, any engine object with a native side) in a `MonoBehaviour` field initializer throws
  `CreateImpl 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 flags `if (_block == null)
  _block = new` as a lazy acquire and the fix is moved into the declaration: the allocation site for a native
  handle is `Awake` (paired with `OnDestroy` for `Material`/`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 a `SetActive(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.
