M4.3 — Pathfinding with NavigationAgent2D¶
What you'll learn
- Replace straight-line chasing with a navigation mesh (
NavigationRegion2D+NavigationAgent2D). - Steer toward
get_next_path_position, re-pathing on a timer rather than every frame. - Judge when an open room needs no navigation, and where avoidance helps crowding.
How it applies
- Walls are non-negotiable in a dungeon. The instant the arena has obstacles, a straight-line chaser walks into a corner and grinds against it while the player strolls around. Pathfinding is what lets enemies come around obstacles — the difference between threatening AI and stuck AI.
- Pathfinding has a cost, and that cost is a tuning decision. Computing a path is per-agent work; doing it every frame for a roomful of enemies is wasted CPU. Re-pathing on a timer keeps the AI responsive while bounding the cost — exactly the "tick the fast thing, do the expensive thing slowly" discipline good engines apply everywhere.
- Crowding looks bad without avoidance. Several enemies pathing to the same player stack into one sprite. Agent avoidance spreads them, which reads as a group surrounding the player rather than merging into it.
- Graceful fallback matters. A small, open arena may not need a baked mesh; the straight-line steer from M4.2 is a fine fallback there. Knowing when you don't need navigation is as useful as knowing how to use it.
Concepts¶
The problem with straight-line steering¶
M4.2's Chase moves the enemy directly toward the player: velocity = (player - enemy).normalized() *
speed. With no obstacles this is fine. Add a wall between them and the enemy drives into it — move_and_slide
slides it along the wall, but it has no notion of going around. The fix is to follow a path that
routes through the walkable space, not a straight line through walls.
NavigationRegion2D and NavigationAgent2D¶
Godot's 2D navigation has two halves:
NavigationRegion2Ddefines the navigation mesh: the polygon of walkable floor. You author or bake it from the level geometry (the arena floor minus the walls). It lives in the world, once.NavigationAgent2Dis a per-enemy node that, given atarget_position, computes a path across the region and hands back the next point to move toward viaget_next_path_position(). Each enemy carries its own agent.
The enemy no longer steers straight at the player; it sets the agent's target_position to the player's
position and moves toward whatever next point the agent returns. The agent does the routing.
Driving movement from the agent¶
The Chase state's steering changes from "toward the player" to "toward the agent's next path point":
Example
Chase, rewritten to use the agent. The enemy has a NavigationAgent2D child exposed as
enemy.agent:
# res://scripts/states/enemy_chase.gd (navigation version)
extends State
@onready var enemy: CharacterBody2D = owner
@export var attack_range: float = 28.0
func physics_update(_delta: float) -> void:
var target: Node2D = enemy.target
if target == null:
transitioned.emit(&"Idle")
return
if enemy.global_position.distance_to(target.global_position) <= attack_range:
transitioned.emit(&"Attack")
return
var next_point := enemy.agent.get_next_path_position()
var dir := (next_point - enemy.global_position).normalized()
enemy.velocity = dir * enemy.speed
enemy.sprite.play("run")
enemy.sprite.flip_h = enemy.velocity.x < 0.0
enemy.move_and_slide()
The only change from M4.2 is the steering source: get_next_path_position() instead of the player's
position directly. Everything else — the range check, the animation, move_and_slide — is identical.
The agent turns "go to the player" into "go to the next reachable point on the way to the player."
Re-path on a timer, not every frame¶
The agent's target_position must be kept roughly current as the player moves, but recomputing the whole
path every physics frame is expensive and unnecessary — the player doesn't teleport. Update the target on
a timer (e.g., 5–10 times a second), which is far more responsive than the player can exploit and a
fraction of the cost:
Example
A repeating Timer (child of the enemy) refreshes the agent's target:
# in enemy.gd
@onready var agent: NavigationAgent2D = $NavigationAgent2D
func _on_repath_timer_timeout() -> void:
if target != null:
agent.target_position = target.global_position
With the timer at, say, 0.1 s, the path refreshes ten times a second. The enemy still moves every physics frame (toward the current next point); only the expensive recompute is throttled. This is the same decoupling idea as separating a fast tick from a slow commit.
Avoidance for crowding¶
NavigationAgent2D supports local avoidance: enable avoidance_enabled and set a radius, and agents
steer around each other so a pack doesn't collapse into one point. When avoidance is on, you read the
agent's velocity through its velocity_computed signal / set_velocity flow rather than applying your
computed velocity directly — the agent adjusts it to avoid neighbors. For a first pass, plain pathing
without avoidance is fine; turn it on when enemy stacking becomes visible.
When you don't need any of this¶
A single open room with no interior walls does not need a baked mesh — the M4.2 straight-line steer
reaches the player fine, and move_and_slide handles the outer walls. Reach for NavigationRegion2D
when the level has obstacles enemies must route around. Adding navigation to a level that doesn't need it
is cost without benefit; the judgment of when it is warranted is part of the skill.
Walkthrough¶
- In the arena (for now,
main.tscn'sWorld, or a test room), add aNavigationRegion2D. Give it aNavigationPolygoncovering the walkable floor (exclude walls). For a tile-based arena (M8), you can bake the polygon from the tilemap; for a quick test, draw a rectangle polygon over the open floor. - Open
enemy.tscn. Add aNavigationAgent2Dchild; expose it inenemy.gdas@onready var agent := $NavigationAgent2D. - Add a repeating
Timerchild to the enemy (Autostart on, Wait Time0.1), connect itstimeoutto_on_repath_timer_timeoutinenemy.gd, settingagent.target_position = target.global_position. - Replace
enemy_chase.gdwith the navigation version above (steer towardget_next_path_position). - Place a wall (a
StaticBody2Dwith a collision shape on theworldlayer) between the player's start and the skeleton, and make sure the navigation polygon routes around it. - Press
F5. Stand on the far side of the wall: the skeleton should path around it to reach you, rather than grinding into it. Move along the wall and watch it re-route as the timer refreshes the target.
Optional sanity check
Temporarily raise the repath timer to 1.0 s and move quickly past the enemy: it will visibly lag,
pathing toward where you were up to a second ago — proof the target is only refreshed on the timer,
not continuously. Restore 0.1. Then remove the navigation region entirely and confirm the enemy
falls back to walking straight at you (and into the wall): proof navigation is what provides the
routing, and that a wall-free room wouldn't miss it.
Self-check quiz¶
Q1 — What does NavigationAgent2D.get_next_path_position() return, and how does the enemy use it?
A. The player's exact position; the enemy moves straight there. B. The next point along a computed path across the navigation mesh; the enemy steers toward it each frame, which routes it around obstacles. C. The enemy's own position; used to detect being stuck. D. A boolean for whether a path exists.
Reveal answer
B. The agent computes a path across the NavigationRegion2D to its target_position and
returns the next waypoint; steering toward that waypoint each frame walks the enemy along the path,
around walls. A is the straight-line approach the agent replaces. C and D misdescribe the method.
Q2 — Why update the agent's target_position on a timer instead of every physics frame?
A. The agent only accepts updates on a timer.
B. Recomputing a path is expensive per agent; the player moves slowly enough that refreshing several
times a second is responsive while every-frame recompute wastes CPU across many enemies.
C. Timers are more accurate than _physics_process.
D. Updating every frame would teleport the enemy.
Reveal answer
B. Pathing is the costly operation; the movement still happens every frame toward the current next point, but the recompute is throttled because the target doesn't change meaningfully frame to frame. A is false (you can set it anytime). C is irrelevant. D is false (it wouldn't teleport, just waste work).
Q3 — When is it reasonable to skip NavigationRegion2D and keep the straight-line steer?
A. Never — navigation is always required.
B. In a single open room with no interior obstacles, where moving straight at the player reaches them
and move_and_slide handles the outer walls.
C. Only on mobile.
D. When there are more than ten enemies.
Reveal answer
B. Navigation earns its cost when enemies must route around obstacles; an open room has nothing to route around, so the straight-line steer is sufficient and cheaper. A overstates it. C is unrelated. D is backwards — more enemies makes navigation's cost matter more, but the deciding factor is obstacles, not count.
Integration question¶
Q4 — open
The navigation version of Chase differs from the M4.2 version by exactly one line — the steering
source. Explain why that small change is possible (what about the FSM and the enemy assembly made it a
one-line swap), and describe the cost/benefit reasoning a developer should apply before adding a
NavigationRegion2D to a given level.
Reveal expected answer
It is a one-line swap because the FSM isolates behavior per state and the rest of the enemy is
unchanged: Chase is a self-contained state whose only job is "produce a velocity toward the
player and hand off to Attack/Idle," so changing how the direction is computed (from
player - enemy to next_path_point - enemy) touches nothing else — the range check, animation,
move_and_slide, the detection that sets target, and the other states are all untouched. The
component/state architecture localizes the change to the one place responsible for it. The
cost/benefit reasoning before adding a NavigationRegion2D: navigation's benefit is routing around
obstacles, and its cost is authoring/baking the mesh plus per-agent path computation (bounded by
re-pathing on a timer). If a level has interior walls or pillars enemies must go around, the
benefit is essential and the cost is justified; if the level is a single open arena, there is
nothing to route around, so the straight-line steer delivers identical behavior for free and the
mesh is pure overhead. Add navigation when geometry demands it, not by default.