Singleton Game Managers: Boon Or Bane For Unity Game Architecture?
The Pitfalls of Singleton Overuse
The overreliance on singleton design patterns can create tangled dependencies between game systems that are difficult to isolate for testing or reuse. Game managers implemented as singletons encourage tight coupling between the manager and other game systems by providing easy access to the manager instance anywhere in code. This results in hidden links between potentially distant parts of the game architecture.
Overreliance Leads to Tangled Dependencies
Making game systems such as input, audio, or UI easily available through singleton managers allows rapid prototyping and development. However, these singleton systems tend to accumulate dependencies to other parts of the game, such as to utility scripts, game state objects, etc. As systems grow in complexity, the web of interdependencies to the rest of the game makes the systems hard to detach for reuse or isolation.
Difficult to Test and Reuse Game Systems
The entanglement of dependencies that forms when overusing singletons makes testing systems in isolation difficult. Since singleton systems rely on the existence of other systems in the game via references to singleton instances, complex mocking strategies must be employed to isolate them in tests. Testing the systems in their actual runtime context turns into integration testing rather than unit testing the system itself. This slows down and complicates the test process.
Similarly, detaching useful gameplay systems into isolated modules for reuse in other projects becomes challenging when the assumption of the existence of other singletons is baked throughout the code. The tight coupling limits the ability to repurpose gameplay systems between games.
Obscures Architectural Links Between Systems
Uncontrolled access to singleton managers obscures the true architectural dependencies between game systems. Instead of exposing required systems as explicit dependencies of a system, access is gained implicitly through the singleton instance. This obscures the architectural links in the code and may introduce further issues down the line when changing or removing a system.
Alternatives to Singletons
While singletons provide easy access to game systems throughout code, alternatives exist that can promote better architectural design and testability while still providing most of the advantages of singletons:
Service Locator
A service locator wraps the game systems behind an interface that is requested when the functionality needs to be accessed. This is similar to a singleton but removes some of the hidden coupling between systems by introducing an abstraction layer for requesting systems.
Dependency Injection
Dependency injection allows declaring explicit dependencies for a system or component for injection as constructor parameters, avoiding the need to directly create or find other systems. Dependency direction is also reversed, with dependencies declaring their needs instead of instantiating hard links to other systems.
Event Buses
Event buses allow communication between systems through events instead of relying directly on singleton references. Components fire events when actions occur, while interested systems subscribe listeners to desired events. Communication becomes more unidirectional and decoupled.
Entity Component Systems
In an entity component system architecture, game entities are composed from reusable components, which contain game logic and communicate through component interfaces. This allows reuse of components between entities and minimizes coupling between systems.
When Are Singletons Appropriate?
While alternatives to singletons exist that may promote better design, certain game systems still benefit from or even necessitate a singleton design pattern.
Managing Global State
Certain global state is intrinsic to a game and is appropriately managed via a singleton. Examples include high-level game states like whether the player has completed the tutorial, unlocked zones, etc. Since much of the game logic may depend on the global state, having a central singleton manager simplifies tracking and reacting to state changes.
Providing Access to Core Systems
Some core game systems provide basic engine-level services for input handling, audio management, UI, etc. Since these systems form the foundation that more complex game systems build upon, providing singleton access aids integrating the low-level core systems while minimizing complexity.
Simple Managers
For game managers that control well-scoped systems with minimal dependencies themselves, singletons can provide simplicity and ease of integration compared to more complex architectures. If testing burden and architectural coupling risks stay low, simple singletons may be the best approach.
Implementing a Singleton Manager
When implementing a singleton game manager in Unity, several best practices should be followed:
Making a MonoBehaviour Singleton Persistent
Since MonoBehaviour instances can be destroyed when scenes change, simply placing a singleton manager script on a GameObject is insufficient. Setting the script execution order to execute before all others guarantees its Awake() runs first. The Awake() method can then verify if an instance exists already and destroy duplicates.
Lazy Instantiation
Instantiating singletons upfront, such as in static constructors, can cause subtle bugs with Unity lifecycle events. Instead, the singleton instance should be lazily instantiated the first time it is requested. Combined with the Awake() method above, this ensures proper lifecycle handling.
Accessing the Singleton Instance
Provide easy access to calling code while encapsulating implementation details. A simple static Instance property provides access while allowing modifying the underlying singleton implementation freely if needed.
Example Singleton Manager Implementation
Here is an example GameManager singleton implementation demonstrating the key aspects discussed:
public class GameManager : MonoBehaviour { private static GameManager _instance; public static GameManager Instance { get { if (_instance == null) { _instance = FindObjectOfType(); } return _instance; } } private void Awake() { if (_instance != null && _instance != this) { Destroy(gameObject); return; } _instance = this; } // Additional GameManager systems and logic }
Persistent Singleton Pattern
The Awake() method ensures only one GameManager instance will exist at anytime, destroying duplicates. Combined with proper script execution order, this makes the singleton persistent.
Lazy Instantiation
The Instance property encapsulates the logic for instantiating the singleton the first time it is accessed. This ensures proper lifecycle handling rather than instantiating upfront.
Encapsulated Instance Access
The Instance property allows simple access to the singleton instance from anywhere without exposing implementation details.
Testing Singleton Managers
While singletons introduce testing challenges, strategies exist to test singleton manager implementations effectively:
Mocking the Singleton
By encapsulating access through a simple Instance property, mock implementations can be substituted seamlessly during tests. By not relying on static constructors or fields, mocking the singleton via subclassing places minimal test burden.
Dependency Injection
For singleton managers utilizing dependencies to other systems, constructor injection should be used rather than relying on finding other singleton instances at runtime. This allows seamlessly injecting mocks while testing the manager in isolation.
Conclusion
Singletons provide convenient access to game systems needed globally throughout code. However they risk introducing hidden coupling between systems that lead to tangled dependencies.
Guidelines to Avoid Pitfalls
By following best practices like lazy instantiation, persistent singletons, and dependency injection, the pitfalls from overuse of singletons can be avoided while retaining their simplicity advantages.
Use Singletons Judiciously
Game architecture involving singletons should analyze coupling risks carefully to strike the right balance. For simple system managers, singletons may impose minimal testing and architecture burden. But for complex gameplay systems, alternatives promoting improved testability and modularity should be considered.