Introduction #
Most non-trivial gameplay code needs to talk to other systems: an ability needs the audio system, a UI screen needs the save system, almost everything needs input. Dependency injection and the service locator pattern are the two common answers to “how does this code get a reference to that system,” without every class reaching for a global singleton by hand.
Best Practices #
- Prefer injection for anything you’ll want to test or swap. A constructor or field that receives its dependency is trivial to substitute with a mock or a stub. A hardcoded call to a locator is not.
- Prefer a locator for genuinely engine-wide systems. Something like input or audio that legitimately has one instance for the whole game session, reached from dozens of unrelated places, is exactly what a well-scoped locator (or subsystem manager) is for.
- Don’t let either pattern hide a real circular dependency. If two systems need each other to construct, that’s a design smell no amount of injection or locating actually fixes.
Dependency injection #
With dependency injection, a class declares what it needs, and something else (a constructor caller, or a DI framework) supplies it. The class never asks a global for its dependency, it just receives it.
public interface IAudioService
{
void PlaySound(string id);
}
public class AbilityCast
{
private readonly IAudioService audio;
// the dependency is supplied here, not fetched from a global
public AbilityCast(IAudioService audio)
{
this.audio = audio;
}
public void Cast()
{
audio.PlaySound("cast_fx");
}
}
// the caller decides which implementation to supply
var cast = new AbilityCast(new DefaultAudioService());
This makes AbilityCast trivially testable (hand it a fake IAudioService in a unit test) and makes swapping audio backends a one-line change at the call site, not a search-and-replace through every class that plays a sound.
Service locator #
A service locator instead exposes a central registry that any code can query for the system it needs, at the point it needs it, rather than receiving it up front.
public class AbilityCast
{
public void Cast()
{
// fetched on demand from a central registry, not supplied by a caller
ServiceLocator.Get<IAudioService>().PlaySound("cast_fx");
}
}
This is exactly the shape of Heathen’s own Game Framework, modeled directly on Unreal Engine’s own Subsystem family (EngineSubsystem, GameInstanceSubsystem, WorldSubsystem, LocalPlayerSubsystem). A SubsystemManager owns the registered subsystems for a given scope, and any code with access to that manager can fetch the one it needs by type, without every caller needing a constructor-injected reference threaded all the way down to it.
Which one, and when #
These aren’t rivals, most real codebases use both. Small, focused, easily-testable classes lean on injection. Broad, session-wide systems that dozens of unrelated call sites need to reach lean on a locator or subsystem manager, exactly the role Game Framework’s Subsystems fill. The mistake is using a locator everywhere out of convenience, which quietly turns every class into something that can only be tested against the real global state, not the pattern itself.