close

DEV Community

Cover image for Unity Foundational Architecture: Managing Global State

Unity Foundational Architecture: Managing Global State

Table of Contents:

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 a 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");
}
Enter fullscreen mode Exit fullscreen mode

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.

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 and 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.

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 boiler plate 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 boiler plate 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 new operator 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 inheritence and lose coupling. It is the main reason behind the following downsides of the pattern:

  • 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.
  • One other thing to note is the tight coupling to the Singleton's concrete type. In a large scale application this becomes much much more important. If you really need the lose 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...
}
Enter fullscreen mode Exit fullscreen mode
// A client can call business logic from anywhere using the Singleton's type...
GameManager.Instance.XYZ();
Enter fullscreen mode Exit fullscreen mode

Pretty simple right? Sure, but rewriting this logic for every Singleton is annoying, let's try to mitigate the writing of boiler plate 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;
    }
}
Enter fullscreen mode Exit fullscreen mode
// 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();
Enter fullscreen mode Exit fullscreen mode

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); 
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. The Execution Order Race Condition : Unity runs script Awake() cycles in an unpredictable, random order. If a normal gameplay script runs its Awake() before your GameManager executes its own, calling GameManager.Instance will return null and 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 to 0) even wakes up. In an edge case, you can always set a lower value for DefaultExecutionOrder on concrete classes.
  2. 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() or OnDisable() 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 static HasQuit flag. In OnApplicationQuit(), we set this to true. If any script tries to access the singleton during the shutdown sequence, the Instance property returns null instead of spawning a zombie instance. This can lead to null refs in cleanup methods like OnDisable() and OnDestroy() so you should simply check the flag before using the Instance property in these cases.
  3. 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, Instance still 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 into EditorApplication.playModeStateChanged callback. This automatically intercepts the in-editor play state switch and resets HasQuit right before the runtime boots up, ensuring compatibility with Fast Enter Play Mode in Editor.
  4. Lack of Architectural Control - Eager vs. Lazy : Our simple, naive GameManager monobehaviour 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 the Instance is accessed.
    • Solution : We create a custom MBSingletonOptionsAttribute that 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.
  5. Boilerplate Code : As usual, there is still the issue of boilerplate code that we don't want to rewrite for each concrete type.
    • Solution : We do the same thing that we did for the POCO singleton and use generics to create a generic base class that handles all the generic singleton logic.

Alright let's start implementing a solution that solves our problems:

// other using statements...

#if UNITY_EDITOR
using UnityEditor;
#endif

// singletons should execute before "normal" scripts which have DefaultExecutionOrder = 0
[DefaultExecutionOrder(-50)]
public abstract class MBSingleton<T> : MonoBehaviour where T : MonoBehaviour
{
    private static T _instance;

    [SerializeField, Tooltip("Destroy this object when a new scene is loaded.")]
    private bool _destroyOnLoad = false;

#if UNITY_EDITOR
    static MBSingleton()
    {
        EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
        EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
    }
    static void OnPlayModeStateChanged(PlayModeStateChange playModeState)
    {
        if (playModeState == PlayModeStateChange.ExitingEditMode)
        {
            HasQuit = false;
        }
    }
#endif

    protected MBSingleton() { }

    public static bool IsInitialized => _instance != null;

    /// <summary>
    /// True if application is quitting. When Unity quits it destroys game objects in a random order and this can create issues when working with singletons. <br />
    /// As such, you should check "if (!YourSingleton.HasQuit)" before using your singleton in cleanup methods like OnDisable, OnDestroy, etc...
    /// </summary>
    public static bool HasQuit { get; private set; }

    public static T Instance => GetOrCreate();
    public static T GetOrCreate(GameObject gameObject = null)
    {
        if (HasQuit) 
            return null;

        // no need for double checked locking pattern as MonoBehaviours are only created on the main tread
        if (_instance == null)
            Create(gameObject);

        return _instance;
    }

    private static void Create(GameObject gameObject = null)
    {
        // we'll implement this soon, keep reading...
    }

    /// <summary>
    /// Override this method to ensure that you run Initialization logic for your singleton only once (this will not be invoked on accidental duplicates before they are destroyed).
    /// </summary>
    protected virtual void Initialize()
    {
        //
    }

    protected virtual void Awake()
    {
        // we must check backing field (_instance) here because if null we want to call GetOrCreate() and pass it this gameObject
        if (_instance == null)
            GetOrCreate(gameObject);
        else
        {
            // an instance already exists and this is a duplicate, destroy it
            Destroy(gameObject);
            return;
        }

        DestroyOnLoad = _destroyOnLoad;
    }

    protected virtual void OnDestroy()
    {
        _instance = null;
    }

    protected virtual void OnApplicationQuit()
    {
        // When Unity quits it destroys game objects in a random order and this can create issues for monobehaviour singletons. 
        // So we prevent possible lazy re-instatiation and access to the instance when the application quits to prevent problems.
        HasQuit = true;
    }

    public bool DestroyOnLoad
    {
        get => _destroyOnLoad;
        set
        {
            _destroyOnLoad = value;
            if (_destroyOnLoad)
            {
                transform.SetParent(null);
                Scene activeScene = SceneManager.GetActiveScene();
                if (gameObject.scene != activeScene)
                    SceneManager.MoveGameObjectToScene(gameObject, activeScene);
            }
            else
            {
                transform.SetParent(null);
                DontDestroyOnLoad(gameObject);
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

What this gives us is:

  • A MonoBehaviour that we can attach to a GameObject and a variable to control whether or not we want this singleton to live and die with the scene or whether we want it to survive scene load/unloads and exist in the persistent DontDestroyOnLoad scene. We also give the option to switch at runtime.
  • Static memory management and cleanup in editor for fast enter play mode.
  • A default DefaultExecutionOrder of -50 to allow us to execute before most normal scripts.
  • A virtual Initialize() method that can be used in place of Awake() for initialization. Why? Because when a new gameobject is spawned the Awake method is called even if it's a duplicate and our code will destroy it. We haven't implement the logic that calls it yet but the idea is to have this method for initialization on the singular "true" instance.
  • A Create(GameObject) method that we can either pass in the GameObject (for eager loading) or null (for lazy loading). To be honest, I mostly exclusively use Eager loading when it comes to monobehaviour singletons, mostly for the easy setup and dependency injection (serialized fields). However, it doesn't hurt to support both paradigms here.

Now, let's implement the heart of our MBSingleton script, the Create(GameObject) function as well as a LazyFallbackInstantiation(GameObject) function to allow us to Lazily construct our MBSingleton gameobject and configure any parameters.

private static void Create(GameObject gameObject = null)
{
    if (gameObject != null && gameObject.scene.name != null)
    {
        _instance = gameObject.GetComponent<T>();
        (_instance as MBSingleton<T>).Initialize();
        return;
    }

    T[] existingInstances = FindObjectsByType<T>();
    if (existingInstances.Length > 0)
    {
        _instance = existingInstances[0];

        for (int i = 1; i < existingInstances.Length; ++i)
        {
            DestroyImmediate(existingInstances[i]);
        }

        return;
    }

    Type typeofT = typeof(T);

    // handle how the user wants this type to be loaded ...
    MBSingletonOptionsAttribute typeOptionsAttr = typeofT.GetCustomAttribute<MBSingletonOptionsAttribute>();

    // eager loading (default) : the gameobject instance will be created when it's gameobject is loaded into memory by Unity
    if (typeOptionsAttr == null || typeOptionsAttr.loadType == EInstanceLoadingType.Eager)
        return;

    // lazy loading : create the gameobject instance for the user if it doesn't already exist (instance will always create a new GameObject)
    if (typeOptionsAttr.loadType == EInstanceLoadingType.Lazy)
    {
        // create a new GameObject that will house the singleton instance (lazy instantiation) ...
        string goNamePostFix = " (Lazy)";

        // if it's a prefab reference, then instantiate the prefab
        if (gameObject != null && gameObject.scene.name == null && gameObject.GetComponent(typeofT) != null)
        {
            // need to set instance before Instantiate() call otherwise we'll have an infinitely recursive situation in Awake()
            if (gameObject.TryGetComponent(out _instance))
            {
                gameObject = Instantiate(gameObject);
                gameObject.name += goNamePostFix;
                _instance = gameObject.GetComponent<T>();
                (_instance as MBSingleton<T>).Initialize();

                return;
            }
            else
                _instance = null;
        }

        // create an empty gameobject and attach this component.
        if (_instance == null)
        {
            gameObject = new GameObject(typeofT.Name + goNamePostFix, typeofT);
            T singletonComponent = gameObject.GetComponent<T>();
            _instance = singletonComponent;

            MBSingleton<T> mbSingleton = singletonComponent as MBSingleton<T>;
            mbSingleton.LazyFallbackInstantiation(gameObject);
            mbSingleton.Initialize();

            return;
        }
    }
}

/// <summary>
/// Override this method if you need to lazily construct the singleton from scratch.
/// </summary>
/// <param name="gameObjectRoot">The instantiated root gameobject.</param>
protected virtual void LazyFallbackInstantiation(GameObject gameObjectRoot)
{
    // to be overriden by subclasses for lazy loading...
}
Enter fullscreen mode Exit fullscreen mode

With this we have a very robust and solid MonoBehaviour Singleton solution that can be used in any project. The best place to eagerly load your monobehaviour singletons are in the bootstrap scene or wherever you do your bootstrapping logic. You can also place MBSingletons in specific scenes so that they are tied to that scene, just remember to set the DestroyOnLoad variable to true so that it will not be persisted in the DontDestroyOnLoad scene.

Service Locator

Problem & Intent

The problem: We want global access to objects (services) and we want lose coupling by making everyone depend on a single global instance to fetch objects by interface/abstract type insetad of concrete type. The Service Locator pattern is an architectual pattern that solves these problems by providing a global point of access to an object instance (service) without tightly coupling users to the concrete class that implements it.

Solution

All implementations of the Service Locator pattern have these things in common.

  • A static global instance (Singleton) that acts as the middleman and a registry for our services.
  • A collection of service instances that is managed by our locator and a method to retrieve an instance of a service.
  • An interface that acts as a contract for our services to ensure they all implement some common functionality that our locator expects of them.

Key Difference from Singleton

If we look at the Singleton, a script is permanently glued to (dependant on) a specific concrete class. If your PlayerHealth script calls AudioManager.Instance.Play("Ouch"), it is hardcoded to depend on that exact AudioManager type. This makes unit testing more difficult because you can't easily swap the real audio manager for a dummy one without changing all the calls in the codebase with your new type.

Common Misuse

The Service Locator can index the services by their concrete type, however this is a common misuse of the pattern and will end up degrading your solution into a more complex singleton pattern which defeats our initial intent. DO NOT use Service Locator if you plan to do this as you are just adding more complexity to your project for no reason (instead just use Singleton). If you are using the Service Locator and creating services then you should also create an interface for each service you create in which you will almost explicitly use to talk to the service. You will be programming to an interface instead of a concrete type which means your dependencies are now losely coupled.

The Downside

While it sounds perfect, the Service Locator has one major downside and ironically it is a result of its greatest strength: hidden dependencies. When you look at a script that uses a Singleton, you immediately see the dependency (GameManager.Instance). With a Service Locator, a script might compile perfectly fine but throw a null reference exception at runtime and, at a glance, it may not be obvious which instance of the service is being used making it difficult to trace. However, if Singletons are causing friction in your project then this downside is an acceptable alternative and with the debugging tools Unity and IDEs offer developers it is often very manageable.

Implementation

Let's start by creating a ServiceLocator class using our Generic Singleton<T> implementation from earlier. While some implementations use a static class, we are going to just make it a singleton to save some time. If you do go the static class route, you have to make the class and every member/function static and you would have to make sure to handle cleanup for when the domain is not reloaded in editor and you re-enter play mode (Fast Enter Playmode).

public sealed class ServiceLocator : Singleton<ServiceLocator>
{
    private readonly Dictionary<Type, IService> _services = new Dictionary<Type, IService>();

    public void Register<T>(T service) where T : IService
    {
        var typeofT = typeof(T);
        if (!_services.ContainsKey(typeofT))
            _services.Add(typeofT, service);
        else
            Debug.LogWarning($"[{nameof(ServiceLocator)}] Service of type `{typeofT}` has already been registered!");
    }

    public void Unregister<T>() where T : IService
    {
        _services.Remove(typeof(T));
    }

    public T Get<T>() where T : IService
    {
        var typeofT = typeof(T);
        if (_services.TryGetValue(typeofT, out var service))
        {
            return (T)service;
        }

        Debug.LogError($"[{nameof(ServiceLocator)}] Service of type `{typeofT}` has not been registered!");
        return default;
    }
}

public interface IService
{
    // Called the moment the service is successfully added to the Locator
    public void OnRegister();
    // Called right before the service is removed from the Locator
    public void OnBeforeUnregister();
    // Called after the service is removed from the Locator
    public void OnUnregister();
}
Enter fullscreen mode Exit fullscreen mode

We will have to register all our services somewhere in our game's code. Again, the best place for this would be where we do our bootstrapping logic. You can make use of Unity's [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] attribute for pure C# services. If we want to make use of dependency injection through the use of Unity's SerializeField attribute then we need to do this in Awake(). Let's make a script that allows us to register services on startup.

// We want services to be registered before the Awake() of normal scripts fire.
[DefaultExecutionOrder(-10)]
public class ServiceBootstrapper : MonoBehaviour
{
    [Header("Services")]
    [SerializeField] 
    private GameManager _gameManager;
    [SerializeField] 
    private AudioManager _audioManager;

    private void Awake()
    {
        // register our services here ...

        YourService yourService = new();
        Register<IYourService>(yourService);

        ServiceLocator.Register<IGameService>(_gameManager);
        ServiceLocator.Register<IAudioService>(_audioManager);
    }
}
Enter fullscreen mode Exit fullscreen mode
// Now instead of this (tight coupling!):
GameManager.Instance.XYZ();

// We now do this (lose coupling):
ServiceLocator.Get<IGameService>().XYZ();
Enter fullscreen mode Exit fullscreen mode

What we now have is a proper ServiceLocator implementation with the following traits:

  • A static global instance that will hold a reference to a dictionary of services by type.
  • A way to add to our collection of services using our Register<T>(T service) function.
  • A way to remove from our collection of services using our Unregister<T>() function.
  • A way to fetch from our collection of services using our Get<T>() function.
  • We have a class that handles the bootstrapping process of registering our services and gives us control over the registration order as well.
  • A base interface for all our services so that they can all abide to a common contract. Currently, the only methods that we enforce is two callback methods OnRegister(), OnBeforeUnregister() and OnUnregister() to give our services some hooks into the service registration process.
  • When creating a service, we can define a unique interface to use as our contract for our service (provided it inherits from the IService interface) and then we can swap out our services at any time as long as the new instance implements the same interface. This is very useful for unit testing where we'd want to mock a service and alter its functionality for testing.

That's pretty much it. There's a bit more setup involved than what we had with singletons (we need to create the interface and the concrete type and then we need to add it to our bootstrapper to register it), but on larger scale projects with bigger teams that require you to do a lot of unit testing this pattern can save you some headaches down the line.

Conclusion

Managing global state doesn't have to mean turning your Unity project into a tangled web of dependencies. The key is choosing the right architectural tool for the specific problem at hand rather than rigidly adhering to a single doctrine.

When data never changes at runtime, Constants keep your codebase clean, eliminate typo-induced bugs from magic strings, and provide full IDE autocompletion without any instantiation overhead.

For dynamic global systems, the choice between a Singleton and a Service Locator ultimately comes down to the scale and needs of your game. Despite the criticism it sometimes receives in software engineering circles, the Singleton pattern remains incredibly valuable—especially for indie developers. It is quick to implement, easy to pick up, and solves the immediate need for global access efficiently without over-engineering your prototype.

However, as a project grows in complexity, the Singleton's limitations become more apparent. If your architecture demands loose coupling, interface-driven design, and robust unit testing with mock objects, the Service Locator provides the flexibility your codebase needs to scale healthily.

There is no one-size-fits-all answer. The only true mistake is treating global access as a lazy shortcut to avoid passing parameters. By evaluating the long-term health of your project and balancing the need for testability against the realities of rapid development, you can build a foundation that empowers you to focus on what actually matters: shipping your game.

Top comments (0)