Unity Damage System: IDamageable, Hitboxes and Knockback

Illustration: a sword slash hitting a target with impact sparks

Bullets hit enemies, swords hit barrels, explosions hit the player, and spikes hit everyone. If each attacker checks if (other.CompareTag("Enemy")) and digs out a specific health script, you end up with tangled code that breaks every time you add a new kind of target. This guide builds a clean Unity damage system around an IDamageable interface, with hitboxes and hurtboxes, damage info, invincibility frames and death events that any part of the game can react to.

The IDamageable Interface

An attacker shouldn’t care what it hit, only whether it can be damaged:

public interface IDamageable
{
    void TakeDamage(in DamageInfo info);
}

Players, enemies, crates and destructible walls all implement it. A bullet just does:

if (other.TryGetComponent(out IDamageable target))
    target.TakeDamage(new DamageInfo(10, transform.position, gameObject));

Adding a new destructible object never requires touching the bullet. If you used an interface for interaction before (like the Press E interaction system), this is the same pattern.

Passing Damage Info

A bare int is enough at first, but you’ll soon want to know where the hit came from (for knockback and hit effects) and who caused it (for score and friendly fire). Bundle it in a small struct:

using UnityEngine;

public enum DamageType { Normal, Fire, Explosion, Fall }

public readonly struct DamageInfo
{
    public readonly int Amount;
    public readonly Vector3 Point;
    public readonly GameObject Source;
    public readonly DamageType Type;

    public DamageInfo(int amount, Vector3 point, GameObject source, DamageType type = DamageType.Normal)
    {
        Amount = amount; Point = point; Source = source; Type = type;
    }
}

A readonly struct passed with in avoids allocations, so firing hundreds of bullets doesn’t create garbage. Add fields like Knockback or IsCritical as your game needs them; every caller already uses the struct, so it’s a small change.

A Health Component

using System;
using UnityEngine;

public class Health : MonoBehaviour, IDamageable
{
    [SerializeField] int max = 100;
    [SerializeField] float invulnerableAfterHit = 0f;

    public int Current { get; private set; }
    public int Max => max;
    public bool IsDead => Current <= 0;
    public bool IsFull => Current >= max;

    public event Action<DamageInfo> Damaged;
    public event Action<DamageInfo> Died;
    public event Action<int, int> Changed;   // current, max

    float invulnerableUntil;

    void Awake() => Current = max;

    public void TakeDamage(in DamageInfo info)
    {
        if (IsDead || Time.time < invulnerableUntil || info.Amount <= 0) return;

        Current = Mathf.Max(0, Current - info.Amount);
        invulnerableUntil = Time.time + invulnerableAfterHit;

        Changed?.Invoke(Current, max);
        Damaged?.Invoke(info);
        if (Current == 0) Died?.Invoke(info);
    }

    public void Heal(int amount)
    {
        if (IsDead || amount <= 0) return;
        Current = Mathf.Min(max, Current + amount);
        Changed?.Invoke(Current, max);
    }

    public void SetInvulnerable(float seconds) => invulnerableUntil = Mathf.Max(invulnerableUntil, Time.time + seconds);
}

Health only tracks numbers and raises events. It doesn’t play sounds, destroy objects or update UI. Those are separate components that listen, which keeps each piece small. The health bar tutorial shows a UI that listens to a component like this.

Hitboxes and Hurtboxes

For melee, the terms are:

  • Hurtbox: the area that can be hit. Usually the character’s collider, or several colliders on bones for precise hits.
  • Hitbox: the area that deals damage. A sword’s trigger collider, enabled only during the swing.

A reusable hitbox:

using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(Collider))]
public class Hitbox : MonoBehaviour
{
    [SerializeField] int damage = 20;
    [SerializeField] GameObject owner;

    readonly HashSet<IDamageable> hitThisSwing = new();

    void OnEnable() => hitThisSwing.Clear();

    void OnTriggerEnter(Collider other)
    {
        var target = other.GetComponentInParent<IDamageable>();
        if (target == null || hitThisSwing.Contains(target)) return;
        if (other.transform.root == owner.transform.root) return; // don't hit yourself

        hitThisSwing.Add(target);
        target.TakeDamage(new DamageInfo(damage, other.ClosestPoint(transform.position), owner));
    }
}

Enable the hitbox GameObject during the active frames of the attack with an Animation Event or from code, and disable it afterwards. The HashSet makes sure one swing only hits each target once, even if the target has several hurtbox colliders. GetComponentInParent lets hurtboxes on child bones find the Health on the root.

For fast attacks, an OverlapSphere or OverlapBox check on the active frame is often more reliable than a trigger, because triggers depend on physics steps. For bullets, the raycast tutorial shows hitscan checks that avoid trigger timing entirely.

Invincibility Frames

After taking a hit, players usually get a short window where further hits are ignored. Otherwise standing in a fire or touching an enemy drains all health in a few frames. The invulnerableAfterHit field handles that. Typical values: 0.5 to 1.5 seconds for the player, 0 to 0.1 for enemies.

Show it to the player: flash the sprite or make it semi-transparent during the window. A coroutine toggling the renderer every 0.1 seconds is the classic retro look. Dodges and dashes can call SetInvulnerable for their duration too.

Teams and Friendly Fire

Stop enemies shooting each other with a team check. The simplest approach is physics layers: put enemy bullets on an EnemyProjectile layer that doesn’t collide with the Enemy layer in the collision matrix. No code needed, and it’s cheap. The collision layers guide shows how.

When you need rules in code (allies take half damage, for example), add a Team component and compare info.Source‘s team inside TakeDamage.

Reacting to Damage and Death

Separate components subscribe to Health’s events:

using UnityEngine;

[RequireComponent(typeof(Health))]
public class EnemyDeath : MonoBehaviour
{
    [SerializeField] GameObject deathFxPrefab;
    [SerializeField] int scoreValue = 100;

    void OnEnable()  => GetComponent<Health>().Died += OnDied;
    void OnDisable() => GetComponent<Health>().Died -= OnDied;

    void OnDied(DamageInfo info)
    {
        if (deathFxPrefab) Instantiate(deathFxPrefab, transform.position, Quaternion.identity);
        Destroy(gameObject);
    }
}

Other listeners handle hit flashes, knockback, sound, damage numbers and camera shake. You can mix and match them on different prefabs: a crate gets a break effect, the player gets a game over screen.

📚 Write cleaner gameplay code

Interfaces and events are core patterns for game code that stays maintainable. Our list of recommended programming books includes a great one on game programming patterns.

Troubleshooting

TryGetComponent can’t find IDamageable

The collider is on a child and Health is on the parent. Use GetComponentInParent.

One swing hits the same enemy several times

The enemy has multiple colliders or stays in the trigger. Track hit targets per swing with a HashSet.

Player dies instantly when touching an enemy

Contact damage runs every frame with no invincibility. Add invulnerability after hits.

MissingReferenceException after an enemy dies

Something still references the destroyed object or listens to its events. Unsubscribe in OnDisable and check for null.

Hitbox never triggers

Triggers need a Rigidbody on one side. Add a kinematic Rigidbody to the weapon or rely on the character’s Rigidbody.

FAQ

Interface or base class for damageables?

An interface. Players, enemies and props have little else in common, and interfaces don’t force an inheritance tree.

Should health be int or float?

Int avoids rounding surprises and is easier to show. Use float if you have damage over time with fractions.

Where do I put armor and resistances?

Inside TakeDamage, before subtracting, or in a separate modifier component the Health asks first.

How do I do damage over time?

Apply small hits on a timer from a status effect component, with the right DamageType so other systems can react.

Do I need events or can I call things directly?

Direct calls work for tiny games. Events let you add reactions without editing Health each time.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *