doc 6 of 11

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:

LayerNameWho's on itMask
1Worldplayer body
5Playerplayer body
6Player_Hitboxthe hitbox shape (B)15
7Player_Hurtboxplayer hurtbox (D)
13Enemygolem outer area7
15Enemy_Hurtboxgolem 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 = false on 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_receivedEnemyStats):

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 -= amount inside a method calls the setter — that's GDScript, and it's why the signal fires from anywhere without anyone remembering to emit it. The prev > 0.0 guard 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_free defers 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 computes from + 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 monitoring off — 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.finished line is missing or the tween never runs (a create_tween() bound to a node that was freed mid-tween kills it — the text is added to Globals.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_entered on 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.