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.
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:
attempts == 3 afterward, a real, permanent failure can silently hide behind three quiet retries).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.
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.
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.
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.
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.
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.
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.
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.
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
}
}
}
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.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.
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();
}
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.
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();
}
}
}
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.
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.
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.
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().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.
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.
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.
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
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.
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.
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.
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).
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.
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.
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;
}
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.
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.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.
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.
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.
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:
GC.Collect, tied to a spawn/destroy call I make constantly" — Object Pool (Section 6).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.
Tick/Update); one central loop calls it on every registered object each frame.deltaTime the current frame took.event's underlying structure: one variable that internally holds an ordered list of subscribed methods, all invoked when the event fires.Execute() method, enabling remapping, queuing, undo, replay, and AI reuse of the same actions.Get() that never get returned via Release(), causing the pool to keep growing.Instance fields.Init, or Inspector field) instead of letting the object reach out to find it itself.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);
}
}
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.
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;
}
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.
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 ... */ }
}
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.