Skip to content

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.

Godot's 2D navigation has two halves:

  • NavigationRegion2D defines 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.
  • NavigationAgent2D is a per-enemy node that, given a target_position, computes a path across the region and hands back the next point to move toward via get_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

  1. In the arena (for now, main.tscn's World, or a test room), add a NavigationRegion2D. Give it a NavigationPolygon covering 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.
  2. Open enemy.tscn. Add a NavigationAgent2D child; expose it in enemy.gd as @onready var agent := $NavigationAgent2D.
  3. Add a repeating Timer child to the enemy (Autostart on, Wait Time 0.1), connect its timeout to _on_repath_timer_timeout in enemy.gd, setting agent.target_position = target.global_position.
  4. Replace enemy_chase.gd with the navigation version above (steer toward get_next_path_position).
  5. Place a wall (a StaticBody2D with a collision shape on the world layer) between the player's start and the skeleton, and make sure the navigation polygon routes around it.
  6. 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.