Free Ebook cover Godot 4 for Beginners: Build a Small 2D Game from Scratch

Godot 4 for Beginners: Build a Small 2D Game from Scratch

New course

10 pages

Enemies, Hazards, and Simple AI Patterns for 2D Platformers

Capítulo 7

Estimated reading time: 12 minutes

+ Exercise

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 Area2D on the receiver (player/enemy) that detects incoming damage.
  • Hitbox: an Area2D on 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 App

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 it Hitbox)
  • 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 it Hurtbox)
  • 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 your Hitbox.tscn)
  • Optional: Hurtbox if 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 > 0

Tuning 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 > 0

Tuning 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 Hurtbox that listens for player hitboxes.
  • Player has a Hitbox enabled 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 TypeRecommended NodeWhyNotes
Spikes / lava / static danger zoneArea2D + CollisionShape2D + HitboxNo need for physics response; just detect overlapUse body_entered or hitbox/hurtbox overlap; can be one-way (only hurts player)
Moving sawblade on a pathArea2D (if it shouldn’t push) or CharacterBody2D (if it should collide)Area2D is simpler for “damage only”; body if it must block movementMany platformers keep it as Area2D to avoid pushing the player unexpectedly
Projectile (arrow/fireball)Area2D for overlap-based hit, or CharacterBody2D for solid collisionArea2D is easiest for fast bullets; CharacterBody2D if you need bouncing/ground collisionFor Area2D projectiles, add a separate raycast or use area_entered/body_entered to destroy on impact
Falling rock that blocks the playerRigidBody2D or CharacterBody2DNeeds physical interactionStill 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 = 1
  • knockback to something like Vector2(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
	pass

Detection: 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 Area2D named DetectionArea
  • 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.

Now answer the exercise about the content:

In a reusable damage system using Hitboxes and Hurtboxes, which setup best keeps collision detection separate from health and reactions?

You are right! Congratulations, now go to the next page

You missed! Try again.

The Hurtbox (Area2D) senses incoming Hitboxes and emits a damaged signal carrying a damage dictionary. The player/enemy script decides how to apply HP changes, knockback, and invincibility, keeping collision sensing reusable and separate.

Next chapter

UI, HUD, and Menus with Control Nodes in Godot 4

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.