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:
# 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: passcheck_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.
# 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")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.
# 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 pointsPlace 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.
# 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 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.
# 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 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.
# 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 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:
# 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 = nullGroup-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.


