doc 5 of 6

Part 04 — the agent that follows

You will build: An enemy CharacterBody2D driven by a NavigationAgent2D that chases the player across the navigation mesh from Part 03, arrives and stops dead instead of vibrating in place, and a second enemy that slides past the first without either of them locking up.

Part 03 left you with a mesh and a one-shot query. You could ask the navigation server for a path between two points and draw it. That is enough for a door, a patrol, a click-to-move cursor. It is not enough for something that hunts you, because a hunted target moves and the path goes stale the instant you compute it.

NavigationAgent2D is the node for that case. It is also the node that produces more confused forum posts than any other in the navigation system, because three separate things about it are counter-intuitive and all three fail silently.

The node is not a mover#

Start here, because every symptom later in this part is misread by people who skip it.

NavigationAgent2D does not move your character. It does not write to velocity. It does not call move_and_slide(). It has no idea your body exists beyond reading its global position. Adding it to a scene and setting a target produces exactly zero motion, forever, and that is correct behaviour.

What it actually is: a path cache with a waypoint state machine attached. It holds the most recent path the navigation server gave it, it remembers which waypoint along that path you are currently heading toward, and it advances that bookmark as you get close. You still write all the movement code. The agent tells you where to point.

Hold onto that. When the agent "does not work", the question is almost never "why is the agent broken" and almost always "what is my movement code doing with what the agent handed me".

The scene#

Build an enemy scene:

Enemy            (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
└── NavigationAgent2D

Put the player in a group called player (Node dock → Groups tab → add player). The enemy will find it that way rather than through an exported NodePath, so you can drop enemies into a level without wiring each one.

Add the enemy as a child of whatever node holds your NavigationRegion2D or navigation-enabled TileMapLayer scene from Part 03. The agent picks up the default navigation map of the world it is in, so a body sitting in the same scene as the mesh needs no configuration to find it.

The naive version, which does nothing#

Write this. It is the version almost everyone writes first, and watching it fail is the point.

extends CharacterBody2D

@export var speed: float = 90.0

@onready var agent: NavigationAgent2D = $NavigationAgent2D

var player: Node2D


func _ready() -> void:
	player = get_tree().get_first_node_in_group("player")
	agent.target_position = player.global_position


func _physics_process(_delta: float) -> void:
	var next_point: Vector2 = agent.get_next_path_position()
	velocity = global_position.direction_to(next_point) * speed
	move_and_slide()

Run it. The enemy sits still. No error, no warning, no red text in the output panel. The agent reports nothing because nothing went wrong from its point of view.

This is the first-frame map synchronisation problem from Part 03, wearing a disguise. The navigation server processes changes during its own sync phase, which happens with the physics step — not when you call it. During _ready() the map may still be empty. The path query goes out against an empty map, comes back empty, and the agent stores an empty path. Nothing ever asks it to try again, because you set the target exactly once.

In Part 03 this failure was visible: map_get_path() handed you an empty PackedVector2Array and you could print its size. Here the failure hides inside the node. get_next_path_position() on an agent with no path returns the agent's own position, so direction_to() gives you a zero vector, so velocity is zero, so the enemy stands there looking deliberate.

The documented guard is a check on the map's iteration counter:

if NavigationServer2D.map_get_iteration_id(agent.get_navigation_map()) == 0:
	return

An iteration id of 0 means the map has never synchronised. Query it and you get nothing. Wait a frame and try again.

For a chaser you do not need that guard as a special case, because a chaser re-targets continuously anyway. Move the target assignment into _physics_process and the problem dissolves: the first frame or two produce an empty path, and the third one works.

The guard earns its place when the target is assigned once — a patrol route, a "walk to the exit" command issued at level start. Keep it in your notes for that case.

The per-frame contract#

Here is the version that walks:

func _physics_process(_delta: float) -> void:
	agent.target_position = player.global_position

	var next_point: Vector2 = agent.get_next_path_position()
	velocity = global_position.direction_to(next_point) * speed
	move_and_slide()

Two things in those four lines are contracts, not conveniences, and both are worth understanding before you build anything on top of them.

Assigning target_position queues a recompute. It does not produce a path. The assignment returns immediately and the agent's path is still the old one. The new path lands after the server's next sync. That is why setting a target and immediately reading the result gives you stale or empty data — there is nothing to read yet. Everything about this node's timing follows from that one fact.

get_next_path_position() is not a getter. The name reads like one. It is not. Calling it advances the agent's internal path logic — it is the tick function for the waypoint state machine, and the return value is a side effect of ticking it. The class reference is explicit that it must be used once every physics frame after setting a target.

Once. Not zero times, not twice.

Call it zero times in a frame — say, because you wrapped it in an if can_move: that is sometimes false — and the agent never notices it has arrived at the current waypoint, so it never advances to the next one. Your enemy walks to the first corner and grinds against the wall there.

Call it twice — say, once to compute a direction and once to compute a facing angle — and you tick the state machine twice per frame. Waypoints get consumed faster than you cover them, the agent skips corners it should have rounded, and paths through tight geometry go through walls.

If you need the value more than once, store it in a local:

@onready var sprite: Sprite2D = $Sprite2D

# ...then, inside _physics_process:
var next_point: Vector2 = agent.get_next_path_position()
var to_next: Vector2 = global_position.direction_to(next_point)
velocity = to_next * speed
sprite.rotation = to_next.angle()

One call, two uses. That is the shape to reach for every time.

Steering with global_position.direction_to(next_point) matters too: the waypoint comes back in global space. Subtracting position instead of global_position works only while the enemy sits at the scene root with no parent transform, which is exactly the setup you have right now and exactly the setup that changes the moment you nest enemies under a spawner node.

Arrival, and why the enemy vibrates#

Run the chasing version and stand still. The enemy reaches you and then shakes — a fast, tight jitter that never settles.

The instinct is to add an arrival check after the movement:

	move_and_slide()
	if agent.is_target_reached():
		velocity = Vector2.ZERO

That does not fix it, and understanding why is the whole lesson.

The jitter is not a movement problem. It is caused by continuing to call get_next_path_position() after the path has ended. Every call past the end triggers a path update, every update produces a slightly different result, and the body chases that noise. The docs say this outright: calling it past the end of the path makes the agent jitter in place due to the repeated path updates.

So the fix is not to correct the movement afterward. The fix is to not tick the state machine at all:

func _physics_process(_delta: float) -> void:
	agent.target_position = player.global_position

	if agent.is_navigation_finished():
		velocity = Vector2.ZERO
		return

	var next_point: Vector2 = agent.get_next_path_position()
	velocity = global_position.direction_to(next_point) * speed
	move_and_slide()

The guard sits before the call, very early in the function. It is not an arrival check. It is a permission check on whether you are allowed to tick the agent this frame.

That reordering is the single most useful thing in this part. An arrival check after moving reads as more careful — you moved, then you looked. It is the wrong shape, and the symptom it produces looks like a physics bug rather than a call-order bug, which is why it costs people an afternoon.

Note the velocity = Vector2.ZERO before the return. Without it, velocity keeps whatever value it had last frame. Nothing moves, because move_and_slide() is skipped, but anything else reading velocity — an animation tree, a footstep emitter, a sprite flip — believes the enemy is still sprinting.

Tuning the two distances against your sprite#

The agent exposes two radii, both in pixels, both with defaults chosen for a body larger than most 2D characters:

  • path_desired_distance (default 20.0) — how close you must get to an intermediate waypoint before the agent advances to the next one.
  • target_desired_distance (default 10.0) — how close to the final target counts as arrived.

For a 16-pixel character those numbers are enormous. target_desired_distance of 10 means the enemy stops a full body-width short of you, which reads on screen as timidity rather than as a tuning value. path_desired_distance of 20 means the enemy declares a corner waypoint "reached" while still more than a tile away from it, cuts the turn early, and swings wide into the wall it was supposed to round.

Do not copy a number from here. Reason it out from your own agent, because the deciding quantity is not the sprite size on its own.

Compute how far your agent travels in one physics frame. At speed = 90.0 and 60 physics ticks per second, that is 1.5 pixels per frame. Now the two failure modes have a shape:

  • Radius much larger than the sprite → stops short, cuts corners, swings wide.
  • Radius smaller than one frame of travel → the agent steps clean over the acceptance zone every frame and never lands inside it. It never registers arrival. It orbits or oscillates.

So the usable window is roughly: larger than the distance covered in a frame, smaller than the character's own radius. At 90 px/s with a 16-pixel sprite, something in the 4–8 range for both will behave. At 400 px/s the floor rises to about 7 pixels per frame and a value of 4 is guaranteed jitter — which is why a value that worked fine on the slow patrol enemy breaks on the fast one, and why the bug is usually reported as "fast enemies are broken".

Set both in the inspector, or in code:

func _ready() -> void:
	player = get_tree().get_first_node_in_group("player")
	agent.path_desired_distance = 6.0
	agent.target_desired_distance = 6.0

Change the enemy's speed to 400.0, watch it fail with those values, then raise them. Feeling that relationship once is worth more than any recommended number.

target_reached is not navigation_finished#

Two signals, similar names, and the difference bites exactly once — confusingly, and usually at the worst moment.

  • navigation_finished() fires when the agent has run out of path.
  • target_reached() fires when the agent got within target_desired_distance of the actual target.

Most of the time both fire together and the distinction looks academic. It stops being academic when the target is off the mesh. Click a point inside a wall, or chase a player who has stepped onto a tile with no navigation polygon, and the path ends at the nearest reachable point. The agent finishes its navigation. It never reaches the target. navigation_finished fires; target_reached does not.

That is your hook for "I could not get there" behaviour — give up, path to the last known position, switch to a search state:

func _ready() -> void:
	player = get_tree().get_first_node_in_group("player")
	agent.navigation_finished.connect(_on_navigation_finished)


func _on_navigation_finished() -> void:
	if agent.is_target_reached():
		return
	# Path ended without arriving: the target is off-mesh or walled off.
	give_up.call_deferred()


func give_up() -> void:
	# A placeholder until you decide what this enemy should do instead.
	# Part 05 lists the options and what each one reads as on screen.
	push_warning("No route to the player.")

Note call_deferred. Assigning a new target_position from inside one of these handlers risks infinite recursion — the handler sets a target, the target produces an immediate finish, the finish calls the handler. Deferring pushes the assignment to the end of the frame and breaks the loop. The class reference flags this explicitly; treat "never re-target from inside a navigation callback" as a rule with no exceptions.

is_target_reachable() exists too, and answers the same question without waiting for the signal. It reads the path the agent is already holding — whether that path's end lands within target_desired_distance of the target — so it costs nothing extra. The catch is in the same sentence: it can only tell you about the target you last assigned, not about a destination you are still weighing up. To ask "could I get there?" about an arbitrary point, run NavigationServer2D.map_get_path() yourself and look at where the returned path ends. Part 05 turns that into a has_route_to() helper.

If your enemy has more than one behaviour, this is the natural boundary to hand off to a state machine. The chase logic in this part is a single state; navigation_finished without target_reached is a transition out of it. See Part 02 — enum machine for the smallest version of that.

Two enemies, and the checkbox that freezes both#

Duplicate the enemy. Put both in the level. They chase, they arrive, and they stand in the same spot, one sprite fully inside the other, which looks like a rendering bug rather than two bodies agreeing on the same destination.

The inspector offers an obvious remedy: avoidance_enabled. Tick it on both. Run.

Both enemies stop moving entirely.

Nothing errored. Nothing warned. This is the third silent failure in this part, and it is the most instructive one, because the checkbox did not enable a feature — it changed the movement contract out from under you.

With avoidance off, you own velocity. You compute a vector, assign it, call move_and_slide().

With avoidance on, you no longer own it. You hand your desired velocity to the agent with set_velocity(), the avoidance system reconciles it against every other avoidance-enabled agent nearby, and it hands back a safe velocity through the velocity_computed signal. Moving with your own vector while avoidance is on means you are ignoring the entire system. Moving with nothing — which is what happens when you never connect the signal — means you never move at all.

That is the freeze. The safe velocity was computed and delivered to nobody.

The full shape:

extends CharacterBody2D

@export var speed: float = 90.0

@onready var agent: NavigationAgent2D = $NavigationAgent2D

var player: Node2D


func _ready() -> void:
	player = get_tree().get_first_node_in_group("player")
	agent.path_desired_distance = 6.0
	agent.target_desired_distance = 6.0
	agent.velocity_computed.connect(_on_velocity_computed)


func _physics_process(_delta: float) -> void:
	agent.target_position = player.global_position

	if agent.is_navigation_finished():
		velocity = Vector2.ZERO
		return

	var next_point: Vector2 = agent.get_next_path_position()
	var desired_velocity: Vector2 = global_position.direction_to(next_point) * speed

	if agent.avoidance_enabled:
		agent.set_velocity(desired_velocity)
	else:
		velocity = desired_velocity
		move_and_slide()


func _on_velocity_computed(safe_velocity: Vector2) -> void:
	velocity = safe_velocity
	move_and_slide()

The branch on avoidance_enabled is not defensive padding. It is what lets you tick the box on and off in the inspector without the script becoming wrong — which you will want to do while tuning, and which the version without the branch punishes.

move_and_slide() lives inside the callback when avoidance is on. The signal fires during the physics step, so that is a legitimate place to move the body. It looks unusual the first time.

The properties worth knowing, with their defaults:

  • radius (10.0) — the agent's footprint for avoidance. Set it to roughly your collision shape's radius. Too small and enemies clip through each other; too large and they refuse to pass through doorways because each thinks the other has filled it.
  • neighbor_distance (500.0) — how far out to look for other agents. Generous default. Lowering it is a performance lever in a crowd.
  • max_neighbors (10) — how many nearby agents to actually consider. With more than ten bodies in a scrum, the ones beyond the cap are ignored, and you get occasional overlaps that look random. Raise it or accept the artifact.
  • time_horizon_agents (1.0) — how many seconds ahead to predict other agents' movement. Higher values swerve earlier and wider, which reads as cautious. Lower values cut it close and look aggressive.
  • time_horizon_obstacles (0.0) — the same, for NavigationObstacle2D. Zero by default, which means obstacle avoidance does nothing until you raise it.
  • max_speed (100.0) — the ceiling the avoidance system will return. Set it to your speed or above. Leaving it at the default while your enemy runs at 400 caps every safe velocity at a quarter of what you asked for, and the symptom is "avoidance makes my enemies slow" rather than anything about a cap.

Note the plural names: time_horizon_agents and time_horizon_obstacles. A bare time_horizon is not a property. Older tutorials assign it, GDScript accepts the assignment on a node without complaint in some contexts, and the tuning you thought you did did nothing.

Avoidance is a velocity-level system. It steers bodies around each other in the moment. It does not change paths — both enemies still believe they are walking the same route, they merely refuse to occupy the same pixel while doing it. For the same reason, NavigationObstacle2D does not block pathfinding; paths route straight through one, and only avoidance-enabled agents swerve. That node has an opt-in for carving the mesh, and it is a different mechanism worth its own treatment.

The shortcut that is not one#

Sooner or later you will want avoidance without pathfinding — a swarm of floating things that drift toward the player in a straight line and merely need to not stack up. Adding a NavigationAgent2D for the avoidance alone, ignoring the path entirely, seems reasonable.

It does not work, and the failure mode is an avoidance system that appears to be doing nothing at all.

An agent used purely for avoidance still requires target_position to be assigned. The class reference states it directly. Assign it, even if you never read the path back.

When this node is the wrong tool#

NavigationAgent2D is not free. It holds a path, it queries the server, and with avoidance on it participates in a neighbour search every frame. That cost buys you one thing: a path that keeps up with a moving target.

If the target does not move, you are paying for nothing:

  • Click-to-move. One destination, chosen by the player, valid until they click again. A single NavigationServer2D.map_get_path() from Part 03 and a list of points you walk down yourself.
  • A patrol route computed at level start. The mesh does not change; neither does the route. Compute it once, store the PackedVector2Array, loop it.
  • An off-screen agent that recalculates every few seconds. A timer plus a one-shot query is cheaper, and has fewer moving parts, than a node ticking every physics frame.

The deciding question is not "how complicated is my movement". It is: does the destination change faster than I am willing to recompute? If yes, the agent earns its cost. If no, Part 03's approach is less machinery and fewer silent failure modes.

A useful middle ground, once your chaser count grows: keep the agent, but stop re-targeting every frame. Assigning target_position queues a recompute, and a player who moved two pixels rarely justifies a fresh path.

@export var repath_interval: float = 0.2

var _repath_timer: float = 0.0


func _physics_process(delta: float) -> void:
	_repath_timer -= delta
	if _repath_timer <= 0.0:
		_repath_timer = repath_interval
		agent.target_position = player.global_position

	if agent.is_navigation_finished():
		velocity = Vector2.ZERO
		return

	var next_point: Vector2 = agent.get_next_path_position()
	velocity = global_position.direction_to(next_point) * speed
	move_and_slide()

The trade-off is honest and worth stating rather than resolving: a longer interval costs less and makes the enemy commit to a stale route for a fraction of a second, which reads as momentum. A shorter one is more responsive and reads as twitchy. Neither is correct. Ten chasers at 0.2 is a different game from ten chasers at 0.0, and which one you want is a design question, not a performance one.

Checkpoint — definition of done#

  • With the target assigned only in _ready(), the enemy does not move — and you have seen that with your own eyes rather than taking the section's word for it.
  • With the target assigned in _physics_process, the enemy paths around the mesh's obstacles and reaches you.
  • Standing still, the enemy arrives and stops completely: no shake, no drift, no sub-pixel oscillation.
  • Removing the is_navigation_finished() early return brings the jitter back, confirming that is where it came from.
  • With path_desired_distance set to 30.0, the enemy visibly cuts corners and swings wide into walls.
  • With speed at 400.0 and both distances at 2.0, the enemy fails to settle on arrival — and raising the distances fixes it.
  • Two enemies with avoidance off end up standing inside each other.
  • Two enemies with avoidance on and velocity_computed connected reach you side by side, neither freezing.
  • Disconnecting velocity_computed while avoidance is on freezes both enemies solid.
  • A target placed off the mesh fires navigation_finished without is_target_reached() returning true.
  • You can explain: why is_navigation_finished() has to be checked before get_next_path_position() rather than after move_and_slide(), in terms of what that call actually does.

Stretch (no instructions)#

  • Give the enemy a memory. When the player leaves line of sight, keep chasing the last known position, and on navigation_finished switch to a wander state instead of standing still.
  • Make one enemy a flanker: target a point offset from the player, perpendicular to the player's facing direction, so two enemies approach from different sides instead of queueing up.
  • Spawn twenty chasers and find where it starts to hurt. Then find out which lever bought the most back: repath_interval, neighbor_distance, or max_neighbors.
  • Draw the agent's current path with get_current_navigation_path() and a _draw() call. Watching the route update in real time makes the repath interval trade-off immediately legible.

If you get stuck#

  • Enemy never moves, no errors → the target was assigned in _ready() before the navigation map synchronised. Move the assignment into _physics_process, or guard with NavigationServer2D.map_get_iteration_id(agent.get_navigation_map()) == 0.
  • Enemy never moves, avoidance is ticked onvelocity_computed is not connected, or the callback does not call move_and_slide(). With avoidance on, nothing else moves the body.
  • Enemy shakes on arrivalget_next_path_position() is being called past the end of the path. The is_navigation_finished() guard must sit before it, not after move_and_slide().
  • Enemy shakes while still travelling, at high speedpath_desired_distance is smaller than the distance covered in one physics frame, so the waypoint acceptance zone is stepped over rather than entered.
  • Enemy stops a body-width short of the playertarget_desired_distance is too large. The 10.0 default is bigger than most 2D sprites.
  • Enemy walks to the first corner and grinds thereget_next_path_position() is not being called every frame. Check for an if or an early return that skips it on some frames.
  • Enemy skips corners and clips wall geometryget_next_path_position() is being called more than once per frame. Store it in a local and reuse the local.
  • Enemy paths correctly but drifts at a constant offset → steering from position instead of global_position. The waypoint comes back in global space; the enemy is parented under something with a transform.
  • Avoidance is on but enemies still overlapradius is at the default and smaller than the collision shape, or more than max_neighbors bodies are in the scrum and the extras are being ignored.
  • Avoidance is on and enemies crawlmax_speed is still 100.0 while speed is higher. Safe velocities are capped at max_speed.
  • Avoidance appears to do nothing at alltarget_position was never assigned. An agent used only for avoidance still requires one.
  • Crash or freeze right after connecting navigation_finished → a new target_position is being assigned inside the handler. Wrap it in call_deferred.
  • time_horizon assignment has no effect → the properties are time_horizon_agents and time_horizon_obstacles. The singular name does not exist.

Next: Part 05 — paths that look like decisions. Read it once your enemy chases correctly and still looks stupid doing it — that part is about the gap between a valid path and a believable one.