You have already built characters that move, collide, and react to input. This chapter is about giving those characters a combat system: a way to deal damage, take damage, die, cast abilities like fireballs or heals, and apply effects like poison or a speed buff. This is the core loop of almost every action or gacha game — HoYoverse titles like Genshin Impact and Honkai: Star Rail are built on exactly the pieces in this chapter, just with a lot more content stacked on top.
The goal here is not just "make numbers go down." The goal is to build it so a designer (someone who is not a programmer) can add a brand new skill by filling in some numbers in the Unity Editor, with zero new code. That one idea — data-driven design — shapes almost every choice in this chapter.
Break "combat" into small, separate jobs. Each job should be its own piece of code that barely knows the others exist:
The word for keeping these jobs separate is decoupling (removing direct, hard-wired dependencies between pieces of code, so each piece can change without breaking the others). A coupled combat system might have a Fireball() method written directly inside your PlayerCharacter class, checking if (target is EnemyGoblin) and doing something special for that one enemy type. A decoupled one has a generic Ability that works on anything with a Health component, cast by anything with an AbilityRunner component — player, enemy, or boss, it does not matter. This chapter builds it the decoupled way from the ground up.
Keep that picture in your head. Every section below builds one box in it. By section 10 you will see the whole thing wired together with real code.
Every damageable thing in the game — the player, an enemy, a destructible crate — needs the same small set of behavior: hold a current HP, lower it when hit, clamp it at 0, and know when it has died. We start with an interface (a contract listing methods a class promises to have, no code inside, from an earlier chapter) so anything can be a valid target, not just one specific class.
public interface IDamageable
{
void TakeDamage(float amount, GameObject source);
}
Now the real Health component. It lives on any GameObject that can be hurt, and it fires events (a way for one object to announce "something happened" so others can react, from the delegates/events chapter) so UI, animation, and AI can respond without Health knowing they exist.
using UnityEngine;
using System;
public class Health : MonoBehaviour, IDamageable
{
[SerializeField] private float maxHp = 50f;
private float currentHp;
public bool IsDead { get; private set; }
public float CurrentHp => currentHp;
// other systems subscribe to these; Health never asks who is listening
public event Action<float, float> OnDamaged; // (amount, hpAfter)
public event Action OnDied;
void Awake()
{
currentHp = maxHp;
}
public void TakeDamage(float amount, GameObject source)
{
if (IsDead) return; // dead things don't take more damage
currentHp = Mathf.Max(0f, currentHp - amount);
OnDamaged?.Invoke(amount, currentHp);
Debug.Log(gameObject.name + " took " + amount + " dmg, hp = " + currentHp);
if (currentHp <= 0f)
{
IsDead = true;
OnDied?.Invoke();
Debug.Log(gameObject.name + " died");
}
}
}
Trace what happens when a Goblin GameObject with maxHp = 50 takes two hits:
// somewhere else, e.g. a weapon script
Health goblinHealth = goblin.GetComponent<Health>();
goblinHealth.TakeDamage(20f, null);
goblinHealth.TakeDamage(40f, null);
Console output:
Goblin took 20 dmg, hp = 30
Goblin took 40 dmg, hp = 0
Goblin died
Notice hp stopped at 0, not -10 — Mathf.Max(0f, ...) clamps it. Notice also the if (IsDead) return; guard at the top: without it, a third hit after death would fire OnDamaged and possibly OnDied again, which confuses anything listening (a death animation playing twice, for example).
IsDead guard. In a real game a character can take several near-simultaneous hits in one frame (an explosion overlapping a sword swing). Without the guard, OnDied can fire more than once, which usually means "the death sound plays twice," or worse, "loot drops twice."Any script can now listen without Health ever knowing it exists:
void Start()
{
Health h = GetComponent<Health>();
h.OnDied += () => Debug.Log("Play death animation, disable AI, drop loot");
}
Health.TakeDamage only fires once something decides who got hit. That decision is hit detection, and Unity gives you three common approaches. Each one fits a different kind of attack.
A hitbox is a Collider marked as a trigger (a collider that detects overlap but does not physically push things), attached to a weapon or a bone, that you turn on only while the attack should be able to connect. Unity calls OnTriggerEnter automatically whenever something enters it.
using UnityEngine;
[RequireComponent(typeof(Collider))]
public class HitboxController : MonoBehaviour
{
public float damage = 10f;
private Collider hitboxCollider;
void Awake()
{
hitboxCollider = GetComponent<Collider>();
hitboxCollider.isTrigger = true;
hitboxCollider.enabled = false; // off until the attack says so
}
public void Activate() { hitboxCollider.enabled = true; }
public void Deactivate() { hitboxCollider.enabled = false; }
void OnTriggerEnter(Collider other)
{
IDamageable target = other.GetComponent<IDamageable>();
if (target != null)
{
target.TakeDamage(damage, gameObject);
}
}
}
The important part is Activate() / Deactivate() — the collider only exists, for hit purposes, during a short window. That window is exactly what section 4 is about.
A raycast fires an invisible line from a point in a direction and reports the first thing it hits. It costs one call, resolves instantly, and needs no collider glued to a weapon. It fits guns, arrows that travel too fast to simulate, or "is there a clear line to the target."
public void FireHitscan(Vector3 origin, Vector3 direction, float range, float damage)
{
RaycastHit hit;
if (Physics.Raycast(origin, direction, out hit, range))
{
IDamageable target = hit.collider.GetComponent<IDamageable>();
if (target != null)
{
target.TakeDamage(damage, gameObject);
}
Debug.Log("Raycast hit " + hit.collider.name + " at distance " + hit.distance);
}
else
{
Debug.Log("Raycast hit nothing");
}
}
An overlap query (Physics.OverlapSphere, OverlapBox) asks "what is inside this shape right now?" and returns every collider it finds in one call. There is no swing, no travel time — it checks a single instant, which is exactly what an explosion or a ground-slam wants.
public void GroundSlam(Vector3 center, float radius, float damage)
{
Collider[] hits = Physics.OverlapSphere(center, radius);
foreach (Collider col in hits)
{
IDamageable target = col.GetComponent<IDamageable>();
if (target != null)
{
target.TakeDamage(damage, gameObject);
}
}
Debug.Log("Ground slam hit " + hits.Length + " colliders");
}
An attack is not "on" the whole time it plays. Fighting games split an attack into three phases, and the same idea applies to any action game:
Here is a melee attack built as a coroutine (a method that can pause and resume across frames using yield, which you have used before for timed sequences). It turns the hitbox from section 3 on only during the active window.
using System.Collections;
using UnityEngine;
public class MeleeAttacker : MonoBehaviour
{
public HitboxController weaponHitbox;
public float windupTime = 0.15f;
public float activeTime = 0.15f;
public float recoveryTime = 0.25f;
private bool isAttacking = false;
public void Attack()
{
if (isAttacking) return; // ignore input mid-swing
StartCoroutine(AttackSequence());
}
private IEnumerator AttackSequence()
{
isAttacking = true;
Debug.Log("Windup start");
yield return new WaitForSeconds(windupTime);
Debug.Log("Active frames start");
weaponHitbox.Activate();
yield return new WaitForSeconds(activeTime);
weaponHitbox.Deactivate();
Debug.Log("Active frames end");
yield return new WaitForSeconds(recoveryTime);
Debug.Log("Recovery end, can act again");
isAttacking = false;
}
}
Trace an Attack() call with the numbers above (0.15s windup, 0.15s active, 0.25s recovery):
Windup start (t = 0.00s)
Active frames start (t = 0.15s)
Active frames end (t = 0.30s)
Recovery end, can act again (t = 0.55s)
Anything that enters the hitbox between t = 0.15s and t = 0.30s gets hit. Before or after that window, the hitbox is off — walking into the character during windup or recovery does nothing at all.
WaitForSeconds is fine for learning, but real games almost always drive Activate()/Deactivate() from Animation Events (markers placed directly on the animation timeline that call a method when playback reaches them). That way the active window always matches the actual sword-swing pose in the animation, even if an artist changes the animation's timing later.Now the interesting part. Say you hard-code a fireball:
public class PlayerCharacter : MonoBehaviour
{
public void CastFireball(GameObject target)
{
if (mana < 30f) return;
mana -= 30f;
target.GetComponent<IDamageable>()?.TakeDamage(25f, gameObject);
// ...cooldown logic, effects, all written by hand, right here...
}
}
This works for one ability. Add ten more and PlayerCharacter grows a method for each one, all copy-pasted with slightly different numbers. Want a designer to try "Fireball does 40 damage instead of 25, and costs 45 mana"? They need to find you, you need to edit code, recompile, and hand it back. In a real studio that is hours of wasted time, every single day, for every tuning pass.
The fix is a ScriptableObject (a Unity class that holds data and lives as an asset file in your project — not attached to a GameObject in a scene, just sitting in your Project window like a texture or a prefab). One Ability script, written once, can produce unlimited different ability assets — Fireball, Heal, Dash Strike — each just a different set of numbers filled in through the Inspector. No new code for a new ability.
Concretely: instead of one PlayerCharacter script full of if-statements, you get one AbilityData class (next section) and one AbilityRunner component (section 7) that can play back any ability asset a designer drags into a list. New skill = new asset, filled in through the Inspector, zero new lines of code.
The [CreateAssetMenu] attribute adds a right-click menu entry in the Unity Editor ("Create > Combat > Ability") so a designer can make a new ability asset without touching a script at all.
using UnityEngine;
[CreateAssetMenu(menuName = "Combat/Ability", fileName = "NewAbility")]
public class AbilityData : ScriptableObject
{
[Header("Identity")]
public string abilityName = "New Ability";
[Header("Cost and timing")]
public float manaCost = 10f;
public float cooldown = 2f;
[Header("Effect")]
public float damage = 15f;
public StatusEffectData[] appliedEffects; // buffs/debuffs/DoT this ability applies (section 9)
}
(StatusEffectData is another small ScriptableObject, covered fully in section 9 — for now just know it is a data asset describing one buff, debuff, or damage-over-time effect, the same way AbilityData describes one ability.)
Once this script exists, a designer right-clicks in the Project window, picks Create > Combat > Ability, and fills in the Inspector — no code involved:
Make a second asset, Heal.asset, with a separate negative-damage convention or a dedicated healAmount field, and you already have two abilities using the exact same code. This is the whole point: the class is code, written once by a programmer; the asset is data, made as many times as needed by a designer.
[Header("...")] just adds a bold label above the following fields in the Inspector — it does nothing at runtime. It costs nothing to add and makes a designer's life much easier once an ability asset grows to fifteen fields.The AbilityData asset is just numbers — it has no idea who is casting it, how much mana they currently have, or what is on cooldown. That live, per-character state belongs on a MonoBehaviour attached to the caster: the Ability Runner (sometimes called an ability "caster" or "executor").
using System.Collections.Generic;
using UnityEngine;
public class AbilityRunner : MonoBehaviour
{
public float currentMana = 100f;
public float maxMana = 100f;
// per-instance cooldown state: which ability, how much time is left
private Dictionary<AbilityData, float> cooldowns = new Dictionary<AbilityData, float>();
void Update()
{
if (cooldowns.Count == 0) return;
// snapshot the keys first -- you cannot modify a Dictionary
// while a foreach is walking over it
List<AbilityData> keys = new List<AbilityData>(cooldowns.Keys);
foreach (AbilityData key in keys)
{
float remaining = cooldowns[key] - Time.deltaTime;
if (remaining <= 0f) cooldowns.Remove(key);
else cooldowns[key] = remaining;
}
}
public bool CanCast(AbilityData ability)
{
if (cooldowns.ContainsKey(ability)) return false;
if (currentMana < ability.manaCost) return false;
return true;
}
public bool TryCast(AbilityData ability, GameObject target)
{
if (!CanCast(ability))
{
Debug.Log("Cannot cast " + ability.abilityName + " (on cooldown or not enough mana)");
return false;
}
currentMana -= ability.manaCost;
cooldowns[ability] = ability.cooldown;
target.GetComponent<IDamageable>()?.TakeDamage(ability.damage, gameObject);
StatusEffectController statuses = target.GetComponent<StatusEffectController>();
if (statuses != null && ability.appliedEffects != null)
{
foreach (StatusEffectData effect in ability.appliedEffects)
statuses.ApplyEffect(effect);
}
Debug.Log(gameObject.name + " cast " + ability.abilityName + " on " + target.name
+ " (mana left: " + currentMana + ")");
return true;
}
}
Trace it: a Wizard has 50 mana and a Fireball asset with manaCost = 30, cooldown = 4, damage = 25. Cast it on a 50-hp Goblin at t = 0, then try again at t = 1:
// t = 0
runner.TryCast(fireball, goblin); // succeeds
// t = 1 (only 1 second has passed, cooldown is 4)
runner.TryCast(fireball, goblin); // fails
Goblin took 25 dmg, hp = 25
Wizard cast Fireball on Goblin (mana left: 20)
Cannot cast Fireball (on cooldown or not enough mana)
AbilityData asset itself, instead of in the runner's dictionary. A ScriptableObject asset is normally one shared instance — if a Goblin and a Wizard both reference the same Fireball.asset and you write the remaining cooldown onto a field on that asset, casting Fireball on the Wizard would also put it on cooldown for the Goblin, because they are reading and writing the exact same object. Keep the numbers (cost, cooldown length, damage) on the asset; keep the state (mana left, time remaining) on the MonoBehaviour instance, one per character.Look back at TryCast: it never once wrote Player, EnemyGoblin, or any concrete character class. It only asked target.GetComponent<IDamageable>() and target.GetComponent<StatusEffectController>(). This is the barrel-and-goblin idea from the interfaces chapter, applied to combat: if it implements IDamageable, it is a legal target — the ability system does not care what it actually is.
// three completely different classes, none related by inheritance,
// all valid Fireball targets because all implement IDamageable
public class Health : MonoBehaviour, IDamageable { /* section 2 */ }
public class DestructibleCrate : MonoBehaviour, IDamageable
{
public void TakeDamage(float amount, GameObject source)
{
Debug.Log("Crate smashed open!");
Destroy(gameObject);
}
}
public class ShieldGenerator : MonoBehaviour, IDamageable
{
public float shieldHp = 200f;
public void TakeDamage(float amount, GameObject source)
{
shieldHp -= amount;
Debug.Log("Shield generator hp: " + shieldHp);
}
}
The same AbilityRunner.TryCast(fireball, someTarget) line now works whether someTarget is a player, a goblin, a crate, or a boss's shield generator, with zero changes to AbilityRunner. That is what "decoupled" buys you in practice.
The other half of decoupling is: who calls TryCast? AbilityRunner does not know or care. A player-controlled character calls it from an input script; an enemy calls it from an AI script. Both simply sit on top of the same AbilityRunner component.
// player side
public class PlayerInput : MonoBehaviour
{
public AbilityRunner runner;
public AbilityData fireball;
void Update()
{
if (Input.GetKeyDown(KeyCode.Q))
{
GameObject target = FindClosestEnemy();
runner.TryCast(fireball, target);
}
}
}
// enemy side -- same AbilityRunner, different decision-maker
public class SimpleEnemyAI : MonoBehaviour
{
public AbilityRunner runner;
public AbilityData fireball;
public GameObject player;
void Update()
{
if (runner.CanCast(fireball) && DistanceToPlayer() < 10f)
{
runner.TryCast(fireball, player);
}
}
}
AbilityRunner has no if (isPlayer) anywhere in it. The input script and the AI script are the only pieces that know who is deciding when to cast. Swap a player for an AI-controlled boss and the whole ability system keeps working, unchanged.
Player, Goblin, Boss). If it shows up, that piece is coupled to one character and will not generalize. Interfaces (IDamageable) and generic components (AbilityRunner, StatusEffectController) should not need to.A status effect is a temporary change applied to a character: damage over time (DoT) like poison or burn, a debuff that weakens them (a slow, reduced damage), or a buff that strengthens them (haste, a damage boost). Like abilities, we want these to be data, not a hard-coded class per effect.
using UnityEngine;
[CreateAssetMenu(menuName = "Combat/StatusEffect", fileName = "NewStatusEffect")]
public class StatusEffectData : ScriptableObject
{
public string effectName = "Poison";
public float duration = 5f;
public float tickInterval = 1f; // how often it deals damage
public float damagePerTick = 4f; // 0 for a pure buff/debuff with no DoT
public float moveSpeedMultiplier = 1f; // 1 = no change, 0.5 = half speed
}
Applying it needs some runtime state per active effect (how much time is left, when the next tick fires) — that lives in a plain C# class, not the ScriptableObject, for the same reason cooldowns live on the runner and not on AbilityData.
using System.Collections.Generic;
using UnityEngine;
public class ActiveEffect
{
public StatusEffectData data;
public float timeRemaining;
public float tickTimer;
}
public class StatusEffectController : MonoBehaviour
{
private List<ActiveEffect> active = new List<ActiveEffect>();
private IDamageable damageable;
void Awake()
{
damageable = GetComponent<IDamageable>();
}
public void ApplyEffect(StatusEffectData data)
{
active.Add(new ActiveEffect { data = data, timeRemaining = data.duration, tickTimer = data.tickInterval });
Debug.Log(gameObject.name + " gained " + data.effectName);
}
// a simple modifier system: multiply every active effect's speed modifier together
public float GetMoveSpeedMultiplier()
{
float multiplier = 1f;
foreach (ActiveEffect effect in active)
multiplier *= effect.data.moveSpeedMultiplier;
return multiplier;
}
void Update()
{
// walk backwards so RemoveAt does not skip the next item
for (int i = active.Count - 1; i >= 0; i--)
{
ActiveEffect effect = active[i];
effect.timeRemaining -= Time.deltaTime;
effect.tickTimer -= Time.deltaTime;
if (effect.tickTimer <= 0f && effect.data.damagePerTick > 0f)
{
effect.tickTimer = effect.data.tickInterval;
damageable?.TakeDamage(effect.data.damagePerTick, gameObject);
}
if (effect.timeRemaining <= 0f)
{
Debug.Log(gameObject.name + " lost " + effect.data.effectName);
active.RemoveAt(i);
}
}
}
}
Trace a Burn effect (duration = 3, tickInterval = 1, damagePerTick = 4) applied to a 50-hp Goblin at t = 0:
Three ticks of 4 damage each over 3 seconds, then the effect removes itself. Because damagePerTick and moveSpeedMultiplier are just numbers on an asset, the exact same StatusEffectController code handles a poison DoT, a burning debuff, and a "Haste" buff with moveSpeedMultiplier = 1.5 and damagePerTick = 0 — no new classes needed.
for loop in Update (i = active.Count - 1; i >= 0; i--) is a standard C# pattern whenever you remove items from a list while looping over it. Looping forward and calling RemoveAt shifts every later item down one slot, so you silently skip the item that slid into the spot you just removed.Here is every piece from this chapter in one picture, following a single button press from input to the target reacting.
Follow the arrows and notice what talks to what: AbilityRunner talks to the target only through IDamageable and StatusEffectController — two small, generic contracts. Health never talks to AbilityRunner at all; it just fires events into the void and lets whoever cares subscribe. The AbilityData and StatusEffectData assets are read many times by many characters but never written to at runtime. Every arrow in that picture is a small, specific connection — nothing needs to know the whole diagram to do its own job.
AbilityRunner, Health, or StatusEffectController at all. If adding a new ability forces you to edit any of those three, something has become coupled that should not be, and it is worth stepping back and asking which interface is missing.A combo just means: if the player presses attack again soon enough after the last one, chain into the next step instead of restarting. The whole mechanism is a timestamp and a short window.
using UnityEngine;
public class ComboController : MonoBehaviour
{
public float comboWindow = 0.6f; // seconds allowed between hits to keep the combo alive
public AbilityData[] comboSteps; // e.g. [ Slash1, Slash2, Slash3 ]
private int comboIndex = 0;
private float lastAttackTime = -999f;
public AbilityRunner runner;
public void OnAttackInput(GameObject target)
{
float sinceLastAttack = Time.time - lastAttackTime;
if (sinceLastAttack > comboWindow)
{
comboIndex = 0; // too slow: combo reset to the start
}
AbilityData step = comboSteps[comboIndex];
runner.TryCast(step, target);
Debug.Log("Combo step " + comboIndex + ": " + step.abilityName);
comboIndex = (comboIndex + 1) % comboSteps.Length;
lastAttackTime = Time.time;
}
}
Trace three attack presses at t = 0.0s, t = 0.3s, and t = 1.5s, with comboWindow = 0.6s and three combo steps:
The second press landed inside the window (0.3s <= 0.6s), so it advanced to step 1. The third press came 1.2 seconds after the second — past the window — so it reset back to step 0 instead of continuing to step 2. Notice each combo step is just a different AbilityData asset cast through the same AbilityRunner from section 7: the combo system decides which ability to cast next, and needs no damage or cooldown logic of its own at all.
TakeDamage method; anything implementing it is a valid combat target.Action) — a way for an object to announce something happened without knowing who is listening.OverlapSphere/OverlapBox) — finds everything inside a shape at one instant; good for AoE bursts.Health class from section 2 (maxHp = 40), predict the exact console output for this sequence of calls, including whether and when the object dies.
Health h = enemy.GetComponent<Health>();
h.TakeDamage(15f, null);
h.TakeDamage(10f, null);
h.TakeDamage(20f, null);
h.TakeDamage(5f, null);
Trace the hp: 40 -> 25 -> 15 -> 0 (clamped, dies) -> the 4th call is ignored because IsDead is now true.
Enemy took 15 dmg, hp = 25
Enemy took 10 dmg, hp = 15
Enemy took 20 dmg, hp = 0
Enemy died
The third call would take hp to 15 - 20 = -5, but Mathf.Max(0f, ...) clamps it to 0, and since currentHp <= 0f is now true, OnDied fires. The fourth call, TakeDamage(5f, null), hits the if (IsDead) return; guard immediately and prints nothing at all.
Wizard has currentMana = 60 and an AbilityData called IceBolt with manaCost = 25 and cooldown = 3. The wizard calls TryCast(iceBolt, target) at t = 0, t = 1, and t = 3.5. For each call, say whether it succeeds, and give currentMana right after (assume Update ticks the cooldown down correctly between calls).t = 0: CanCast is true (no cooldown yet, and mana 60 >= 25). Succeeds. Mana becomes 60 - 25 = 35. Cooldown is set to 3, so IceBolt is locked until roughly t = 3.
t = 1: only 1 second has passed, so the cooldown still has about 2 seconds left. CanCast is false. Fails. Mana stays 35 (nothing is spent on a failed cast).
t = 3.5: 3.5 seconds have passed since the cast at t = 0, which is past the 3-second cooldown, so the cooldown entry has ticked down to 0 and been removed. Mana 35 >= 25, so CanCast is true. Succeeds. Mana becomes 35 - 25 = 10.
(a) For each attack, name the best hit detection approach from section 3 (hitbox collider, raycast, or overlap query) and give one line of reasoning: a sniper rifle shot, a sword swing with a wind-up animation, a ground-slam that damages everything nearby the instant it lands.
(b) In section 8, AbilityRunner.TryCast never mentions a specific character class. What would break if it instead required its target parameter to be of type PlayerCharacter instead of a plain GameObject checked for IDamageable?
(a) Sniper rifle shot: raycast — it is an instant line from the gun to whatever it first hits, with no travel time to simulate. Sword swing with a wind-up: hitbox collider — the blade needs a shape that follows the swing animation and is only "live" during the active frames. Ground slam: overlap query — it checks everything inside a radius at one single instant, with no shape moving through space over time.
(b) If TryCast required the target to be a PlayerCharacter, the exact same ability could no longer be cast on an enemy, a destructible crate, or a boss's shield generator — every one of those would fail to compile or fail at runtime, because none of them is a PlayerCharacter. You would need a second, near-duplicate TryCast for every other target type. Checking for IDamageable instead means any class that promises to implement TakeDamage is automatically a legal target, with no changes to AbilityRunner at all — that is the entire benefit of coding against an interface instead of a concrete class.
That is a full combat and ability system: a decoupled Health/IDamageable pair for taking damage, three hit detection tools picked by the shape of the attack, active-frame timing so hits only land in the right window, AbilityData assets so designers add skills without code, an AbilityRunner that owns the live cooldown/resource state, a StatusEffectController for buffs/debuffs/DoT built from the same data-driven idea, and a small combo layer on top that just picks which ability asset to cast next. Every piece talks to the others only through an interface or an event — that is what makes it possible to add ability number fifty without touching ability number one.