Quick answer: Call get_next_path_position() every physics frame to advance the agent along its path, lower target_desired_distance to reduce early stopping, and defer your initial set_target_position() call with call_deferred() so the NavigationServer3D has had one frame to initialize before you query it.
Your enemy is supposed to chase the player across the level, but it freezes in place, slides to a stop a meter away from the target, or orbits endlessly around the destination without ever “arriving.” NavigationAgent3D is one of the most capable systems in Godot 4, but it requires you to wire up several parts correctly before any of it works.
The Symptom
Navigation problems in Godot 4 fall into a few clear categories:
- Agent doesn’t move at all —
get_next_path_position()always returns the agent’s own position - Agent stops too early — the character halts 0.5–2 meters away from the target instead of reaching it
- Agent gets stuck — the character oscillates or spins in place without progressing
- Agent walks through walls or falls off edges — the navigation mesh doesn’t cover the floor correctly
target_reachedsignal never fires — the agent reaches the vicinity of the target but the signal doesn’t emit
What Causes This
Not calling get_next_path_position() every physics frame. NavigationAgent3D does not move your character. It computes a path and exposes the next waypoint via get_next_path_position(). If you call this method once and store the result, your character moves to the first waypoint and stops. You must call it every _physics_process() frame to continuously advance along the path.
target_desired_distance is too large. This property defines the radius around the final target within which the agent considers the goal “reached.” The default is 1.0 meter. If you want the character to walk to within 10 centimeters of the target, you need to lower this value. A high target_desired_distance is the single most common cause of the “stops too early” complaint.
The navigation map hasn’t been baked before the first path query. NavigationServer3D processes navigation map updates asynchronously. When a scene loads, the server needs at least one physics frame to process the map and navigation region data. If you call set_target_position() in _ready(), the server may not have a valid map yet, and the path will be empty. The agent then has nowhere to go.
The navigation mesh doesn’t cover the floor. If the floor geometry isn’t included in the NavigationRegion3D’s source geometry, or the NavigationMesh was baked without the floor mesh selected, the server has no walkable surface and cannot generate paths at all. The agent will treat its own position as the only reachable point.
path_desired_distance is too large. This controls how close the agent must get to each intermediate waypoint before advancing to the next one. If set too high, the agent skips waypoints and takes shortcuts that may lead through walls, or it overshoots turns and gets confused.
The Fix: Core Movement Loop
Here is the correct, minimal movement loop for a CharacterBody3D using NavigationAgent3D:
extends CharacterBody3D
@export var move_speed: float = 4.0
@onready var nav_agent: NavigationAgent3D = $NavigationAgent3D
func _ready() -> void:
# Defer the first target set so NavigationServer3D has time to initialize
call_deferred("_set_initial_target")
func _set_initial_target() -> void:
nav_agent.set_target_position($Player.global_position)
func _physics_process(delta: float) -> void:
if nav_agent.is_navigation_finished():
return # Target reached — stop moving
# Get the next waypoint on the path — call this EVERY frame
var next_pos: Vector3 = nav_agent.get_next_path_position()
var direction: Vector3 = global_position.direction_to(next_pos)
velocity = direction * move_speed
move_and_slide()
Two critical points: call_deferred("_set_initial_target") ensures the target is set after the navigation server has processed the map, and get_next_path_position() is called every frame so the character follows the full path rather than heading for only the first waypoint.
Fixing the “Stops Too Early” Problem
Select your NavigationAgent3D node in the Inspector and review these two properties:
- Target Desired Distance: How close to the final target counts as “arrived.” Default is
1.0. For precision movement, set this to0.1or0.25. - Path Desired Distance: How close to each waypoint the agent must get before advancing. Default is
1.0. For tight corridors or precise pathing, set to0.5.
func _ready() -> void:
nav_agent.target_desired_distance = 0.25 # Stop within 25cm of the target
nav_agent.path_desired_distance = 0.5 # Advance to next waypoint within 50cm
call_deferred("_set_initial_target")
Using the target_reached Signal
Instead of polling distance every frame to check arrival, connect to the target_reached signal. This fires automatically when the agent considers the destination reached, based on target_desired_distance:
func _ready() -> void:
nav_agent.target_reached.connect(_on_target_reached)
nav_agent.target_desired_distance = 0.25
call_deferred("_set_initial_target")
func _on_target_reached() -> void:
print("Enemy reached the player!")
begin_attack_sequence()
If target_reached is never firing, the most likely cause is that target_desired_distance is set higher than the distance the agent actually gets to. Check with a debug print:
func _physics_process(delta: float) -> void:
var dist = global_position.distance_to(nav_agent.target_position)
print("Distance to target: ", dist,
" | is_finished: ", nav_agent.is_navigation_finished())
# ... movement code
If the distance printed is, say, 0.3 but is_navigation_finished() returns false, your target_desired_distance is set below 0.3 and the agent doesn’t think it has arrived yet. Increase target_desired_distance slightly, or lower it and improve your movement speed so the agent can actually close the distance.
Setting Up the NavigationRegion3D and Baking
If the agent doesn’t move at all, check that the navigation mesh is baked and covers the floor:
- Add a
NavigationRegion3Dnode to your scene and assign aNavigationMeshresource to it. - In the
NavigationMeshresource, set Geometry → Source Geometry Mode to Root Node Children or Group With Children so the bake includes your floor mesh. - Click Bake NavigationMesh in the toolbar (or call it from code:
$NavigationRegion3D.bake_navigation_mesh()). - Verify the blue mesh overlay covers the floor in the editor. Gaps or missing areas indicate geometry that wasn’t included.
To bake at runtime (for procedurally generated levels), wait for baking to finish before setting target positions:
extends Node3D
@onready var nav_region: NavigationRegion3D = $NavigationRegion3D
@onready var enemy: CharacterBody3D = $Enemy
func _ready() -> void:
nav_region.bake_finished.connect(_on_nav_bake_finished)
nav_region.bake_navigation_mesh()
func _on_nav_bake_finished() -> void:
print("Navigation mesh baked — spawning enemies now")
enemy.$NavigationAgent3D.set_target_position($Player.global_position)
Continuously Chasing a Moving Target
For an enemy that chases a moving player, update the target position each frame (or at a lower rate to save performance) and let the navigation system recalculate the path automatically:
extends CharacterBody3D
@export var move_speed: float = 3.5
@export var path_update_interval: float = 0.25 # Recalculate 4 times per second
@onready var nav_agent: NavigationAgent3D = $NavigationAgent3D
var _time_since_update: float = 0.0
var player: Node3D # Set this reference however suits your architecture
func _physics_process(delta: float) -> void:
_time_since_update += delta
if _time_since_update >= path_update_interval:
_time_since_update = 0.0
if player:
nav_agent.set_target_position(player.global_position)
if nav_agent.is_navigation_finished():
velocity = Vector3.ZERO
move_and_slide()
return
var next_pos = nav_agent.get_next_path_position()
var dir = global_position.direction_to(next_pos)
velocity = dir * move_speed
move_and_slide()
Recalculating every frame is expensive for large numbers of enemies. The path_update_interval approach keeps the path current while limiting the server query rate. For 20+ enemies on screen at once, stagger the updates by offsetting the initial timer value per enemy so they don’t all recalculate on the same frame.
Related Issues
Navigation movement is affected by physics precision. If your character reaches the target correctly but visually judders while walking, see Fix: Godot Physics Interpolation Causing Jitter. For signal-based issues where target_reached connects but the handler never fires, see Fix: Godot Signal.disconnect() Throwing “Invalid Connection” Error.
NavigationAgent3D plans the route—you still have to drive the car.