---
title: "App & scene flow, lifecycle guards"
slug: /workflows/scene-flow/
markdown: /workflows/scene-flow.md
section: scene-flow
tier: 2
summary: "Load matches, swap scenes and guard every flow: single-flight entry, guards cleared in finally, stubs that fail closed."
sources: ['unity/scene-flow.md']
canonical: https://unityworkflowlibrary.dev/workflows/scene-flow/
read_first: /read-first.md
---

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

# App & scene flow, lifecycle guards

> Load matches, swap scenes and guard every flow: single-flight entry, guards cleared in finally, stubs that fail closed.

Loading a match, swapping scenes, and the guards around those flows: single-flight entry, coroutine guards that must clear in `finally`, and stubbed services that must fail closed.

## Overview - the app & scene flow (read this first)

Scene flow is the primary Unity topic: every project runs the same sequence, and each rule below guards one step of it.

1. **Bootstrap** - project/stack set up once, then a bootstrap scene creates services and worlds behind a loading cover (never on a menu click frame).
2. **Load** - single-flight entry into a match or level; the loading cover renders before work, work is frame-budgeted, progress is phase-weighted, and the gate opens only on content the client can verify.
3. **Transitions / additive** - load the new scene before unloading the old one; queued additive loads integrate one per frame; per-scene settings (fog, lighting) follow the active scene.
4. **Runtime lifecycle** - coroutine guards clear in `finally`, DDOL singletons live alone, stubs fail closed, readiness gates reject sentinels and plateaus, verify the driver not the display.
5. **Teardown** - unload, `Resources.UnloadUnusedAssets`, cleanup owned by `OnDestroy`, and return-to-menu that a fast user cannot latch.

## Contents (rules in this file, by flow step)

Rules keep their original order and text below; this list maps them onto the flow.

- **Load:** MATCH-LAUNCH-SINGLE-FLIGHT; LOADING-COVER-MUST-RENDER-BEFORE-WORK; LOADING-WORK-IS-FRAME-BUDGETED; LOADING-PERCENT-IS-PHASE-WEIGHTED; LOADING-GATES-ON-VERIFIABLE-CONTENT
- **Transitions / additive:** Additive scene swaps (load before unload); QUEUED-ADDITIVE-LOADS-INTEGRATE-ONE-PER-FRAME
- **Runtime lifecycle:** COROUTINE-GUARD-FINALLY; DDOL-SINGLETON-SOLO-GO; AD-SIM-FAIL-CLOSED; STABLE-IS-NOT-CONVERGED; TOTAL-COUNT-PLATEAU-IS-BLIND-TO-REPLACEMENT; REFRAME-ROTATION-FROM-OWN-AXIS-IS-IDENTITY; DRIVEN-VALUE-IS-NOT-THE-SOURCE-OF-TRUTH
- **Teardown:** Additive scene swaps (`UnloadUnusedAssets` after unload); DDOL-SINGLETON-SOLO-GO (`OnDestroy` cleanup); COROUTINE-GUARD-FINALLY (return-to-menu)

## Related scene rules elsewhere

Each of these links back here with a "See also" line.

| Flow step | Rule / section | File |
| --- | --- | --- |
| Bootstrap | Identify the starting point (scan, confirm, record) | the project setup page |
| Bootstrap | 2. Bootstrap the selected stack | the [Project setup](/workflows/new-unity-project/) page |
| Bootstrap / load | Loading/bootstrap systems placement; subscene "Loading Assetcache" stall; SUBSCENE-RUNTIME-BOUNDARY | the ecs core page |
| Load | Spawned objects vs app-driven scene flow | the netcode gameobjects page |
| Transitions | FOG-BELONGS-TO-THE-ACTIVE-SCENE | the rendering page |
| Editor | Building a scene from an editor script; SCENE-SWITCH-UNLOADS-ASSETS-LOADED-BEFORE-IT | the editor tooling page |
| Editor | Editor capture rigs and additive scene swaps | the editor tooling page |
| Editor / agent | Scene-tree map; PLAY-TRANSITION-GOES-ALONE | the [Live play testing via Unity MCP](/workflows/live-play-testing-unity-mcp/) page |
| Runtime lifecycle | See also: SILENT-SIGNAL-COUNTS-AS-SATISFIED, FIRST-TIME-FLAG-IS-NOT-A-RETRY-FLAG, BUDGETING-SPREADS-COST-DOES-NOT-REMOVE-IT, TIMEOUT-ADVANCE-HIDES-THE-SCREEN | the gameplay page |
| Load / runtime | See also: NETWORKED-OBJECTS-OUTLIVE-MENU-SCENES-ONLY-IF-PLACED-RIGHT, RESTORE-BEFORE-CAPTURE | the [Netcode overview](/workflows/networking-overview/) page |
| Editor / agent | SCENE-OWNERSHIP-BY-ASSET | your agent instructions |

## Rules


- **COROUTINE-GUARD-FINALLY — a bool guard latched around a coroutine is cleared in the iterator's
  `finally`, never only at the routine's last line.** The lethal shape: `_busy = true` at call
  time, `_busy = false` as the coroutine's final statement, and an early-return
  `if (_busy) return;` at the entry. Any `StopCoroutine` on that routine (a competing flow entry,
  a scene-flow restart) — or an unhandled exception mid-routine — skips the last line and latches
  the guard for the REST OF THE PROCESS: the guarded action silently no-ops forever, and only an
  app restart recovers. Unity runs the iterator's `finally` blocks on `StopCoroutine`/Dispose AND
  on exception unwind, and C# allows `yield return` inside `try { } finally { }` (just not
  try/catch), so wrap the whole routine body and clear the guard in `finally`; ALSO clear it at
  every `StopCoroutine` site that cancels the guarded flow (belt + suspenders — a stuck flag must
  have no surviving path). The trigger window is usually a FAST USER: UI becomes interactive a
  beat before the old routine's final housekeeping lines run, so a quick next action kills it
  mid-flight. (Seen in a mobile strategy game: replaying a match 0.85s after leaving stopped
  ReturnToMenuRoutine after `_returningToMenu = true` — every later pause LEAVE, eliminate-card
  LEAVE, and results CONTINUE was a silent no-op until the app was force-killed; read as
  "can't leave game / continue does nothing".)

- **AD-SIM-FAIL-CLOSED — gate a simulated/stubbed reward on `Application.isEditor` ALONE, never on
  "the SDK keys are unset".** Keys-unset is precisely the state a real release ships in when
  someone forgets to paste them, so OR-ing it into the simulation branch
  (`useSimulation => !settings.IsConfigured || Application.isEditor`) means a shipped build fakes
  a 1.5s "ad", never contacts the network, and **grants the reward for free** — no ad, no error,
  no revenue. Three states, not two: editor → simulate (so the flow stays play-testable); build
  with keys → real SDK; build without keys → **no reward entrypoint at all** (hide the button,
  `LogWarning` at init). The general rule: a stub that stands in for a paid or verified external
  service must fail CLOSED outside the editor, because the misconfigured case is a shipping
  accident, not a development convenience. Corollary for rewarded video specifically: the editor
  has no ad inventory, so real ad rendering is only ever testable on device. (a mobile card game XP ad-double.)

- **MATCH-LAUNCH-SINGLE-FLIGHT — a Play / Quick Match / Host click must start at most one
  load and one listen-server.** Three ways this leaks "many servers" from a mash:
  (1) `StopCoroutine` on the load routine does **not** cancel an already-started
  `LoadSceneAsync(Game, Additive)` — restarting the owner stacks Game scenes and
  bootstraps. First click wins; ignore later `StartMatchLoad` while a forward load
  is in flight (return-to-menu may still be aborted for fast replay).
  (2) Creating NetCode/ECS worlds on the **menu click frame** forces a PanelRenderer
  reload that wipes the "preparing" modal and re-enables the button. Start worlds
  behind the Loading scene, once.
  (3) A `_busy` bool the player cannot see is not a lock — `SetEnabled(false)` the
  button on the same line you set the flag, before any `await`. A CANCEL that only
  closes the modal while the load continues is a lie; omit it or actually abort.
  (Seen in a mobile strategy game: mashed QUICKMATCH after energy Cloud Code wait.)

- **Additive scene swaps: load the new scene BEFORE unloading the old one**, never the reverse —
  unloading first deletes the ground the player is standing on for the whole load. Scope every
  spawn/registry lookup by `Scene` for the overlap window, resolve spawn points only after `isDone` so
  the new scene's `OnEnable` has run, and call `Resources.UnloadUnusedAssets` after the unload:
  `UnloadSceneAsync` frees GameObjects but NOT the terrain data, splatmaps, meshes and materials they
  referenced, which accumulate across every swap. Fail loudly when `LoadSceneAsync` returns null — that
  is a scene missing from the build list, and it fails silently at runtime.

- **DDOL-SINGLETON-SOLO-GO — a component whose `Awake` calls `DontDestroyOnLoad(gameObject)` (or
  destroys duplicates with `Destroy(gameObject)`) must live ALONE on its GameObject.** Co-hosting
  it with scene-lifetime components hijacks them two ways: (1) the whole GO leaves the scene, so
  the neighbours' `OnDestroy` never fires on scene unload — cleanup they own (entity teardown,
  unsubscribes) silently leaks; (2) on scene re-entry the duplicate guard `Destroy(gameObject)`
  kills the FRESH GO — including the innocent neighbours — leaving the new scene without them.
  Audit `Awake` for `DontDestroyOnLoad` before adding any component to an existing singleton's GO.
  (Seen in a tower-defense game: content-catalog singleton on the sim director's GO — director
  survived into the menu with its entities leaked, and re-entry would have deleted the director.)

- **LOADING-GATES-ON-VERIFIABLE-CONTENT — hide the loading screen on content the CLIENT can verify
  (collision mirror ready, stream complete, presentation bound), never on "the owned ghost exists".**
  The ghost replicates at server phase `Spawned`, two phases before `JoinComplete` and long before
  the mirror the motor is gated on — the player gets a body that cannot move in a world that has
  not streamed. And server diagnostic statics are process-global, written only by server systems:
  correct in editor host mode, their initial value FOREVER in a client build. Client UI reads
  client-world components or a replicated field. (a survival game)

- **LOADING-WORK-IS-FRAME-BUDGETED — a preload system that drains its whole queue in one `OnUpdate`
  (or `.Run`s a heavy job) freezes the main thread.** The loading UI sits at 0% with a dead
  spinner, then snaps to complete; NetCode and input starve meanwhile. Budget ~12 ms of realtime per
  tick and always finish at least one item — wall clock is unchanged, only the UI becomes honest.
  Do not remove the budget "for faster loads". (a project `docs/pitfalls/terrain-mesh-chunk-gen.md:23`,
  `rock-volume-mining.md:10`.)
- **LOADING-PERCENT-IS-PHASE-WEIGHTED — a percent that reads one late phase sits at 0 and snaps.**
  `ready/total` stayed 0 while tiles loaded and entities spawned (all still awaiting a mesh).
  Weight the phases (tiles 20 + spawned 30 + meshed 50), let ONE owner write the stage string while
  it owns loading (a system publishing a bare "Generating…" over the percent line is a clobber),
  and ease the displayed value toward the target so residual jumps do not read as a freeze.
  (a project `docs/pitfalls/ui-login-flow.md:56`.)

- **STABLE-IS-NOT-CONVERGED - a readiness gate that polls until N identical samples must REJECT the
  source's not-ready sentinel.** 0, the default, the undeformed base value: a source that is not ready
  yet returns the SAME wrong number every poll, so "three identical samples" certifies the sentinel as
  converged. Test `sample != sentinel` FIRST, then stability. And a gate never "times out and admits
  anyway" - fail loud or keep holding; admitting on timeout ships the exact broken state the gate
  exists to catch. (a space sandbox game: `PlanetSurfaceReadiness` accepted three identical bare-sphere
  heights of exactly 0.0 from SGT `SchedulePoints` - real surface 2237 m - then timed out and dropped
  the player onto invisible terrain; the method's own comment already said early samples return 0.)

- **REFRAME-ROTATION-FROM-OWN-AXIS-IS-IDENTITY - `FromToRotation(root.TransformDirection(localAxis),
  worldAxis)` is a NO-OP in exactly the default authored case, and the translation that "fixes" it
  drags every already-generated child.** `TransformDirection(+Y)` under an identity root rotation
  returns `+Y`, so `FromToRotation(+Y, +Y)` is identity: the re-orientation silently does nothing on
  every root nobody hand-rotated. The code then reaches its target frame the only way left - by
  slamming `root.position` - and on a planet-scale root that is a megametre translation. Everything
  the root already generated (streamed LOD tiles, baked scatter, pooled props) rides along with no
  regeneration, and a readiness gate reading their WORLD bounds now measures geometry megametres from
  the spawn column. Derive the corrective rotation from the GEOMETRY - the direction from the root to
  the target point - never from the root's own local axis, and invalidate generated children whenever
  the root moves. (a space sandbox game, suspected not yet play-confirmed: `PlanetSurfaceSpawnFrame.AlignTo`
  no-opped its rotation, translated the planet root by -4.48e6 m on Y, and the drop gate saw
  `renderedYMax=-4484426` with `coveringTiles=0`; its `DirtyScatterSpawners` re-dirties props only,
  never landscape tiles. See STABLE-IS-NOT-CONVERGED in this file - same gate, same slice.)
  NOT line-gateable: a `FromToRotation.*TransformDirection` grep catches only the single-expression
  form and came back CLEAN on the real two-line shape. Do not add that pattern back for comfort.

## TOTAL-COUNT-PLATEAU-IS-BLIND-TO-REPLACEMENT

A gate that waits for a total to hold still (vertex count, entity count, bytes) cannot see work that replaces items without changing the total. a space sandbox game: SGT LOD swaps kept the triangle total flat for 15 s while still adding visuals; the plateau gate opened at 12.1 s, the honest gate at 27.0 s. Converge on the producer's own signal - an add/replace event quiet window (>= 1 s) AND its pending-work counters - and keep the old predicate scored in the success log so a regression shows as slack. See STABLE-IS-NOT-CONVERGED in this file.

## DRIVEN-VALUE-IS-NOT-THE-SOURCE-OF-TRUTH

Before you discard, overwrite or "verify no change landed", read the object that DRIVES the value,
never the object that DISPLAYS it. A driven component is a runtime output: it carries the authored
default on disk no matter what the driver says, so comparing it can only ever return "unchanged".
That is a FALSE NEGATIVE with the shape of a successful check - the discard then reads as verified-safe.

one shipped project, a near miss between two lanes: lane B was told to discard play-mode dirt before
a bake and first checked whether lane A's camera work was in it. They read the `Camera` component
(`field of view: 40`, unchanged) and concluded the rig edit had not landed. But Cinemachine writes that
field every frame from the active vcam's lens - the authored source is the vcam
(`CinemachineFreeLook.m_Lens.FieldOfView`, and `m_Orbits[i].m_Height/m_Radius`), which the check never
opened. The conclusion happened to be right only because of ordering: lane A's save came 20 minutes
later. Reverse the order and the same check discards a peer's saved work while reporting it verified.

The family is much wider than Cinemachine - anything with a driver has this shape:

| you read | it is driven by | read this instead |
| --- | --- | --- |
| `Camera.fieldOfView` / lens | active Cinemachine vcam | the vcam's `m_Lens` + `m_Orbits` |
| `Transform` pos/rot | Animator, constraint, IK, Timeline, physics | the clip / constraint source / rigidbody |
| `RectTransform` size / pos | a parent layout group or content fitter | the layout group's own fields |
| material property on a renderer | a MaterialPropertyBlock or a global | the block / the global setter |
| a serialized field on a driven prefab instance | the prefab, or a PropertyModification | the prefab asset + the override list |

Same shape as MECANIM-OVERRIDE-LAYER-WD (the animation page): verify the composited RESULT or
the authored SOURCE, never the middle layer that merely reflects one of them.

Operationally: a check whose negative result authorises a destructive act (discard, revert, overwrite,
delete) must be able to return positive. If you cannot name the edit that WOULD have made it fire, you
have not verified anything. Cheapest form for a scene: grep the `.unity` YAML for the driver's own
serialized fields and quote `file:line` - lane A confirmed the same edit as `Game.unity:23127
FieldOfView: 45` and `:23207-23208 m_Height: 20 / m_Radius: 24`, which the component read could not see.

- **LOADING-COVER-MUST-RENDER-BEFORE-WORK — displaying a loading panel does not mean the player has seen it.**
  Synchronous scene initialization or reset work can consume a minimum duration before the first
  rendered cover. A task/coroutine yield alone does not establish UI paint. Have the presentation
  owner acknowledge its current cover paint only after a later rendered frame; begin heavy work
  and the single owner's authored minimum from that acknowledgement. Include the cover in readiness
  with timeout/cancellation, reject stale transition receipts, and dismiss through the existing
  release event. Scheduled callbacks cancel their own receipts. Verify actual load/reset events
  behind a rendered input-blocking cover, missing-provider behavior, Retry and fresh entry; inspect
  a gameplay capture. (one shipped project: four production transitions and isolated gate proofs.)

## QUEUED-ADDITIVE-LOADS-INTEGRATE-ONE-PER-FRAME

- Starting a second additive `LoadSceneAsync` before the first finishes saves no frames: Unity integrates queued
  scene loads one per frame, in request order. Measured: the additive arena scene landed 2 frames after the main
  scene whether it was requested in parallel or in sequence. Check with a `sceneLoaded` / `activeSceneChanged` probe
  that logs `Time.frameCount`. Save time by making each scene cheaper to integrate, not by overlapping requests.
