MarbleMaze: Event-Driven Manager Architecture
MarbleMaze: Event-Driven Manager Architecture

MarbleMaze: Event-Driven Manager Architecture

Created on:
Team Size: 2
Time Frame: 3 Months
Tool Used: Unity/C#

MarbleMaze is coordinated by more than 20 singleton managers β€” audio, levels, currencies, lives, customisation, achievements, notifications, cloud save, scene transitions, and more. None of them hold a direct reference to another. Instead, they publish and subscribe to C# Action<T> events, leaving each system completely replaceable in isolation. This post is an architectural walkthrough of how the pieces fit together.

The Singleton Backbone

Every manager follows the same lightweight pattern: a static Instance property, a null-check guard in Awake that destroys duplicates, and no DontDestroyOnLoad:

public static LevelManager Instance { get; private set; }

private void Awake()
{
    if (Instance != null && Instance != this) { Destroy(gameObject); return; }
    Instance = this;
}

DontDestroyOnLoad is intentionally absent. Each scene owns its own manager objects. Scene loading and unloading handles the lifetime β€” a manager is only alive while its scene is loaded. SceneController manages which scenes are loaded at any given moment, so managers never need to protect themselves from early destruction.

Bootstrap: CoreManager

The entry point of the whole game is CoreManager.Start:

void Start()
{
    GoogleUpdateManager.Instance?.CheckForUpdate();

    SavingManager.Instance.LoadSession();

    SceneController.Instance
        .NewTransition()
        .Load(SceneDatabase.Slots.Menu, SceneDatabase.Scenes.StartMenu)
        .Perform();
}

Three things happen in sequence: a Play Store update check, a full session restore from disk (which populates all managers with persisted state), and the first scene transition. The rest of the game unfolds from events and user input β€” CoreManager never touches anything again.

No Direct Manager References: The Event Contract

The defining constraint is that managers do not call methods on each other. Instead, each publishes what it knows and subscribes to what it needs.

A concrete example: when the player loses a life, three systems need to react β€” the in-game HUD needs to update its heart icons, the difficulty engine needs to know for the next level, and PlayerMovement needs to expand its ground-detection radius. None of those systems call LifeManager directly. LifeManager.RemoveLife fires two events and both updates propagate outward:

public void RemoveLife()
{
    if (currentLife > 0)
    {
        currentLife--;
        CoinManager.Instance.ReduceCurrencyAmount(CoinType.HEART, 1);
    }

    LevelManager.Instance.IncreaseLivesLostToThisLevel();
    OnLifeRemoved?.Invoke();
}

CoinManager.ReduceCurrencyAmount then fires OnCoinChanged:

OnCoinChanged?.Invoke(type, coins[type], previousCoins[type]);

CoinManager.OnCoinChanged β†’ LifePannel.UpdateCurrencyValue (HUD) LevelManager.OnLifeLostToThisLevel β†’ PlayerMovement (ground radius) LifeManager.OnLifeRemoved β†’ LifePannel.OnLifeRemoved (shake/flash animation)

A single player death fans out to three independent subsystems with no coupling between any of them.

The Event Map

EventPublisherSubscribers
OnCoinChanged(CoinType, int, int)CoinManagerAll CurrencyPannel instances, LifePannel
OnCoinSet(CoinType, int)CoinManagerAll CurrencyPannel instances (snap, no animation)
OnHeartTimerTick(TimeSpan)CoinManagerHeartPannelManager (live countdown)
OnCoinTimerTick(TimeSpan)CoinManagerRewarded video button cooldown UI
OnLifeRemovedLifeManagerLifePannel, game-over check
OnLifeLostToThisLevel(int)LevelManagerPlayerMovement (adaptive radius)
OnStarCountChanged(int)LevelManagerStarPannel (in-game star counter)
OnPowerUpStateChanged(PowerUpState)PowerUpManagerPowerUpButtonManager, audio
OnCloudSaveCompletedCloudSaveManagerUserInfoManager (toast notification)
OnCloudLoadCompletedCloudSaveManagerSavingManager (session restore trigger)
OnAuthenticationReadyLoginManagerCloudSaveManager, AchievementManager

All events are plain C# Action<T> or Action delegates β€” no UnityEvent overhead, no message-passing framework, no ScriptableObject event channels. The subscribers register in OnEnable and deregister in OnDisable, so scene unloading automatically cleans up every subscription.

Fluent Builder: SceneController

Scene transitions follow a Fluent Builder pattern. SceneTransitionPlan accumulates the load/unload list and options through chained calls, then executes when Perform() is called:

SceneController.Instance
    .NewTransition()
    .Load(SceneDatabase.Slots.Content, SceneDatabase.Scenes.Game)
    .Unload(SceneDatabase.Scenes.GamesMenu)
    .WithOverlay()
    .Perform();

The builder methods each return this, enabling the chain:

public SceneTransitionPlan Load(SceneDatabase.Slots slot, SceneDatabase.Scenes scene, bool setActive = true)
{
    ScenesToLoad[slot.ToString()] = scene.ToString();
    if (setActive) ActiveScene = scene.ToString();
    return this;
}

public SceneTransitionPlan WithOverlay()
{
    Overlay = true;
    return this;
}

public void Perform()
{
    SceneController.Instance.StartCoroutine(
        SceneController.Instance.ExecutePlan(this));
}

Perform hands the plan to ExecutePlan, which runs the full async pipeline: fade-in overlay β†’ unload scenes β†’ optional Resources.UnloadUnusedAssets() β†’ load scenes additively β†’ set active scene β†’ fade-out overlay. The isBusy guard prevents overlapping transitions:

private IEnumerator ExecutePlan(SceneTransitionPlan plan)
{
    if (isBusy) yield break;
    isBusy = true;
    yield return StartCoroutine(ChangeSceneRoutine(plan));
}

SceneDatabase.Slots and SceneDatabase.Scenes are enums that make call sites type-safe β€” no raw strings in the codebase.

Pattern Inventory

Every major design pattern in the codebase serves a specific architectural goal:

PatternWhereWhy
StrategyIDataService / JsonDataServiceSwap storage backend without touching call sites
State MachinePlayerMovement, PowerUpManager, FlapingDoorsAnimationControlled transitions, no illegal state combinations
CompositeOrderedCondition (tutorial)Sequence sub-conditions without nesting coroutines
Fluent BuilderSceneController.NewTransition()Readable multi-step scene transitions at call sites
ObserverAll Action<T> eventsZero coupling between publishers and subscribers
FactoryGridFactory, PhysicalMazeGeneratorCentralize complex object construction
SingletonAll managersSingle source of truth per system, discoverable via Instance

Dependency Flow: Session Startup

The startup sequence illustrates how the architecture handles a complex multi-system initialization without any direct wiring:

CoreManager.Start()
  └─ SavingManager.LoadSession()
       β”œβ”€ RestorePlayerDataFromFile()
       β”‚    └─ CoinManager.SetCurrencyAmount(CoinType.HEART, n)
       β”‚         └─ OnCoinSet β†’ CurrencyPannel.SetCurrencyValue() [all subscribed panels]
       β”œβ”€ RestoreGameDataFromFile()
       β”‚    └─ LevelManager.levelDataDictionnary populated
       └─ RestoreTutorialDataFromFile()
            └─ TutorialManager flags set
  └─ CloudSaveManager (async, via OnAuthenticationReady event)
       └─ ForceCloudLoad() if needed
            └─ ApplyCloudPayload()
                 └─ SavingManager.ForceLocalOverwrite<T>()
                      └─ OnCloudLoadCompleted β†’ SavingManager.LoadSession() [second pass with cloud data]

CoreManager triggers the chain with one line. Cloud sync happens independently and asynchronously β€” if it completes before the first scene loads, managers get cloud data; if it completes after, a second LoadSession call updates them in-place and events propagate the changes to any already-open UI.

What This Enables

The zero-coupling rule has two practical payoffs:

Isolated testing β€” any manager can be removed from a scene without breaking others. The null check (CoinManager.Instance?.ReduceCurrencyAmount(...)) is the only concession to this; it prevents crashes in scenes that only load a subset of managers.

Predictable event ordering β€” because no manager calls another’s methods directly, there are no hidden call chains to trace. The subscriber list is the entire blast radius of any event. When a bug appears in the HUD, the search space is every subscriber of OnCoinChanged β€” a known, finite list β€” rather than β€œsomewhere in the systems that write to CoinManager.”

← Back to Project Overview
© 2026 Samuel Styles