Tier 2 · Scene flow and lifecycle

App & scene flow, lifecycle guards

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

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)

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 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 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 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 .Runs 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.