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
Build it in this order and you get a working result at every step, which makes it obvious where things broke:
-
NavigationRegion3Dwith the level geometry under it. The floor, ramp, platform and walls are visible in the editor. - A
NavigationMeshresource on the region, baked and saved. You now have a walkable surface. -
CharacterBody3Dwith aNavigationAgent3Dchild. A grounded body can follow one target across the ramp. - Physics-step mouse raycast. Left click produces a world-space target.
- Closest-point clamping and a path line. The target snaps to the navmesh and the route is visible.
- 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()
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)
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
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)
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
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()
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)