Topic 2 · Scene flow
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.
- Bootstrap - project/stack set up once, then a bootstrap scene creates services and worlds behind a loading cover (never on a menu click frame).
- 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.
- 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.
- 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. - Teardown - unload,
Resources.UnloadUnusedAssets, cleanup owned byOnDestroy, 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 (
UnloadUnusedAssetsafter unload); DDOL-SINGLETON-SOLO-GO (OnDestroycleanup); 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 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 = trueat call time,_busy = falseas the coroutine's final statement, and an early-returnif (_busy) return;at the entry. AnyStopCoroutineon 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'sfinallyblocks onStopCoroutine/Dispose AND on exception unwind, and C# allowsyield returninsidetry { } finally { }(just not try/catch), so wrap the whole routine body and clear the guard infinally; ALSO clear it at everyStopCoroutinesite 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.isEditorALONE, 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,LogWarningat 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)
StopCoroutineon the load routine does not cancel an already-startedLoadSceneAsync(Game, Additive)— restarting the owner stacks Game scenes and bootstraps. First click wins; ignore laterStartMatchLoadwhile 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_busybool the player cannot see is not a lock —SetEnabled(false)the button on the same line you set the flag, before anyawait. 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
Scenefor the overlap window, resolve spawn points only afterisDoneso the new scene'sOnEnablehas run, and callResources.UnloadUnusedAssetsafter the unload:UnloadSceneAsyncfrees GameObjects but NOT the terrain data, splatmaps, meshes and materials they referenced, which accumulate across every swap. Fail loudly whenLoadSceneAsyncreturns null — that is a scene missing from the build list, and it fails silently at runtime. -
DDOL-SINGLETON-SOLO-GO — a component whose
AwakecallsDontDestroyOnLoad(gameObject)(or destroys duplicates withDestroy(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'OnDestroynever fires on scene unload — cleanup they own (entity teardown, unsubscribes) silently leaks; (2) on scene re-entry the duplicate guardDestroy(gameObject)kills the FRESH GO — including the innocent neighbours — leaving the new scene without them. AuditAwakeforDontDestroyOnLoadbefore 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 beforeJoinCompleteand 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 projectdocs/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/totalstayed 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 projectdocs/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 != sentinelFIRST, 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:PlanetSurfaceReadinessaccepted three identical bare-sphere heights of exactly 0.0 from SGTSchedulePoints- 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, soFromToRotation(+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 slammingroot.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.AlignTono-opped its rotation, translated the planet root by -4.48e6 m on Y, and the drop gate sawrenderedYMax=-4484426withcoveringTiles=0; itsDirtyScatterSpawnersre-dirties props only, never landscape tiles. See STABLE-IS-NOT-CONVERGED in this file - same gate, same slice.) NOT line-gateable: aFromToRotation.*TransformDirectiongrep 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
LoadSceneAsyncbefore 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 asceneLoaded/activeSceneChangedprobe that logsTime.frameCount. Save time by making each scene cheaper to integrate, not by overlapping requests.