Coding Quests
The Scroll Library
Tutorials

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

February 21, 2026Updated June 11, 20267 min read

Good enemy AI is the difference between a game that feels alive and a shooting gallery. And here's the part that took me embarrassingly long to learn: good doesn't mean complex. The enemies that feel smart in indie games are almost always a handful of dumb states composed well.

This tutorial builds a complete 3D enemy AI system in Godot 4 using state machines, navigation, and detection areas. By the end you'll have an enemy that patrols, spots the player, chases, attacks in range, and flinches when hit.

The Enemy Scene 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 big sphere collider that notices when the player gets close. The AttackRange is a smaller sphere that triggers attacks. Two spheres, two jobs. Keep them separate and the logic stays clean.

The Base AI State

Same pattern as a player state machine, and if you haven't built one of those yet, the Godot 4 state machine tutorial covers the pattern from scratch. 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

check_transitions is where each state decides whether to hand off to a different state. Keeping transition logic inside the state that owns it beats a central controller stuffed with nested ifs. Trust me on this one. I've written the other version.

Idle State: Waiting With Awareness

The simplest state. The enemy stands still, plays an idle animation, waits. After a random duration it moves on to patrol, and if it spots the player it goes straight 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")

That randf_range on the idle time looks like a throwaway detail. It isn't. Without it, every enemy in the room idles for exactly the same duration and they all start walking in sync like a marching band. Randomize it and they instantly read as individuals.

Patrol State: Walking a Route

Patrol uses Godot's NavigationAgent3D to walk between waypoints, which are just 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

Place Marker3D nodes under a "Waypoints" node in the enemy scene and the patrol state cycles through them. The NavigationAgent3D handles obstacle avoidance on its own, which is most of the reason to use it.

Chase State: Pursuing the Player

When the enemy detects the player, it switches to chase. Same navigation system, but the target is the player's position and the speed goes up.

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 doing quiet work for game feel. Without it, enemies chase forever and every encounter becomes a marathon. With it, the player can break engagement by running, which means they control the pacing of combat. One export variable, and the whole game plays differently.

Attack State: Strike and Recover

When the enemy is in range, it attacks: play an animation, enable a hitbox during the swing, then sit in a recovery window before acting again.

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 is the player's turn. After the enemy swings, there's a 1.5 second window to punish or reposition. Cut that cooldown and the enemy attacks every frame, and combat stops being a conversation and becomes a blender.

Stagger State: Reacting to Damage

When the enemy takes damage, it should show it. A brief stagger state plays a hit animation, freezes the enemy, then hands back to normal 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 lands. It interrupts whatever the enemy was doing and plays the reaction. Half a second of stagger is enough to make every hit feel like it connected.

Player Detection

The DetectionArea is an Area3D with a large SphereShape3D collider. When the player walks in, every AI state gets 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

Group-based detection, nothing fancy. Add your player to the "player" group and every enemy's detection area finds it automatically. No singletons, no hardcoded node paths that snap the moment you reorganize a scene.

How to Make Enemy AI Feel Smart

A few small touches make this basic AI read as far smarter than it is.

Don't attack the instant the player enters range. Add a randomized 0.3 to 0.8 second delay first. The player gets a beat to react, and the enemy looks like it's deciding to attack instead of executing a trigger.

Circle strafing is the other big one. Instead of running straight in and swinging, have the enemy sidestep around the player before committing. It's basic trigonometry, and it makes enemies look tactical.

Two more cheap wins. Lerp velocity to zero over a few frames instead of stopping dead, so enemies don't snap into position. And give every enemy an aggro leash, a maximum chase distance from its spawn point. An enemy that follows you across the entire map isn't persistent, it's broken.

Common Enemy AI Mistakes

All enemies acting in sync is the one everybody hits first. Randomize idle times, patrol speeds, and attack delays. Even small variation kills the hive-mind look.

Recalculating navigation every physics frame is next. NavigationAgent3D path recalculation is not free. Update the target position every 0.2 to 0.5 seconds instead of every frame. Nobody will notice, and your frame time will thank you.

No hit reaction makes combat feel disconnected even when the numbers all work. A color flash or a 0.3 second stagger is the minimum bar.

And watch the Y axis when you check distances. An enemy on a ledge above the player can be "in attack range" by 3D distance while having no way to actually land a hit. Decide per check whether you want full 3D distance or horizontal-only.

Going Further

This gets you a solid single enemy. A shipping game wants more: multiple attack types, group behavior so enemies flank instead of forming a polite queue, boss patterns with phases and telegraphed attacks, difficulty scaling, and performance work once dozens of agents are alive at once. If you're heading toward full souls-like combat, the souls-like architecture guide shows where this AI plugs into the bigger picture.

We also built a 22-lesson State Machine AI quest that covers all of it, from basic patrol to combat AI with circle strafing, combo attacks, knockback physics, and optimization. Each lesson builds one behavior, and by the end you have a reusable AI system for any 3D game. Your enemies stop being target practice. Go give them a brain.

FAQ

Should I use NavigationAgent3D or move_toward for enemy movement?

Use NavigationAgent3D whenever the level has obstacles the enemy needs to path around, and remember it needs a NavigationRegion3D with a baked navigation mesh to work at all. For open arenas with nothing in the way, steering straight at the player with plain velocity math is simpler and cheaper. Plenty of games mix both: pathfinding at range, direct movement up close.

How do enemies detect the player in Godot 4?

The standard setup is an Area3D with a sphere collider plus a group check, exactly like the detection code above. To stop enemies from seeing through walls, add a RayCast3D line-of-sight check before confirming detection. The distance check says "close enough," the ray says "actually visible."

Do I need a behavior tree instead of a state machine?

For most indie enemies, no. A state machine handles patrol, chase, attack, and stagger cleanly, and it's far easier to debug when something misbehaves. Behavior trees earn their complexity once you need deeply nested decision making, things like squad tactics or dozens of interruptible behaviors per enemy.

godottutorialenemy-ai3d

Stop reading. Start building.

Beat the demo boss by writing real Godot code, then build this for real in the State Machine AI Quest.

Free, and no card needed. Built by a real person, with new quests every month.

Get the next Godot build in your inbox

New quests, project breakdowns, and game-dev tips. Free, no spam, unsubscribe anytime.

Written by Coding Quests

We teach Godot 4 by making you build complete systems: inventories, save systems, action roguelike controllers, enemy AI. The scrolls are free. The quests are where it sticks.