All Scrolls

Godot 4 Enemy AI Tutorial: Patrol, Chase, and Attack

Coding Quests/February 21, 2026/Tutorials

Godot 4 Enemy AI Tutorial: Patrol, Chase, and Attack

Good enemy AI makes the difference between a game that feels alive and one that feels like target practice. But "good AI" doesn't mean complex. The best enemy behaviors in indie games - the ones that feel smart and fair - are built from simple states composed together.

This tutorial shows you how to build a complete 3D enemy AI system in Godot 4 using state machines, navigation, and detection areas. The result: an enemy that patrols, spots the player, chases them down, attacks when in range, and reacts to damage.

The Setup

Your enemy needs a few key nodes:

CharacterBody3D (Enemy)
├── StateMachine
│   ├── IdleState
│   ├── PatrolState
│   ├── ChaseState
│   ├── AttackState
│   └── StaggerState
├── NavigationAgent3D
├── DetectionArea (Area3D - large sphere)
├── AttackRange (Area3D - small sphere)
├── AnimationPlayer
└── CollisionShape3D

The NavigationAgent3D handles pathfinding. The DetectionArea is a large sphere collider that detects when the player is nearby. The AttackRange is a smaller sphere that triggers attacks when the player is close enough.

The Base State

Same pattern as a player state machine. Every AI state extends a base class:

GDScript
# ai_state.gd
class_name AIState
extends Node
var state_machine: StateMachine
var enemy: CharacterBody3D
var nav_agent: NavigationAgent3D
var player: CharacterBody3D # Set by detection area
func enter() -> void:
pass
func exit() -> void:
pass
func physics_update(delta: float) -> void:
pass
func check_transitions() -> void:
pass

The check_transitions method is where each state decides whether to hand off to a different state. This keeps transition logic inside the state that owns it, rather than in a central controller.

Idle State: Waiting With Awareness

The simplest state. The enemy stands still, plays an idle animation, and waits. After a random duration, it transitions to patrol. If it detects the player, it transitions to chase.

GDScript
# idle_state.gd
extends AIState
@export var min_idle_time: float = 2.0
@export var max_idle_time: float = 5.0
var idle_timer: float = 0.0
func enter() -> void:
idle_timer = randf_range(min_idle_time, max_idle_time)
enemy.get_node("AnimationPlayer").play("idle")
enemy.velocity = Vector3.ZERO
func physics_update(delta: float) -> void:
idle_timer -= delta
check_transitions()
func check_transitions() -> void:
if player:
state_machine.transition_to($"../ChaseState")
return
if idle_timer <= 0.0:
state_machine.transition_to($"../PatrolState")

The randf_range for idle duration is a small but important detail. Without it, every enemy idles for exactly the same time and they all move in sync - breaking the illusion of independent behavior.

Patrol State: Walking a Route

Patrol uses Godot's NavigationAgent3D to walk between waypoints. Waypoints can be Marker3D nodes placed in the level.

GDScript
# patrol_state.gd
extends AIState
@export var patrol_speed: float = 2.0
@export var waypoint_threshold: float = 1.0
var waypoints: Array[Marker3D] = []
var current_waypoint_index: int = 0
func enter() -> void:
enemy.get_node("AnimationPlayer").play("walk")
waypoints = _get_waypoints()
if waypoints.size() > 0:
_set_target(waypoints[current_waypoint_index].global_position)
func physics_update(delta: float) -> void:
if nav_agent.is_navigation_finished():
current_waypoint_index = (current_waypoint_index + 1) % waypoints.size()
_set_target(waypoints[current_waypoint_index].global_position)
var next_pos := nav_agent.get_next_path_position()
var direction := (next_pos - enemy.global_position).normalized()
direction.y = 0 # Keep movement horizontal
enemy.velocity = direction * patrol_speed
_face_direction(direction)
enemy.move_and_slide()
check_transitions()
func check_transitions() -> void:
if player:
state_machine.transition_to($"../ChaseState")
func _set_target(pos: Vector3) -> void:
nav_agent.target_position = pos
func _face_direction(dir: Vector3) -> void:
if dir.length() > 0.1:
enemy.look_at(enemy.global_position + dir)
func _get_waypoints() -> Array[Marker3D]:
var points: Array[Marker3D] = []
var group := enemy.get_node_or_null("Waypoints")
if group:
for child in group.get_children():
if child is Marker3D:
points.append(child)
return points

The waypoint system is simple: place Marker3D nodes under a "Waypoints" group node in your enemy scene. The patrol state cycles through them. The NavigationAgent3D handles obstacle avoidance automatically.

Chase State: Pursuing the Player

When the enemy detects the player, it switches to chase. Chase uses the same navigation system but targets the player's position and moves faster.

GDScript
# chase_state.gd
extends AIState
@export var chase_speed: float = 4.5
@export var attack_range: float = 2.0
@export var give_up_distance: float = 15.0
func enter() -> void:
enemy.get_node("AnimationPlayer").play("run")
func physics_update(delta: float) -> void:
if not player:
check_transitions()
return
nav_agent.target_position = player.global_position
var next_pos := nav_agent.get_next_path_position()
var direction := (next_pos - enemy.global_position).normalized()
direction.y = 0
enemy.velocity = direction * chase_speed
_face_direction(direction)
enemy.move_and_slide()
check_transitions()
func check_transitions() -> void:
if not player:
state_machine.transition_to($"../IdleState")
return
var distance := enemy.global_position.distance_to(player.global_position)
# Close enough to attack
if distance < attack_range:
state_machine.transition_to($"../AttackState")
return
# Too far away - give up
if distance > give_up_distance:
player = null
state_machine.transition_to($"../PatrolState")
func _face_direction(dir: Vector3) -> void:
if dir.length() > 0.1:
enemy.look_at(enemy.global_position + dir)

The give_up_distance is important for game feel. Without it, enemies chase forever. With it, the player can outrun an enemy and break engagement - which creates natural escape moments and lets the player control the pace of combat.

Attack State: Strike and Recover

When the enemy is in range, it attacks. The attack plays an animation, enables a hitbox during the swing, then waits for a recovery period before the next action.

GDScript
# attack_state.gd
extends AIState
@export var attack_cooldown: float = 1.5
var is_attacking: bool = false
var cooldown_timer: float = 0.0
@onready var hitbox: Area3D = enemy.get_node("HitboxComponent")
func enter() -> void:
is_attacking = true
cooldown_timer = attack_cooldown
enemy.velocity = Vector3.ZERO
# Face the player
if player:
enemy.look_at(Vector3(player.global_position.x, enemy.global_position.y, player.global_position.z))
enemy.get_node("AnimationPlayer").play("attack")
# Animation events enable/disable the hitbox
func physics_update(delta: float) -> void:
if not is_attacking:
cooldown_timer -= delta
if cooldown_timer <= 0.0:
check_transitions()
func check_transitions() -> void:
if not player:
state_machine.transition_to($"../IdleState")
return
var distance := enemy.global_position.distance_to(player.global_position)
if distance > 2.5:
state_machine.transition_to($"../ChaseState")
else:
# Attack again
enter()
# Called by animation event
func attack_finished() -> void:
is_attacking = false

The cooldown creates a window where the player can attack or dodge after the enemy swings. Without it, the enemy attacks every frame and combat feels unfair.

Stagger State: Reacting to Damage

When the enemy takes damage, it should react visually. A brief stagger state plays a hit animation, pauses the enemy, then returns to the previous behavior.

GDScript
# stagger_state.gd
extends AIState
var stagger_timer: float = 0.0
func enter() -> void:
stagger_timer = 0.5
enemy.velocity = Vector3.ZERO
enemy.get_node("AnimationPlayer").play("hit_react")
func physics_update(delta: float) -> void:
stagger_timer -= delta
if stagger_timer <= 0.0:
check_transitions()
func check_transitions() -> void:
if player:
state_machine.transition_to($"../ChaseState")
else:
state_machine.transition_to($"../IdleState")

The enemy's health component calls state_machine.transition_to(stagger_state) when damage is received. This interrupts whatever the enemy was doing and plays the reaction - making combat feel impactful.

Player Detection

The DetectionArea is an Area3D with a large SphereShape3D collider. When the player enters, all AI states get a reference:

GDScript
# enemy_controller.gd (on the root CharacterBody3D)
extends CharacterBody3D
@onready var detection_area: Area3D = $DetectionArea
@onready var state_machine: StateMachine = $StateMachine
func _ready() -> void:
detection_area.body_entered.connect(_on_player_detected)
detection_area.body_exited.connect(_on_player_lost)
func _on_player_detected(body: Node3D) -> void:
if body.is_in_group("player"):
for state in state_machine.get_children():
if state is AIState:
state.player = body
func _on_player_lost(body: Node3D) -> void:
if body.is_in_group("player"):
for state in state_machine.get_children():
if state is AIState:
state.player = null

Simple group-based detection. Add your player to the "player" group, and every enemy's detection area will find it automatically. No singleton references, no hardcoded paths.

Making AI Feel Smart

A few small additions make AI feel dramatically better:

Randomized attack delay. Don't attack the instant the player is in range. Add a 0.3–0.8 second delay. This gives the player time to react and makes the enemy feel like it's "deciding" to attack.

Circle strafing. Instead of running straight at the player and attacking, have the enemy circle to the side. It's a simple trigonometry trick that makes enemies look tactical.

Deceleration. Don't stop instantly when reaching the target. Lerp the velocity to zero over a few frames. It looks more natural and prevents the snapping-to-position feel.

Aggro leash. Give enemies a maximum chase distance from their spawn point. An enemy that chases you across the entire map breaks immersion.

Common Mistakes

All enemies acting in sync. Randomize idle times, patrol speeds, and attack delays. Even small variations break the hive-mind feel.

Navigation on every frame. NavigationAgent3D path recalculation is not free. Update the target position every 0.2–0.5 seconds, not every physics frame.

No hit reaction. Enemies that take damage with no visual feedback feel broken. Even a brief color flash or a 0.3-second stagger makes combat feel connected.

Forgetting Y-axis. When calculating distance for chase/attack ranges, consider whether you want 3D distance or horizontal-only distance. An enemy on a ledge above you shouldn't attack if they can't reach you.

Going Further

This tutorial covers a solid single-enemy AI. Production games need more:

  • Multiple attack types - light, heavy, ranged, area-of-effect
  • Group behavior - enemies flanking and surrounding the player
  • Boss patterns - phase transitions, telegraphed attacks, vulnerability windows
  • Difficulty scaling - adjusting aggression, damage, and reaction time
  • Performance - handling dozens of AI enemies efficiently

We built a 22-lesson State Machine AI course that covers all of this - from basic patrol to advanced combat AI with circle strafing, combo attacks, knockback physics, and performance optimization. Each lesson builds one behavior, and by the end you have a complete, reusable AI system for any 3D game.

godottutorialenemy-ai3d