DEV Community

Cover image for 3D Pathfinding in Godot 4: NavigationAgent3D, the Three Radii, and When You Actually Need AStar3D
Vav Labs
Vav Labs

Posted on • Originally published at vav-labs.com

3D Pathfinding in Godot 4: NavigationAgent3D, the Three Radii, and When You Actually Need AStar3D

Originally published on vav-labs.com. Everything below is Godot 4.7.1 stable, and there's a runnable MIT-licensed project with a verification receipt at the end.

You add a NavigationRegion3D, bake a mesh, drop a NavigationAgent3D on your character, set target_position, hit Play, and the character stands there doing nothing at all.

That's the normal first run. All of it is documented, and people still lose an evening to it (me included). The reason is that 3D navigation in Godot is really four separate systems that have to agree with each other: the baked mesh, the physics body, the agent, and your own movement code. Any one of them can be wrong on its own terms while looking fine in the inspector.

So here's the whole contract in one place, in the order you'd actually build it.

Navmesh or AStar3D? Decide by movement space

Before any setup, pick the representation. This is the choice people get wrong most often, and it's not really about 2D versus 3D.

Your movement space Use
Grounded movement over authored 3D geometry NavigationMesh + NavigationAgent3D
Voxels, stacked tiles, waypoints, other predefined positions An explicit AStar3D point graph
Fully volumetric flight or swimming An explicit 3D graph or something purpose-built

A navmesh describes continuous walkable space. One polygon can cover a whole floor, and the bake follows your authored geometry instead of chopping the level into cubes. That's the ordinary answer for a character walking through a scene.

AStar3D describes points and connections, and it's the better fit when the positions themselves are the rules: a voxel that can be occupied, a flying unit hopping between lattice nodes, a turn-based actor paying for one exact step. The catch is that AStar3D won't inspect a GridMap and discover the graph for you. You add every point, connect every legal pair, and keep the thing in sync when the world changes.

This post builds the first case.

The scene, and the order to build it

Nothing exotic. Everything here is authored before Play.

Main (Node3D)
├── NavigationRegion3D
│   └── LevelGeometry (floor, ramp, platform, walls)
├── Player (CharacterBody3D)
│   ├── CollisionShape3D
│   ├── MeshInstance3D
│   └── NavigationAgent3D
├── MovingObstacle (AnimatableBody3D)
│   └── NavigationObstacle3D
├── TargetMarker
├── PathLine
└── Camera3D
Enter fullscreen mode Exit fullscreen mode

Build it in this order and you get a working result at every step, which makes it obvious where things broke:

  1. NavigationRegion3D with the level geometry under it. The floor, ramp, platform and walls are visible in the editor.
  2. A NavigationMesh resource on the region, baked and saved. You now have a walkable surface.
  3. CharacterBody3D with a NavigationAgent3D child. A grounded body can follow one target across the ramp.
  4. Physics-step mouse raycast. Left click produces a world-space target.
  5. Closest-point clamping and a path line. The target snaps to the navmesh and the route is visible.
  6. Optional avoidance obstacle. Local steering reacts to motion without touching the global path.

Baking the NavigationMesh

Put the walkable geometry under NavigationRegion3D, create a NavigationMesh on the region, bake. Turn on the navigation debug view while you're adjusting things — the colored overlay should connect the lower floor to the upper platform and keep visible clearance around dividers.

NavigationMesh property Starter value What it changes
agent_radius 0.50 Erodes clearance around walls and ledges
agent_height 2.0 Minimum vertical space the body needs
agent_max_climb 0.50 Joins surfaces across small height changes
agent_max_slope 35.0 Keeps the 20-degree ramp walkable
cell_size 0.25 Horizontal bake resolution
cell_height 0.25 Vertical bake resolution

Two things that bite here. cell_size and cell_height have to match the navigation map, and Godot rounds the bake radius up to a multiple of cell_size. So a coarse cell size can quietly erode more space than the radius field suggests, and your doorway disappears for reasons the inspector never shows you.

Keep the three radii separate

This is the one I'd put on a sticky note. Three different settings all read like "the agent radius" during a quick inspector pass, and they don't do the same job.

Setting Starter value Owner
CapsuleShape3D.radius 0.48 Physics collision
NavigationMesh.agent_radius 0.50 Bake-time wall and ledge clearance
NavigationAgent3D.radius 0.55 Local avoidance only

The bake radius should cover the physical body plus a small margin. The avoidance radius can run a little wider, because it controls preferred spacing between agents, not whether a doorway is legal.

Get the relationship backwards and you get two distinct bugs. Bake for a body smaller than the collision capsule and the path will happily route around a corner that physics won't let you take. Bake too large and narrow doors vanish from the mesh entirely. When something looks wrong at a doorway, debug the baked surface and the collision shape together, not one at a time.

Wait until the navigation map is usable

The empty-first-path case is documented: the map hasn't synchronized the region yet. For a small scene, deferring setup and waiting one physics frame is the normal baseline.

The starter is stricter than that, because it assigns an automatic target the moment the scene is ready. It waits for a map iteration, a registered region, and a closest point near the authored spawn:

func _ready() -> void:
    _spawn_transform = global_transform
    floor_snap_length = 0.45
    floor_max_angle = deg_to_rad(45.0)
    agent.velocity_computed.connect(_on_velocity_computed)
    _finish_navigation_setup.call_deferred()


func _finish_navigation_setup() -> void:
    while true:
        var map_rid := agent.get_navigation_map()
        var map_has_data := NavigationServer3D.map_get_iteration_id(map_rid) > 0 \
            and not NavigationServer3D.map_get_regions(map_rid).is_empty()
        var closest_point := NavigationServer3D.map_get_closest_point(
            map_rid, global_position) if map_has_data else Vector3.ZERO
        if map_has_data and closest_point.distance_to(global_position) < 2.0:
            break
        await get_tree().physics_frame
    _navigation_is_ready = true
    navigation_ready.emit()
Enter fullscreen mode Exit fullscreen mode

Don't copy the two-metre check into a reusable library. It belongs to this authored spawn point. In your scene, pick a readiness assertion that matches where your character is actually supposed to start.

Raycast the click during the physics step

Mouse input arrives outside the physics callback, but direct_space_state wants to be queried during physics processing. Store the screen position, consume it in _physics_process():

func _unhandled_input(event: InputEvent) -> void:
    if event is InputEventMouseButton \
            and event.button_index == MOUSE_BUTTON_LEFT \
            and event.pressed:
        _pending_click = event.position
        get_viewport().set_input_as_handled()


func _physics_process(_delta: float) -> void:
    if _pending_click == null:
        return
    var click_position: Vector2 = _pending_click
    _pending_click = null
    _raycast_target(click_position)


func _raycast_target(screen_position: Vector2) -> void:
    var ray_origin := camera.project_ray_origin(screen_position)
    var ray_end := ray_origin + camera.project_ray_normal(screen_position) * 200.0
    var query := PhysicsRayQueryParameters3D.create(ray_origin, ray_end, 1)
    query.exclude = [player.get_rid()]
    var hit := get_world_3d().direct_space_state.intersect_ray(query)
    if hit.is_empty():
        _set_status("No level surface under that click.")
        return
    _set_target(hit.position)
Enter fullscreen mode Exit fullscreen mode

A physics hit is not a navigation target

A click can land on the side of a wall, on geometry outside the baked region, or next to a disconnected surface. Normalize it explicitly:

func set_navigation_target(requested_position: Vector3) -> Vector3:
    if not _navigation_is_ready:
        return global_position

    var reachable_target := NavigationServer3D.map_get_closest_point(
        agent.get_navigation_map(), requested_position)
    agent.target_position = reachable_target
    _has_target = true
    _last_path = PackedVector3Array()
    return reachable_target
Enter fullscreen mode Exit fullscreen mode

Keep both values around if your UI ever needs to explain a correction — the starter displays the requested world position next to the clamped navmesh target. When a character stops short of where you clicked, get_final_position() gives you the reachable end of the current path and is_target_reachable() tells you what the agent thinks of the request.

NavigationAgent3D does not move your character

It computes path information. That's it. Your controller asks for one next point per physics frame, flattens the steering onto the ground plane, applies acceleration, and calls move_and_slide():

func _physics_process(delta: float) -> void:
    if not is_on_floor():
        velocity.y -= _gravity * delta
    elif velocity.y < 0.0:
        velocity.y = -0.1

    var desired_velocity := Vector3.ZERO
    if _navigation_is_ready and _has_target:
        if agent.is_navigation_finished():
            _has_target = false
        else:
            var next_path_position := agent.get_next_path_position()
            var direction := global_position.direction_to(next_path_position)
            direction.y = 0.0
            if direction.length_squared() > 0.0001:
                direction = direction.normalized()
                desired_velocity = direction * move_speed
            _emit_path_if_changed()

    var current_horizontal := Vector3(velocity.x, 0.0, velocity.z)
    desired_velocity = current_horizontal.move_toward(
        desired_velocity, acceleration * delta)

    if agent.avoidance_enabled and _navigation_is_ready:
        agent.velocity = desired_velocity
    else:
        _on_velocity_computed(desired_velocity)
Enter fullscreen mode Exit fullscreen mode

Keep get_next_path_position() in the physics loop. Calling it from signals like waypoint_reached can retrigger path updates and recurse on you.

The ramp that ate an evening

The first version of this starter had a route that was completely valid and completely unwalkable. The navmesh connected the floor to the upper platform, the path line drew a clean cyan arc up the ramp, and the capsule walked to the bottom of the ramp and stopped.

Nothing was wrong with the pathfinding. The baked route approached the vertical side face of the ramp collider, and no combination of floor_snap_length, gravity, or floor_max_angle was going to get a CharacterBody3D up a wall. Making both ramp transitions physically flush is what fixed it.

A valid path is not a promise that the body can execute it. When movement stalls and the path looks correct, stop reading navigation code and go inspect the collider.

The other tuning result worth stealing: path_desired_distance = 0.65. Smaller values made this particular accelerated body overshoot each waypoint and then curve back toward it, forever. Derive that number from your controller's speed and stopping behaviour rather than copying mine.

Avoidance changes velocity, not the path

The moving obstacle in the starter is a NavigationObstacle3D that publishes its velocity every physics frame so the avoidance server can predict it:

func _physics_process(delta: float) -> void:
    var previous_position := global_position
    _phase = fmod(_phase + TAU * cycles_per_second * delta, TAU)
    var next_position := _spawn_position + travel_axis.normalized() \
        * sin(_phase) * travel_distance
    last_reported_velocity = (next_position - previous_position) / maxf(delta, 0.0001)
    navigation_obstacle.velocity = last_reported_velocity
    global_position = next_position
Enter fullscreen mode Exit fullscreen mode

The player sends its desired horizontal velocity through agent.velocity, and velocity_computed hands back a locally safer one:

func _on_velocity_computed(safe_velocity: Vector3) -> void:
    velocity.x = safe_velocity.x
    velocity.z = safe_velocity.z
    move_and_slide()
Enter fullscreen mode Exit fullscreen mode

While all of that happens, the cyan path line doesn't move. That's correct behaviour, and it surprises people. Avoidance doesn't rebake the mesh, doesn't pick a new route, and doesn't know your physics collider exists. It nudges velocity and nothing else.

Which also means avoidance can pin an agent against a wall if you park a moving obstacle in a narrow corridor. If an object is supposed to make a route illegal, you need an actual navigation change, not steering.

Symptom table

Symptom Check first
First path comes back empty Wait for map and region data to synchronize
Path computes, character doesn't move NavigationAgent3D doesn't move its parent; call your controller
Body clips or sticks at a doorway Compare CollisionShape3D against the baked agent_radius
A moving object gets ignored Enable avoidance and supply the obstacle's velocity
Character stops short of the click Compare the request with get_final_position() and is_target_reachable()
Path crosses the ramp, body stops at its foot Inspect the collider for a vertical lip or side-face approach
Body circles a waypoint Raise path_desired_distance, or retune speed and acceleration
Path line changes when you only expected steering Something is assigning a new target. Avoidance alone doesn't reroute

When AStar3D is the right answer

Reach for it when the legal positions and edges are explicit game data:

  • A voxel world where each block position can be occupied or disabled
  • A dungeon built from stacked tile layers
  • Fixed 3D waypoints with one-way or authored connections
  • A flying or swimming lattice with discrete neighbours
  • Turn-based 3D movement with exact per-node costs

You own that graph completely. Add each point, connect the legal pairs, disable and reconnect points when the world changes, then move the actor along the returned point path. Nothing discovers it for you.

Run the verified starter

The download is the exact authored scene and scripts from this article, MIT-licensed: Godot 4.7.1 source project, 13,833 bytes, SHA-256 bcfb590993cfd025b810f745bf0303d39061cdf6c177f0c27bf66be637104d1c.

Beside it is a machine-readable verification receipt: 21 named checks, 21 passed, 0 failed, on Godot 4.7.1-stable (official, engine hash a13da4fe). It records the ZIP hash, per-file hashes, a clean import of the extracted project, and a scene smoke test that reports 27 navmesh polygons and 26 vertices from the saved bake.

Honest boundary, straight from the receipt: this is deterministic tutorial correctness. It makes no claim about FPS, throughput, crowd capacity, path optimality, production-readiness, or dynamic blockers. If you want a number for "how many agents can I run," you'll have to profile your own project.


The full version is on vav-labs.com, and it embeds the scene as a playable web export so you can click around before downloading anything. It also carries the FAQ and the links out to the related guides.

If any of this doesn't match what you're hitting in your own scene, I'd genuinely like to hear about it. The failure modes here are more varied than one post can cover.

Top comments (0)