Scene Flow

Async scene loading with a loading screen, cancellation, and additive multi-scene support.

One awaitable API for scene transitions · Automatic loading screen, progress events, cooperative cancel · Additive multi-scene layering built in · No-op replacement: NullSceneService

CGS moves your game between scenes through ISceneService — one awaitable API for every menu-to-gameplay-to-menu transition. You call await _scene.LoadAsync("Gameplay") and the service coordinates the rest: it shows your loading screen automatically, holds it on screen long enough to avoid a one-frame flash, publishes progress events for your progress bar, and supports cooperative cancellation (the player presses Esc, the load stops).

The same service also handles additive scenes — scenes loaded on top of the current one instead of replacing it, which is how you layer a UI scene or stream in a neighboring area. Single-mode and additive loading are two surfaces of one service; there is no extra setup.

What it does

  • One async call per transition. LoadAsync returns a Task<SceneLoadResult> you can await. Success and failure come back as a result object; cancellation and timeout throw OperationCanceledException.
  • Automatic loading screen. Pass a panel in the options and the service pushes it before the load and pops it after, with a configurable minimum display time.
  • Progress events. The service publishes load started / progress / completed / canceled events on the Event Bus — the natural place to fade music out and in.
  • Additive layering. Load and unload extra scenes on top of the base scene, inspect the full list of loaded scenes, and choose which one Unity treats as active.

Getting the service

Resolve once and cache the reference (never inside Update):

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)]
public class MenuController : MonoBehaviour
{
    private ISceneService _scene;

    private void Awake()
    {
        _scene = ServiceLocator.Resolve<ISceneService>();
    }
}

Loading a scene (single mode)

Single-mode loading replaces the current scene — the standard main-menu-to-gameplay transition.

Task<SceneLoadResult> LoadAsync(string sceneName,
                                SceneLoadOptions opts = default,
                                CancellationToken ct = default)

Loads a scene by name. The scene must be registered in Build Settings (File > Build Settings > Scenes in Build). Returns a SceneLoadResult on success or error; throws OperationCanceledException on cancel or timeout.

Task<SceneLoadResult> ReloadCurrentAsync(SceneLoadOptions opts = default,
                                         CancellationToken ct = default)

Reloads the currently active scene — a convenience for checkpoint respawns and debug reloads.

Options (SceneLoadOptions)

FieldWhat it does
LoadingScreenPanelAn optional panel (IPanel from the UI Framework) that the service pushes when the load starts and pops when it finishes.
MinDisplayDurationMinimum seconds to keep the loading screen visible. Prevents a jarring flash when the load finishes in a few frames.
LoadTimeoutSecondsPer-call timeout override. 0 (the default) uses the service-wide setting. On expiry the load throws OperationCanceledException with a TimeoutException inside.

Result (SceneLoadResult)

PropertyMeaning
Succeededtrue when the scene activated normally.
ErrorThe captured exception when Succeeded is false (for example, the scene is missing from Build Settings). null on success.
SceneNameThe scene that was the load target.
TotalDurationEnd-to-end load time as a TimeSpan.

Load phases and queries

A load moves through phases you can observe at any time:

string ActiveSceneName { get; } // Active scene name; empty before the first load completes
bool IsLoading { get; }         // true while any scene operation is in flight
LoadPhase CurrentPhase { get; } // Idle, Unloading, Loading, MinDisplayHold, Activating, Active

Events published

Subscribe through the Event Bus:

EventWhenTypical use
SceneLoadStarted(FromSceneName, ToSceneName)A load beganFade out the old music
SceneLoadProgress(SceneName, Progress, Phase)Progress updates, Progress in 0..1Drive a progress bar
SceneLoadCompleted(FromSceneName, ToSceneName, TotalDuration)The load finishedFade in the new music
SceneLoadCanceled(FromSceneName, AttemptedSceneName, PhaseAtCancel, ElapsedBeforeCancel, Cause)The load was canceled or timed outReturn to the menu cleanly

Additive scenes

An additive load adds a scene to the ones already loaded instead of replacing them. Use it for a persistent UI scene over gameplay, a streamed neighboring area, or a shared "managers" scene.

Task<SceneLoadResult> LoadAdditiveAsync(string sceneName,
                                        SceneLoadOptions opts = default,
                                        CancellationToken ct = default)

Loads a scene additively on top of the base scene. The active scene does not change — ActiveSceneName still reports the base scene. Loading a scene that is already additively loaded is a safe no-op: the service logs a warning and returns a completed task (there is no reference counting — each scene is loaded at most once).

Task UnloadAdditiveAsync(string sceneName, CancellationToken ct = default)

Unloads one additively loaded scene. Passing the base scene's name, or a name that is not loaded, is a safe no-op with a warning — the base scene can never be removed this way (use LoadAsync to replace it).

IReadOnlyList<string> ActiveSceneList { get; }

Every loaded scene: the base scene first, then the additive scenes in the order they were loaded. Each read returns a fresh snapshot you can keep or mutate freely.

bool SetActiveScene(string sceneName)

Tells Unity which loaded scene is "active" — the scene that receives newly instantiated objects and drives lighting settings. The scene must already appear in ActiveSceneList; returns false (with a warning, no exception) if it is not loaded.

Behavior worth knowing:

  • Additive loads skip the loading screen. LoadingScreenPanel and MinDisplayDuration are ignored for additive operations (with a log message if you set them) — additive loads are meant to be light and seamless.
  • The same events fire. Additive loads and unloads publish the same SceneLoadStarted / SceneLoadProgress / SceneLoadCompleted events as single-mode loads. You can tell them apart because ActiveSceneName does not change during an additive operation.
  • A single-mode load clears the additive list. When LoadAsync completes, Unity has unloaded every previous scene — the service resets ActiveSceneList to just the new base scene.
  • Still one operation at a time. Additive loads and unloads share the same in-flight gate as single-mode loads: while IsLoading is true, starting another operation is rejected.

Full example

using System;
using System.Threading;
using System.Threading.Tasks;
using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)]
public class GameFlow : MonoBehaviour
{
    private ISceneService _scene;
    private IPanel _loadingScreen;
    private CancellationTokenSource _menuCts;

    private void Awake()
    {
        _scene = ServiceLocator.Resolve<ISceneService>();
        _loadingScreen = GetComponent<IPanel>(); // Your loading screen panel
    }

    // --- Single-mode: menu -> gameplay with a loading screen ---

    public async void OnPlayButtonClicked()
    {
        _menuCts = new CancellationTokenSource();
        try
        {
            var opts = new SceneLoadOptions
            {
                LoadingScreenPanel = _loadingScreen,
                MinDisplayDuration = 1.0f // At least 1 second on screen
            };
            var result = await _scene.LoadAsync("Gameplay", opts, _menuCts.Token);
            if (!result.Succeeded)
                Debug.LogError($"Scene load failed: {result.Error.Message}");
        }
        catch (OperationCanceledException)
        {
            Debug.Log("Scene load was canceled (player hit Esc)");
        }
    }

    public void OnEscapePressed()
    {
        _menuCts?.Cancel(); // Cancel the in-flight load
    }

    // --- Additive: layer a HUD scene over gameplay ---

    public async Task ShowHudAsync()
    {
        await _scene.LoadAdditiveAsync("HudOverlay");

        Debug.Log(_scene.ActiveSceneName);                    // "Gameplay" — unchanged
        Debug.Log(string.Join(", ", _scene.ActiveSceneList)); // "Gameplay, HudOverlay"
    }

    public async Task HideHudAsync()
    {
        await _scene.UnloadAdditiveAsync("HudOverlay");
    }
}

Turning it off

ServiceLocator.Replace<ISceneService>(new NullSceneService());

With the null implementation, every load call returns an already-completed success task — no actual loading, no events, no loading panel. Useful for headless testing or mocking gameplay flows. It still respects cancellation tokens (a token that is already canceled produces a canceled task).

Common pitfalls

  • Main thread only. Call the load methods and read the properties only on the main thread. The service checks this in Debug builds.

  • One scene operation at a time. You cannot start another load while IsLoading is trueawait the first one or cancel it. This applies across single-mode loads, additive loads, and additive unloads.

  • Cancellation is cooperative. Once the new scene's Awake calls begin (LoadPhase.Activating), cancellation is ignored and the load completes — Unity cannot safely abort a scene mid-activation. Cancel is honored during the Loading and MinDisplayHold phases.

  • MinDisplayDuration runs on the UI clock. The minimum-display wait uses the Time service's UI clock, which keeps flowing while gameplay is paused. If you pause the UI clock during a load, the wait pauses with it.

  • Scenes must be in Build Settings. A name that is not registered fails with a SceneLoadResult whose Error explains the problem — check the result instead of assuming success.

  • IL2CPP + code stripping. If your project builds with IL2CPP and your loading panel or event subscribers get stripped, add these to your project's link.xml:

    <type fullname="CommonGameSystem.Core.SceneLoadResult" preserve="all"/>
    <type fullname="System.Threading.Tasks.Task`1[[CommonGameSystem.Core.SceneLoadResult]]" preserve="all"/>
    
  • Bootstrap — how the service starts automatically
  • UI Framework — the panel type used for loading screens
  • Event Bus — subscribing to the four scene events
  • Time — the UI clock that times the minimum display hold