6.7 Gameplay Architecture & Patterns

Phase 6 · Gameplay Programming · Study time: 30–50 h

Structuring gameplay code so it scales — components, event systems, state machines, and the game-specific design patterns (Command, Observer, State, Object Pool).

This chapter is about how to arrange the gameplay code you already know how to write, so a game with thousands of lines still makes sense a year from now. Earlier chapters showed you the GameObject + component idea in Unity — this chapter builds on it. You will learn six patterns that show up in almost every real game, when to reach for each one, and — just as important — when not to.

1. Why Gameplay Code Turns Into Spaghetti

Every game starts small. One script moves the player, another spawns enemies, another shows the score. Then the game grows. The enemy script needs to tell the UI script when the player dies. The UI script needs to tell the sound script to play a sting. The sound script needs to check whether the game is paused, which lives in yet another script. Six months in, almost every script calls into almost every other script directly, and changing one line breaks three unrelated features.

This mess has a name: spaghetti code (code where the flow of control is so tangled that you cannot trace one piece without pulling in ten others, like a bowl of noodles). It is not caused by bad programmers. It is caused by not having a plan for how pieces of a game are allowed to talk to each other.

PlayerHealth --> UIManager PlayerHealth --> SoundManager UIManager --> ScoreManager EnemyAI --> PlayerHealth EnemyAI --> UIManager SoundManager --> ScoreManager ScoreManager --> UIManager Arrows go in every direction. Change one box, and you risk breaking three others you did not expect.

Gameplay architecture (the way you organize and connect your game's code) is the answer. It is not about writing clever code — it is about controlling coupling (how much one piece of code depends on the exact details of another piece). Low coupling means you can change or delete one script without every other script breaking. The rest of this chapter is a toolbox of well-known shapes — design patterns (reusable, named solutions to problems that keep showing up in software) — that help you keep coupling low as your game grows. You will also learn when a pattern is the wrong tool, because using one where you do not need it creates its own kind of spaghetti.

2. Recap: Composition Over Inheritance

An earlier chapter introduced Unity's core building block: a GameObject is an empty container, and you give it behavior by attaching components (small, focused scripts like Transform, Rigidbody, or your own Health script). This is composition (building something by combining small, independent pieces) instead of inheritance (building something by extending a parent class and inheriting its fields and methods).

Why does this matter for architecture? Because inheritance trees grow in the wrong direction as a game grows. Imagine modeling enemies like this:

Entity +-- Character +-- Enemy +-- FlyingEnemy +-- FlyingEnemy_WithShield +-- FlyingEnemy_WithShield_ThatExplodes

Every new combination of features (flies + has a shield + explodes on death) forces you to either duplicate code or add another layer to the tree. Real games need combinations, not a strict hierarchy. Composition solves this: instead of a class for every combination, you build small components — Flight, Shield, ExplodeOnDeath — and attach whichever ones a given enemy needs.


// One enemy: flies and explodes, no shield
GameObject boss = new GameObject("Boss");
boss.AddComponent<Health>();
boss.AddComponent<Flight>();
boss.AddComponent<ExplodeOnDeath>();

// A different enemy: walks and has a shield, does not explode
GameObject grunt = new GameObject("Grunt");
grunt.AddComponent<Health>();
grunt.AddComponent<GroundMovement>();
grunt.AddComponent<Shield>();

No shared base class had to predict every future combination. This chapter's patterns build on top of this idea: once behavior lives in small, separate components, you still need rules for how those components find out about each other without hard-wiring direct references everywhere. That is what the rest of this chapter covers.

3. The Game Loop: Update Method

Every real-time game, no matter the engine, runs the same basic loop underneath everything: read input, update the world, draw the frame, repeat, roughly 60 times a second. This is the game loop. Unity hides it from you, but it is still there — the engine's core runs something conceptually like this every frame:


// This is NOT Unity code. It is what a game engine's core loop
// looks like underneath, written as plain pseudocode-C#.
while (gameIsRunning)
{
    ProcessInput();      // read keyboard, mouse, gamepad
    Update(deltaTime);   // move things, run AI, check rules
    Render();             // draw the current frame
}

deltaTime (the time in seconds since the last frame) is passed in so movement stays smooth even if the frame rate changes — you already used this idea when moving a player with transform.position += speed * Time.deltaTime.

Unity calls this loop for you, and gives every MonoBehaviour a chance to run code once per frame through the Update method:


using UnityEngine;

public class Spinner : MonoBehaviour
{
    public float degreesPerSecond = 90f;

    // Unity's game loop calls this once per frame automatically.
    void Update()
    {
        transform.Rotate(0f, degreesPerSecond * Time.deltaTime, 0f);
    }
}

Expected result: the object spins smoothly, at the same real-world speed whether the game runs at 30 fps or 144 fps, because each call multiplies by that frame's deltaTime.

Unity actually offers a few loop methods, called in this order every frame:

Input events (mouse/keyboard callbacks) | v FixedUpdate() -- runs at a fixed rate, used for physics | v Update() -- runs once per rendered frame, used for gameplay logic | v LateUpdate() -- runs after all Update() calls, used for cameras | v Render frame
Tip Put physics-affecting code (Rigidbody forces, velocity) in FixedUpdate, and put everything else (input reading, timers, animation triggers) in Update. Put camera-follow code in LateUpdate so the camera always moves after the player has already moved that frame.

When to use it: in Unity, the engine already gives you this loop — you rarely build your own. But understanding it explains why FixedUpdate/Update/LateUpdate ordering matters, and you need the raw concept directly if you ever write a non-Unity tool, a simulation on a background thread, or a turn-based system with your own "tick."

4. The Component Pattern

You already use the Component pattern every time you attach a script to a GameObject. As an architecture tool, its rule is simple: give each component one job. A Health component only tracks hit points and death. A PlayerMovement component only reads input and moves the transform. A WeaponFire component only spawns projectiles. None of them should also handle UI, saving, or sound directly.

Components typically need to reach other components on the same object. The most direct way is GetComponent:


using UnityEngine;

public class WeaponFire : MonoBehaviour
{
    private Health health;

    void Awake()
    {
        // Cache the reference once...
        health = GetComponent<Health>();
    }

    void Update()
    {
        // ...instead of calling GetComponent every frame.
        if (health.IsAlive && Input.GetButtonDown("Fire1"))
        {
            Fire();
        }
    }

    void Fire() { /* spawn a bullet, shown in section 8 */ }
}
Common mistake Calling GetComponent<T>() inside Update() every frame. It is a search through the GameObject's components and is needlessly slow if repeated 60 times a second. Cache the result once in Awake() or Start() and reuse the variable.

GetComponent works well when one component needs a specific sibling component on the same object. It works badly as a way for far-apart systems (a Health component and a UIManager on a totally different GameObject) to talk — that direct reference is exactly the coupling from Section 1. The next pattern, Observer, is how a component tells distant, unrelated listeners about something without knowing who those listeners are.

5. The Observer Pattern: Event Systems

The Observer pattern lets one object (the publisher, or subject) announce that something happened, without knowing or caring which other objects are listening. Listeners (called observers or subscribers) register interest ahead of time and get notified automatically. In C#, the built-in tool for this is event.

Picture the problem from Section 1: when the player dies, the UI needs to show a game-over screen, the sound system needs to play a sound, and the score system needs to save a high score. Without events, Health would need a direct reference to all three:


// BAD: Health is now coupled to three unrelated systems.
public class Health : MonoBehaviour
{
    public UIManager ui;
    public SoundManager sound;
    public ScoreManager score;

    public void Die()
    {
        ui.ShowGameOverScreen();
        sound.PlayDeathSound();
        score.SaveHighScore();
        // Add a fourth system later? Edit this class again.
    }
}

With an event, Health only announces "I died" and does not know who is listening:


using System;
using UnityEngine;

public class Health : MonoBehaviour
{
    // An event other scripts can subscribe to. No listener references here.
    public event Action OnDeath;

    private int hp = 100;

    public void TakeDamage(int amount)
    {
        hp -= amount;
        if (hp <= 0)
        {
            OnDeath?.Invoke(); // "?" means: only invoke if someone subscribed
        }
    }
}

using UnityEngine;

public class UIManager : MonoBehaviour
{
    public Health playerHealth;

    void OnEnable()
    {
        playerHealth.OnDeath += ShowGameOverScreen; // subscribe
    }

    void OnDisable()
    {
        playerHealth.OnDeath -= ShowGameOverScreen; // unsubscribe
    }

    void ShowGameOverScreen()
    {
        Debug.Log("Game Over screen shown");
    }
}

Expected output when the player's hp reaches 0: the console prints Game Over screen shown, and if SoundManager and ScoreManager also subscribed the same way, their methods run too — Health never mentions any of their class names.

Health.TakeDamage() drops hp to 0 Health --(raises)--> OnDeath event OnDeath --(notifies)--> UIManager (shows Game Over screen) OnDeath --(notifies)--> SoundManager (plays death sound) OnDeath --(notifies)--> ScoreManager (saves high score) Health does not hold a reference to UIManager, SoundManager, or ScoreManager. Each one subscribed to OnDeath on its own.
Common mistake Subscribing in OnEnable/Start but forgetting to unsubscribe in OnDisable/OnDestroy. The event still holds a reference to the destroyed listener, which either throws an exception when invoked or quietly leaks memory (the garbage collector cannot free an object something still references). Always pair a += with a matching -=.

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

6. The Command Pattern: Input Remapping and Undo

Hardcoding input like this seems fine at first:


void Update()
{
    if (Input.GetKeyDown(KeyCode.Space)) Jump();
    if (Input.GetKeyDown(KeyCode.E))     Interact();
}

It breaks down the moment you need key remapping (letting the player choose their own keys), a replay system, an undo feature (common in puzzle games and level editors), or an AI that needs to queue up the same actions a player can take. The fix is the Command pattern: wrap every action in an object with an Execute() method, instead of calling the target method directly.


public interface ICommand
{
    void Execute();
    void Undo();
}

public class MoveCommand : ICommand
{
    private readonly Transform mover;
    private readonly Vector3 direction;
    private Vector3 previousPosition;

    public MoveCommand(Transform mover, Vector3 direction)
    {
        this.mover = mover;
        this.direction = direction;
    }

    public void Execute()
    {
        previousPosition = mover.position;
        mover.position += direction;
    }

    public void Undo()
    {
        mover.position = previousPosition;
    }
}

Now key bindings are just a lookup table from key to command, and remapping means editing the table, not the code that reads input:


using System.Collections.Generic;
using UnityEngine;

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

    void Awake()
    {
        bindings = new Dictionary<KeyCode, ICommand>
        {
            { KeyCode.W, new MoveCommand(player, Vector3.forward) },
            { KeyCode.S, new MoveCommand(player, Vector3.back) },
        };
    }

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

        if (Input.GetKeyDown(KeyCode.Z) && history.Count > 0)
        {
            history.Pop().Undo(); // Ctrl+Z style undo
        }
    }
}

Expected output: pressing W moves the player forward one step and pushes that command onto history; pressing Z pops the last command and calls its Undo(), moving the player back to previousPosition. To remap W to a different key, you change one dictionary key, not the Update logic.

When to use it: input remapping, undo/redo (editors, puzzle games), replay/rewind systems, or queuing actions for an AI. When not to: a small prototype with fixed, never-remapped controls — wrapping every action in a class adds ceremony you may never need.

7. The State Pattern: State Machines

Enemy AI often starts as a pile of booleans:


// BAD: "boolean soup" -- gets worse with every new behavior added.
if (!isChasing && !isAttacking && playerInSight) isChasing = true;
if (isChasing && distanceToPlayer < 2f) { isChasing = false; isAttacking = true; }
if (isAttacking && !playerInSight) { isAttacking = false; isChasing = false; }
// ...six more flags later, nobody can tell which combinations are even valid.

A state machine (a system that is always in exactly one named state, with clear rules for moving to another state) makes the same logic explicit. Each state is its own small piece of code with an entry action, an update, and rules for leaving.

Idle --(player in sight)--> Chase Chase --(close enough)------> Attack Chase --(lost sight)--------> Idle Attack --(target backs away)--> Chase

public interface IEnemyState
{
    void Enter(EnemyAI enemy);
    void Tick(EnemyAI enemy);
}

public class IdleState : IEnemyState
{
    public void Enter(EnemyAI enemy) { enemy.PlayAnimation("Idle"); }

    public void Tick(EnemyAI enemy)
    {
        if (enemy.CanSeePlayer())
        {
            enemy.ChangeState(new ChaseState());
        }
    }
}

public class ChaseState : IEnemyState
{
    public void Enter(EnemyAI enemy) { enemy.PlayAnimation("Run"); }

    public void Tick(EnemyAI enemy)
    {
        enemy.MoveTowardPlayer();
        if (enemy.DistanceToPlayer() < 2f)
        {
            enemy.ChangeState(new AttackState());
        }
        else if (!enemy.CanSeePlayer())
        {
            enemy.ChangeState(new IdleState());
        }
    }
}

public class AttackState : IEnemyState
{
    public void Enter(EnemyAI enemy) { enemy.PlayAnimation("Attack"); }

    public void Tick(EnemyAI enemy)
    {
        if (enemy.DistanceToPlayer() >= 2f)
        {
            enemy.ChangeState(new ChaseState());
        }
    }
}

using UnityEngine;

public class EnemyAI : MonoBehaviour
{
    private IEnemyState currentState;

    void Start()
    {
        ChangeState(new IdleState());
    }

    void Update()
    {
        currentState.Tick(this);
    }

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

    public bool CanSeePlayer() { /* raycast check */ return false; }
    public float DistanceToPlayer() { /* distance check */ return 999f; }
    public void MoveTowardPlayer() { /* move logic */ }
    public void PlayAnimation(string name) { Debug.Log("Playing: " + name); }
}

Expected output: when the enemy starts, the console prints Playing: Idle. Once CanSeePlayer() returns true, it prints Playing: Run and the enemy starts moving. This is a lot easier to follow and extend than a growing pile of booleans — adding a new state means writing a new class, not editing every existing if.

When to use it: any entity with a handful of clearly distinct modes: enemy AI, player movement modes (grounded/swimming/climbing), menu screens, traffic lights, game phases (menu/playing/paused/game-over). When not to: two states controlled by one simple toggle (isPaused) do not need a class hierarchy — a single bool is clearer.

8. The Object Pool: Reusing Bullets and Enemies

An earlier chapter covered the C# garbage collector (GC — the runtime system that automatically frees memory for objects nothing references anymore) and its stop-the-world pause (a moment where the GC halts your program to clean up, which can show up as a frame hitch). A machine gun that calls Instantiate() for every bullet and Destroy() when it hits something creates a constant stream of garbage for the GC to collect — exactly the pattern that causes those pauses in a fast-paced game.


// BAD: creates and destroys a new object every shot.
public class BadGun : MonoBehaviour
{
    public GameObject bulletPrefab;

    public void Fire()
    {
        GameObject bullet = Instantiate(bulletPrefab, transform.position, transform.rotation);
        Destroy(bullet, 2f); // destroyed after 2 seconds -- more garbage later
    }
}

The Object Pool pattern fixes this by allocating a fixed batch of objects once, then reusing them: instead of destroying a bullet, you deactivate it and put it back in a pool; instead of instantiating a new one, you take an inactive one out of the pool.


using System.Collections.Generic;
using UnityEngine;

public class ObjectPool : MonoBehaviour
{
    public GameObject prefab;
    public int startSize = 20;

    private readonly Queue<GameObject> available = new Queue<GameObject>();

    void Awake()
    {
        for (int i = 0; i < startSize; i++)
        {
            GameObject obj = Instantiate(prefab);
            obj.SetActive(false);
            available.Enqueue(obj);
        }
    }

    public GameObject Get(Vector3 position, Quaternion rotation)
    {
        GameObject obj = available.Count > 0
            ? available.Dequeue()
            : Instantiate(prefab); // pool ran dry: grow instead of failing

        obj.transform.SetPositionAndRotation(position, rotation);
        obj.SetActive(true);
        return obj;
    }

    public void Release(GameObject obj)
    {
        obj.SetActive(false);
        available.Enqueue(obj);
    }
}

public class Gun : MonoBehaviour
{
    public ObjectPool bulletPool;

    public void Fire()
    {
        GameObject bullet = bulletPool.Get(transform.position, transform.rotation);
        // The bullet script itself calls bulletPool.Release(gameObject)
        // when it hits something or times out, instead of Destroy().
    }
}
available queue: [bullet1][bullet2][bullet3]... Get() --> removes one from the queue, activates it, returns it Release() --> deactivates it, adds it back onto the queue Instantiate() and Destroy() are called only startSize times total, not once per shot.

Expected result: after the first 20 bullets are pre-allocated in Awake(), firing never calls Instantiate again during normal play — bullets are recycled. No new garbage means no extra GC pauses caused by bullets, which matters most in bullet-hell shooters, particle-heavy effects, or any game spawning dozens of objects per second.

Tip The same pattern works for enemies, pickups, damage-number popups, and audio sources — anything spawned and destroyed frequently. Unity 2021+ also ships a built-in UnityEngine.Pool.ObjectPool<T> class that does the bookkeeping shown above for you.

When to use it: objects spawned and destroyed frequently, especially in performance-sensitive loops. When not to: an object created once per level (a boss, a door) gains nothing from pooling and just adds bookkeeping.

9. Service Locator and Singleton, and Their Dangers

Some systems really do need exactly one instance that anything can reach — an AudioManager, a GameManager tracking score, a SaveSystem. The Singleton pattern gives a class one shared, globally reachable instance:


using UnityEngine;

public class AudioManager : MonoBehaviour
{
    public static AudioManager Instance { get; private set; }

    void Awake()
    {
        if (Instance != null && Instance != this)
        {
            Destroy(gameObject); // enforce only one instance ever exists
            return;
        }
        Instance = this;
        DontDestroyOnLoad(gameObject);
    }

    public void PlaySound(AudioClip clip)
    {
        GetComponent<AudioSource>().PlayOneShot(clip);
    }
}

// Anywhere in the game:
AudioManager.Instance.PlaySound(explosionClip);

A Service Locator is a close cousin: instead of every system exposing its own static Instance, one central registry hands out references to whichever systems ask for them (something like Locator.Get<IAudioService>()). Both solve the same problem — reaching a shared system from anywhere — and both share the same dangers.

PlayerController --> AudioManager.Instance EnemyAI --> AudioManager.Instance UIManager --> AudioManager.Instance PauseMenu --> AudioManager.Instance Every script can reach in and call AudioManager.Instance from anywhere -- convenient, but none of these dependencies show up in a constructor or the Inspector. You only find them by reading every script.
Common mistake Reaching for a singleton as the default answer to "how do these two scripts talk?" Singletons are global mutable state (data any part of the program can read and change from anywhere), and that causes real problems:
  • Hidden dependencies — PlayerController secretly depends on AudioManager, ScoreManager, and GameManager, but nothing in its class signature says so. You only find out by reading every line.
  • Initialization order bugs — if something calls AudioManager.Instance before its Awake() has run, you get a null reference.
  • Hard to test — you cannot easily swap in a fake AudioManager for a unit test when code reaches out to a fixed global.
  • Only one, forever — need two independent game sessions (split-screen, a minigame sandbox) and the "only one instance" assumption breaks.

When to use it: a small number of truly single-instance, whole-game systems (audio, save system, the top-level game manager), used sparingly. When not to: as a general substitute for passing references or using events — if two gameplay components need to talk, prefer a direct reference (set in the Inspector) or an event (Section 5) before reaching for a singleton.

10. When Not to Use a Pattern

Every pattern in this chapter solves a real problem — but only once that problem actually exists. Reaching for a pattern before you need it is called over-engineering, sometimes joked about as pattern-itis (adding abstraction for its own sake). Signs you have it:

Each of these adds indirection (extra layers of "the real code is somewhere else") without buying anything back. Indirection has a cost: more files, more places to look when tracing a bug, more concepts a beginner on your team has to learn before they can change one line.

Tip Follow YAGNI ("You Aren't Gonna Need It"): write the plain, direct version first — a field, a direct method call, a single if. Reach for a pattern only when the pain it solves actually shows up: you are about to write the same tangled coupling from Section 1 a third time, or a real feature (remapping, undo, pooling for a measured GC spike) is now on your task list.

A useful test before adding a pattern: can you point at the specific problem it removes, in your actual game, today? "It might be useful later" is not that answer.

11. Data-Oriented Design: A Different Lens

Every pattern so far is still object-oriented (organizing code around objects that bundle data and behavior together, like a Health component). That style is great for clarity, but it can be slow at large scale because of something you learned in the C chapters: cache behavior (the CPU keeps recently used memory close by for fast access; reading scattered memory is much slower than reading memory laid out next to itself).

Picture 10,000 enemies, each its own GameObject with a Transform, a Health, and an AIState component, scattered separately across the heap. Updating "move every enemy" jumps all over memory — a cache miss (the CPU needed data that was not close by, and has to wait for it) on nearly every object.

Data-oriented design (DOD — organizing code around how data is laid out and processed in bulk, rather than around objects) flips the approach: instead of 10,000 separate objects, keep one tightly packed array of positions, one array of health values, one array of AI states, and loop straight through each array.


// Object-oriented: one array of full objects (fields scattered together)
public class EnemyData
{
    public Vector3 position;
    public int hp;
}
EnemyData[] enemies = new EnemyData[10000];

// Data-oriented: separate, tightly packed arrays, one per field
Vector3[] positions = new Vector3[10000];
int[] hitPoints = new int[10000];

// Moving every enemy now touches only the positions array --
// the CPU stays inside one tight block of memory instead of
// jumping to a different EnemyData object 10000 times.
for (int i = 0; i < positions.Length; i++)
{
    positions[i] += Vector3.forward * Time.deltaTime;
}
Object-oriented layout (scattered): [Enemy0: pos,hp,state] ... [Enemy1: pos,hp,state] ... (spread across heap) Data-oriented layout (packed): positions: [p0][p1][p2][p3]...[p9999] (one tight array) healths: [h0][h1][h2][h3]...[h9999] (another tight array) states: [s0][s1][s2][s3]...[s9999] (another tight array) Looping straight through a packed array reads memory the CPU already has close by -- far fewer cache misses.

Unity's DOTS/ECS (Data-Oriented Technology Stack / Entity Component System — a Unity toolset built specifically for this layout) is the production version of this idea: instead of a MonoBehaviour per enemy, you get an "entity" that is really just an ID, with its data spread across packed arrays ("components" in the ECS sense, not the same thing as a Unity script component) that systems iterate in bulk.

When to use it: genuinely large counts of similar objects, where a measured profiler result shows the object-oriented version is too slow (thousands of bullets, particles, RTS units). When not to: almost everything else. A normal GameObject/Component game with dozens or a few hundred active objects will never notice the difference, and DOD code is harder to write and read. Learn it as a lens for when scale becomes the actual bottleneck, not as a default.

12. Putting It All Together

A small slice of a real shooter uses several of these patterns at once, each solving one specific problem:

Player presses the Fire key InputManager --(looks up the key)--> FireCommand.Execute() [Command] FireCommand --(asks for a bullet)--> BulletPool.Get() [Object Pool] Bullet --(own Update() each frame)--> flies forward [Game Loop] Bullet --(on hit)--> enemy.TakeDamage() enemy.Health --(hp reaches 0)--> OnDeath event fires [Observer] OnDeath --(subscriber)--> enemy.StateMachine to DeadState [State] OnDeath --(subscriber)--> ScoreManager.AddPoints()

Notice what each pattern is doing here, and only here: Command decouples "which key" from "which action." Object Pool decouples "spawn a bullet" from "allocate memory." Observer decouples "something died" from "everyone who cares." State decouples "what the enemy is doing" from a pile of booleans. None of them talk to a global singleton, except perhaps ScoreManager, kept deliberately small. This is the goal of gameplay architecture: each piece has one job, and the connections between pieces are as loose as they can be while still working.

13. Glossary

14. Exercises

Exercise 1 — Decouple It The script below directly couples a Coin pickup to a UIManager and a SoundManager. Rewrite Coin to raise a C# event instead, and write listeners for the UI and sound systems that subscribe to it.

public class Coin : MonoBehaviour
{
    public UIManager ui;
    public SoundManager sound;

    void OnTriggerEnter(Collider other)
    {
        ui.AddScore(10);
        sound.PlayCoinSound();
        Destroy(gameObject);
    }
}
Show answer

using System;
using UnityEngine;

public class Coin : MonoBehaviour
{
    public static event Action<int> OnCoinCollected;

    void OnTriggerEnter(Collider other)
    {
        OnCoinCollected?.Invoke(10);
        Destroy(gameObject);
    }
}

public class UIManager : MonoBehaviour
{
    void OnEnable()  { Coin.OnCoinCollected += AddScore; }
    void OnDisable() { Coin.OnCoinCollected -= AddScore; }

    void AddScore(int amount) { Debug.Log("Score +" + amount); }
}

public class SoundManager : MonoBehaviour
{
    void OnEnable()  { Coin.OnCoinCollected += PlayCoinSound; }
    void OnDisable() { Coin.OnCoinCollected -= PlayCoinSound; }

    void PlayCoinSound(int amount) { Debug.Log("Coin sound played"); }
}

Coin no longer holds a reference to either manager. Both managers subscribe on their own in OnEnable and unsubscribe in OnDisable, so a destroyed manager never gets called by mistake. Adding a third listener later means writing a third subscriber, not editing Coin.

Exercise 2 — Build a State Machine Design a state machine for a door with three states: Closed, Opening, and Open. Rule: pressing "E" while Closed moves to Opening; after 1 second in Opening, it moves to Open; pressing "E" while Open moves back to Closed immediately. Write the state interface and the three state classes (a full MonoBehaviour wrapper is not required — just the states and a sketch of how Tick reads elapsed time).
Show answer

public interface IDoorState
{
    void Enter(Door door);
    void Tick(Door door, float deltaTime);
}

public class ClosedState : IDoorState
{
    public void Enter(Door door) { door.PlayAnimation("Closed"); }

    public void Tick(Door door, float deltaTime)
    {
        if (door.InteractPressed())
        {
            door.ChangeState(new OpeningState());
        }
    }
}

public class OpeningState : IDoorState
{
    private float timer;

    public void Enter(Door door)
    {
        timer = 0f;
        door.PlayAnimation("Opening");
    }

    public void Tick(Door door, float deltaTime)
    {
        timer += deltaTime;
        if (timer >= 1f)
        {
            door.ChangeState(new OpenState());
        }
    }
}

public class OpenState : IDoorState
{
    public void Enter(Door door) { door.PlayAnimation("Open"); }

    public void Tick(Door door, float deltaTime)
    {
        if (door.InteractPressed())
        {
            door.ChangeState(new ClosedState());
        }
    }
}

Each state only knows its own entry action and its own exit rule. OpeningState keeps its own timer field, which starts fresh every time because a brand new state object is created on each transition. The Door class (not shown in full) just needs a currentState field, a ChangeState method that calls Enter, and an Update that calls Tick — the same shape as the EnemyAI example in Section 7.

Exercise 3 — Spot the Over-Engineering The code below is from a two-day game jam prototype. The jump key is fixed forever (no remapping, no undo, no replay system planned). Explain in one or two sentences why this is over-engineered for that project, then rewrite it as the simplest version that still works.

public interface ICommand { void Execute(); void Undo(); }

public class JumpCommand : ICommand
{
    private readonly Rigidbody rb;
    private Vector3 previousVelocity;

    public JumpCommand(Rigidbody rb) { this.rb = rb; }

    public void Execute()
    {
        previousVelocity = rb.linearVelocity;
        rb.linearVelocity += Vector3.up * 5f;
    }

    public void Undo() { rb.linearVelocity = previousVelocity; }
}

public class InputManager : MonoBehaviour
{
    public Rigidbody player;
    private ICommand jumpCommand;

    void Awake() { jumpCommand = new JumpCommand(player); }

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space)) jumpCommand.Execute();
    }
}
Show answer

This is pattern-itis: the project has one fixed key that will never be remapped, and nothing ever calls Undo() — jumping cannot sensibly be undone anyway. The ICommand interface, the class, and the constructor add three extra pieces of indirection for a feature that is really one line. For a two-day jam, the direct version is easier to write, read, and debug:


public class PlayerJump : MonoBehaviour
{
    public Rigidbody player;

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            player.linearVelocity += Vector3.up * 5f;
        }
    }
}

If the game later grows a real need — key remapping shipped as a feature, or a replay system — that is the moment to introduce ICommand, not before.

← Back to all chapters