โค๏ธ 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
IDamageableinterface as the single contract for "can be hurt" - Write a reusable
HealthMonoBehaviour that implements it withTakeDamageandHeal - Broadcast
OnHealthChangedandOnDiedas 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:
maxHealthis[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.Minso health never goes negative or overshoots the maximum. - Dead things ignore damage. The
IsDeadguard 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 ownPlayerDeathlistener that subscribes to the sameOnDiedevent and calls into your UI/scene manager. TheHealthcomponent 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.
(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.
- Create the
IDamageableinterface and the fullHealthcomponent from this lesson. - Make three GameObjects: a Player capsule (
maxHealth100), an Enemy cube (30), and a Barrel cylinder (10). AddHealthto each and set the values in the Inspector. - 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). - 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
IDamageableis a tiny contract โ read-only health plus oneTakeDamagemethod โ that decouples attackers from what they hit.- A single reusable
HealthMonoBehaviour implements it, clamps values, and guards against damaging or healing a corpse. - Events (
OnHealthChanged,OnDied) let UI and gameplay react withoutHealthknowing they exist โ the Module 2 pattern applied to combat. - Death handling lives in listeners, so the same
Healthcan pop a barrel, ragdoll an enemy, or end the game. - Always subscribe in
OnEnableand unsubscribe inOnDisableโ 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.