M3.2 — The Health Component¶
What you'll learn
- Build a reusable
Healthcomponent as the single place HP changes (clamp and emit once). - Emit
health_changedanddied, and identify their consumers. - Wire the M3.1
Hurtbox'shurtintoHealth.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
diedsignal — and theSignalBus.enemy_diedit 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_healthdiffers.
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)andheal(amount)methods,- signals
health_changed(current, max)anddied.
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 toHealth.take_damage. Now a hit becomes damage with no glue code beyond the connection. - A health bar (UI) connects to
health_changedand redraws. Because the signal carries bothcurrentandmax, 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 emitsSignalBus.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¶
- Create
res://scripts/health.gdwith theclass_name Healthbody above (_set_current,take_damage,heal, the two signals). Type it incrementally. - Make it a component scene (so it is droppable and can later own child nodes like a regen
Timer):Scene → New Scene → Node, renameHealth, attachhealth.gd. Save asres://scenes/component/health.tscn. - Add
Healthto the player: openplayer.tscn, draghealth.tscnontoPlayer. In the Inspector set the player'smax_health(e.g.,30). - Wire the player's Hurtbox to its Health. In
player.gd's_ready, connect them:The hurtbox's@onready var hurtbox: Hurtbox = $Hurtbox @onready var health: Health = $Health func _ready() -> void: hurtbox.hurt.connect(health.take_damage)hurt(amount)now feedstake_damage(amount)directly — matching signatures, no shim. - Connect a temporary listener to verify: in
_ready, alsohealth.health_changed.connect(func(c, m): print("HP ", c, "/", m))andhealth.died.connect(func(): print("player died")). - Reuse the M3.1 sanity-check: overlap a temporary enemy-layer
Hitbox(damage = 5) with the player'sHurtbox. Run: the Output should printHP 25/30then continue dropping by 5 per overlap, and printplayer diedwhen 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.