๐งฉ Lesson 1.4: Scaling Design Patterns โ Command, State, Service Locator & Event Bus
Patterns are named solutions to problems that recur so often they've earned a vocabulary. You've met a few implicitly โ the ScriptableObject-event pattern in Intermediate, the composition root in Lesson 1.2. This lesson gives you four more that specifically help code scale: Command (turn actions into objects you can queue, undo, and replay), State (a clean code-level finite state machine), Service Locator (a central registry, revisited with its trade-offs), and the Event Bus (decoupled publish/subscribe messaging). Each comes with concise C# you can lift into a project today.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Encapsulate actions as Command objects and use them for undo/redo and input buffering
- Build a code-level finite state machine with an
IStateinterface, and relate it to the Animator FSM from Intermediate - Implement a Service Locator and judge it honestly against DI from Lesson 1.2
- Decouple systems with an Event Bus โ one publisher, many unaware subscribers
- Pick the right pattern for a given scaling problem rather than reaching for the same tool every time
Estimated Time: 65 minutes ยท Prerequisite: Lessons 1.1โ1.2; comfort with interfaces, delegates, and generics
In This Lesson
Why Patterns, and Which Four
A design pattern isn't a library you install; it's a shape you recognize and reproduce. Its value is twofold: it's a proven structure for a recurring problem, and it's a shared name so a teammate instantly knows what you built. The risk is over-application โ patterns add indirection, and indirection you don't need is just complexity. The skill is matching the pattern to a real pressure.
These four each answer a specific scaling pressure:
- Command โ when you need to treat "an action" as data: queue it, log it, undo it, replay it.
- State โ when an object's behavior depends on a mode and the
if/switchsoup is getting unmanageable. - Service Locator โ when many systems need shared services and you want one place to find them (with eyes open about the cost).
- Event Bus โ when a change in one system must notify many others that shouldn't know about each other.
Command
The Command pattern turns a method call into an object. Instead of calling player.Jump() directly, you create a JumpCommand and execute it. That reification unlocks everything you can do with objects but not with raw calls: store them in a list, send them over a network, and โ because a command can also know how to reverse itself โ undo them.
public interface ICommand
{
void Execute();
void Undo();
}
public class MoveCommand : ICommand
{
private readonly Transform _t;
private readonly Vector3 _delta;
public MoveCommand(Transform t, Vector3 delta) { _t = t; _delta = delta; }
public void Execute() => _t.position += _delta;
public void Undo() => _t.position -= _delta; // the inverse
}
An invoker keeps a history stack, giving you undo/redo almost for free โ the core of any level editor or turn-based game:
public class CommandInvoker
{
private readonly Stack<ICommand> _undo = new();
private readonly Stack<ICommand> _redo = new();
public void Execute(ICommand cmd)
{
cmd.Execute();
_undo.Push(cmd);
_redo.Clear(); // a new action invalidates the redo branch
}
public void Undo()
{
if (_undo.Count == 0) return;
var cmd = _undo.Pop();
cmd.Undo();
_redo.Push(cmd);
}
public void Redo()
{
if (_redo.Count == 0) return;
var cmd = _redo.Pop();
cmd.Execute();
_undo.Push(cmd);
}
}
The same reification powers input buffering: in a fighting game you enqueue commands as inputs arrive and execute them when the character is ready, so a slightly-early button press still lands. Because commands are just data, you can also record a replay (a list of commands + timings) or send them across the wire โ a preview of the RPC thinking in Module 6.
๐ Definition
Command: encapsulate a request as an object, so requests can be parameterized, queued, logged, and undone. The three roles are the command (knows how to do and undo one thing), the invoker (triggers commands and keeps history), and the receiver (the object the command acts on).
State (a Code-Level FSM)
You already know finite state machines from the Animator in Intermediate โ states with entry/exit and transitions between them. The State pattern is that same idea in code, for game logic rather than animation: an enemy that is Patrolling, then Chasing, then Attacking; a game that is in Menu, Playing, or Paused. Rather than a growing tangle of booleans and ifs, each state becomes its own class behind an interface.
public interface IState
{
void Enter();
void Tick(); // called each frame while active
void Exit();
}
public class StateMachine
{
private IState _current;
public void ChangeState(IState next)
{
_current?.Exit(); // clean up the old state
_current = next;
_current.Enter(); // set up the new one
}
public void Tick() => _current?.Tick();
}
Each state owns its own behavior and decides when to hand off:
public class PatrolState : IState
{
private readonly Enemy _enemy;
private readonly StateMachine _fsm;
public PatrolState(Enemy e, StateMachine fsm) { _enemy = e; _fsm = fsm; }
public void Enter() => _enemy.PlayAnim("Walk");
public void Exit() { }
public void Tick()
{
_enemy.FollowWaypoints();
if (_enemy.CanSeePlayer())
_fsm.ChangeState(new ChaseState(_enemy, _fsm)); // transition
}
}
The payoff is the same as the Animator's: adding an AttackState doesn't touch the others, each state's logic lives in one place, and the transitions are explicit calls you can read. It's the code cousin of the animation FSM โ same mental model, applied to gameplay.
๐ก When to graduate to it. One or two modes? A simpleenumand aswitchis fine โ don't over-engineer. Reach for the State pattern when the number of modes, per-mode data, and transition rules grow past what a singleswitchcan hold clearly. That inflection point is where it earns its indirection.
Service Locator
The Service Locator is a central registry that hands out shared services on request. You met it in Lesson 1.2 as DI's compromised cousin; here's the pattern itself, stated plainly, plus when it's a legitimate choice.
public static class ServiceLocator
{
private static readonly Dictionary<Type, object> _services = new();
public static void Register<T>(T service) => _services[typeof(T)] = service;
public static T Get<T>()
{
if (_services.TryGetValue(typeof(T), out var s)) return (T)s;
throw new InvalidOperationException($"No service registered for {typeof(T).Name}");
}
}
// Register once at startup (the composition root):
ServiceLocator.Register<IAudioService>(new AudioService());
// Fetch anywhere:
ServiceLocator.Get<IAudioService>().Play("footstep");
Its appeal is real: minimal wiring, easy to add to an existing codebase, and โ crucially, unlike a raw singleton โ consumers depend on IAudioService, so implementations remain swappable and fakeable.
โ ๏ธ The trade-off vs DI
The locator is pulled from, so dependencies stay hidden: a class using ServiceLocator.Get reveals nothing in its signature about what it needs, and a test must populate the global registry before that class runs. True DI (Lesson 1.2) pushes dependencies in, making them explicit and the class trivially testable. Rule of thumb: prefer injection for your own gameplay classes; a Service Locator is a defensible pragmatic choice for cross-cutting services (logging, analytics) or when retrofitting into code that wasn't built for DI.
Event Bus
The Event Bus (a.k.a. message bus) is the decoupling powerhouse. A publisher raises an event without knowing who โ if anyone โ listens; subscribers react without knowing who raised it. When the player dies, one Publish(new PlayerDied()) can update the UI, stop the music, trigger a respawn timer, and record analytics โ none of those systems aware of each other or of the player.
public interface IEvent { }
public static class EventBus
{
private static readonly Dictionary<Type, Delegate> _handlers = new();
public static void Subscribe<T>(Action<T> handler) where T : IEvent
{
_handlers.TryGetValue(typeof(T), out var d);
_handlers[typeof(T)] = (Action<T>)d + handler; // combine delegates
}
public static void Unsubscribe<T>(Action<T> handler) where T : IEvent
{
if (_handlers.TryGetValue(typeof(T), out var d))
_handlers[typeof(T)] = (Action<T>)d - handler;
}
public static void Publish<T>(T evt) where T : IEvent
{
if (_handlers.TryGetValue(typeof(T), out var d))
((Action<T>)d)?.Invoke(evt); // fan out to all subscribers
}
}
// An event is just a data payload:
public struct PlayerDied : IEvent { public int Score; }
// Many unaware subscribers:
EventBus.Subscribe<PlayerDied>(e => ui.ShowGameOver(e.Score));
EventBus.Subscribe<PlayerDied>(e => audio.StopMusic());
// One publisher, ignorant of all of them:
EventBus.Publish(new PlayerDied { Score = 4200 });
One Publish fans out to every subscriber. Here's that fan-out drawn out โ the shape that makes the bus worth its indirection:
PlayerDied into the Event Bus, which fans it out to four subscribers. Because everyone talks only to the bus, you can add or remove a subscriber (say, a new "record high score" system) without touching the publisher or the others โ the essence of decoupling.โ ๏ธ The cost of decoupling: unsubscribe, and traceability
Two hazards. First, always unsubscribe (typically in OnDisable/OnDestroy) โ a static bus holding a delegate to a destroyed object is a memory leak and a null-reference waiting to fire. Second, an event bus makes "who reacts to this?" harder to trace: the flow is invisible in the call graph. Use it for genuinely broadcast, many-to-many notifications; don't route point-to-point calls through it just to feel decoupled.
Choosing Between Them
Patterns are tools, not trophies. A quick decision guide:
- Need to queue, log, undo, or replay actions? โ Command.
- An object's behavior switches by mode and the branching is sprawling? โ State.
- Many systems need a shared service and you accept hidden dependencies for less wiring? โ Service Locator (but prefer DI for your own code).
- One change must notify many unrelated systems? โ Event Bus.
They compose. The Module 1 mini-project (Lesson 1.5) wires services through a container (DI/locator hybrid), and a real game would layer a State machine for game flow and an Event Bus for cross-system notifications on top. The goal is never "use all the patterns" โ it's to recognize the pressure and reach for the shape that relieves it.
โ The meta-rule
Introduce a pattern when the pain it removes is already present, not in anticipation. A pattern applied to a problem you don't yet have is just indirection you'll have to read past forever. Let the code tell you when it's ready.
Hands-on Challenge
๐๏ธ Exercise 1: Undo/redo with Command
Objective: Build a tiny editor-style tool with working undo and redo.
- Implement
ICommandwithExecute()andUndo(), plus aSpawnCommandthat instantiates an object on execute and destroys it on undo. - Use the
CommandInvokerfrom this lesson. Bind keys: click to spawn (execute), Ctrl+Z to undo, Ctrl+Y to redo. - Spawn three objects, undo twice, redo once, then spawn a new one โ confirm the redo stack clears.
โ What to verify
Undo destroys the most recently spawned object and pushes the command to the redo stack; redo re-spawns it. After undoing and then executing a new command, the redo stack is cleared (you branched history) โ which is why Execute() calls _redo.Clear(). That branching behavior is exactly how real editors handle undo after a new edit.
๐๏ธ Exercise 2: Wire an Event Bus
Define a ScoreChanged : IEvent carrying the new score. Have a scoring system Publish it, and two independent subscribers โ a UI label and a "high score" tracker โ react, neither knowing about the other or the scorer. Then add a third subscriber (a sound cue on milestone scores) without editing the publisher or the existing subscribers. Finally, make each subscriber unsubscribe in OnDisable.
โ The lesson in it
Adding the third subscriber touches only new code โ the publisher and existing listeners are untouched, proving the decoupling. The OnDisable unsubscribe is not optional housekeeping: skip it and the static bus keeps invoking a handler on a destroyed object, leaking memory and eventually throwing. Decoupling buys flexibility but bills you for lifetime discipline.
๐ฏ Quick Quiz
Question 1: Which capability does the Command pattern most directly enable?
Question 2: In the code-level State pattern, what does StateMachine.ChangeState do in order?
Question 3: What's the main downside of a Service Locator compared with true dependency injection?
Question 4: Why must Event Bus subscribers unsubscribe (e.g. in OnDisable)?
Summary
๐ Key Takeaways
- Command reifies an action as an object with
Execute/Undo, enabling undo/redo, input buffering, replays, and networked actions. - State gives each mode its own
IStateclass withEnter/Tick/Exitโ the code cousin of the Animator FSM, for gameplay logic. - Service Locator is a central registry: less wiring than DI and swappable behind interfaces, but it hides dependencies โ prefer DI for your own gameplay code.
- Event Bus fans one published event out to many unaware subscribers, decoupling systems โ at the cost of traceability and mandatory unsubscribing.
- Choose by the pressure you actually feel; patterns compose, but introduce each only when its pain is already present.
๐ What's Next?
You now have the full Module 1 toolkit: assemblies, dependency injection, async/await, and a vocabulary of patterns. Time to build something that uses them together. In Lesson 1.5: A Game Bootstrapper with a Service Container (Mini-Project), you'll assemble a real boot sequence โ a container registering services behind interfaces, initialized in order with Awaitable, then loading the main scene.
๐งฉ A vocabulary of shapes
Command, State, Service Locator, Event Bus โ four proven structures for the pressures big projects apply. Next, we put the whole module to work in one build-along.