Skip to content

M3.1 — Hitboxes & Hurtboxes

What you'll learn

  • Build reusable Hitbox and Hurtbox Area2D components shared by player and enemies.
  • Configure collision layers (what a node is on) vs masks (what it scans) — the top combat bug.
  • Carry damage on the hitbox and forward it from the hurtbox on area_entered.

How it applies

  • Combat is the genre's core verb, and this is its plumbing. Every hit in the game — player on enemy, enemy on player, a trap on anyone — runs through hitbox-meets-hurtbox. Getting this seam clean once means every later combat feature attaches to it instead of reinventing it.
  • Layers/masks are a fairness and correctness surface. Whether the player's attack can hit the player (it must not), whether enemies hit each other (usually not), whether a hit registers at all — all are decided by the layer/mask matrix. A wrong bit is a bug that looks like "damage sometimes doesn't happen," which is miserable to debug without understanding the matrix.
  • Components are testable seams. A Hurtbox that knows nothing about who owns it can be dropped onto the player, a skeleton, or a destructible barrel, and exercised in isolation. This is the unit boundary a tester wants: the component is the unit, the actor is the integration.
  • Decoupling deal-damage from take-damage. The hitbox does not know what it hit; the hurtbox does not know what hit it. Each side only knows its own job, so new attackers and new victims compose without touching each other — the same respond-don't-reach-in principle as signals.

Concepts

The pattern

Split combat contact into two responsibilities, each an Area2D:

  • A hitbox is the damaging region of an attack — the arc of a sword swing, the body of a projectile. It carries how much damage it deals. It is active only while the attack is live.
  • A hurtbox is the damageable region of an actor — usually a bit smaller than the sprite so near misses don't count. It receives damage and forwards it to the actor's Health (M3.2).

When a hitbox overlaps a hurtbox, the hurtbox takes the hitbox's damage. Using two Area2D nodes (which detect overlap without physically colliding) keeps damage detection separate from the body movement and wall collision built in M2 — a sword arc should register a hit without shoving the enemy like a solid object.

Layers and masks: "I am" vs "I look for"

Every Area2D and physics body has two bit sets:

  • Collision layer — the layers the node is on. "I am a player hurtbox."
  • Collision mask — the layers the node scans. "I look for enemy hitboxes."

Detection is asymmetric and one-directional by configuration: node A detects node B when A's mask includes a layer B is on. For combat you deliberately make the hitbox scan for hurtboxes (or vice-versa) and not the reverse, so each contact is handled once. Name the layers in Project → Project Settings → Layer Names → 2D Physics so the matrix is readable instead of raw bit numbers.

This book's layer assignment:

Layer Name What is on it
1 world TileMap walls
2 player player body
3 enemy enemy bodies
4 player_hurtbox the player's Hurtbox
5 enemy_hurtbox each enemy's Hurtbox
6 player_hitbox the player's attack Hitbox
7 enemy_hitbox each enemy's attack Hitbox
8 pickup loot drops

The combat wiring that follows from it:

  • Player's attack Hitbox: on player_hitbox (layer 6), mask scans enemy_hurtbox (layer 5).
  • Each enemy's Hurtbox: on enemy_hurtbox (layer 5); it does not need a mask if the hitbox is the detector. (You can detect from either side; pick one side as the detector and be consistent. This book detects from the hurtbox side — see below — so the hurtbox masks the attacker's hitbox layer.)

Example

The classic bug: the player's attack does nothing against an enemy. You check the damage value (fine), the animation (fine), the overlap (the shapes clearly cross). The defect is in the matrix: the hurtbox's mask doesn't include the layer the hitbox is on, so area_entered never fires. Nothing in the code is wrong; one checkbox is. This is why the layer/mask mental model — layer = I am, mask = I look for — has to be solid before writing combat.

Detecting from the hurtbox side

A robust convention (used by the GDQuest combat demo and this book): the hurtbox is the detector. It masks the attacker's hitbox layer and, on area_entered, reads the entering hitbox's damage and emits its own hurt(amount) signal. The hitbox is then dumb — it just exists with a damage value while the attack is active. Centralizing detection on the hurtbox means each actor decides for itself what it does when hit (take damage, flash, knock back), which is exactly where that decision belongs.

Reusable component scenes

Both boxes are tiny scenes you author once and instance on every actor:

Example

Hitbox — an Area2D with a CollisionShape2D and a damage value. The whole script:

# res://scripts/hitbox.gd
class_name Hitbox
extends Area2D

## Damage this hitbox deals to any hurtbox it overlaps while active.
@export var damage: int = 1

No logic — it is a damage-tagged region. M3.3 enables/disables it during the attack animation.

Example

Hurtbox — an Area2D that, on contact with a hitbox, reads the damage and announces it:

# res://scripts/hurtbox.gd
class_name Hurtbox
extends Area2D

## Emitted when a hitbox overlaps. The owner's Health (M3.2) connects to this.
signal hurt(amount: int)

func _ready() -> void:
    area_entered.connect(_on_area_entered)

func _on_area_entered(area: Area2D) -> void:
    var hitbox := area as Hitbox
    if hitbox == null:
        return
    hurt.emit(hitbox.damage)

area as Hitbox is a safe cast: if the overlapping area is not a Hitbox, it yields null and the guard returns. So the hurtbox only reacts to genuine hitboxes, ignoring other Area2D (pickups, triggers) that happen to overlap.

Walkthrough

  1. Name the layers: Project → Project Settings → Layer Names → 2D Physics. Set layers 1–8 to the names in the table above. (Naming is cosmetic but turns every later layer/mask checkbox from a guess into a choice.)
  2. Build the Hitbox component: Scene → New Scene → Area2D, rename Hitbox, add a CollisionShape2D child (a shape covering the attack's reach). Attach res://scripts/hitbox.gd (the two-line class). Save as res://scenes/component/hitbox.tscn.
  3. Build the Hurtbox component: new scene, Area2D root renamed Hurtbox, add a CollisionShape2D child (sized a bit inside the sprite for fairness). Attach res://scripts/hurtbox.gd. Save as res://scenes/component/hurtbox.tscn.
  4. Set the layer/mask bits in the Inspector:
  5. On hurtbox.tscn's Hurtbox: you will set its layer per-owner (player vs enemy) when you instance it, and its mask to the attacker's hitbox layer. For now, on the player's instance, set layer = player_hurtbox (4), mask = enemy_hitbox (7). (The player's hurtbox looks for enemy hitboxes.)
  6. On hitbox.tscn's Hitbox: set per-owner when instanced — the player's attack hitbox on player_hitbox (6); it needs no mask because the hurtbox is the detector.
  7. Instance a Hurtbox on the player: open player.tscn, drag hurtbox.tscn onto Player. Position its shape over the character. Confirm its layer = player_hurtbox, mask = enemy_hitbox.
  8. There is no attacker yet to test against — M3.3 builds the player's attack hitbox and M4 the enemy's. For now, confirm the components exist, the layers are named, and the player carries a hurtbox.

Optional sanity check

Temporarily instance a Hitbox somewhere overlapping the player's Hurtbox, set the hitbox's layer to enemy_hitbox (7) and a damage of 5, and add a temporary print("hurt ", amount) to the hurtbox's hurt handler (or connect the signal to a temporary printer). Run: the hurtbox should print hurt 5 once on overlap. Then flip the hurtbox's mask off enemy_hitbox and confirm it prints nothing — the live demonstration that the mask, not the overlap, gates detection. Remove the temp hitbox and prints.

Self-check quiz

Q1 — A hitbox clearly overlaps a hurtbox, the damage value is set, but no hit registers. Where is the bug most likely to be?

A. The damage value is too low to register. B. The detector's collision mask does not include the layer the other box is on, so area_entered never fires. C. Area2D cannot detect other Area2D. D. The sprites are not overlapping, only the shapes.

Reveal answer

B. Overlap alone is not enough — detection requires the detector's mask to include a layer the target is on. A wrong bit there means the signal never fires regardless of overlap or damage. A is false (any positive damage registers; zero still fires the signal). C is false — Area2D detecting Area2D is exactly the mechanism. D inverts what matters: the shapes' overlap is what counts, not the sprites'.

Q2 — Why does the hurtbox's handler cast with area as Hitbox and return if the result is null?

A. To convert the damage to an integer. B. So the hurtbox reacts only to actual hitboxes — ignoring other Area2D (pickups, triggers) it may overlap, and avoiding the error of reading .damage on an area that has no such property. C. Because area_entered passes a Hitbox only sometimes. D. The cast converts the entering area into the actor that owns it, so .damage resolves on the owner.

Reveal answer

B. area_entered passes any Area2D, not just hitboxes; the safe cast yields null for anything that isn't a Hitbox, and the guard skips it — so the hurtbox both ignores non-hitboxes (pickups, triggers) and never reads .damage on an area that lacks it (which would error). A is false (no conversion happens). C misstates the signal — it passes whatever entered, which is why the type check exists. D is false: as Hitbox narrows the type to Hitbox, it does not reach the owning actor.

Q3 — Why are the hitbox and hurtbox built as separate component scenes rather than baked into each actor?

A. Components render faster. B. A dependency-free component can be reused on the player, every enemy, and destructibles, and tested in isolation; baking it into actors duplicates and couples it. C. Godot requires Area2D to be in its own scene. D. To reduce the project's file count.

Reveal answer

B. The reuse and isolation are the point: one Hurtbox scene serves every damageable thing, and a fix applies everywhere at once. Baking the boxes into each actor copies the logic per actor and couples it to that actor's internals. A is false. C is false (areas work anywhere). D is backwards — components add files, and that is worth it.

Integration question

Q4 — open

Trace a complete player-hits-enemy event using only what this chapter built, and name the layer/mask bits that must be correct at each step. Then explain why the hurtbox, not the hitbox, was chosen as the detector, and what that choice means for an enemy that should flash and knock back when hit versus one that should only take damage.

Reveal expected answer

The event: the player's attack Hitbox (on layer player_hitbox/6, damage set, active during the swing) overlaps the enemy's Hurtbox (on enemy_hurtbox/5, mask including player_hitbox/6 because the hurtbox is the detector). The overlap fires the hurtbox's area_entered; it casts the area to Hitbox, reads damage, and emits hurt(damage), which the enemy's Health (M3.2) consumes. The bits that must be right: the hitbox must be on player_hitbox, and the hurtbox's mask must include player_hitbox; if either is wrong the signal never fires. The hurtbox was chosen as the detector so each actor owns its own reaction to being hit: the enemy's Hurtbox emits a generic hurt(amount), and that enemy decides what to do with it — one enemy's setup connects hurt only to its Health (take damage), another also connects it to a flash and a knockback (M3.4). Centralizing detection on the victim keeps the attacker dumb and lets reactions vary per actor without changing the hitbox at all.