Most Unity projects past a certain age contain an AudioManager.Instance, a GameManager.Instance, and a generic Singleton<T> base class that both of them inherit from. The pattern spreads because it answers a question that comes up constantly: how does this script get hold of that object? Inspector references do not cross scene boundaries cleanly, Find calls are slow and brittle, and a static property is one line. Unity’s own scripting reference for FindFirstObjectByType warns that the call is very resource intensive and points at the singleton pattern as the usual alternative.

The costs arrive later, and Unity adds a few of its own on top of the ones every language has. A static field can outlive the play session that filled it. A destroyed object can still look alive to a C# null check. Startup order between scripts is something nobody decided. Tests that touch a global have nothing to swap.

This post separates the two jobs a singleton does, walks through the Unity-specific failure modes, and builds a small replacement: an ambient service holder with an explicit install step, a reset hook, and a test override. The plain C# parts were compiled and run on .NET 8 for this post, and the output quoted below is real. The Unity glue code needs the Editor, so it was not run, and each such snippet says so.

The version everyone writes

Here is the usual shape. This is Unity-only code and was not run for this post.

public class AudioManager : MonoBehaviour
{
    public static AudioManager Instance { get; private set; }

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

        Instance = this;
        DontDestroyOnLoad(gameObject);
    }

    void OnDestroy()
    {
        if (Instance == this) Instance = null;
    }
}

This version is better than many in circulation. It rejects duplicates, survives scene loads, and clears itself on destruction. It still leaves several problems open, and most of them are about time rather than structure.

Startup order. Instance is assigned inside Awake. Any other script that reads Instance in its own Awake may run before it, and Unity does not promise an Awake order between unrelated scripts unless Script Execution Order has been configured by hand. The failure is a NullReferenceException that appears on some machines, some scenes and some builds, which is the worst kind to chase.

Static state outlives the session. In current Unity 6 documentation, the Editor does not reload the scripting domain when entering Play mode by default. The manual spells out what follows: static variables keep their values between Play mode sessions, and static events keep their registered subscribers. A singleton that holds an instance in a static field will therefore still hold the previous session’s instance when the next session starts. A project with domain reload switched on will not show the problem, and it appears the day someone switches reload off to save iteration time.

Destroy is not immediate. The Destroy documentation says actual destruction is delayed until after the current Update loop. A duplicate that calls Destroy(gameObject) inside Awake is still alive for the rest of the frame, and its OnDestroy runs later. If that OnDestroy does an unconditional Instance = null, the duplicate clears the live instance’s registration on its way out. The if (Instance == this) guard in the snippet above exists for exactly this reason.

Fake null. A Unity object is a thin C# wrapper around a native object, and the native side has an explicit lifetime. Unity’s 2014 post on its custom == operator explains the consequence: a wrapper can still exist after the native object behind it was destroyed. Unity overloads == so that instance == null reports true for such a destroyed object. The C# operators ?? and ?. cannot be overloaded and do not see the difference. A holder that checks with ?? can hand out a dead object while looking perfectly healthy.

No seam for tests. A static property gives a test nothing to replace. Code that reads AudioManager.Instance in an EditMode test either gets null or gets a real audio system that has no business running in a unit test.

None of these problems is an argument against sharing one object across a game. They are arguments against a design where the lifetime of that object was never written down anywhere.

Two jobs in one pattern

The Gang of Four description reads: ensure a class has one instance, and provide a global point of access to it. Robert Nystrom’s chapter on the pattern in Game Programming Patterns suggests splitting that sentence at the “and” and judging each half separately. The halves are worth very different amounts, so they should be judged separately.

Limiting a class to one instance is sometimes a real requirement. A wrapper around a file system API or an audio device may break if two copies fight over the same underlying resource. Global access is a convenience, and it is almost always the reason a singleton gets written. Convenience is also what makes the pattern spread. If a class is global, any script can call it, including scripts that should never know it exists. The chapter’s example is a new teammate who needs boulders to make a sound and finds that physics code can reach the audio player with one line.

Once the two halves are separate, each gets its own question, and the answers often differ. The diagram below turns those questions into a rough decision path.

Decision path for how code should reach a service, from moving logic onto the object to an ambient service with a reset hook

The sections that follow go through the branches in order. Most services should leave the path early.

First question: does the manager need to exist

Many singletons are managers that exist to look after other objects. Game Programming Patterns gives a blunt example: a Bullet class with only getters and setters, and a BulletManager that owns every operation on bullets. The manager looks like an obvious singleton because anything that touches a bullet needs it. The fix in the chapter is to delete the manager and give Bullet its own Move and IsOnScreen methods.

Unity projects have the same pattern with different names: an EnemyManager that exists only to call methods on enemies, a UIManager that reaches into every panel. Before making one of these global, check whether the behavior belongs on the objects it manages. If it does, the global access problem disappears because nobody needs to reach the manager.

A manager still makes sense when it owns something that no single object can own: a pool, a budget, a schedule across a group. Those cases move on to the next questions.

One instance without global access

Sometimes the requirement is real: only one instance may exist, such as a mixer talking to the platform audio device. The same chapter points out that this half of the pattern does not need the other half. A class can enforce its own uniqueness and still be created and passed around like any other object.

// Single instance without global access.
public sealed class AudioMixer : IDisposable
{
    static bool s_exists;

    public AudioMixer()
    {
        if (s_exists) throw new InvalidOperationException("AudioMixer already exists.");
        s_exists = true;
    }

    public void Dispose() => s_exists = false;
}

The constructor refuses a second instance and Dispose allows a new one later, so tests and scene reloads can rebuild it. The tradeoff is that the check happens at runtime. The classic pattern makes a second instance impossible to write, while this version makes it fail on the first run that tries. For a game that is almost always fine, because the failure is immediate, loud and points at the line that created the duplicate.

The run confirmed both behaviors: a second AudioMixer throws, and a new one can be created after the first is disposed.

Second question: can it be passed in

When a service has a clear owner and a modest number of callers, passing it through a constructor or an Init call is the simplest option and often the best one. The chapter calls it the first thing to consider before dismissing it as too cumbersome. Here is a cooldown that depends on a clock instead of on a global:

// Preferred shape: the dependency arrives through the constructor.
public sealed class Cooldown
{
    readonly IClock _clock;
    readonly double _duration;
    double _readyAt;

    public Cooldown(IClock clock, double duration) { _clock = clock; _duration = duration; }

    public bool TryUse()
    {
        if (_clock.Now < _readyAt) return false;
        _readyAt = _clock.Now + _duration;
        return true;
    }
}

Under test, the clock is a field the test can set:

ok   injected: first use allowed
ok   injected: blocked at t=1.9
ok   injected: allowed at t=2.0
ok   injected path never touched Ambient

The cooldown has no dependency on Unity’s Time class, no static state, and no setup beyond a constructor call. In a real project the production clock would wrap Time.time or Time.unscaledTime, depending on which one pauses with the game.

The cost is plumbing. Every object between the place where the service is created and the place where it is used has to carry it along. That is acceptable for a Cooldown created by an ability, and it becomes painful for something like logging, which appears everywhere and is rarely the main concern of the function that calls it. Dependency injection frameworks exist to remove that plumbing, and the earlier posts on dependency injection in Unity and a hand-built container in C# cover that route. A container is a larger commitment than some projects need.

Third question: a root object or an ambient service

Two options remain for code that cannot be handed what it needs. The first is to reduce the number of globals to one. The book suggests a single Game or context object that owns the log, the file system and the audio player, so that only the context is reachable from anywhere. Code that already knows about the context reads the audio player from it. The downside, as the chapter admits, is that more code becomes coupled to the context type.

The second is an ambient service: a global slot for a cross-cutting dependency such as time, logging or audio, designed with the lifetime problems from the first section in mind. It is closely related to the service locator pattern from an earlier post, with a deliberately small surface. The slot has an explicit install step, it refuses duplicates, it only lets the current owner remove itself, it can be swapped inside a scope for tests, and one call resets every slot.

using System;
using System.Collections.Generic;

// One registry of reset callbacks. A single non-generic entry point
// clears every Ambient<T> that has been installed.
public static class AmbientRegistry
{
    static readonly List<Action> s_resets = new();

    public static void Add(Action reset) => s_resets.Add(reset);

    public static void ResetAll()
    {
        foreach (var reset in s_resets) reset();
    }
}

// Global access to one service, with an explicit lifetime.
public static class Ambient<T> where T : class
{
    static T? s_current;
    static bool s_registered;

    public static bool IsInstalled => s_current != null;

    public static T Current => s_current ??
        throw new InvalidOperationException(
            $"{typeof(T).Name} is not installed. Install it during bootstrap, before first use.");

    public static void Install(T instance)
    {
        if (instance == null) throw new ArgumentNullException(nameof(instance));
        if (s_current != null)
            throw new InvalidOperationException($"{typeof(T).Name} is already installed.");

        if (!s_registered)
        {
            AmbientRegistry.Add(() => s_current = null);
            s_registered = true;
        }
        s_current = instance;
    }

    // Only the current owner may uninstall. A rejected duplicate that is
    // destroyed later must not knock out the live instance.
    public static void Uninstall(T instance)
    {
        if (ReferenceEquals(s_current, instance)) s_current = null;
    }

    // Test seam: swap in a replacement, restore the previous value on Dispose.
    public static Scope Override(T replacement)
    {
        var previous = s_current;
        s_current = replacement;
        return new Scope(previous);
    }

    public readonly struct Scope : IDisposable
    {
        readonly T? _previous;
        public Scope(T? previous) => _previous = previous;
        public void Dispose() => s_current = _previous;
    }
}

A few decisions in that code are worth spelling out.

Current throws instead of returning null or creating a default. A lazily created instance would bring back the problem Nystrom describes in the singleton chapter: initialization cost that lands in the middle of gameplay, at a moment nobody chose. An audio system that takes a few hundred milliseconds to start belongs in a loading screen. Failing loudly with the type name and an instruction turns a mystery null into a one-line fix.

Install throws on a second call instead of silently replacing the first. Silent replacement feels forgiving and hides real bugs, such as two bootstrap paths both installing a service or a scene that was loaded twice.

Uninstall takes the instance and ignores anyone who is not the current owner. That is the Destroy timing problem from earlier, handled once in the holder instead of in every OnDestroy.

Override returns a disposable scope that restores the previous value, including when scopes nest. It is the test seam. Code written against Ambient<IClock> can run against a fake clock without being rewritten:

// Legacy shape: reaches for the ambient service.
public sealed class LegacyCooldown
{
    readonly double _duration;
    double _readyAt;

    public LegacyCooldown(double duration) => _duration = duration;

    public bool TryUse()
    {
        var now = Ambient<IClock>.Current.Now;
        if (now < _readyAt) return false;
        _readyAt = now + _duration;
        return true;
    }
}

// In a test:
var fake = new FakeClock { Now = 0 };
using (Ambient<IClock>.Override(fake))
{
    var legacy = new LegacyCooldown(2.0);
    Check("legacy: first use allowed", legacy.TryUse());
    fake.Now = 1.0;
    Check("legacy: blocked at t=1", !legacy.TryUse());
    fake.Now = 2.0;
    Check("legacy: allowed at t=2", legacy.TryUse());
}

The registry exists because each Ambient<T> is its own static class with its own field. A single AmbientRegistry.ResetAll() call clears every slot that was ever installed, which is what a startup hook needs.

Wiring it into Unity startup

The following is Unity-only code and was not run for this post. It relies on the callback order documented for RuntimeInitializeOnLoadMethod: SubsystemRegistration is invoked at runtime startup, before the first scene loads, and BeforeSceneLoad runs after the first scene’s objects are loaded but before Awake is called on them.

public static class Bootstrap
{
    // Runs before the first scene, in the Editor and in builds.
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
    static void ResetStatics() => AmbientRegistry.ResetAll();

    // Runs before any Awake, so every script can rely on these.
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    static void InstallServices()
    {
        Ambient<IClock>.Install(new UnityClock());
        Ambient<ILog>.Install(new UnityLog());
    }
}

The split between the two phases matters. The documentation states that the order of callbacks within a single load type is not guaranteed, so a reset and an install placed in the same phase could run in either order and the reset could erase the install. Using two phases removes the question.

Plain C# services are the easy case. Services that live in a scene, such as a pooled audio source manager, need an extra step because the holder cannot see Unity’s fake null:

public class SceneAudio : MonoBehaviour, IAudio
{
    void Awake()     => Ambient<IAudio>.Install(this);
    void OnDestroy() => Ambient<IAudio>.Uninstall(this);
}

Inside the generic holder, instance == null and ?? are plain reference checks, so a destroyed MonoBehaviour would still be returned if nothing uninstalled it. The OnDestroy line is not optional. A duplicate that destroys itself uninstalls nothing, because Uninstall ignores non-owners, and the live instance stays registered. Install in Awake also throws for that duplicate, which is the desired outcome during development: the error points straight at the second copy in the scene.

What the run showed

The test program is a single console file with assertions that throw on failure. Part of it simulates the stale-statics problem: a session installs services and sets a bare static field, the “scene object” is destroyed, and a second session starts without any reset. A fake object stands in for a destroyed Unity object, so this models the failure and is not a measurement of Unity itself. The relevant lines of output:

ok   Install twice throws
     message: IClock is already installed.
...
     naive holder at start of session 2: null=False, alive=False
     ambient installed at start of session 2 without reset: True
ok   naive holder keeps a dead instance
ok   ambient without reset still installed
ok   second Install without reset throws
ok   after ResetAll ambient is empty
ok   session 2 Install succeeds after reset

The bare static field still holds a dead object at the start of session two, and the ambient holder still reports an installed service. Without a reset, the second Install throws, which is the loud failure that was wanted. After ResetAll, the second session installs cleanly. The program also checks that one reset clears two different service types, that nested overrides restore in order, and that Uninstall from a non-owner leaves the live instance alone. In total, 24 checks passed. The full output is below.

ok   Current before Install throws
     message: IClock is not installed. Install it during bootstrap, before first use.
ok   message names the type
ok   Install twice throws
     message: IClock is already installed.
ok   live instance survived the rejected install
ok   Uninstall by non-owner is ignored
ok   override active
ok   nested override active
ok   inner scope restored outer override
ok   outer scope restored the real service
ok   legacy: first use allowed
ok   legacy: blocked at t=1
ok   legacy: allowed at t=2
ok   injected: first use allowed
ok   injected: blocked at t=1.9
ok   injected: allowed at t=2.0
ok   injected path never touched Ambient
     naive holder at start of session 2: null=False, alive=False
     ambient installed at start of session 2 without reset: True
ok   naive holder keeps a dead instance
ok   ambient without reset still installed
ok   second Install without reset throws
ok   after ResetAll ambient is empty
ok   session 2 Install succeeds after reset
ok   ResetAll cleared both service types
ok   second AudioMixer throws
ok   mixer can be created again after Dispose

24 checks passed

Where this still goes wrong

An ambient service is still global state. The dependency is hidden from the type’s signature, which is the main complaint against singletons and service locators alike. Reserving the pattern for cross-cutting services keeps the damage small: time, logging, audio output, perhaps input. Gameplay state, such as the player’s inventory or the current match, belongs to objects that are passed around, because those are the things tests and features need to vary.

The holder is not thread-safe. The fields are plain statics and the checks are not atomic. That is fine for code on Unity’s main thread. Code running in jobs or on background threads should receive the data it needs as arguments. If a project needs cross-thread access, a lock or Volatile access is the upgrade, though that is usually a sign the dependency should be passed in instead.

Each Ambient<T> is a separate slot per exact type. Installing a SystemClock under Ambient<SystemClock> and reading Ambient<IClock> finds nothing. Pick the interface as the key and use it on both sides.

An override that is not disposed leaks into later tests. Wrap it in using, and have a test fixture call AmbientRegistry.ResetAll() in its teardown as a backstop.

Finally, avoid putting a default instance in a static field initializer to save the install step. That reintroduces lazy, unowned creation and gives the stale-session problem back.

Practical takeaway

Treat a singleton as two decisions. Decide whether a class must have one instance, and enforce that with a runtime check or a constructor that refuses duplicates. Then decide how code reaches it: move the behavior onto the object, pass the service in, reach it through one root object, or give it an ambient slot with an install step.

For the ambient case, write the lifetime down. Install during bootstrap, reset on SubsystemRegistration, uninstall from OnDestroy with an ownership check, and give tests an override scope. The code for that is small enough to copy into a project and cheap to delete later if the project outgrows it and moves to a container.

References