Skip to main content

๐Ÿงฉ Lesson 2.1: Thinking in Systems: Decoupling & Interfaces

In Module 1 you made things move. Now we make things maintainable. As a project grows, the biggest enemy is not hard math โ€” it's coupling: scripts that reach into each other so tightly that changing one breaks five others. This lesson shows you why that happens and hands you your first professional tool for fixing it: the C# interface.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Recognize tight coupling and explain why GetComponent spaghetti hurts
  • Describe what decoupling means and why it makes games easier to change
  • Define a C# interface and use it as a contract between systems
  • Write and implement IDamageable and IInteractable
  • "Program to an interface" so a caller never needs to know the concrete type
  • Recognize when a coroutine is the right tool for time-based logic

Estimated Time: 40 minutes  ยท  Prerequisite: Lesson 1.4 (Animation Events & a Character Controller) and comfort with C# classes and GetComponent from Fundamentals

In This Lesson

The Spaghetti Problem

Imagine your Bullet script needs to damage whatever it hits. The quickest thing that works looks like this:

// โŒ The tempting, tightly-coupled version
void OnTriggerEnter(Collider other)
{
    Enemy enemy = other.GetComponent<Enemy>();
    if (enemy != null) enemy.TakeDamage(10);

    Barrel barrel = other.GetComponent<Barrel>();
    if (barrel != null) barrel.Explode();

    Player player = other.GetComponent<Player>();
    if (player != null) player.Hurt(10);
    // ...and a new branch every time you add a destructible thing ๐Ÿ˜ฑ
}

It works today. But look at what it demands: the Bullet now knows about Enemy, Barrel, and Player. Add a breakable Crate tomorrow and you must come back and edit Bullet again. Every one of these GetComponent calls is a hard wire soldering two scripts together. That is tight coupling.

๐Ÿ“– Definition

Coupling is how much one piece of code depends on the concrete details of another. Tight coupling means a change in one class forces changes in the classes wired to it. Loose coupling (or decoupling) means classes cooperate through small, stable agreements and can change independently.

Coupled vs. Decoupled

Coupling is easiest to see. On the left below, the Bullet has a direct dependency arrow to every concrete type it can hit โ€” a tangle that grows with the game. On the right, the Bullet depends on a single idea, IDamageable, and every destructible thing depends on that same idea instead. The tangle collapses into a hub.

flowchart LR subgraph Tight["โŒ Tightly coupled"] direction TB BulletA["Bullet"] BulletA --> EnemyA["Enemy"] BulletA --> BarrelA["Barrel"] BulletA --> PlayerA["Player"] BulletA --> CrateA["Crate (new!)"] end subgraph Loose["โœ… Decoupled"] direction TB BulletB["Bullet"] IDmg{{"IDamageable"}} BulletB --> IDmg EnemyB["Enemy"] -.implements.-> IDmg BarrelB["Barrel"] -.implements.-> IDmg PlayerB["Player"] -.implements.-> IDmg CrateB["Crate (new!)"] -.implements.-> IDmg end

Figure 1: On the left, every new destructible type adds a wire into Bullet. On the right, Bullet talks to one contract and never changes again.

Notice the payoff on the right: Bullet has one outgoing dependency, and adding Crate never touches Bullet at all. The new type just signs the same contract. That contract is what a C# interface gives you.

Interfaces: A Contract

A class says "here is what I am and how I do it." An interface says only "here is what I can do" โ€” a list of methods and properties with no bodies. Any class that implements the interface promises to provide those members. The caller relies on the promise and stays blind to the details.

๐Ÿ“– Definition

An interface is a named set of members (methods, properties, events) with no implementation. A class that lists an interface after its : must supply every member. By convention interface names start with a capital I โ€” IDamageable, IInteractable, IPoolable.

Think of it like a wall socket. Your lamp doesn't care what power plant is behind the wall โ€” coal, solar, nuclear. It only relies on the contract of the socket: the right shape, the right voltage. Swap the power source and the lamp keeps working. An interface is that socket for your code.

๐Ÿ’ก Interface vs. inheritance. You already know base classes. A class can only inherit from one base, but it can implement many interfaces. A Barrel can be both IDamageable and IInteractable at once. Interfaces describe capabilities that cut across your class hierarchy, which is exactly what gameplay needs.

Writing IDamageable

Here is the contract. It lives in its own file and, crucially, does not derive from MonoBehaviour โ€” an interface is a plain C# type:

// IDamageable.cs
public interface IDamageable
{
    int CurrentHealth { get; }
    void TakeDamage(int amount);
}

Now any component that can be hurt signs the contract by listing it after MonoBehaviour and providing the members:

// Enemy.cs
using UnityEngine;

public class Enemy : MonoBehaviour, IDamageable
{
    [SerializeField] int maxHealth = 30;
    int health;

    public int CurrentHealth => health;      // satisfies the contract

    void Awake() => health = maxHealth;

    public void TakeDamage(int amount)        // satisfies the contract
    {
        health -= amount;
        if (health <= 0) Destroy(gameObject);
    }
}
// ExplosiveBarrel.cs
using UnityEngine;

public class ExplosiveBarrel : MonoBehaviour, IDamageable
{
    int health = 1;
    public int CurrentHealth => health;

    public void TakeDamage(int amount)
    {
        // any damage sets off the barrel
        Instantiate(explosionVfx, transform.position, Quaternion.identity);
        Destroy(gameObject);
    }

    [SerializeField] GameObject explosionVfx;
}

Two completely different behaviors โ€” one loses health, one explodes instantly โ€” but both honor the same promise: "call TakeDamage(amount) and I'll handle the rest."

โš ๏ธ Interfaces don't do [SerializeField]

You can't drag an interface into an Inspector field, because the Inspector serializes concrete Unity types. That's fine โ€” the usual pattern is to find the interface at runtime with GetComponent<IDamageable>() (which works!) rather than wiring it in the editor. If you truly need an inspector reference, expose a MonoBehaviour field and cast it.

Programming to an Interface

Now rewrite the bullet. Watch how the three branches collapse into one, and how Bullet stops mentioning Enemy, Barrel, or Player entirely:

// Bullet.cs โ€” knows about ONE thing: the contract
using UnityEngine;

public class Bullet : MonoBehaviour
{
    [SerializeField] int damage = 10;

    void OnTriggerEnter(Collider other)
    {
        // GetComponent works on interfaces too
        if (other.TryGetComponent<IDamageable>(out var target))
        {
            target.TakeDamage(damage);
        }
        Destroy(gameObject);
    }
}

This is what "program to an interface, not an implementation" means. The bullet asks "are you damageable?" instead of "are you an Enemy? a Barrel? a Player?" Add a Crate : MonoBehaviour, IDamageable next week and the bullet damages it correctly the moment you press Play โ€” you never open Bullet.cs again.

โœ… Pro Tip

Prefer TryGetComponent over GetComponent + null-check in Update or physics callbacks. It avoids allocating a wrapper when nothing is found and reads more clearly: one line, no stray null variable hanging around.

IInteractable in Practice

The same pattern powers the "press E to use" systems you see in every game โ€” doors, chests, levers, NPCs. One contract, many implementers:

// IInteractable.cs
public interface IInteractable
{
    string Prompt { get; }     // e.g. "Open", "Talk", "Pick up"
    void Interact(GameObject interactor);
}
// Door.cs
using UnityEngine;

public class Door : MonoBehaviour, IInteractable
{
    [SerializeField] Animator animator;
    bool open;

    public string Prompt => open ? "Close" : "Open";

    public void Interact(GameObject interactor)
    {
        open = !open;
        animator.SetBool("Open", open);
    }
}

The player's interaction script casts a short ray, and โ€” exactly like the bullet โ€” never names Door, Chest, or NPC:

// PlayerInteractor.cs (Input handling shown fully in Module 3)
void TryInteract()
{
    if (Physics.Raycast(transform.position, transform.forward,
                        out RaycastHit hit, 3f))
    {
        if (hit.collider.TryGetComponent<IInteractable>(out var target))
        {
            ShowPrompt(target.Prompt);        // "Open"
            target.Interact(gameObject);
        }
    }
}
๐Ÿ’ก A class can wear many hats. Give the ExplosiveBarrel both IDamageable and IInteractable and it can be shot and picked up. The bullet sees the damage contract; the player's interactor sees the interaction contract. Neither knows about the other.

A Note on Coroutines

Decoupled systems often need to do something over time โ€” a barrel that flashes red for half a second before exploding, a door that takes a moment to swing. You could juggle timers in Update, but Unity has a cleaner tool you'll lean on constantly: the coroutine.

๐Ÿ“– Definition

A coroutine is a method that can pause itself and resume on a later frame. It returns IEnumerator and uses yield return to hand control back to Unity until a condition is met โ€” the next frame, a number of seconds, or the end of the frame. You start one with StartCoroutine(...).

using System.Collections;
using UnityEngine;

public class DelayedExplosion : MonoBehaviour, IDamageable
{
    public int CurrentHealth => 1;

    public void TakeDamage(int amount) => StartCoroutine(FuseThenBoom());

    IEnumerator FuseThenBoom()
    {
        // flash a warning, then wait half a second without freezing the game
        GetComponent<Renderer>().material.color = Color.red;
        yield return new WaitForSeconds(0.5f);
        Destroy(gameObject);
    }
}

Read yield return new WaitForSeconds(0.5f) as "pause here, let the rest of the game keep running, and resume this method half a second later." No timer variable, no Update bookkeeping. We will use coroutines for spawning, fades, and cooldowns throughout the course; for now just recognize the shape.

โš ๏ธ Coroutines are not threads

A coroutine still runs on Unity's main thread โ€” WaitForSeconds doesn't run code in parallel, it just resumes your method later. And if the GameObject is disabled or destroyed, its coroutines stop. Never rely on a coroutine to finish cleanup on a dying object; do that in OnDisable or OnDestroy instead.

Hands-on Challenge

๐Ÿ‹๏ธ Exercise 1: One weapon, three targets

Objective: Prove that programming to an interface actually decouples your code.

  1. Create IDamageable with a CurrentHealth getter and TakeDamage(int).
  2. Make three components that implement it differently: Enemy (loses health), ExplosiveBarrel (dies on any hit), and Crate (needs three hits).
  3. Write a Bullet that uses TryGetComponent<IDamageable> and calls TakeDamage โ€” with no mention of the three concrete types.
  4. Fire at all three and confirm each reacts correctly, from the same bullet script.
๐Ÿ’ก Hint: my bullet doesn't damage anything

Check that the target objects have colliders and that at least one collider in the pair is a trigger (or switch to OnCollisionEnter). Also confirm your components list , IDamageable after MonoBehaviour โ€” without it, TryGetComponent<IDamageable> returns false.

โœ… Success check

The bullet script contains zero references to Enemy, ExplosiveBarrel, or Crate. You can add a fourth destructible type without editing Bullet.cs at all.

๐Ÿ‹๏ธ Exercise 2: Interact with the world

Add IInteractable with a Prompt string and Interact(GameObject). Make a Door and a Chest that implement it. Write a raycast-based interactor that prints the Prompt and calls Interact on whatever it hits โ€” again, without naming Door or Chest. Bonus: give the Chest a coroutine that plays a short "opening" delay before revealing loot.

๐ŸŽฏ Quick Quiz

Question 1: Why is the GetComponent<Enemy>() / GetComponent<Barrel>() chain in a bullet considered tightly coupled?

Question 2: What does an interface actually contain?

Question 3: When is a coroutine the natural choice?

Summary

๐ŸŽ‰ Key Takeaways

  • Tight coupling โ€” scripts that name each other's concrete types โ€” makes every change ripple; it grows worse as the project grows.
  • Decoupling lets systems cooperate through small, stable agreements so they can change independently.
  • An interface is a contract: a list of members with no bodies. Names start with I.
  • Implement an interface with class Foo : MonoBehaviour, IDamageable and provide every member.
  • Program to an interface: TryGetComponent<IDamageable> lets one caller serve every implementer, present and future.
  • A coroutine (IEnumerator + yield return) pauses and resumes across frames โ€” perfect for time-based logic.

๐Ÿš€ What's Next?

Interfaces decouple who calls whom. But there's an even looser way to connect systems: let one system announce that something happened and let anyone who cares listen โ€” without the announcer knowing who's listening at all. In Lesson 2.2: Events & Delegates we build exactly that with C# event, Action, and UnityEvent.

๐Ÿงฉ You think in contracts now

Ask "what can this thing do?" instead of "what is it?" That single shift โ€” programming to interfaces โ€” is the backbone of every clean, extensible Unity project you'll build from here on.