Stage 04 — Something that hurts: the damage path
You will build: the full damage path — a hitbox shape, a hurtbox target, a stats node that
owns health, floating damage numbers, enemy death, and the player getting hurt in return.
You'll learn: physics layers as a contract · areas that monitor vs areas that are
monitored · signals as the only door into a stat · queue_free and what "dying mid-frame"
means.
Why this exists#
Up to now "hit" was a noun — a position. This stage makes it a path: a number leaves the player's side of the screen, crosses a layer contract, enters a shape, signals a stats node, and changes a value that a UI bar and a death check both read. Every combat game you will ever build is this path with different furniture. Get the path right once and the furniture is swappable; get it wrong (the shape computing damage, the sprite storing health) and every piece of furniture is a load-bearing lie.
The architecture doc's layer table is the contract. Set it up before writing any script.
Build it#
A — The layers#
Project Settings → Layer Names:
| Layer | Name | Who's on it | Mask |
|---|---|---|---|
| 1 | World | — | player body |
| 5 | Player | player body | — |
| 6 | Player_Hitbox | the hitbox shape (B) | 15 |
| 7 | Player_Hurtbox | player hurtbox (D) | — |
| 13 | Enemy | golem outer area | 7 |
| 15 | Enemy_Hurtbox | golem hurtbox | — |
Two rules worth saying out loud:
- The hitbox's mask lists exactly what it may hit. The player hitbox masks layer 15 and nothing else — it cannot see the floor, the drops, or the player's own hurtbox.
- A hurtbox is a target. It sits on a layer, masks nothing, and has monitoring off —
it never needs to be told about overlaps; it only needs to be findable. Set
monitoring = falseon both hurtboxes. (An area with monitoring off still appears in other areas'get_overlapping_areas()— being seen costs nothing.)
B — The hurtbox, and the only door into a stat#
entities/hurtbox.gd on the shared Hurtbox scene (Area2D + CollisionShape2D):
class_name Hurtbox
extends Area2D
signal damage_received(amount: int)
func take_damage(amount: int) -> void:
damage_received.emit(amount)
The shape forwards; it never computes. Who listens is the entity's stats node — connect it
in Golem.tscn (lightning bolt, Hurtbox.damage_received → EnemyStats):
class_name EnemyStats
extends Node
signal health_changed(current_health: float, previous_health: float)
signal health_depleted
@export var move_speed := 35.0
@export var max_health := 10.0
var health := max_health:
set = _set_health
const FLOAT_TEXT: PackedScene = preload("res://entities/float_text.tscn")
@onready var parent_enemy: Enemy = get_parent()
func _set_health(value: float) -> void:
var prev := health
health = clampf(value, 0.0, max_health)
if health != prev:
health_changed.emit(health, prev)
if health <= 0.0 and prev > 0.0:
health_depleted.emit()
func _on_damage_received(amount: int) -> void:
health -= amount
var lt: Node2D = FLOAT_TEXT.instantiate()
lt.position = parent_enemy.global_position + Vector2(0, -15)
lt.setup(str(amount))
Globals.entities.add_child(lt)
func _on_health_depleted() -> void:
parent_enemy.queue_free()
Three deliberate choices:
- The setter is the only writer of
health.health -= amountinside a method calls the setter — that's GDScript, and it's why the signal fires from anywhere without anyone remembering to emit it. Theprev > 0.0guard means death announces once, not on every point of overshoot. - Death is
queue_free()on the enemy, decided by the stats node. The stats node is the authority on "is it alive"; freeing the whole scene is a consequence, not a computation.queue_freedefers the actual deletion to end of frame — you may keep using the node for the rest of this one, and it is the reason you can call it from inside a signal fired during physics without "free object" errors. - The float text is spawned here, by the thing that knows the number. The amount exists
only inside
_on_damage_received; the visual follows the data, not the other way round.
C — The float text#
entities/float_text.tscn: Node2D root + Label child (a RichTextLabel works too —
the reference project used one; for a single line, Label is fewer nodes). Center the label
on the origin. Script:
extends Node2D
var text_value := ""
@onready var label: Label = $Label
func setup(value: String) -> void:
text_value = value
func _ready() -> void:
label.text = text_value
_play()
func _play() -> void:
var side := [-1, 1].pick_random()
var drift := Vector2(side * randf_range(5.0, 10.0), -randf_range(40.0, 50.0))
var tween := create_tween()
tween.set_parallel(true)
tween.tween_property(self, "scale", Vector2(1.6, 1.6), 0.15) \
.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
tween.tween_property(self, "position", position + drift) \
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT).set_delay(0.1)
tween.tween_property(self, "modulate:a", 0.0, 0.5) \
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN).set_delay(0.35)
await tween.finished
queue_free()
The pop, the drift, the fade — three parallel properties, one timeline, auto-free at the end.
A FloatText that never frees itself is a memory leak wearing a costume; await tween.finished
is the costume check.
Honest footnote: the reference project wrote its vertical drift as
randf_range(-40, -50)— from above to. That still returns a number between −50 and −40 (Godot computesfrom + randf() * (to - from)), so it worked. It was a lie in the reading, though: the arguments are documented as a range, and a reversed range is how a future edit to one endpoint silently inverts the whole thing. Write ranges in order.
D — The hitbox: a shape with a one-frame job#
entities/hitbox.gd on a Hitbox scene (Area2D + CollisionShape2D, a 14-radius circle):
class_name Hitbox
extends Area2D
var damage := 0
func _on_area_entered(hurtbox: Area2D) -> void:
if not hurtbox is Hurtbox:
return
hurtbox.take_damage(damage)
var enemy := hurtbox.get_parent()
if enemy is Enemy:
enemy.knockback += global_position.direction_to(hurtbox.global_position) * 10.0
The hitbox's only opinions are: "how much" (a number it's told, stage 05 finds its home) and "which way does the shove go" (from itself toward the target — the stage 03 damping does the rest). It does not know it's a sword, and that's what makes it reusable.
E — The player's side: a debug swing#
Add to the player scene: a Hurtbox instance (layer 7) and a Hitbox instance (layer 6,
mask 15) parked behind the sprite with its shape's disabled = true. Add input action
attack (LMB or J).
# player additions
var facing := Vector2.RIGHT
@onready var hitbox: Hitbox = $Hitbox
@onready var hitbox_shape: CollisionShape2D = $Hitbox/HitboxShape
@onready var player_stats: PlayerStats = $PlayerStats
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("attack"):
_swing()
func _swing() -> void:
hitbox.damage = 10
hitbox.global_position = global_position + facing * 22.0
hitbox_shape.disabled = false
await get_tree().create_timer(0.08).timeout
hitbox_shape.disabled = true
And in _physics_process, where you already compute direction, keep the facing fresh:
if direction != Vector2.ZERO: facing = direction.
Now: walk into golems, press attack. Numbers float. Golems with 10 hp die in one swing. And the golems hurt you — their outer area masks layer 7:
# in Enemy._on_area_entered, beside the separation nudge:
func _on_player_hurtbox_entered(hurtbox: Area2D) -> void:
if hurtbox is Hurtbox:
hurtbox.take_damage(5)
Wire a PlayerStats (health + the same setter pattern as the enemy's) into the player's
hurtbox, and a CanvasLayer → PlayerUI with one ProgressBar:
func _ready() -> void:
var stats: PlayerStats = Globals.player.player_stats
health_bar.max_value = stats.max_health
stats.health_changed.connect(func (current: float, _prev: float) -> void:
health_bar.value = current)
stats.health_depleted.connect(_on_dead)
func _on_dead() -> void:
print("You are dead. Stage 09 will do better than a print.")
One contact = one hit, because area_entered fires on entering. A golem standing on you
hits once and then nothing — the reference project had exactly this hole, and the honest fix
(a per-second damage cooldown on the stats node) is a stretch goal, not a stage.
F — Commit#
git add . && git commit -m "stage 04: damage path — hitbox, hurtbox, stats, float text, death"
Checkpoint — definition of done#
- A swing on a golem shows a floating "10", shoves it, and (with 10 max health) kills it
- The dead golem is gone from the group: print
get_tree().get_nodes_in_group("enemies").size()before and after — and the spawner fills the slot - Standing still, golems touching you drain the bar; the bar's number and the stats' number are the same number (print both once)
- Both hurtboxes have
monitoringoff — check in the inspector, not by memory - Kill five golems in one swing overlap and count the float texts: five numbers, not one, not thirty
- You can name every step of the damage path in order, out loud
- Zero warnings; committed
Stretch (no instructions)#
Contact damage with a one-per-second cooldown so a camped golem keeps its promise. (The cooldown belongs on the stats node — think about why before you put it on the area.)
If you get stuck#
- Hitbox never sees the golem → layer/mask contract broken. The hitbox's mask must include 15; the hurtbox's layer must include 15. One of the two inspector checkboxes is wrong, and the error is silent by design — areas report nothing when they see nothing.
Invalid call to non-existent function 'take_damage'→ you hit a hurtbox scene that has the script, but the golem's hurtbox is a different instance without it, or you connected to the wrong node.get_parent()in the hitbox is the golem; the stats listen on the hurtbox. Draw the three nodes and the arrow.- Float texts pile up forever → the
await tween.finishedline is missing or the tween never runs (acreate_tween()bound to a node that was freed mid-tween kills it — the text is added toGlobals.entities, which outlives everything, so check the binding). - Player bar jumps by 5 when nothing is touching you → the golem's outer area masks 7 and
fires
area_enteredon spawn, every time the ring drops a golem on top of you. Either move the ring out of your hurtbox radius or require the overlap to persist one frame.