Skip to main content

โค๏ธ Lesson 7.1: Health & Damage with Interfaces

Almost everything in an action game can be hurt: the player, enemies, a wooden barrel, a breakable crate, a boss's weak point. Back in Module 2 you learned that an interface is a contract that lets unrelated objects be treated the same way. This lesson cashes that idea in for real โ€” one IDamageable contract, one reusable Health component, and an attacker that never needs to know what it just hit.

๐ŸŽฏ Learning Objectives

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

  • Design an IDamageable interface as the single contract for "can be hurt"
  • Write a reusable Health MonoBehaviour that implements it with TakeDamage and Heal
  • Broadcast OnHealthChanged and OnDied as C# events so UI and other systems react without coupling
  • Handle death cleanly in one place and let listeners decide what "death" means per object
  • Make a player, an enemy, and a barrel all damageable through the same attacker code

Estimated Time: 45 minutes  ยท  Prerequisite: Module 2 (Lesson 2.1 Interfaces, Lesson 2.2 Events & Delegates)

In This Lesson

Why an Interface for Damage?

Imagine an attack script that has to hurt whatever it hits. The naive version grows a tangle of type checks:

// โŒ The coupling trap โ€” this only gets worse
if (hit.TryGetComponent<Enemy>(out var enemy))       enemy.Damage(10);
else if (hit.TryGetComponent<PlayerHealth>(out var p)) p.Hurt(10);
else if (hit.TryGetComponent<Barrel>(out var barrel)) barrel.Break();
// ...a new branch every time you add a destructible thing

Every new destructible object forces you to edit the attacker. The attacker ends up knowing about every type in the game โ€” the exact coupling Module 2 warned against. An interface flips this around: the attacker depends on one small contract, and any object that can be hurt promises to honour it.

// โœ… The interface way โ€” this line never changes
hit.GetComponent<IDamageable>()?.TakeDamage(10);

One line, forever. Add a new destructible barrel next year and the attacker doesn't care โ€” as long as the barrel implements IDamageable, it just works.

๐Ÿ“– Definition

Interface (recap from Lesson 2.1): a contract listing methods and properties a class promises to provide, with no implementation of its own. Code that depends on the interface can talk to any class implementing it, without knowing the concrete type. It is the cleanest tool Unity gives you for "different things, same capability."

The IDamageable Contract

Keep the contract tiny. The only thing an attacker truly needs is a way to deal damage. We will also expose current and max health as read-only properties, because UI and AI often want to know them.

public interface IDamageable
{
    // Read-only state anyone can query
    float CurrentHealth { get; }
    float MaxHealth { get; }
    bool IsDead { get; }

    // The one action every attacker calls
    void TakeDamage(float amount);
}

Notice what is not here: there is no Heal, no Die, no reference to a particle effect. The contract is only what a caller from outside genuinely needs. Healing is an ability of the Health component, not something an attacker asks for, so it lives on the concrete class rather than in the shared interface. Keep interfaces minimal โ€” a small contract is easy to honour and hard to misuse.

โœ… Pro Tip

Prefix interfaces with a capital I โ€” IDamageable, IInteractable, ISaveable. It is a near-universal C# convention, and at a glance you can tell a contract from a class. Unity's own codebase follows it.

The Health Component

Now the workhorse: a single Health MonoBehaviour that implements the contract. Drop it on any GameObject that should be hurtable. It owns the numbers, clamps them, and refuses to take damage once dead.

using UnityEngine;

public class Health : MonoBehaviour, IDamageable
{
    [SerializeField] float maxHealth = 100f;
    float currentHealth;

    // --- IDamageable contract ---
    public float CurrentHealth => currentHealth;
    public float MaxHealth => maxHealth;
    public bool IsDead => currentHealth <= 0f;

    void Awake()
    {
        currentHealth = maxHealth;
    }

    public void TakeDamage(float amount)
    {
        if (IsDead || amount <= 0f) return;    // ignore hits on a corpse

        currentHealth = Mathf.Max(currentHealth - amount, 0f);

        if (currentHealth == 0f)
            Die();
    }

    public void Heal(float amount)
    {
        if (IsDead || amount <= 0f) return;    // can't heal the dead
        currentHealth = Mathf.Min(currentHealth + amount, maxHealth);
    }

    void Die()
    {
        // death handling goes here โ€” we'll flesh this out below
    }
}

A few deliberate choices worth calling out:

  • maxHealth is [SerializeField] so you set it per-object in the Inspector โ€” 100 for the player, 30 for a grunt, 10 for a barrel. Same script, different numbers.
  • We clamp with Mathf.Max/Mathf.Min so health never goes negative or overshoots the maximum.
  • Dead things ignore damage. The IsDead guard stops a barrel from "dying" five times if three bullets land in the same frame.
  • The properties are expression-bodied (=>) getters โ€” they expose the numbers without letting outside code overwrite them.

Events: OnHealthChanged & OnDied

Here is where Module 2's events pay off. When health changes, a health bar should redraw. When something dies, a score counter might tick up, a ragdoll might spawn, an enemy spawner might get told to make another. The Health component must not know about any of those systems โ€” it just announces what happened and lets whoever is listening react.

We use C# events (the Action pattern from Lesson 2.2). OnHealthChanged passes the new fraction (0โ€“1) so a bar can fill itself; OnDied passes the source Health so a listener knows who died.

using System;
using UnityEngine;

public class Health : MonoBehaviour, IDamageable
{
    [SerializeField] float maxHealth = 100f;
    float currentHealth;

    // Fired whenever health changes: passes a 0..1 fraction for UI bars
    public event Action<float> OnHealthChanged;
    // Fired once, when this object dies: passes itself so listeners know who
    public event Action<Health> OnDied;

    public float CurrentHealth => currentHealth;
    public float MaxHealth => maxHealth;
    public bool IsDead => currentHealth <= 0f;

    void Awake()
    {
        currentHealth = maxHealth;
    }

    public void TakeDamage(float amount)
    {
        if (IsDead || amount <= 0f) return;

        currentHealth = Mathf.Max(currentHealth - amount, 0f);
        OnHealthChanged?.Invoke(currentHealth / maxHealth);

        if (currentHealth == 0f)
            Die();
    }

    public void Heal(float amount)
    {
        if (IsDead || amount <= 0f) return;
        currentHealth = Mathf.Min(currentHealth + amount, maxHealth);
        OnHealthChanged?.Invoke(currentHealth / maxHealth);
    }

    void Die()
    {
        OnDied?.Invoke(this);
    }
}

The ?.Invoke(...) null-conditional call means "fire this only if someone is actually subscribed" โ€” no listeners, no error. This is the same safe-invoke pattern you used in Lesson 2.2.

โš ๏ธ Always unsubscribe

Any script that does health.OnDied += HandleDeath in OnEnable must do health.OnDied -= HandleDeath in OnDisable. Forgetting to unsubscribe keeps the dead object referenced, leaks memory, and can call methods on destroyed objects. Subscribe in OnEnable, unsubscribe in OnDisable โ€” always as a pair.

A health bar now becomes trivial and completely decoupled โ€” it never looks up the player, it just listens:

using UnityEngine;
using UnityEngine.UI;

public class HealthBar : MonoBehaviour
{
    [SerializeField] Health health;   // drag any Health here
    [SerializeField] Image fill;      // the bar's fill Image

    void OnEnable()  => health.OnHealthChanged += UpdateBar;
    void OnDisable() => health.OnHealthChanged -= UpdateBar;

    void UpdateBar(float fraction) => fill.fillAmount = fraction;
}

Handling Death

Death means different things to different objects. The player triggers a game-over screen; an enemy plays a death animation then despawns; a barrel spawns splinters and is destroyed instantly. If we bake any of that into Health, it stops being reusable.

The clean split: Health only announces death through OnDied. Per-object behaviour lives in small listener scripts. Here are three, each attached alongside a Health:

using UnityEngine;

// On the barrel: pop and disappear
public class BarrelDeath : MonoBehaviour
{
    [SerializeField] Health health;
    [SerializeField] GameObject splinterVfx;

    void OnEnable()  => health.OnDied += Shatter;
    void OnDisable() => health.OnDied -= Shatter;

    void Shatter(Health _)
    {
        if (splinterVfx) Instantiate(splinterVfx, transform.position, Quaternion.identity);
        Destroy(gameObject);
    }
}
using UnityEngine;

// On the enemy: play a death anim, then clean up
public class EnemyDeath : MonoBehaviour
{
    [SerializeField] Health health;
    [SerializeField] Animator animator;

    void OnEnable()  => health.OnDied += HandleDeath;
    void OnDisable() => health.OnDied -= HandleDeath;

    void HandleDeath(Health _)
    {
        animator.SetTrigger("Die");        // from Module 1's Animator
        GetComponent<Collider>().enabled = false;
        Destroy(gameObject, 2f);           // give the animation time to play
    }
}

Same Health component on both โ€” the difference is entirely in who is listening. This is exactly the decoupling goal of Module 2: the thing that owns the data broadcasts, and the things that care subscribe.

๐Ÿ’ก Where should the player's game-over live? Give the player its own PlayerDeath listener that subscribes to the same OnDied event and calls into your UI/scene manager. The Health component still has no idea a "game over" screen exists โ€” and that is the point.

One Contract, Many Implementers

Step back and look at the shape of the whole system. A single attacker talks to a single interface, and any number of objects implement it. Nothing on the left knows about anything on the right except the contract in the middle.

flowchart LR ATK["Attacker
(sword, bullet, trap)"] -- "GetComponent<IDamageable>()
?.TakeDamage(amount)" --> IFACE{{"IDamageable
(the contract)"}} IFACE -.implemented by.-> P["Player
Health"] IFACE -.implemented by.-> E["Enemy
Health"] IFACE -.implemented by.-> B["Barrel
Health"] IFACE -.implemented by.-> D["Destructible
Door Health"] P -- "OnDied" --> PD["PlayerDeath โ†’
Game Over UI"] E -- "OnDied" --> ED["EnemyDeath โ†’
anim + despawn + score"] B -- "OnDied" --> BD["BarrelDeath โ†’
splinter VFX"]

Figure 1: One attacker depends only on IDamageable. Every hurtable object implements it, and each reacts to death in its own listener โ€” the attacker is oblivious to all of it.

Because Health already implements IDamageable, making a new object destructible is a two-step chore with zero code: add the Health component, set its maxHealth in the Inspector. Want a death effect? Add a small listener. The attacker code you wrote once never changes again โ€” that is the entire promise of programming to an interface.

โœ… Pro Tip

Put IDamageable on the same GameObject as its collider (or use GetComponentInParent when the collider is a child hitbox โ€” more on that next lesson). Attackers will look the interface up via the collider they hit, so it must be reachable from there.

Hands-on Challenge

๐Ÿ‹๏ธ Exercise 1: Make three different things destructible

Objective: Prove the contract works across unrelated objects.

  1. Create the IDamageable interface and the full Health component from this lesson.
  2. Make three GameObjects: a Player capsule (maxHealth 100), an Enemy cube (30), and a Barrel cylinder (10). Add Health to each and set the values in the Inspector.
  3. Write a temporary tester that damages whatever you click: on a mouse click, raycast from the camera and call hit.collider.GetComponent<IDamageable>()?.TakeDamage(10).
  4. Click each object repeatedly. The barrel should "die" first, the enemy next, the player last โ€” with no type checks anywhere in your tester.
๐Ÿ’ก Hint: nothing happens when I click

Make sure each object has a Collider (raycasts hit colliders, not renderers) and that Health is on the same GameObject as that collider. Add a Debug.Log(name + " took damage") at the top of TakeDamage to confirm the call lands.

โœ… Success check

Your tester has exactly one line that deals damage, with no mention of Player, Enemy, or Barrel. Adding a fourth destructible object requires no edit to the tester at all.

๐Ÿ‹๏ธ Exercise 2: Wire a health bar with zero coupling

Add a UI Image (Image Type: Filled) above the enemy and attach the HealthBar listener. Drag the enemy's Health into its health field. Damage the enemy and watch the bar drain โ€” without the bar ever calling FindObjectOfType or referencing the enemy's class. Confirm you subscribe in OnEnable and unsubscribe in OnDisable.

๐ŸŽฏ Quick Quiz

Question 1: Why does the attacker call GetComponent<IDamageable>() instead of GetComponent<Health>()?

Question 2: Where does the logic that shows a "Game Over" screen belong?

Question 3: Why does TakeDamage start with if (IsDead) return;?

Summary

๐ŸŽ‰ Key Takeaways

  • IDamageable is a tiny contract โ€” read-only health plus one TakeDamage method โ€” that decouples attackers from what they hit.
  • A single reusable Health MonoBehaviour implements it, clamps values, and guards against damaging or healing a corpse.
  • Events (OnHealthChanged, OnDied) let UI and gameplay react without Health knowing they exist โ€” the Module 2 pattern applied to combat.
  • Death handling lives in listeners, so the same Health can pop a barrel, ragdoll an enemy, or end the game.
  • Always subscribe in OnEnable and unsubscribe in OnDisable โ€” as a pair.
  • Programming to the interface means new destructible objects need no change to attacker code โ€” ever.

๐Ÿš€ What's Next?

You have the contract and the component โ€” but so far we have only simulated hits by clicking. In Lesson 7.2: Hit Detection: Raycasts & Hitboxes you'll deal real damage: hitscan weapons with Physics.Raycast and layer masks, melee swings with trigger colliders and OverlapSphere, and a quick look at projectiles โ€” all funnelling into the very same GetComponent<IDamageable>()?.TakeDamage() call.

โค๏ธ You built a system, not a script

One contract, one component, many reactions. The player, the enemy, and the barrel share the exact same damage pipeline โ€” and adding the next hurtable thing costs you nothing.