Vav Labs
Back to blog

Godot pathfinding / 2026-07-17 / 15 min read

Verified as of Godot 4.7.1 stable

3D Pathfinding in Godot 4: Navmesh, Agents, and When You Need A*

Set up 3D pathfinding in Godot 4 with NavigationRegion3D, NavigationAgent3D, click-to-move, navmesh baking, and the AStar3D decision.

Godot 3D navigation starter with a cyan path crossing a ramp toward an upper platform and an orange moving obstacle below.

Runnable Godot 4.7.1 scene

Try the baked 3D navigation scene

Click a walkable surface to move the capsule. The cyan line is the navmesh path; the orange obstacle changes local steering without rebuilding that path.

Loads only after launch. Requires WebAssembly and WebGL 2.

HTML transcript

  1. The authored scene opens with a capsule on the lower floor and a target on the upper platform.
  2. A cyan line shows the baked navigation path across the level and up the ramp.
  3. Left click chooses another world-space target and clamps it to the closest navmesh point.
  4. The orange obstacle supplies velocity for local avoidance; the cyan path remains unchanged.

Fallback coverage

  • The poster is a real Chromium capture of the exact Godot Web export.
  • The source ZIP contains the editor-visible scene, saved navmesh, and tutorial scripts.
  • The visible article includes the same target, movement, grounding, and avoidance code.
  • The source and Web receipts bind the clean project package and browser export separately.

The direct answer

For a grounded character moving across floors, ramps, stairs, and bridges in Godot 4, bake a NavigationMesh inside a NavigationRegion3D. Add a NavigationAgent3D to the character, assign target_position, call get_next_path_position() once per physics frame, and move the parent CharacterBody3D yourself.

Use AStar3D when movement is constrained to explicit points: voxel cells, stacked tile positions, waypoints, or a coarse lattice in open space. A GridMap can store those cells, but it doesn't create or connect the AStar3D graph for you.

This tutorial builds the first case. The downloadable scene has an authored floor, walls, a real ramp, an upper platform, click-to-move, a visible path, and one moving avoidance obstacle. It was tested as a physics scene, not just as a path query.

Your movement spaceUse
Grounded movement over authored 3D geometryNavigationMesh + NavigationAgent3D
Voxels, tiles, waypoints, or other predefined 3D positionsAn explicit AStar3D point graph
Fully volumetric flight or swimmingAn explicit 3D graph or a purpose-built volumetric solution

Choose by movement space: navmesh or AStar3D?

A navmesh describes continuous walkable space. One polygon can cover a large floor, and the baked mesh follows authored geometry without turning the whole level into tiny cubes. That's the ordinary choice for a character walking through a 3D scene.

AStar3D describes discrete points and connections. It's the better fit when the positions themselves are rules: a voxel can be occupied, a flying unit can move between selected lattice nodes, or a turn-based actor must pay for one exact step. You add every point and connection yourself, then keep the graph synchronized with the world.

If that decision is still fuzzy, the grid-versus-navmesh guide owns the broader representation tradeoff. This page stays with the 3D implementation.

Working in 2D instead? The 2D pathfinding guide compares AStarGrid2D, AStar2D, and polygon navigation over one verified arena.

Navmesh for spaces; AStar3D for discrete points.

Build the starter in this order

The scene tree is ordinary on purpose. Everything in it is authored before Play. The scripts handle targets, movement, the changing path line, and reset state.

StepAddWorking result
1NavigationRegion3D and authored level geometryThe floor, ramp, upper platform, and walls are visible in the editor.
2A NavigationMesh resourceThe level has a saved baked walkable surface.
3CharacterBody3D with NavigationAgent3DA grounded body can follow one target across the ramp.
4Physics-step mouse raycastLeft click produces a world-space target.
5Closest-point normalization and path lineThe target is clamped to the navmesh and the route is visible.
6Optional avoidance obstacleLocal steering reacts to motion without changing the global path.
Main (Node3D)
├── NavigationRegion3D
│   └── LevelGeometry (floor, ramp, platform, walls)
├── Player (CharacterBody3D)
│   ├── CollisionShape3D
│   ├── MeshInstance3D
│   └── NavigationAgent3D
├── MovingObstacle (AnimatableBody3D)
│   └── NavigationObstacle3D
├── TargetMarker
├── PathLine
└── Camera3D

Bake the NavigationMesh

Add the walkable level geometry under NavigationRegion3D, create a NavigationMesh resource on the region, and bake it. Turn on navigation debug view while adjusting the scene. The colored overlay should connect the lower floor to the upper platform and keep clearance around the dividers.

cell_size and cell_height must match the navigation map. Godot also rounds the bake radius up to a multiple of cell_size, so a coarse cell size can remove more space than the radius field suggests.

NavigationMesh propertyStarter valueWhat it changes
agent_radius0.50Erodes clearance around walls and ledges
agent_height2.0Sets the minimum vertical space required by the body
agent_max_climb0.50Joins nearby surfaces across small height changes
agent_max_slope35.0Keeps the 20-degree ramp walkable
cell_size0.25Sets horizontal bake resolution
cell_height0.25Sets vertical bake resolution

Keep the three radii separate

Three settings can all look like "the agent radius" in an inspector pass. They don't do the same job.

The bake radius should cover the physical body with a small margin. The avoidance radius can be a little wider because it controls preferred spacing, not doorway legality.

If the mesh was baked for a smaller body than the collision capsule, the path can pass a corner that physics can't. If the bake radius is too large, narrow doors disappear from the navmesh entirely. Debug the baked surface and the collision shape together.

SettingStarter valueOwner
CapsuleShape3D.radius0.48Physics collision
NavigationMesh.agent_radius0.50Bake-time wall and ledge clearance
NavigationAgent3D.radius0.55Local avoidance only

Wait until the navigation map is usable

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

The starter uses a stricter scene-specific guard because it assigns an automatic target as soon as the scene is ready. It waits for an iteration, a registered region, and a closest point near the authored spawn.

The two-metre check belongs to this authored spawn, not to a reusable library. In your scene, choose a readiness assertion that matches where the character is supposed to begin.

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()
	movement_status_changed.emit("Navigation map synchronized. Click a walkable surface.")

Raycast the click during the physics step

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

The ray query uses the level's collision layer and excludes the player.

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()
	elif event.is_action_pressed("restart_demo"):
		_reset_demo()
		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 _on_navigation_ready() -> void:
	_set_target(default_target.global_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)

Clamp the requested target to the navmesh

A physics hit isn't automatically a reachable navigation point. A click can land on the side of a wall, on geometry outside the baked region, or near a disconnected surface. Normalize the requested position explicitly.

Keep both values if the UI needs to explain a correction. The starter shows the requested world position beside the clamped navmesh target. get_final_position() reports the reachable end of the current path, and is_target_reachable() tells you whether the agent considers the requested target reachable.

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()
	movement_status_changed.emit("Following the baked navmesh path.")
	return reachable_target

Move the CharacterBody3D yourself

NavigationAgent3D computes path information. It doesn't move the parent body. The controller asks for one next point, flattens the steering direction onto the grounded plane, applies acceleration, and moves with move_and_slide().

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

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
			movement_status_changed.emit("Target reached. Click another walkable surface.")
		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)

Tune the grounded execution, not only the path

The grounded details matter. floor_snap_length, gravity, and the body's maximum floor angle must agree with the physical ramp. The first artifact version had a valid navmesh route that entered the vertical side of a ramp collider. The path was mathematically fine; the body couldn't execute it. Making both ramp transitions physically flush fixed the real scene.

The other useful tuning result was path_desired_distance = 0.65. With a smaller value, this accelerated body passed too far around a waypoint and kept correcting toward it. Set the distance from the controller's speed, acceleration, and stopping behavior instead of copying one universal number.

Avoidance changes velocity, not the path

The orange cylinder is a NavigationObstacle3D. It moves every physics frame and publishes the velocity that the avoidance server needs for prediction.

The cyan path stays unchanged while the body steers locally. That's expected. Avoidance doesn't rebake the navmesh, choose a new route, or understand the physics collider.

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

Drive the body with the safe velocity

The player sends its desired horizontal velocity through agent.velocity. The velocity_computed signal returns a locally safer velocity.

Avoidance can still trap an agent against a wall when a moving obstacle is placed in a narrow passage. Use a real navigation change when the object must make the route illegal. The avoidance-versus-pathfinding guide owns that larger decision.

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

3D navigation symptoms and their likely cause

The NavigationAgent setup guide owns the equivalent 2D contract. The NavigationServer2D reference goes deeper on maps, regions, synchronization, and direct server queries; its server model transfers, but its geometry and examples remain 2D.

SymptomCheck first
The first path is immediately emptyWait for the navigation map and region data to synchronize.
A path computes but the character doesn't moveNavigationAgent3D doesn't move its parent; call your body controller.
The body clips or sticks at a doorwayCompare CollisionShape3D with the baked NavigationMesh.agent_radius.
A moving object is ignoredEnable avoidance and supply the obstacle's current velocity.
The character stops before the clickCompare the request with get_final_position() and is_target_reachable().
The path crosses a ramp but the body stops at its footInspect the physics ramp for a vertical lip or a side-face approach.
The body circles a waypointIncrease path_desired_distance or retune speed and acceleration.
The path line changes when you expected only steeringSome code is assigning a new target or changing navigation data. Avoidance alone doesn't reroute.

When you actually need AStar3D

Use AStar3D when the legal positions and edges are explicit game data.

AStar3D doesn't inspect a GridMap and discover the graph. Add each point, connect the legal pairs, disable or reconnect points when the world changes, and move the actor according to the returned point path.

For a small engine-independent grid baseline, the Godot and Unity A* starter projects show the search and movement pieces separately. They use 2D cells, but the graph ownership is the same. This article doesn't stretch that example into a volumetric tutorial it hasn't built.

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

Run the verified starter

The prepared download contains the exact authored scene and scripts shown in this article. Download the Godot 4.7.1 source project, then inspect the machine-readable verification receipt beside it.

The receipt records the exact ZIP hash, source-file hashes, the 20 workspace checks, a clean extracted-project import, and the final package rerun: 21 named checks in total.

Measurement-gap: the proof covers the authored scene, saved bake, connected upper-platform route, grounded ramp traversal, target clamping, visible path, and avoidance wiring. It doesn't claim a crowd benchmark, runtime rebaking performance, or a general solution for volumetric movement.

This page is part of the maintained Game Pathfinding guide, which routes grid, graph, navmesh, shared-field, tactical, and diagnostic questions to the page that owns each implementation.

Frequently asked questions

Does Godot have built-in 3D pathfinding?

Yes. NavigationServer3D finds paths over navigation maps built from baked navigation meshes. NavigationRegion3D holds a region's mesh, while NavigationAgent3D provides a scene-facing path and avoidance helper. You can use the system from GDScript or C# without an add-on.

Should I use a navmesh or AStar3D for a 3D game?

Use a navmesh for grounded movement across continuous authored geometry. Use AStar3D when movement is limited to predefined points such as voxels, waypoints, stacked tiles, or a 3D lattice. Fully volumetric movement still needs a graph or another volume-aware representation.

Why does NavigationAgent3D return an empty path on the first frame?

The navigation map may not have synchronized its region data yet. Defer the first target and wait for at least one physics frame. If setup is more dynamic, also verify that the map has a registered region and usable navigation data before sending the query.

Does NavigationAgent3D move the CharacterBody3D automatically?

No. The agent returns path positions and, when enabled, a safe avoidance velocity. Your controller still updates velocity, applies gravity and floor rules, and calls move_and_slide().

Why does my 3D character get stuck on corners or ramps?

For corners and doorways, compare the physical collision radius with the navmesh bake radius. For ramps, inspect the collider transition as well as the baked overlay. A valid path can still approach a vertical lip or side face that the physics body can't climb.

Does NavigationObstacle3D make the agent find another path?

No. Avoidance changes local velocity; it doesn't alter the navigation path. Use changed navigation data, a link rule, or another route-validity mechanism when an obstacle must make a corridor unavailable. Compare avoidance with pathfinding.