What We’re Building: Enemies, Hazards, and a Reusable Damage System
In a 2D platformer, “danger” usually comes from two sources: enemies (that move and react) and hazards (that simply hurt you when touched). The key to keeping your project maintainable is to standardize how damage is dealt and received, so you don’t write one-off collision code for every spike, projectile, and enemy.
This chapter focuses on three goals:
- Create an enemy scene with patrol behavior (using raycasts or boundary markers).
- Implement damage interactions with the player using groups + signals.
- Choose the right node type for hazards (Area2D vs physics bodies) and keep AI logic separated from movement and collision response.
Standardizing Damage: “Hurtboxes” and “Hitboxes” with Groups
A practical pattern is to separate “who can be hurt” from “what deals damage”:
- Hurtbox: an
Area2Don the receiver (player/enemy) that detects incoming damage. - Hitbox: an
Area2Don the attacker/hazard that represents the damaging region.
We’ll standardize interactions using:
- Groups (e.g.,
hurtbox,hitbox) to identify roles. - Signals to notify the owning character when damage happens.
Damage Data: Use a Small Dictionary
Instead of passing only a number, pass a dictionary so you can extend later (knockback, invincibility, source, etc.). Example:
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
{
"amount": 1,
"knockback": Vector2(200, -250),
"source": self
}Step-by-Step: Create a Reusable Hitbox (Damage Dealer)
1) Hitbox Scene
Create a new scene Hitbox.tscn:
- Root:
Area2D(name itHitbox) - Child:
CollisionShape2D
In the Inspector, set the collision layer/mask so it overlaps with hurtboxes. (Exact layer numbers depend on your project; the important part is that hitboxes and hurtboxes can detect each other.)
2) Script: Hitbox.gd
extends Area2D
class_name Hitbox
@export var damage_amount: int = 1
@export var knockback: Vector2 = Vector2.ZERO
func get_damage_data() -> Dictionary:
return {
"amount": damage_amount,
"knockback": knockback,
"source": self
}
func _ready() -> void:
add_to_group("hitbox")This hitbox doesn’t decide who to damage; it only provides damage data. The receiver decides what to do with it.
Step-by-Step: Create a Reusable Hurtbox (Damage Receiver Sensor)
1) Hurtbox Scene
Create Hurtbox.tscn:
- Root:
Area2D(name itHurtbox) - Child:
CollisionShape2D
2) Script: Hurtbox.gd
extends Area2D
class_name Hurtbox
signal damaged(damage_data: Dictionary)
@export var invincible: bool = false
func _ready() -> void:
add_to_group("hurtbox")
area_entered.connect(_on_area_entered)
func _on_area_entered(area: Area2D) -> void:
if invincible:
return
if area.is_in_group("hitbox") and area.has_method("get_damage_data"):
damaged.emit(area.get_damage_data())Now any character can add a Hurtbox and connect damaged to its own health/knockback logic. This keeps collision detection out of your player/enemy scripts.
Enemy Scene: Patrol with RayCasts or Boundary Markers
A classic platformer enemy patrols left/right and turns around when it reaches an edge or hits a wall. Two common approaches:
- Raycasts: detect floor ahead and walls (good for “walk until cliff” behavior).
- Boundary markers: place two points in the level and move between them (good for designer-controlled patrol routes).
Option A: Patrol Using RayCasts (Edge + Wall Detection)
Enemy Scene Setup
Create EnemyPatrol.tscn:
- Root:
CharacterBody2D(name:EnemyPatrol) - Child:
Sprite2D(or AnimatedSprite2D) - Child:
CollisionShape2D - Child:
RayCast2D(name:FloorRay) pointing diagonally down-forward - Child:
RayCast2D(name:WallRay) pointing forward - Child:
Hitbox(instance yourHitbox.tscn) - Optional:
Hurtboxif the enemy can be damaged by the player
Place FloorRay so it starts near the enemy’s feet and points forward/down. Place WallRay at body height pointing forward. Enable both raycasts.
Separation of Responsibilities
- AI decision logic: decide desired direction (left/right) based on raycasts.
- Movement: apply velocity and call
move_and_slide(). - Collision response: react to walls/edges by flipping direction; react to damage via Hurtbox signal.
EnemyPatrol.gd (Raycast Patrol)
extends CharacterBody2D
@export var speed: float = 60.0
@export var gravity: float = 900.0
@export var start_direction: int = -1 # -1 left, +1 right
@onready var floor_ray: RayCast2D = $FloorRay
@onready var wall_ray: RayCast2D = $WallRay
var direction: int
func _ready() -> void:
direction = start_direction
func _physics_process(delta: float) -> void:
_apply_gravity(delta)
_ai_decide_direction()
_apply_movement()
move_and_slide()
func _apply_gravity(delta: float) -> void:
if not is_on_floor():
velocity.y += gravity * delta
else:
velocity.y = 0.0
func _ai_decide_direction() -> void:
# Turn around if there is no floor ahead or a wall ahead
var no_floor_ahead := not floor_ray.is_colliding()
var wall_ahead := wall_ray.is_colliding()
if no_floor_ahead or wall_ahead:
direction *= -1
_flip_rays()
func _apply_movement() -> void:
velocity.x = direction * speed
func _flip_rays() -> void:
# Mirror raycast target positions when direction changes
floor_ray.target_position.x *= -1
wall_ray.target_position.x *= -1
# Optional: flip visuals
if has_node("Sprite2D"):
$Sprite2D.flip_h = direction > 0Tuning in the Inspector: speed, gravity, and start_direction are exported so you can tweak per enemy instance.
Option B: Patrol Using Boundary Markers (Patrol Distance / Points)
If you want predictable patrol routes, use two markers. This avoids raycast tuning and makes level design faster.
Enemy Scene Setup (Markers)
In EnemyPatrol.tscn, add:
Node2D(name:Patrol)- Children of Patrol:
Marker2D(name:Left),Marker2D(name:Right)
Position markers in the editor to define the patrol segment. Alternatively, expose a patrol_distance and compute endpoints automatically.
EnemyPatrol.gd (Marker Patrol)
extends CharacterBody2D
@export var speed: float = 60.0
@export var gravity: float = 900.0
@export var patrol_distance: float = 160.0
@export var use_markers: bool = false
@onready var left_marker: Marker2D = $Patrol/Left
@onready var right_marker: Marker2D = $Patrol/Right
var direction: int = -1
var left_x: float
var right_x: float
func _ready() -> void:
if use_markers:
left_x = left_marker.global_position.x
right_x = right_marker.global_position.x
else:
left_x = global_position.x - patrol_distance * 0.5
right_x = global_position.x + patrol_distance * 0.5
func _physics_process(delta: float) -> void:
_apply_gravity(delta)
_ai_decide_direction()
_apply_movement()
move_and_slide()
func _apply_gravity(delta: float) -> void:
if not is_on_floor():
velocity.y += gravity * delta
else:
velocity.y = 0.0
func _ai_decide_direction() -> void:
if global_position.x <= left_x:
direction = 1
elif global_position.x >= right_x:
direction = -1
func _apply_movement() -> void:
velocity.x = direction * speed
if has_node("Sprite2D"):
$Sprite2D.flip_h = direction > 0Tuning variables exposed: speed, patrol_distance, and use_markers. Designers can either drag markers or just set a distance.
Damage Interactions: Enemy Hurts Player, Player Hurts Enemy
With the Hitbox/Hurtbox pattern, damage becomes “plug-and-play.”
Enemy Damages Player
Instance a Hitbox as a child of the enemy and size it to match the enemy’s body (or only the dangerous parts). When the player’s Hurtbox overlaps it, the player receives the damaged signal.
Player Damages Enemy (Jumping on Head Example)
Create a separate hitbox on the player (e.g., a downward stomp hitbox) or an enemy “head hurtbox” that triggers a special response. A simple approach:
- Enemy has a
Hurtboxthat listens for player hitboxes. - Player has a
Hitboxenabled only during attack frames (or always enabled for a stomp area under the feet).
Because both are just Areas, you can toggle monitoring on the player’s hitbox when attacking.
Connecting Hurtbox to Health: Using Signals Cleanly
Each character owns its health and reactions. The Hurtbox only detects overlap and emits a signal.
Example: Player Script Hook
# In Player.gd (snippet)
@export var max_hp: int = 3
var hp: int
func _ready() -> void:
hp = max_hp
$Hurtbox.damaged.connect(_on_hurtbox_damaged)
func _on_hurtbox_damaged(damage_data: Dictionary) -> void:
hp -= int(damage_data.get("amount", 1))
var kb: Vector2 = damage_data.get("knockback", Vector2.ZERO)
if kb != Vector2.ZERO:
# Apply knockback in a way that fits your movement controller
velocity = kb
# Optional: temporary invincibility
$Hurtbox.invincible = true
get_tree().create_timer(0.4).timeout.connect(func():
$Hurtbox.invincible = false
)This keeps “damage rules” (invincibility time, knockback, death) in the player script, not in every hazard.
Example: Enemy Script Hook
# In EnemyPatrol.gd (snippet)
@export var max_hp: int = 1
var hp: int
func _ready() -> void:
hp = max_hp
if has_node("Hurtbox"):
$Hurtbox.damaged.connect(_on_hurtbox_damaged)
func _on_hurtbox_damaged(damage_data: Dictionary) -> void:
hp -= int(damage_data.get("amount", 1))
if hp <= 0:
queue_free()Area2D vs Physics Bodies for Hazards (Spikes, Projectiles)
Choosing the right node type prevents jittery collisions and simplifies logic.
| Hazard Type | Recommended Node | Why | Notes |
|---|---|---|---|
| Spikes / lava / static danger zone | Area2D + CollisionShape2D + Hitbox | No need for physics response; just detect overlap | Use body_entered or hitbox/hurtbox overlap; can be one-way (only hurts player) |
| Moving sawblade on a path | Area2D (if it shouldn’t push) or CharacterBody2D (if it should collide) | Area2D is simpler for “damage only”; body if it must block movement | Many platformers keep it as Area2D to avoid pushing the player unexpectedly |
| Projectile (arrow/fireball) | Area2D for overlap-based hit, or CharacterBody2D for solid collision | Area2D is easiest for fast bullets; CharacterBody2D if you need bouncing/ground collision | For Area2D projectiles, add a separate raycast or use area_entered/body_entered to destroy on impact |
| Falling rock that blocks the player | RigidBody2D or CharacterBody2D | Needs physical interaction | Still can carry a Hitbox to deal damage on contact |
Step-by-Step: Spikes as an Area2D Hazard
1) Spike Scene
- Root:
Area2D(name:Spikes) - Child:
Sprite2D - Child:
CollisionShape2D - Child:
Hitbox(instance) sized to the spike tips
2) Configure Damage
On the Hitbox instance, set:
damage_amount = 1knockbackto something likeVector2(0, -300)if you want a bounce effect
No script is required on spikes if you rely purely on hitbox/hurtbox overlap.
Step-by-Step: Simple Projectile (Area2D) with Lifetime and Impact
Projectile Scene Setup
- Root:
Area2D(name:Projectile) - Child:
Sprite2D - Child:
CollisionShape2D - Child:
Hitbox
Projectile.gd
extends Area2D
@export var speed: float = 260.0
@export var direction: Vector2 = Vector2.RIGHT
@export var lifetime: float = 2.0
func _ready() -> void:
# Destroy after a while
get_tree().create_timer(lifetime).timeout.connect(queue_free)
area_entered.connect(_on_area_entered)
body_entered.connect(_on_body_entered)
func _physics_process(delta: float) -> void:
global_position += direction.normalized() * speed * delta
func _on_area_entered(area: Area2D) -> void:
# If we hit a hurtbox, damage will be handled by the hurtbox itself.
# We still usually want to destroy the projectile.
if area.is_in_group("hurtbox"):
queue_free()
func _on_body_entered(body: Node) -> void:
# Hit a wall/ground (TileMap collisions are bodies)
queue_free()Why Area2D here? It’s common for projectiles to be overlap-based to avoid tunneling issues and complicated physics responses. If you need solid collision and sliding, switch to CharacterBody2D and use move_and_collide().
Using Groups to Filter What Gets Damaged
Sometimes you want hazards to hurt only the player, not enemies. Add a group to your player root node (e.g., player) and check it in the Hurtbox receiver or in the hazard logic.
Pattern: Receiver-Side Filtering
Keep the hazard generic; the receiver decides whether to accept damage from that source.
# Example inside Player.gd
func _on_hurtbox_damaged(damage_data: Dictionary) -> void:
var source := damage_data.get("source", null)
# Optional: ignore damage from friendly sources
# if source and source.is_in_group("player_attack"):
# return
...Pattern: Dealer-Side Filtering
For a projectile fired by an enemy, you can tag it:
- Add projectile root to group
enemy_attack - Player accepts it; enemies ignore it
# Example inside EnemyPatrol.gd damage handler
func _on_hurtbox_damaged(damage_data: Dictionary) -> void:
var source := damage_data.get("source", null)
if source and source.is_in_group("enemy_attack"):
return
...Keeping AI, Movement, and Collision Response Separate
As enemies get more complex, mixing everything in one block becomes hard to tune. Use a consistent structure:
- AI decision: sets intent (direction, chase, idle) and target values.
- Movement: converts intent into velocity/acceleration.
- Collision response: reacts to environment (turn around, stop, fall) and damage events.
Example Skeleton You Can Reuse
func _physics_process(delta: float) -> void:
_update_sensors()
_ai_update(delta)
_movement_update(delta)
_collision_response_update()
move_and_slide()
func _update_sensors() -> void:
# raycasts, detection areas, etc.
pass
func _ai_update(delta: float) -> void:
# decide direction/state
pass
func _movement_update(delta: float) -> void:
# apply gravity, set velocity.x, etc.
pass
func _collision_response_update() -> void:
# flip direction after wall/edge decisions, handle hit reactions
passDetection: Adding a Player Detection Area (Simple “Chase When Close”)
Patrol is often combined with a basic detection radius. Use an Area2D as a sensor that does not deal damage; it only informs AI.
Enemy Scene Additions
- Add child
Area2DnamedDetectionArea - Add
CollisionShape2D(circle) sized to detection range
EnemyPatrol.gd (Detection Variables + AI)
@export var detection_enabled: bool = true
@export var detection_range: float = 120.0
@export var chase_speed: float = 90.0
@onready var detection_area: Area2D = $DetectionArea
var player_in_range: bool = false
var player_ref: Node2D = null
func _ready() -> void:
# ... existing setup
if detection_area.has_node("CollisionShape2D"):
var shape := detection_area.get_node("CollisionShape2D").shape
if shape is CircleShape2D:
shape.radius = detection_range
detection_area.body_entered.connect(func(body: Node):
if body.is_in_group("player"):
player_in_range = true
player_ref = body
)
detection_area.body_exited.connect(func(body: Node):
if body == player_ref:
player_in_range = false
player_ref = null
)
func _ai_decide_direction() -> void:
if detection_enabled and player_in_range and player_ref:
direction = sign(player_ref.global_position.x - global_position.x)
return
# otherwise use patrol logic (raycasts or markers)
# ...Tuning variables exposed: detection_enabled, detection_range, chase_speed. You can also switch speed when chasing by setting velocity.x = direction * chase_speed while the player is in range.