Skip to content

M3.2 — The Health Component

What you'll learn

  • Build a reusable Health component as the single place HP changes (clamp and emit once).
  • Emit health_changed and died, and identify their consumers.
  • Wire the M3.1 Hurtbox's hurt into Health.take_damage.

How it applies

  • HP is the most-watched number in the game, so it needs one source of truth. A health bar, a low-health screen tint, a death trigger, and an achievement counter all read HP. If several places mutate HP independently, they disagree, and the disagreements surface as "the bar says 20 but the character is dead." One component, one change path, every listener consistent.
  • Death is an event many systems care about. Loot (M6) drops on death, XP (M5) awards on death, the kill counter and sound react to death. The died signal — and the SignalBus.enemy_died it can fan out to — is how all of them learn without the dying actor referencing any of them.
  • Clamping is a correctness boundary (QA: boundary value analysis). HP must never exceed max or drop below zero. Centralizing the clamp in one setter means the boundaries are enforced once, everywhere, instead of at each call site (where one missed clamp is a bug). The largest and smallest representable HP are exactly the boundary cases a tester probes.
  • Reuse. The player and every enemy use the same Health component. A barrel that can be destroyed uses it too. The behavior is identical; only the max_health differs.

Concepts

A component that owns one number well

Health is a Node (no visual, no physics) whose entire job is to hold HP and be the only thing that changes it. It exposes:

  • max_health (exported, tunable per actor),
  • current (the live value),
  • take_damage(amount) and heal(amount) methods,
  • signals health_changed(current, max) and died.

Nothing outside Health writes current directly. Callers ask via take_damage/heal; the component clamps and emits. This is the same discipline as a property setter that emits once on change: a single choke point through which all mutations pass.

Setter as single emit point

Route every change through one private method so the clamp and the signal happen in exactly one place:

Example

The core of health.gd. current is changed only by _set_current, which clamps to [0, max] and emits once:

# res://scripts/health.gd
class_name Health
extends Node

signal health_changed(current: int, max: int)
signal died

@export var max_health: int = 10

var current: int

func _ready() -> void:
    current = max_health
    health_changed.emit(current, max_health)

func _set_current(value: int) -> void:
    var clamped: int = clampi(value, 0, max_health)
    if clamped == current:
        return
    current = clamped
    health_changed.emit(current, max_health)
    if current == 0:
        died.emit()

clampi bounds the value to [0, max_health] in one call, so neither overheal nor negative HP can leak through. The early return when the value is unchanged avoids emitting a "changed" signal that didn't change — listeners only hear real changes.

Example

take_damage and heal are thin wrappers over the single setter — they translate a request into a new value and let _set_current enforce the rules:

func take_damage(amount: int) -> void:
    if amount <= 0:
        return
    _set_current(current - amount)

func heal(amount: int) -> void:
    if amount <= 0:
        return
    _set_current(current + amount)

Negative or zero amounts are rejected up front so take_damage(-5) cannot secretly heal and heal(-5) cannot secretly damage — each method does one thing. All the clamping and emitting still happens in _set_current; these wrappers only decide direction.

Who connects to what

  • The Hurtbox (M3.1) emits hurt(amount) when hit. Connect it to Health.take_damage. Now a hit becomes damage with no glue code beyond the connection.
  • A health bar (UI) connects to health_changed and redraws. Because the signal carries both current and max, the bar needs no other reference to the actor.
  • A death handler connects to died: the player's death opens a game-over flow; an enemy's death emits SignalBus.enemy_died(self, global_position) so loot/XP/counters react (M1.4's bus, paid off).

The actor wires these in its own _ready (or in the editor), deciding which of its components talk to which — the Hurtbox and Health don't reference each other directly; the actor connects them.

Why a Node, not a Resource

Health is per-instance, live, signal-emitting state, so it is a Node in the scene tree (each actor has its own). Contrast with StatBlock (M5), which is shared, inspector-authored data and is therefore a Resource. The rule: live, instance-specific, event-emitting state → Node; shared, serializable, inert data → Resource.

Walkthrough

  1. Create res://scripts/health.gd with the class_name Health body above (_set_current, take_damage, heal, the two signals). Type it incrementally.
  2. Make it a component scene (so it is droppable and can later own child nodes like a regen Timer): Scene → New Scene → Node, rename Health, attach health.gd. Save as res://scenes/component/health.tscn.
  3. Add Health to the player: open player.tscn, drag health.tscn onto Player. In the Inspector set the player's max_health (e.g., 30).
  4. Wire the player's Hurtbox to its Health. In player.gd's _ready, connect them:
    @onready var hurtbox: Hurtbox = $Hurtbox
    @onready var health: Health = $Health
    
    func _ready() -> void:
        hurtbox.hurt.connect(health.take_damage)
    
    The hurtbox's hurt(amount) now feeds take_damage(amount) directly — matching signatures, no shim.
  5. Connect a temporary listener to verify: in _ready, also health.health_changed.connect(func(c, m): print("HP ", c, "/", m)) and health.died.connect(func(): print("player died")).
  6. Reuse the M3.1 sanity-check: overlap a temporary enemy-layer Hitbox (damage = 5) with the player's Hurtbox. Run: the Output should print HP 25/30 then continue dropping by 5 per overlap, and print player died when it reaches 0. Remove the temporary hitbox and the print listeners afterward.

Optional sanity check

Call health.take_damage(1000) once (temporarily) and confirm current lands on exactly 0, not a negative number, and died fires once — the boundary case. Then call health.heal(1000) on a live actor and confirm it caps at max_health, not above. These two probes confirm the clamp holds at both ends, which is the whole reason the single setter exists.

Self-check quiz

Q1 — Why route every HP change through one _set_current method instead of letting take_damage and heal each modify current?

A. It is faster. B. So the clamp to [0, max] and the health_changed/died emission happen in exactly one place, keeping every listener consistent and the boundaries enforced once. C. Godot requires a setter named _set_current. D. So current can be private.

Reveal answer

B. The single choke point means there is one definition of "what happens when HP changes" — clamp, emit, check death — and no call site can skip it. If take_damage and heal each clamped and emitted on their own, a future third mutator could forget to, and a listener would miss a change or HP would escape its bounds. A is not the reason. C is false (the name is a choice). D is a side benefit, not the purpose.

Q2 — The player's Hurtbox emits hurt(amount) and the Health exposes take_damage(amount). What connects them, and where?

A. Health polls the Hurtbox every frame. B. The actor connects hurtbox.hurt to health.take_damage in its _ready; the two components don't reference each other. C. They auto-connect because both are components. D. The SignalBus relays it globally.

Reveal answer

B. The actor owns the wiring: it knows it has both a Hurtbox and a Health and connects one to the other, so the components stay independent and reusable. A wastes work and isn't how signals work. C is false — there is no auto-connection. D is wrong for a local hit; the bus is for events multiple systems care about (like enemy_died), not for an actor's own hurt-to-health wiring.

Q3 — Why is Health a Node while StatBlock (introduced in M5) will be a Resource?

A. Nodes are always better than Resources. B. Health is live, per-instance, signal-emitting state (each actor has its own), which suits a Node; a StatBlock is shared, inspector-authored, serializable data, which suits a Resource. C. Resources cannot hold integers. D. Nodes can't be saved, so anything saved must be a Resource.

Reveal answer

B. The split is by nature of the data: live, instance-specific, event-emitting state belongs in a scene-tree Node; shared, inert, serializable data belongs in a Resource. A is not a rule. C is false. D is misleading — both can participate in saving (M8 serializes Node-held values), so that's not the deciding factor.

Integration question

Q4 — open

The player and an M4 skeleton both carry a Hurtbox (M3.1) and a Health (this chapter) — the same two component scenes. Explain how the identical components produce different behavior on the two actors, using max_health and the died signal as your examples, and why this is the loose-coupling payoff the book keeps citing.

Reveal expected answer

The components are identical; what differs is the per-instance configuration and the connections the owning actor makes. max_health is an exported value set per instance — the player's Health might be 30, the skeleton's 8 — so the same component yields a tanky player and a fragile enemy with no code change. The died signal is the same on both, but each actor connects it differently: the player connects died to a game-over flow, while the skeleton connects died to SignalBus.enemy_died.emit(self, global_position) so the loot and XP systems (M5–M6) react. This is the loose-coupling payoff: the Hurtbox and Health know nothing about who owns them, so one pair of scenes serves every damageable thing in the game; behavior varies entirely through configuration and the actor-level wiring, not through forked component code. A bug fixed in health.gd is fixed for the player, every enemy, and every barrel at once.