17.3 Design Patterns (game-specific)

Phase 17 · Software Engineering & Production · Study time: 25–40 h

The patterns that recur in games — Game Loop, Component, Observer, Command, State, Object Pool — and, just as important, when not to use them.

The Gameplay Architecture chapter (6.7) already used a handful of patterns — Component, Observer, Command, State, Object Pool — to solve one specific problem: keeping a growing game's scripts from turning into spaghetti. This chapter is not a sequel to that problem. It is about the patterns themselves, as tools a professional game programmer names out loud in code review, in engine source you will read for years, and in interviews. You already know the rough shape of five of these patterns from 6.7. Here you will see the formal shape every pattern is built from, sharp edges 6.7 did not have room for, two patterns that chapter never mentioned at all (Flyweight and Dirty Flag), and — the part most tutorials skip — exactly how a pattern turns into a mistake when you reach for it before you need it.

1. What Is a Design Pattern?

A design pattern is a named, reusable solution to a problem that keeps showing up in different programs written by different people who never talked to each other. It is not a library you import and not a language feature — it is a shape you recognize and rebuild by hand each time, because the exact code differs but the structure repeats. Patterns get discovered, not invented: someone notices that the same shape keeps appearing and gives it a name so other programmers can say "use a Command here" instead of re-explaining the whole idea from scratch.

Here is that discovery happening in miniature. Two programmers on the same team, who never read each other's code, each solve a "try again if it fails" problem the same way:


// SaveSystem.cs -- written by one programmer
int attempts = 0;
while (attempts < 3)
{
    if (TrySaveToDisk()) break;
    attempts++;
}

// NetworkClient.cs -- written by a different programmer, months
// later, who never saw SaveSystem.cs
int tries = 0;
while (tries < 3)
{
    if (TrySendRequest()) break;
    tries++;
}

Both blocks are the same shape wearing different variable names: try, and if it fails, try again, up to a limit. Once a shape like this shows up often enough across an entire industry, it earns a description with four parts, and every pattern in this chapter follows the same four:

That last part — consequences — is the part beginners skip, and it is the reason this whole chapter exists. Every pattern below has a real cost. Before you learn any of them, you need to see what happens when a pattern is used for a problem that does not exist yet.

1.1 Patterns Are Tools, Not Goals

A single light switch, controlled by one button and read by one script, does not need a pattern:


// Over-patterned: one light, wrapped in a Command, a Factory,
// and an interface, for a feature nobody asked for.
public interface ILightCommand { void Execute(); }

public class ToggleLightCommand : ILightCommand
{
    private readonly Light light;
    public ToggleLightCommand(Light light) { this.light = light; }
    public void Execute() => light.enabled = !light.enabled;
}

public class LightCommandFactory
{
    public static ILightCommand CreateToggle(Light light) => new ToggleLightCommand(light);
}

// Used like this:
ILightCommand cmd = LightCommandFactory.CreateToggle(myLight);
cmd.Execute();

// ...versus just:
myLight.enabled = !myLight.enabled;

Both versions do exactly the same thing at runtime. The first one costs three extra files and a mental hop through an interface, in exchange for a feature — undo, rebinding, replay — that nobody has asked for and this light will probably never need. This is over-patterning (sometimes called pattern-itis): adding a pattern's structure before its problem actually exists. It is exactly as real a problem as the spaghetti code from 6.7's opening section — it just fails in the opposite direction. Spaghetti code is hard to follow because everything touches everything. Over-patterned code is hard to follow because the one real line of behavior is hidden behind layers nobody needed yet, and a teammate has to open four files to find the line that actually flips the light.

Tip Before adding any pattern from this chapter to your own game, ask one question: can you point at the specific pain it removes, in your actual project, today? If the honest answer is "it might be useful later," write the plain version instead. Section 11 turns this into a full checklist once you have seen every pattern.

2. The Game Loop and the Update Method Pattern

Every real-time game, underneath everything, runs the same loop: read input, update the world, render a frame, repeat, dozens of times a second. Unity hides this loop from you and calls Update() on every MonoBehaviour automatically. But the moment you build any system that is not a MonoBehaviour — a headless simulation, a background AI planner, a custom scripting engine — you have to build that loop yourself, and the way you build it is itself a pattern worth naming: the Update Method pattern. Its shape is simple: every object that needs to act every frame exposes a method (commonly called Tick or Update), and one central loop calls that method on every registered object, once per frame.


public interface IUpdatable
{
    void Tick(float deltaTime);
}

public class GameLoop : MonoBehaviour
{
    private readonly List<IUpdatable> updatables = new List<IUpdatable>();

    public void Register(IUpdatable obj) => updatables.Add(obj);
    public void Unregister(IUpdatable obj) => updatables.Remove(obj);

    void Update()
    {
        float dt = Time.deltaTime;
        foreach (IUpdatable obj in updatables)
        {
            obj.Tick(dt);
        }
    }
}

This looks harmless until one of those objects removes itself from the list during its own Tick — which is exactly what an enemy does when it dies mid-frame:


public class Enemy : IUpdatable
{
    private GameLoop loop;
    private int hp = 10;

    public void Tick(float deltaTime)
    {
        hp -= 1;
        if (hp <= 0)
        {
            loop.Unregister(this); // removes itself from the list being iterated right now
        }
    }
}

Expected result: the game crashes with System.InvalidOperationException: Collection was modified; enumeration operation may not execute. A foreach loop keeps an internal cursor into the list, and C# refuses to let you change the list's shape while that cursor is still walking it.

updatables: [A][B][C][D] foreach cursor is visiting A A.Tick() calls Unregister(B) updatables becomes: [A][C][D] (B removed, C shifted left) the foreach cursor still expects the OLD layout --> throws InvalidOperationException on the very next step Fix: walk backward by index instead of foreach. Removing an item at index j < i only shifts slots the loop has already visited, never the ones still ahead of the cursor at i.

void Update()
{
    float dt = Time.deltaTime;
    for (int i = updatables.Count - 1; i >= 0; i--)
    {
        updatables[i].Tick(dt);
    }
}

Unity's own MonoBehaviour.Update loop has already solved this problem for you internally, which is why calling Destroy() mid-frame never crashes the engine. The bug above is the one to expect the moment you hand-roll a list of "things that tick every frame" — which happens constantly once you start writing systems that are not MonoBehaviours.

Tip You will also hear about fixed timestep versus variable timestep loops — physics needs to run at a constant, predictable interval so the simulation behaves the same regardless of frame rate, while rendering can safely use whatever deltaTime the current frame actually took. Unity's FixedUpdate/Update split is exactly this distinction; the full mechanics belong to the physics chapter, but recognizing it as the same Update Method pattern running at two different rates is worth carrying forward.

When to rely on it directly: almost never, inside Unity — you get it for free through MonoBehaviour. Reach for a hand-rolled IUpdatable list when you need explicit control over update order, a headless simulation with no MonoBehaviours at all, or a fixed-rate server tick. When not to: if plain Update() already does what you need, building your own registration list just adds a second loop to keep in sync with Unity's.

3. The Component Pattern, From the Inside

You already use the Component pattern every time you attach a script to a GameObject: composition (building behavior out of small, independent, attachable pieces) instead of inheritance (extending one deep class hierarchy). What 6.7 did not show is how that actually works underneath — knowledge you need the moment you touch a non-Unity engine, or an interviewer asks you to build one from scratch. Strip away Unity's machinery, and a component system is nothing more than a dictionary keyed by type:


using System;
using System.Collections.Generic;

public class Entity
{
    private readonly Dictionary<Type, object> components = new Dictionary<Type, object>();

    public void AddComponent<T>(T component)
    {
        components[typeof(T)] = component;
    }

    public T GetComponent<T>() where T : class
    {
        components.TryGetValue(typeof(T), out object found);
        return found as T; // null if this entity never got one
    }
}

public class PositionComponent { public float X, Y; }
public class HealthComponent   { public int Hp = 100; }

Entity boss = new Entity();
boss.AddComponent(new PositionComponent { X = 0, Y = 0 });
boss.AddComponent(new HealthComponent   { Hp = 500 });

HealthComponent hc = boss.GetComponent<HealthComponent>();
Console.WriteLine("Boss HP: " + hc.Hp);

Expected output: Boss HP: 500. Nothing here is Unity-specific — no MonoBehaviour, no Instantiate, just a plain class holding a dictionary.

Entity "Boss" components: Dictionary<Type, object> [typeof(PositionComponent)] --> PositionComponent{X=0, Y=0} [typeof(HealthComponent)] --> HealthComponent{Hp=500} GetComponent<HealthComponent>() looks up typeof(HealthComponent) as the dictionary key, casts the stored object back to HealthComponent, returns it.

This is, with a lot more optimization (contiguous memory pools instead of a plain dictionary, integer type IDs instead of reflection), roughly what Unity's own GameObject does internally, and exactly what you would hand-build in a custom C++ engine. Recognizing the dictionary-by-type shape is what turns "I use components" into "I understand components."

Concrete game problem it solves: an enemy that needs to combine flies + has a shield + explodes on death without a class for every combination — attach a Flight, a Shield, and an ExplodeOnDeath component and none of them need to know the others exist. When not to use it: a value with no behavior of its own (a plain Vector3, a config struct) does not need to be a component — giving it one costs a dictionary lookup and an allocation for something a plain field already handles for free. And an object that will only ever have exactly one fixed set of behavior, never combined or swapped, gets nothing from composability it will never use.

4. The Observer Pattern: Event Systems

The Observer pattern lets one object (the publisher) announce that something happened without knowing or caring who is listening. Its textbook game example is a health bar: Health should be able to lose hit points without ever importing, referencing, or even knowing that a UI exists.


using System;
using UnityEngine;

public class Health : MonoBehaviour
{
    // (current, max) -- publisher, no listener references stored here
    public event Action<int, int> OnHealthChanged;

    private int current = 100;
    private int max = 100;

    public void TakeDamage(int amount)
    {
        current = Mathf.Max(0, current - amount);
        OnHealthChanged?.Invoke(current, max);
    }
}

using UnityEngine;
using UnityEngine.UI;

public class HealthBarUI : MonoBehaviour
{
    public Health target;
    public Slider slider;

    void OnEnable()  { target.OnHealthChanged += Refresh; }
    void OnDisable() { target.OnHealthChanged -= Refresh; }

    void Refresh(int current, int max)
    {
        slider.value = (float)current / max;
    }
}

Expected result: when TakeDamage(30) runs, Health knows nothing about sliders, canvases, or UI at all — it just fires an event. HealthBarUI found Health and subscribed to it; Health never found HealthBarUI.

Health (publisher) Observers (subscribers) ----------------------- ----------------------- event OnHealthChanged <--subscribe-- HealthBarUI.Refresh() <--subscribe-- DamagePopup.Show() <--subscribe-- AchievementTracker.Check() TakeDamage(30) | v OnHealthChanged.Invoke(current, max) | +--> HealthBarUI.Refresh(current, max) +--> DamagePopup.Show(current, max) +--> AchievementTracker.Check(current, max) Health never references HealthBarUI, DamagePopup, or AchievementTracker by name. Each subscriber found Health -- Health never found them.

4.1 The Sharp Edge 6.7 Didn't Cover: One Bad Subscriber Breaks Everyone

A C# event is a multicast delegate (one variable that internally holds a list of methods, all invoked in subscription order when you call it). That internal list matters the moment one subscriber throws an exception:


void RiskyAchievementCheck(int current, int max)
{
    int ratio = 100 / current; // crashes with DivideByZeroException when current == 0
}

If AchievementTracker.Check subscribed before HealthBarUI.Refresh in the diagram above, and the player's health hits exactly zero, the exception thrown inside Check propagates straight out of OnHealthChanged?.Invoke(...) — and HealthBarUI.Refresh, which was supposed to run right after it, never runs at all. One careless subscriber silently breaks every subscriber that comes after it, and Health — the class that did nothing wrong — is where the crash appears to originate.


// Defensive version: each subscriber runs in isolation.
public void RaiseHealthChanged(int current, int max)
{
    if (OnHealthChanged == null) return;

    foreach (Delegate d in OnHealthChanged.GetInvocationList())
    {
        try
        {
            ((Action<int, int>)d).Invoke(current, max);
        }
        catch (Exception e)
        {
            Debug.LogException(e); // logged, but the OTHER subscribers still run
        }
    }
}
Tip Unity also offers UnityEvent (assignable and reorderable straight in the Inspector, no code required). It is slower than a C# event and cannot be typed as tightly, but it lets a designer wire up a reaction without opening a script. Use C# event between systems programmers own; use UnityEvent for the handful of hooks you specifically want a designer to wire from the Editor.
Common mistake Subscribing in OnEnable/Start but forgetting to unsubscribe in OnDisable/OnDestroy. The event keeps holding a reference to the destroyed listener, which either throws when invoked or quietly stops the garbage collector from ever freeing it. Every += needs a matching -=.

When to use it: one change needs to ripple out to several unrelated systems, or you do not yet know everything that will eventually need to react (a modder's plugin, a future feature). When not to: a single, always-present dependency (a component that always needs its own Rigidbody) is simpler and easier to trace as a direct reference than as an event with exactly one subscriber.

5. The Command Pattern: Input as Objects

Hardcoded input reads fine at first — if (Input.GetKeyDown(KeyCode.Space)) Jump(); — until you need key remapping, an undo stack, a replay system, or an AI that has to trigger the exact same actions a human player can trigger. The Command pattern fixes all four at once by wrapping every action in an object with an Execute() method, instead of calling the target method directly from the input-reading code.


public interface ICommand
{
    void Execute(Actor actor);
}

public class MoveCommand : ICommand
{
    public Vector3 Direction;
    public void Execute(Actor actor) => actor.Move(Direction);
}

public class AttackCommand : ICommand
{
    public void Execute(Actor actor) => actor.Attack();
}

5.1 The New Angle: The Same Commands, Two Different Sources

Because both a human and an AI only need to produce an ICommand and hand it to Execute(), they can share the exact same downstream code path:


// Player: input maps to a command object.
ICommand playerCmd = Input.GetKeyDown(KeyCode.Space) ? new AttackCommand() : null;
if (playerCmd != null) playerCmd.Execute(playerActor);

// AI: a decision system (state machine, behavior tree -- see the
// Decision-Making chapter) picks a command through the SAME interface.
ICommand aiCmd = enemyBrain.ChooseCommand();
aiCmd.Execute(enemyActor);

Because both paths funnel through the identical ICommand.Execute call, a bot can genuinely "play" the game using the same API a human uses — no special AI-only movement function that might behave subtly differently from the player's. This is also how a scripted tutorial can drive the player character with recorded commands, and how you can feed an AI test suite pre-recorded player command logs to check its reactions.

5.2 Undo and a Command Queue


using System.Collections.Generic;
using UnityEngine;

public class InputManager : MonoBehaviour
{
    public Actor player;
    private Dictionary<KeyCode, ICommand> bindings;
    private Stack<ICommand> history = new Stack<ICommand>();

    void Awake()
    {
        bindings = new Dictionary<KeyCode, ICommand>
        {
            { KeyCode.W, new MoveCommand { Direction = Vector3.forward } },
            { KeyCode.Space, new AttackCommand() },
        };
    }

    void Update()
    {
        foreach (var binding in bindings)
        {
            if (Input.GetKeyDown(binding.Key))
            {
                binding.Value.Execute(player);
                history.Push(binding.Value);
            }
        }

        if (Input.GetKeyDown(KeyCode.Z) && history.Count > 0)
        {
            // Undo needs each command to remember how to reverse itself --
            // omitted here for brevity, shown in full in 6.7's Command section.
            history.Pop();
        }
    }
}
Command queue (executed in order, pushed onto history): Input: W W Space (attack) Z (undo) | | | | v v v v queue: [Move] --> [Move] --> [Attack] (undo pops last) history stack (top = most recently executed): [Attack] [Move] [Move] Z pressed --> pop Attack off history --> Attack.Undo(actor) history stack becomes: [Move] [Move]

5.3 Replays: The Same Idea, Recorded to Disk

If every command a match produces gets appended to a list instead of only pushed onto an undo stack, that list is a replay: play the same commands back in the same order against a fresh game state and — as long as the simulation itself is deterministic — you reproduce the whole match.

Common mistake Storing a live object reference inside a saved command (Transform mover in MoveCommand) instead of a stable ID. A reference is only valid for the session it was created in — replaying the command log tomorrow, against freshly loaded objects, needs an ID lookup, not the old pointer. Full replay determinism is a bigger topic than one pattern (it also needs a fixed timestep and no reliance on unordered floating-point operations), but storing commands by ID rather than by reference is the part that belongs to Command itself.

When to use it: input remapping, undo/redo, replay/rewind, or an AI that should share code with player input. When not to: a small prototype with fixed, never-remapped controls and no undo or replay planned — wrapping every action in a class adds ceremony for features you may never build.

6. The Object Pool Pattern

A machine gun that calls Instantiate() for every bullet and Destroy() when it hits something creates a constant stream of garbage for C#'s garbage collector (GC) to clean up — and GC cleanup can pause your whole game for a frame or more, a stop-the-world pause. The Object Pool pattern avoids this by allocating a fixed batch of objects once, then reusing them instead of destroying and recreating.


public interface IPoolable
{
    void OnGet();     // called when taken out of the pool
    void OnRelease();  // called when put back
}

public class Pool<T> where T : class, IPoolable, new()
{
    private readonly Stack<T> free = new Stack<T>();

    public T Get()
    {
        T item = free.Count > 0 ? free.Pop() : new T();
        item.OnGet();
        return item;
    }

    public void Release(T item)
    {
        item.OnRelease();
        free.Push(item);
    }
}

public class Bullet : IPoolable
{
    public bool Active { get; private set; }

    public void OnGet()     { Active = true; }
    public void OnRelease() { Active = false; }
}

Pool<Bullet> bulletPool = new Pool<Bullet>();

Bullet b = bulletPool.Get();      // reused if one is free, otherwise allocated
// ... bullet flies, hits something ...
bulletPool.Release(b);            // returned to the pool, not destroyed

This version is engine-agnostic — Pool<T> works on any class implementing IPoolable, not only GameObjects, which is the deeper, reusable shape behind the Unity-specific Instantiate/SetActive version 6.7 walked through.

Common mistake A pool leak: some code path takes an object out with Get() but never calls Release() — a bullet that hits a wall but forgets to release itself, say. The pool keeps growing every time it runs dry and allocates a new item, which is the exact same shape as a memory leak, just one layer higher. If your pool's size keeps climbing over a long play session, audit every code path that calls Get() and confirm each one has a matching Release().
Tip This is where the Memory and Asset Optimization chapter's tools become concrete: open the Profiler's memory view during a firefight, and the GC.Alloc column for your bullet-spawning code should read at or near zero once pooling is in place. A nonzero, climbing number in that column, tied to a spawn/destroy call, is usually the first clue that something needs pooling.

Concrete game problem it solves: bullets, particles, damage-number popups, pickups — anything spawned and destroyed many times per second. When not to use it: an object created once per level (a boss, a locked door) gains nothing from pooling and just adds bookkeeping for a Get()/Release() pair that will only ever run once.

7. The State Pattern, Two Ways

6.7 already showed a class-per-state implementation for an enemy AI. This section is not about rebuilding that — it is about the decision you face before writing any state machine at all: which of two implementation shapes to use, and a memory pitfall specific to the class-per-state version that 6.7 did not have room for. Deciding which states exist and when they transition — the actual AI decision-making — belongs to the Decision-Making chapter (finite state machines, behavior trees, GOAP, utility AI); this section only covers how to build the state-holding machinery itself.

7.1 Enum and Switch: The Simple Version

When states carry no data of their own and the whole machine is small, a plain enum with a switch statement is often all you need:


public enum WeaponState { Idle, Firing, Reloading }

public class Weapon : MonoBehaviour
{
    private WeaponState state = WeaponState.Idle;
    private float timer;

    void Update()
    {
        switch (state)
        {
            case WeaponState.Idle:
                if (Input.GetButtonDown("Fire1")) { state = WeaponState.Firing; timer = 0f; }
                break;

            case WeaponState.Firing:
                timer += Time.deltaTime;
                if (timer >= 0.1f) { state = WeaponState.Reloading; timer = 0f; }
                break;

            case WeaponState.Reloading:
                timer += Time.deltaTime;
                if (timer >= 1.5f) state = WeaponState.Idle;
                break;
        }
    }
}

Expected result: pressing fire moves state from Idle to Firing, then automatically to Reloading, then back to Idle after the timers elapse — no allocation happens anywhere in this file, ever.

7.2 Class-Per-State, and the Allocation Pitfall

The class-per-state version (one IWeaponState class per state, each with its own Enter/Tick) reads better once a machine has many states or states with their own private fields, exactly as 6.7 showed for enemy AI. But look closely at how a transition usually gets written:


public void ChangeState(IWeaponState newState)
{
    currentState = newState;
    currentState.Enter(this);
}

// Called on every transition:
weapon.ChangeState(new ReloadingState()); // allocates a new object -- every time

For one weapon, this allocation is nothing. For a horde shooter with 200 enemies each running their own class-per-state AI, transitioning many times a second, this is the same disease Section 6 just fixed for bullets — except now it is state objects generating the garbage instead of projectiles. The fix mirrors Object Pool's idea directly: if a state holds no per-instance mutable data, one shared instance can serve every actor, so transitioning never allocates at all.


public class ReloadingState : IWeaponState
{
    // Safe to share ONLY because this class has no instance fields
    // that would need to differ between two different weapons.
    public static readonly ReloadingState Instance = new ReloadingState();
    private ReloadingState() { }

    public void Enter(Weapon w) { w.PlayAnimation("Reload"); }
    public void Tick(Weapon w)  { /* ... */ }
}

// elsewhere, on every transition:
weapon.ChangeState(ReloadingState.Instance); // zero allocation
Common mistake Sharing a state instance that secretly does hold per-instance data (a timer field, say). Two different weapons transitioning into the same shared ReloadingState.Instance at different times would then overwrite each other's timer, since there is only one object backing both of them. Only share a state instance when it is genuinely stateless — if it needs a field, it needs to stay a fresh new object per transition, or the field needs to move onto the actor itself instead of the state.

Concrete game problem it solves: a pile of interdependent booleans (isChasing, isAttacking, isReloading...) where nobody can tell which combinations are valid, replaced by exactly one named, current state at a time. When not to use it: two states controlled by one obvious toggle (isPaused) do not need either version above — a single bool is clearer than a machine with two states in it.

8. Singleton, Service Locator, and Dependency Injection

Some systems really do need exactly one shared instance reachable from anywhere — an AudioManager, a top-level GameManager. The Singleton pattern gives a class one globally reachable instance:


using UnityEngine;

public class ScoreManager : MonoBehaviour
{
    public static ScoreManager Instance { get; private set; }
    public int Score { get; private set; }

    void Awake()
    {
        if (Instance != null && Instance != this) { Destroy(gameObject); return; }
        Instance = this;
        DontDestroyOnLoad(gameObject);
    }

    public void AddScore(int amount) => Score += amount;
}

// Called from anywhere, forty different times across the codebase:
ScoreManager.Instance.AddScore(10);

Every tutorial reaches for this because it is genuinely convenient: no setup, no wiring, ScoreManager.Instance works from literally any script the moment it compiles. That convenience is also exactly what makes it hurt later.

8.1 The Concrete Pain: Split-Screen Breaks Every Call Site

The game above ships successfully with one shared score. Six months later, the producer asks for local two-player split-screen, where each player needs their own, independent score.

Before split-screen (looks fine, one shared score): PlayerController --> ScoreManager.Instance Coin --> ScoreManager.Instance UIManager --> ScoreManager.Instance AchievementSystem --> ScoreManager.Instance (40+ call sites total) Split-screen needs two independent scores. But Instance is ONE static field -- there is no such thing as "Instance for player 2." Every one of those 40+ call sites has to change, because the assumption "there is exactly one score in the whole process" was baked into the TYPE itself, not into any one place it happened to be used.

Fixing this means touching every one of those forty call sites, because the type ScoreManager itself, not any particular use of it, assumed there could only ever be one. The fix is the design you would have written on day one if split-screen had been a known requirement from the start: a plain, non-static tracker that each player owns their own copy of.


public class ScoreTracker // no static Instance -- just a plain object
{
    public int Score { get; private set; }
    public void AddScore(int amount) => Score += amount;
}

public class PlayerController : MonoBehaviour
{
    public ScoreTracker scoreTracker = new ScoreTracker(); // this player's own copy
}

Beyond this specific pain, singletons cause the same three problems in every project that grows large enough: hidden dependencies (nothing in PlayerController's class signature says it needs ScoreManager — you only find out by reading every line), initialization-order bugs (calling Instance before its own Awake() has run gives you null), and untestable code (you cannot swap in a fake ScoreManager for a unit test when code reaches straight for a fixed global).

8.2 Service Locator: Fixes One of the Three Problems

A Service Locator replaces many separate static Instance fields with one central registry that hands out references on request:


using System;
using System.Collections.Generic;

public static class ServiceLocator
{
    private static readonly Dictionary<Type, object> services = new Dictionary<Type, object>();

    public static void Register<T>(T service) => services[typeof(T)] = service;
    public static T Get<T>() => (T)services[typeof(T)];
}

// Setup, once, at game start:
ServiceLocator.Register<IAudioService>(new AudioManager());

// Anywhere in the game:
ServiceLocator.Get<IAudioService>().Play(explosionClip);

Because Register takes an interface, a test can register a fake IAudioService that does nothing instead of the real one — that fixes the untestable-code problem. It still does nothing for the other two: ServiceLocator.Get<IAudioService>() is still a hidden dependency (nothing in a class's signature says it calls this), and it is still global mutable state that anything in the program can silently rely on.

8.3 Dependency Injection: Fixes the Hidden Dependency Too

Dependency Injection (DI) takes the more direct route: instead of a class reaching out to find what it needs, whatever creates that class hands the dependency in explicitly, usually through a constructor, an Init method, or an Inspector-assigned field.


public class Explosion : MonoBehaviour
{
    private IAudioService audio;

    // The dependency is now part of THIS CLASS'S OWN public shape --
    // you cannot construct a working Explosion without deciding
    // where its audio comes from. It cannot be hidden anymore.
    public void Init(IAudioService audioService)
    {
        audio = audioService;
    }

    void OnHit()
    {
        audio.Play(explosionClip);
    }
}

// Setup:
Explosion e = Instantiate(explosionPrefab).GetComponent<Explosion>();
e.Init(realAudioService);   // or a fake IAudioService, inside a test

The trade-off is visible right there: DI is explicit and trivially testable, but every object that needs a service must be wired up by hand somewhere, which is real extra code. Larger studios often bring in a DI framework (Zenject/Extenject is the common one for Unity) to automate that wiring at scale — worth knowing exists, not worth reaching for on a small project, where the manual Init() version above is plenty.

When to use a Singleton: a genuinely one-of-a-kind, whole-game system (the top-level game manager, an audio device wrapper), used sparingly, on a small project where split-screen or unit tests are not on the roadmap. When not to: as a default answer to "how do two scripts talk" — prefer a direct Inspector reference or an event (Section 4) first, Service Locator or DI once you actually need swappable or testable systems.

9. The Flyweight Pattern

Picture a tile-based world with 200,000 tile instances — grass, water, stone, lava. Each tile type needs the same handful of fields: a texture, a movement cost, whether it is walkable. Storing all of that separately on every one of 200,000 tile instances duplicates the exact same five values across every grass tile, every water tile, and so on. The Flyweight pattern fixes this by splitting an object's data into two kinds: intrinsic state (identical for every instance of a type, and therefore safe to share) and extrinsic state (unique per instance, and therefore kept separate).


using System.Collections.Generic;
using UnityEngine;

// Intrinsic state: identical for every "Grass" tile in the world,
// every "Water" tile, and so on -- so store it exactly once per type.
public class TileType
{
    public readonly string Name;
    public readonly Texture2D Texture;
    public readonly float MovementCost;
    public readonly bool IsWalkable;

    public TileType(string name, Texture2D texture, float cost, bool walkable)
    {
        Name = name; Texture = texture; MovementCost = cost; IsWalkable = walkable;
    }
}

// Factory guarantees only ONE TileType object exists per distinct type.
public static class TileTypeFactory
{
    private static readonly Dictionary<string, TileType> cache = new Dictionary<string, TileType>();

    public static TileType Get(string name, Texture2D texture, float cost, bool walkable)
    {
        if (!cache.TryGetValue(name, out TileType type))
        {
            type = new TileType(name, texture, cost, walkable);
            cache[name] = type;
        }
        return type;
    }
}

// Extrinsic state: unique per tile instance -- a small struct, not a class.
public struct Tile
{
    public TileType Type;   // shared reference, not a copy of the fields
    public int GridX, GridY;
}
Without Flyweight (each tile owns a full copy): Tile[0]: name="Grass" tex=... cost=1 walkable=true x=0 y=0 Tile[1]: name="Grass" tex=... cost=1 walkable=true x=1 y=0 Tile[2]: name="Water" tex=... cost=3 walkable=false x=2 y=0 ... 200,000 tiles, the same handful of fields duplicated over and over across almost all of them With Flyweight (shared intrinsic state): TileType "Grass" <-----------+ TileType "Water" <---------+ | | | Tile[0]: Type --> Grass x=0 y=0 Tile[1]: Type --> Grass x=1 y=0 Tile[2]: Type --> Water x=2 y=0 ... 200,000 tiles, each only a reference plus its own x,y -- but only a few dozen TileType objects exist in total

Expected result: instead of 200,000 copies of five fields, the game holds perhaps thirty TileType objects total (a few kilobytes) plus 200,000 tiny Tile structs, each holding only a reference and two integers.

Common mistake Mutating what was supposed to be shared, read-only intrinsic state — for example, writing tile.Type.MovementCost = 0.5f to make one specific muddy grass tile slower. Because every grass tile points at the same TileType object, that line silently slows down every grass tile in the entire world, not just the one you meant. Anything that genuinely needs to vary per-instance belongs in the extrinsic struct (add a field to Tile itself), never patched onto the shared flyweight.
Tip Unity's own GPU instancing and SRP batching are production-grade versions of this exact idea at the rendering level: thousands of identical meshes share one set of mesh/material data on the GPU, and only a small per-instance transform varies. Recognizing "many instances, mostly identical data" is the same signal whether you are solving it in gameplay code or watching the renderer solve it for you.

Concrete game problem it solves: thousands of instances that mostly share the same data — tile types, particle definitions, item definitions referenced by many dropped copies in the world, foliage instances. When not to use it: a few dozen instances save nothing meaningful from sharing, and the extra factory lookup and indirection just adds complexity for memory you were never going to notice.

10. The Dirty Flag Pattern

Some computations are expensive and read constantly, but the data behind them barely changes. An inventory UI that rebuilds its entire icon layout every single frame is doing exactly this — the layout only actually needs to change on the rare frame something is picked up or dropped.


// BAD: rebuilds the whole layout 60 times a second, even on the
// 599 out of 600 frames where the inventory did not change at all.
public class InventoryUI : MonoBehaviour
{
    public Inventory inventory;

    void Update()
    {
        RebuildLayout(); // expensive: repositions/instantiates every icon
    }

    void RebuildLayout() { /* ... expensive work ... */ }
}

The Dirty Flag pattern fixes this with one bool: only recompute when something has actually flagged the cached result as stale, then clear the flag once the recompute is done. It pairs naturally with Section 4's Observer pattern — the event that changed the data is exactly what should set the flag.


public class InventoryUI : MonoBehaviour
{
    public Inventory inventory; // assume it exposes: public event Action OnChanged;
    private bool isDirty = true; // start dirty so it builds once on the first frame

    void OnEnable()  { inventory.OnChanged += MarkDirty; }
    void OnDisable() { inventory.OnChanged -= MarkDirty; }

    void MarkDirty() => isDirty = true;

    void Update()
    {
        if (!isDirty) return; // nothing changed since the last build -- skip everything
        RebuildLayout();
        isDirty = false;
    }

    void RebuildLayout() { /* ... expensive work ... */ }
}

Expected result: across 600 frames (10 seconds at 60 fps) with exactly 3 pickups, RebuildLayout runs 3 times instead of 600 — a 200x reduction in calls to the expensive method, with the on-screen result identical to the naive version every single frame.

isDirty = true (start) | v Update(): isDirty? --yes--> RebuildLayout() --> isDirty = false | no v skip (near-zero cost) inventory.OnChanged fires (pickup/drop, any frame) --> MarkDirty() --> isDirty = true

This exact idea shows up under different names throughout game engines: Unity's own transform.hasChanged flag, "dirty rectangles" in older 2D renderers that only redraw the screen regions that changed, and the cached, lazily-recomputed world matrices in a scene graph.

Concrete game problem it solves: any expensive recompute that is read far more often than its underlying data actually changes — UI layout, cached world transforms, a minimap, a pathfinding grid that only needs rebuilding when the level geometry changes. When not to use it: if the underlying data changes on almost every single frame anyway (a moving object's own position), the flag adds bookkeeping around a recompute you were going to do every frame regardless — just recompute directly.

11. Choosing a Pattern: Start Simple, Refactor When It Hurts

Every pattern in this chapter is a fix for a pain you can name. The professional habit is not memorizing all ten and reaching for one on day one — it is writing the plain, direct version first, and reaching for a specific pattern only once its specific pain actually shows up in your project. Here is that pain, matched to the pattern that answers it:

Notice the shape of every line: it names a symptom you can actually observe in your own project today — a profiler trace, a repeated line of code, a specific feature request — not a guess about what might be useful someday. That is the whole test. YAGNI ("You Aren't Gonna Need It") is the short version of this rule: write the field, the direct call, the single if, first. A pattern is something you refactor into once the plain version starts visibly hurting, never something you start with because it might be needed eventually. The cost of skipping a pattern you do not need yet is exactly zero. The cost of adding one too early is real: extra files, extra indirection, and a beginner teammate who now has to open four of them to find the one line of behavior that actually matters.

12. Glossary

13. Exercises

Exercise 1 — Kill the Singleton The code below ships successfully as a single-player game. The team now needs local two-player split-screen, where each player needs an independent score. Refactor ScoreManager away from a static Instance, and rewrite Coin so it credits whichever player actually touched it.

public class ScoreManager : MonoBehaviour
{
    public static ScoreManager Instance { get; private set; }
    public int Score { get; private set; }
    void Awake() { Instance = this; }
    public void AddScore(int amount) => Score += amount;
}

public class Coin : MonoBehaviour
{
    void OnTriggerEnter(Collider other)
    {
        ScoreManager.Instance.AddScore(10);
        Destroy(gameObject);
    }
}
Show answer

public class ScoreTracker // plain object -- no static Instance anywhere
{
    public int Score { get; private set; }
    public void AddScore(int amount) => Score += amount;
}

public class PlayerController : MonoBehaviour
{
    public ScoreTracker scoreTracker = new ScoreTracker(); // this player's own copy
}

public class Coin : MonoBehaviour
{
    void OnTriggerEnter(Collider other)
    {
        // Coin no longer assumes there is exactly one score in the game --
        // it credits whichever player's controller actually touched it.
        PlayerController player = other.GetComponent<PlayerController>();
        if (player != null)
        {
            player.scoreTracker.AddScore(10);
        }
        Destroy(gameObject);
    }
}

Two PlayerControllers in a split-screen scene now each own an independent ScoreTracker. Coin reads whichever player it collided with instead of a single global instance. This is exactly the refactor a singleton was supposed to save you from doing, forced to happen anyway, all at once, across every call site — the pain described in Section 8.1.

Exercise 2 — Flyweight an Item Database The code below stores full item data on every single dropped item in the world, duplicating the same four fields for every one of a hundred dropped health potions. Refactor it into a Flyweight: an ItemType holding the shared (intrinsic) data, a factory that hands out one shared instance per item name, and a lightweight DroppedItem holding only a reference plus its own position.

public class DroppedItem : MonoBehaviour
{
    public string itemName;
    public Sprite icon;
    public int value;
    public string description;
}
Show answer

using System.Collections.Generic;
using UnityEngine;

public class ItemType // intrinsic state -- identical for every potion, every sword, etc.
{
    public readonly string Name;
    public readonly Sprite Icon;
    public readonly int Value;
    public readonly string Description;

    public ItemType(string name, Sprite icon, int value, string description)
    {
        Name = name; Icon = icon; Value = value; Description = description;
    }
}

public static class ItemTypeFactory
{
    private static readonly Dictionary<string, ItemType> cache = new Dictionary<string, ItemType>();

    public static ItemType Get(string name, Sprite icon, int value, string description)
    {
        if (!cache.TryGetValue(name, out ItemType type))
        {
            type = new ItemType(name, icon, value, description);
            cache[name] = type;
        }
        return type;
    }
}

public class DroppedItem : MonoBehaviour
{
    public ItemType type;        // shared reference -- extrinsic wrapper
    public Vector3 dropPosition; // unique per instance
}

A hundred dropped health potions in the world now share exactly one ItemType "Health Potion" object created through ItemTypeFactory. Each DroppedItem stores only a reference to that shared object plus its own position, instead of duplicating icon, value, and description a hundred separate times.

Exercise 3 — Add a Dirty Flag to a Minimap The minimap below redraws every single frame in Update, even though the underlying enemy positions it displays only change when an enemy spawns or dies. Assume EnemySpawner exposes a C# event, public event Action OnEnemyCountChanged, fired exactly when that happens. Rewrite Minimap to redraw only when needed.

public class Minimap : MonoBehaviour
{
    void Update()
    {
        RedrawIcons(); // expensive: repositions every icon on the map texture
    }

    void RedrawIcons() { /* ... expensive work ... */ }
}
Show answer

public class Minimap : MonoBehaviour
{
    public EnemySpawner spawner;
    private bool isDirty = true; // start dirty so it draws once on the first frame

    void OnEnable()  { spawner.OnEnemyCountChanged += MarkDirty; }
    void OnDisable() { spawner.OnEnemyCountChanged -= MarkDirty; }

    void MarkDirty() => isDirty = true;

    void Update()
    {
        if (!isDirty) return;
        RedrawIcons();
        isDirty = false;
    }

    void RedrawIcons() { /* ... expensive work ... */ }
}

RedrawIcons now only runs on the frame right after OnEnemyCountChanged fires, instead of sixty times a second regardless of whether anything changed. This combines two patterns from this chapter directly: Observer (the event that flips the flag) supplies exactly the signal Dirty Flag (the flag that gates the expensive work) needs to know when to run.

← Back to all chapters