Unity Foundational Architecture: Managing Global State
Introduction
Every Unity developer eventually hits the exact same wall: how do I get my UI script to talk to my Game Manager without turning my codebase into a tangled web of dependencies? Managing global state is a fundamental challenge in game architecture, and the internet is full of conflicting, often dogmatic advice on how to handle it.
In this article, we are going to look at some popular approaches to managing global state: Static Constants, Singletons and Service Locator. Before we start though, I encourage you to read some of my previous blog posts in this series on project scaffolding or, even more crucial to some sections in this article, the bootstrapping process.
Constants
Not every piece of global data needs an instance to live in, interfaces, or an initialization phase. Some data is constant and never changes at runtime (constants). These constants are usually defined with static readonly or const (at least they should be if they never change).
Example:
public static class MathConstants
{
public const float MilesToKm = 1.60934f;
}
public class CharacterAnimationController : MonoBehaviour
{
// we must use static readonly instead of const here because we need to generate the SpeedHash from the string literal.
public static readonly int SpeedHash = Animator.StringToHash("Speed");
}
Constants are also tied to the type they are defined in. So to keep things neat, you should only define constants where appropriate. Meaning if you have a constant for the max networked inventory slot capacity, this shouldn't be defined in a class called NetworkedConstants and instead should reside inside the class that represents the NetworkedInventory which needs this data. In fact, if this is the only class that needs this data, you can even make it private although it's perfectly fine to make it public as well if there's a need for it in other scripts as long as you are not just creating dependencies just for accessing the constant field.
Magic strings are an architectural vulnerability. If someone changes the setting name from AudioSettings.SFXVolume to AudioSettings.SFXVolum (missing the e), the compiler will not warn you. If the programmer accidentally mistypes the string, the compiler will not warn you. Your code will compile perfectly, but the SetFloat call will silently fail to update the correct data at runtime, breaking your game's logic. By transforming our magic strings into named constants we get:
- No Typos: It prevents bugs caused by misspelling a hardcoded string in multiple files.
- Code Completion: IDE auto-complete works perfectly with named constants.
Singletons & Services
Every Unity developer eventually hits the "Singleton Wall." It usually starts innocently enough, you usually need a single instance of some sort of "manager" (like a GameManager or AudioManager). The general common rules for these managers are the following:
- Only ONE instance should ever be created at any given time (a single source of truth)
- Accessible from anywhere
- Needs to be initialized as the game starts and when hitting play in the editor before your gameplay scripts run and try to access it
- May or may not need to survive scene loads
Let's start by addressing the elephant in the room: the Singleton. It has become a bit of a trend in software engineering circles to label the Singleton as an "anti-pattern." It is true that they have downsides. Specifically, they make mocking dependencies much harder, which can be a headache for unit testing. But let's be realistic. For many developers, and especially for indie devs, the Singleton is incredibly valuable. It is easy to pick up, exceptionally fast to implement, and cleanly solves the immediate problem of global access.
The singleton pattern is very useful and can sometimes also be seen in other patterns like Factory or even the Service Locator itself (part of building a service locator is making a single singleton: the "locator"). It is a widely used pattern for a reason. You should always choose your solutions based on your project's needs and its long term health.
In any case, we will go over how to build and implement both solutions, cut down on boilerplate code using generics, discuss how to make use of Unity's SerializeField for dependency injection, and what a Singleton lacks that would make you choose a ServiceLocator instead.
Now, there is some truth behind the rumors though as the service locator pattern does offer more utility and flexibility. However, some indie developers are content with the good old singleton pattern for their projects. This is totally fine, you should choose your solution based on your project's needs and its long term health. The Singleton pattern also requires less architecture & planning which can be beneficial for small teams, game jam projects, or quick prototypes. In any case, we will cover how to build and implement both solutions, cut down on boilerplate code using generics, and what a Singleton lacks that you'd want a ServiceLocator for.
Singleton Problem & Intent
We all know the problem, we want a single global instance (source of truth) for a given type. The singleton pattern is a creational design pattern that ensures a class has only one instance, while also providing a static global access point to that instance.
Solution
All implementations of the Singleton pattern have these two things in common:
- Make the default constructor private, to prevent other objects from using the
newoperator with the Singleton class. This gets a bit more complex when it comes to MonoBehaviours as we will have to check the instance count. - Create a static creation method that acts as a constructor. Under the hood, this method calls the private constructor to create an object and stores it in a static field. All following calls to this method return the cached object.
What the Singleton Pattern Lacks
The core issue is the lack of inheritance and loose coupling. It is the main reason behind the following downsides of the pattern:
- Unit testing difficulty: It may be difficult to unit test the client code of the Singleton pattern because many test frameworks rely on inheritance when producing mock objects. Since the constructor of the singleton class is private and more importantly you access the instance statically by type name, you will need to think of a creative way to mock the singleton. If you need robust unit testing and the ability to mock instances, you should opt for the Service Locator pattern.
- Tight coupling: One other thing to note is the tight coupling to the Singleton's concrete type. In a large scale application this becomes much more important. If you really need the loose coupling, you should opt for the Service Locator pattern.
Common Misuse
Developers frequently use singletons to bypass passing parameters, turning them into a global access point for arbitrary data and unnecessarily polluting the global scope. My main advice with Singletons is just try not to overuse them and first see if you can architect a solution by creating the least possible amount of Singletons. Luckily, I feel like most experienced devs will quickly learn this and consider this when working with Singletons ... I hope :).
Implementation
Let's start with the simplest implementation. We will create a singleton out of a plain old C# object (POCO).
public sealed class GameManager
{
private static readonly object _mutex = new object();
private static volatile GameManager _instance;
// ensure we are the only ones creating a new object
private GameManager()
{
// initialize the GameManager here...
}
// Static global point of access
public static GameManager Instance => GetOrCreate();
public static GameManager GetOrCreate()
{
// double-checked locking pattern (thread lock)
if (_instance == null)
{
lock (_mutex)
{
if (_instance == null)
_instance = new GameManager();
}
}
return _instance;
}
// other non static methods containing business logic here...
}
// A client can call business logic from anywhere using the Singleton's type...
GameManager.Instance.XYZ();
Pretty simple right? Sure, but rewriting this logic for every Singleton is annoying, let's try to mitigate the writing of boilerplate code by making our solution generic.
public abstract class Singleton<T> where T : class
{
private static readonly object _mutex = new object();
private static volatile T _instance;
private static readonly ConstructorInfo _cachedConstructor;
// The static constructor runs exactly once per unique 'T'
static Singleton()
{
_cachedConstructor = typeof(T).GetConstructor(BindingFlags.Instance | BindingFlags.NonPublic, null, Type.EmptyTypes, null);
if (_cachedConstructor == null)
{
throw new InvalidOperationException($"To enforce singleton behavior, {typeof(T).Name} must have a private or protected parameterless constructor.");
}
}
// Static global point of access
public static T Instance => GetOrCreate();
public static T GetOrCreate()
{
// double-checked locking pattern (thread lock)
if (_instance == null)
{
lock (_mutex)
{
if (_instance == null)
_instance = (T)_cachedConstructor.Invoke(null);
}
}
return _instance;
}
}
// Reduced boilerplate...
public sealed class GameManager : Singleton<GameManager>
{
// Private constructor prevents client from calling 'new GameManager()'
private GameManager() { }
// other non static methods containing business logic here...
}
// Client usage...
GameManager.Instance.XYZ();
Now that we've completed the POCO singleton, let's take a look at a MonoBehaviour singleton implementation. Why? Dependency injection using serialized fields, that's why. This is the typical MonoBehaviour singleton you'd find by doing a quick google search.
public sealed class GameManager : MonoBehaviour
{
public static GameManager Instance { get; private set; }
private void Awake()
{
if (Instance != null && Instance != this)
{
// Destroy duplicate instances
Destroy(gameObject);
return;
}
Instance = this;
// Optional: Keep this object alive across scene changes
DontDestroyOnLoad(gameObject);
}
}
While this works for quick & simple prototypes and throw away code, it quickly falls apart in production. Putting this into a real-world project is like driving a car without insurance, it works fine until you hit an engine edge case.
Here are some of the problems you can encounter with this naive approach:
The Execution Order Race Condition: Unity runs script
Awake()cycles in an unpredictable, random order. If a normal gameplay script runs itsAwake()before yourGameManagerexecutes its own, callingGameManager.Instancewill returnnulland could crash your game on frame one.
Solution: By explicitly locking the base class into[DefaultExecutionOrder(-50)], we guarantee that every singleton in the project initializes before any standard script (which defaults to0) even wakes up. In an edge case, you can always set a lower value forDefaultExecutionOrderon concrete classes.Accessing the Instance After Its Been Cleaned Up: When you exit a Unity game, the engine destroys GameObjects in a random order. If a regular script tries to log data or clean up references inside its
OnDestroy()orOnDisable()methods, it might call the Singleton instance again after Unity has already deleted it. If you are supporting lazy instantiation, this triggers a ghost lazy-reinstantiation loop, spawning leaky objects in a dying scene and causing potential errors or crashes in the application cleanup step.
Solution: We manage a staticHasQuitflag. InOnApplicationQuit(), we set this totrue. If any script tries to access the singleton during the shutdown sequence, theInstanceproperty returnsnullinstead of spawning a zombie instance. This can lead to null refs in cleanup methods likeOnDisable()andOnDestroy()so you should simply check the flag before using theInstanceproperty in these cases.Editor - Fast Enter Play Mode Initialization Bug: To speed up compile times, modern Unity projects heavily rely on disabling Domain Reload under Enter Play Mode Settings. However, skipping the domain reload means static fields are never wiped when you press Stop. The next time you press Play,
Instancestill points to the dead object from your previous session, causing your fresh script to immediately destroy itself.
Solution: We use a C# static constructor and hook intoEditorApplication.playModeStateChangedcallback. This automatically intercepts the in-editor play state switch and resetsHasQuitright before the runtime boots up, ensuring compatibility with Fast Enter Play Mode in Editor.Lack of Architectural Control - Eager vs. Lazy: Our simple, naive
GameManagerMonoBehaviour singleton forces us to use the Eager loading paradigm. Meaning they must be manually dragged into every scene. This is usually fine for most Unity devs. In some rare cases though, you may want Lazy Loading meaning that the singleton's gameobject will be spawned in whenever theInstanceis accessed.
Solution: We create a customMBSingletonOptionsAttributethat allows us to configure which instantiation paradigm a singleton should use. So either lazily spawn an empty container and build the gameobject instance ourselves through code, or let Unity automatically instantiate a fully pre-configured gameobject that exists in the scene and fail (returning null) otherwise.
Comments
No comments yet. Start the discussion.