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:
# ai_state.gdclass_name AIStateextends Nodevar state_machine: StateMachinevar enemy: CharacterBody3Dvar nav_agent: NavigationAgent3Dvar player: CharacterBody3D # Set by detection areafunc enter() -> void: passfunc exit() -> void: passfunc physics_update(delta: float) -> void: passfunc check_transitions() -> void: passThe 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.
# idle_state.gdextends AIState@export var min_idle_time: float = 2.0@export var max_idle_time: float = 5.0var idle_timer: float = 0.0func enter() -> void: idle_timer = randf_range(min_idle_time, max_idle_time) enemy.get_node("AnimationPlayer").play("idle") enemy.velocity = Vector3.ZEROfunc 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.
# patrol_state.gdextends AIState@export var patrol_speed: float = 2.0@export var waypoint_threshold: float = 1.0var waypoints: Array[Marker3D] = []var current_waypoint_index: int = 0func 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 = posfunc _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 pointsThe 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.
# chase_state.gdextends AIState@export var chase_speed: float = 4.5@export var attack_range: float = 2.0@export var give_up_distance: float = 15.0func 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.
# attack_state.gdextends AIState@export var attack_cooldown: float = 1.5var is_attacking: bool = falsevar 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 hitboxfunc 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 eventfunc attack_finished() -> void: is_attacking = falseThe 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.
# stagger_state.gdextends AIStatevar stagger_timer: float = 0.0func 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:
# enemy_controller.gd (on the root CharacterBody3D)extends CharacterBody3D@onready var detection_area: Area3D = $DetectionArea@onready var state_machine: StateMachine = $StateMachinefunc _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 = bodyfunc _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 = nullSimple 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.