Vav Labs
Back to blog

Godot pathfinding / 2026-08-24 / 16 min read

Verified as of Godot 4.7.2 stable, artifact verified 2026-08-24

NavigationServer3D in Godot 4: Path Queries, Map Sync, and Debugging

Use NavigationServer3D directly in Godot 4. Query a map RID, wait for synchronization, diagnose short paths, and inspect query metadata.

A NavigationServer3D diagnostic map with blue walk regions, an amber layer-two bridge, a disconnected island, and paths that either reach or stop short.

The direct answer

The first path you ask NavigationServer3D for can come back empty because the server has not published the map yet. After that is fixed, a path with several points can still stop at the edge of a platform. Both outcomes follow the API contract, but each needs a different check.

This is the 3D counterpart to the NavigationServer2D guide. If you are starting with an empty scene, the 3D pathfinding starter owns setup, baking, click-to-move, and body movement. This page starts with a map RID and a direct query.

NavigationServer3D answers from the last map iteration it published. Setters enter a physics-frame synchronization phase, and asynchronous region or map work can take additional frames. A map you just changed may not be the map you are querying yet. map_get_iteration_id(map) is 0 until the first publication and changes when the map publishes another iteration.

A non-empty map_get_path() result means the server found a route from the snapped start toward the closest reachable navigation-mesh point near the target. The final point is the evidence. Compare it with a snapped target reference instead of the raw request, which may float above or beside the mesh.

Two defaults matter before you trust a short path. map_get_path() creates a default NavigationPathQueryParameters3D, which caps the search at path_search_max_polygons = 4096. Its path_search_max_distance = 0.0 leaves distance unlimited. On a large or fragmented mesh, the polygon cap can also end a route early. For diagnosis, use a query object and set both limits to 0.

Start here: symptom to cause to fix

Reproduce the symptom, then jump to the check that gives you a number. Run the checked query before changing any NavigationAgent3D setting. Path-data failures and movement failures look identical from outside.

Observable tell: each route below ends in an iteration, endpoint error, metadata owner, or known query result that can confirm or reject the suspected cause.

What you seeMost likely causeGo here
Empty path during startup and map_get_iteration_id() == 0The map has not published a usable iteration. The path query itself may be silent. Closest-point getters emit the debug warningCheck synchronization
Path has points but appears to end before the raw targetThe target is off-mesh, disconnected, behind an excluded region, or the search hit its capRun the checked query
The route is stale after a runtime rebakeThe bake finished but the region or map has not published a newer usable iterationCheck rebake readiness
You need the region or link behind each path pointMetadata is disabled, or the short map_get_path() API is hiding itUse query objects
Two regions touch but the path stops at the seamTheir edges do not satisfy the direct endpoint-match case or the proximity marginCheck the 3D caveats
The path uses a link but the actor stops at its startPathfinding found the link. Gameplay code did not perform the traversalHandle the link handoff
A worker-thread request stalls or spikesMore callers are active than the query-slot ceiling, or SceneTree parsing left the main threadSeparate queries from parsing

When you need the server directly

NavigationAgent3D is a wrapper. It holds an RID, forwards property changes to the server, and advances its path logic when you call get_next_path_position() each physics frame. It issues new queries when its state requires them. That is the right tool for moving one body toward a point. It is the wrong tool when the question is about the map itself.

Use the server when you need a query without owning an agent, create maps or regions procedurally, inspect the region and link behind each path point, let one caller reuse a query-object pair, or isolate a map failure from a movement failure. Direct queries give you a verdict with numbers attached.

Everything on the server is identified by an RID. Maps are isolated navigation worlds. Regions contribute NavigationMesh polygons to one map at a time. Links add deliberate edges. Agents and obstacles participate in avoidance. Release every RID you create with NavigationServer3D.free_rid().

Paste this first: a checked path query

Drop this into a Node3D in the world you are querying and call it with global start and target positions. It prints one verdict and returns the same evidence as a dictionary.

The raw target may sit above the floor or slightly outside the mesh and still be reachable. After a synchronized map returns a path, the helper obtains a target reference with map_get_closest_point(). It reports that gap as target_snap_error, then compares the final path point with the snapped reference as endpoint_error. reached means the endpoint is inside tolerance. short means it is not.

map_get_closest_point() ignores navigation layers. The helper safely inspects the closest owner and reports whether that target owner matches the query mask. That is supporting evidence, not a complete diagnosis. An allowed target can still be unreachable because an intermediate region is excluded, as the artifact's layer-2 bridge demonstrates.

The default tolerance of 0.5 is two default cells. It is an editorial starting point, not an engine constant. Pick a tolerance from the game scale.

Observable tell: the returned dictionary separates iteration state, path-array state, target snap, final endpoint error, and the map-wide target owner's layer evidence.

Exact ZIP excerptscripts/articles/navigationserver3d_article_snippets.gd

func query_path_checked(
    start_position: Vector3,
    target_position: Vector3,
    tolerance: float = 0.5,
    navigation_layers: int = 1
) -> Dictionary:
    var map: RID = get_world_3d().get_navigation_map()
    var iteration := NavigationServer3D.map_get_iteration_id(map)
    if iteration == 0:
        return _report("unsynced", "The navigation map has never synchronized.", iteration)

    var path := NavigationServer3D.map_get_path(
        map, start_position, target_position, true, navigation_layers
    )
    if path.is_empty():
        return _report(
            "empty",
            "A synchronized map returned no path. Check that a region contributes polygons and matches the layer mask.",
            iteration
        )

    var target_on_mesh := NavigationServer3D.map_get_closest_point(map, target_position)
    var target_owner := NavigationServer3D.map_get_closest_point_owner(map, target_position)
    var target_owner_valid := (
        target_owner.is_valid()
        and NavigationServer3D.map_get_regions(map).has(target_owner)
    )
    var target_layers := 0
    if target_owner_valid:
        target_layers = NavigationServer3D.region_get_navigation_layers(target_owner)
    var target_owner_matches_layers := (
        target_owner_valid and (target_layers & navigation_layers) != 0
    )

    var start_snap_error := path[0].distance_to(start_position)
    var target_snap_error := target_on_mesh.distance_to(target_position)
    var endpoint_error := path[path.size() - 1].distance_to(target_on_mesh)
    var status := "reached" if endpoint_error <= tolerance else "short"
    var reason := "The endpoint is inside tolerance of the map-wide snapped target."
    if status == "short" and target_owner_valid and not target_owner_matches_layers:
        reason = "The map-wide target snap belongs to a region excluded by this mask; the layer-filtered target is ambiguous."
    elif status == "short":
        reason = "The endpoint stopped before the snapped target: disconnected topology, an excluded intermediate region, or the default search cap."

    var report := _report(status, reason, iteration)
    report.merge({
        "path": path,
        "start_snap_error": start_snap_error,
        "target_snap_error": target_snap_error,
        "endpoint_error": endpoint_error,
        "target_owner_valid": target_owner_valid,
        "target_owner_matches_layers": target_owner_matches_layers,
        "target_snap_scope": "map-wide; map_get_closest_point() is not layer-filtered",
    })
    print(
        "[NavigationServer3D] status=%s iteration=%d points=%d start_snap=%.2f target_snap=%.2f endpoint=%.2f reason=%s"
        % [status, iteration, path.size(), start_snap_error, target_snap_error, endpoint_error, reason]
    )
    return report

func _report(status: String, reason: String, iteration: int) -> Dictionary:
    if status != "reached" and status != "short":
        print("[NavigationServer3D] status=%s iteration=%d reason=%s" % [status, iteration, reason])
    return {"status": status, "reason": reason, "map_iteration": iteration}

Maps, RIDs, and synchronization

Most NavigationServer3D changes are queued instead of becoming visible immediately. The server accepts setter calls and processes them in a physics-frame synchronization phase. With asynchronous map and region iterations enabled, publishing the resulting navigation data can take additional frames.

Queries read the last published state. In the verified Godot 4.7.2 fixture, map_get_path() at iteration 0 returned an empty path without an engine error. The source's one-time warning about a query before first synchronization belongs to closest-point getters. Read the iteration ID instead of depending on console text.

The minimum guard below clears only the iteration-zero state. The checked query that follows is the semantic postcondition and may still report empty or short. For deterministic startup, also watch the relevant region iterations and poll a known query until it gives the expected answer or times out.

The final artifact run reached a usable known route at map iteration 2, after four observed physics frames and two map-specific map_changed emissions. These are observations from one controlled run, not engine timing guarantees.

Iteration IDs are unsigned 32-bit values that wrap to 1. Compare them with !=, not >. Maps and regions have separate IDs because their asynchronous update stages are separate.

Do not use map_force_update() as a readiness barrier. Godot deprecated it in 4.5 as incompatible with asynchronous updates and safe only in a single-threaded context. Poll the relevant iteration, or observe map_changed, then prove readiness with a query.

Observable tell: a non-zero map iteration clears the pre-sync state. The expected region iterations and a known query result establish usable geometry.

Production adaptationscripts/articles/navigationserver3d_article_snippets.gd

@onready var region: NavigationRegion3D = $NavigationRegion3D
@onready var start_marker: Marker3D = $Start
@onready var target_marker: Marker3D = $Target

func _ready() -> void:
    _first_query.call_deferred()

func _first_query() -> void:
    if region.navigation_mesh == null:
        push_error("NavigationRegion3D has no NavigationMesh.")
        return

    var map: RID = get_world_3d().get_navigation_map()
    if not await wait_for_first_published_iteration(map):
        return

    query_path_checked(start_marker.global_position, target_marker.global_position)

func wait_for_first_published_iteration(
    map: RID,
    timeout_seconds: float = 2.0
) -> bool:
    var deadline := Time.get_ticks_msec() + int(timeout_seconds * 1000.0)
    while NavigationServer3D.map_get_iteration_id(map) == 0:
        if Time.get_ticks_msec() > deadline:
            push_warning("Navigation map did not publish its first iteration before the timeout.")
            return false
        await get_tree().physics_frame
    return true

Rebake readiness: region, then map, then a query

A runtime rebake has three finish lines. Bake completion alone does not prove that the region and map have published the new navigation data. Next the region publishes an iteration, then the map rebuilds and publishes its own.

Capture both baselines after bake_finished, wait for the region iteration to change, then the map iteration, apply a timeout, and finish with a validation query that has a known answer in the game. A counter changing is a precondition, not the final proof.

The artifact verifies the same region-then-map-then-query sequence with a bridge enable or disable mutation. It does not execute runtime baking. This helper is parser-gated and backed by the 4.7 API and source contract rather than by the artifact's runtime gate.

Observable tell: the region and map IDs both differ from their baselines, then the known query returns the expected post-mutation status.

Exact ZIP excerptscripts/articles/navigationserver3d_article_snippets.gd

func rebake_and_wait(
    region: NavigationRegion3D,
    timeout_seconds: float = 2.0
) -> bool:
    if region.navigation_mesh == null:
        push_error("Assign a NavigationMesh before baking.")
        return false

    var map: RID = region.get_navigation_map()
    var region_rid: RID = region.get_rid()
    region.bake_navigation_mesh(true)
    if region.is_baking():
        await region.bake_finished

    var region_before := NavigationServer3D.region_get_iteration_id(region_rid)
    var map_before := NavigationServer3D.map_get_iteration_id(map)
    var deadline := Time.get_ticks_msec() + int(timeout_seconds * 1000.0)
    while NavigationServer3D.region_get_iteration_id(region_rid) == region_before:
        if Time.get_ticks_msec() > deadline:
            push_warning("Region iteration did not advance before the timeout.")
            return false
        await get_tree().physics_frame

    while NavigationServer3D.map_get_iteration_id(map) == map_before:
        if Time.get_ticks_msec() > deadline:
            push_warning("Map iteration did not advance before the timeout.")
            return false
        await get_tree().physics_frame
    return true

Query objects and metadata

map_get_path() is the short API. Use NavigationPathQueryParameters3D and NavigationPathQueryResult3D when you need the region or link behind each point, total path length, post-processing, or search limits you control.

A serial caller can create one parameter and result pair and reuse it. Do not mutate and share that pair across concurrent in-flight queries. Give each worker or caller its own objects. The query functions are thread-safe, but one shared mutable request is not the concurrency model tested here.

The result exposes path, scalar path_length, and the metadata arrays path_types, path_rids, and path_owner_ids. Those three arrays align with path points by index. The artifact checks the bridge RID, owner, and region type together at index 1, and the right-region association together at final index 3.

For diagnostics, set both search limits to 0. A capped search can build a route to the polygon found closest to the target so far, which resembles a disconnected result until the active limit is known.

Observable tell: each suspect path point has a same-index segment type, RID, and owner ID, while path_length matches the sum of the returned segments.

OptionUse it whenTrade-off
PATH_POSTPROCESSING_CORRIDORFUNNELActors move freely inside irregular polygonsShortest movement path shaped by the corridor
PATH_POSTPROCESSING_EDGECENTEREDEqual-sized polygons or grid-like movementLonger path with predictable edge waypoints
PATH_POSTPROCESSING_NONEYou need the raw selected corridorDiagnostic output rather than a movement path
simplify_path and simplify_epsilonMinor points create steering jitterAdditional query-time work
included_regions and excluded_regionsThe map is partitioned into known chunksYou maintain the RID lists. Exclusion wins when a region is in both
path_return_max_length and path_return_max_radiusYou want a clipped route legThe path ends early by design and is not an unreachable verdict
path_search_max_polygons and path_search_max_distanceProduction queries need a ceilingUse zero for diagnosis. A low limit returns a poor short path

Exact ZIP excerptscripts/articles/navigationserver3d_article_snippets.gd

var query_parameters := NavigationPathQueryParameters3D.new()
var query_result := NavigationPathQueryResult3D.new()

func query_with_metadata(
    start_position: Vector3,
    target_position: Vector3,
    navigation_layers: int = 1
) -> NavigationPathQueryResult3D:
    query_parameters.map = get_world_3d().get_navigation_map()
    query_parameters.start_position = start_position
    query_parameters.target_position = target_position
    query_parameters.navigation_layers = navigation_layers
    query_parameters.metadata_flags = NavigationPathQueryParameters3D.PATH_METADATA_INCLUDE_ALL
    query_parameters.path_postprocessing = NavigationPathQueryParameters3D.PATH_POSTPROCESSING_EDGECENTERED
    query_parameters.path_search_max_polygons = 0
    query_parameters.path_search_max_distance = 0.0

    query_result.reset()
    NavigationServer3D.query_path(query_parameters, query_result)
    return query_result

func describe_path(result: NavigationPathQueryResult3D) -> void:
    for index in range(result.path.size()):
        var kind := "region"
        if result.path_types[index] == NavigationPathQueryResult3D.PATH_SEGMENT_TYPE_LINK:
            kind = "link"
        var owner := instance_from_id(result.path_owner_ids[index])
        var owner_name: String = str(owner.name) if owner is Node else "server-owned"
        print("%d %s %s %s" % [index, result.path[index], kind, owner_name])

Short 3D caveats and where they go next

Cell size and cell height must match the map. The map and NavigationMesh defaults are both 0.25. A debug build warns when the map cell is larger than the mesh cell because mismatched settings can damage edge rasterization. Baking, obstacle carving, and separate maps for different actor sizes deserve their own treatment.

Seams are proven by a query, not a counter. The documented direct-merge case requires matching edge endpoint positions. Otherwise, nearly parallel edges can connect through edge_connection_margin. A direct edge-key merge does not appear in the proximity-connection count, and the free-edge counter also includes ordinary outer boundaries. The proof of a seam is a checked route across it.

A link does not move anyone. When an agent reaches a link position, gameplay code must perform the jump, teleport, ladder, door, or platform traversal. With metadata enabled, NavigationAgent3D.link_reached exposes the owner and entry or exit positions.

Queries are thread-safe, with a ceiling. Godot's 4.7 thread-safe API guide allows navigation queries from threads and true parallel execution. Additional callers wait when the navigation/pathfinding/max_threads slot pool is full. The default is 4. SceneTree source-geometry parsing remains a main-thread operation.

Avoidance is not pathfinding. An avoidance obstacle changes an agent's safe velocity. Avoidance alone does not change the route. A region, link, layer, or navigation-mesh data update can change it. Continue with NavigationObstacle avoidance vs pathfinding when local steering is the actual symptom.

Proof scope and measurement-gap. The downloadable receipt deliberately does not prove runtime baking, proximity seams, links, avoidance, concurrent throughput, timings, or arbitrary imported navigation geometry. It proves the seven controlled server-level behaviors named in the receipt.

Implementation checklist

Use this order for a new scene and for a path that is empty or ends short.

Observable tell: the same known query returns the expected status after synchronization. Any remaining failure now belongs to agent following, body movement, or special traversal rather than the direct route data.

  1. Bake a NavigationMesh on a NavigationRegion3D whose cell_size and cell_height match the map.
  2. Defer setup and poll with a timeout until map_get_iteration_id(map) != 0. Treat that as an iteration-zero guard, not proof that every expected region is current.
  3. Snap the target with map_get_closest_point() and record target_snap_error. Remember that the snap is map-wide rather than layer-filtered.
  4. Query with map_get_path() and compare the final point with the snapped target. Require the known expected status as the final startup postcondition.
  5. If the route is short, repeat it with a query object, zero search limits, and INCLUDE_ALL metadata. Read the owner of the final point.
  6. After a region mutation, capture baselines, wait for the region and map iterations to change, then repeat a query whose answer is known. For map-only changes, wait on the relevant map postcondition.
  7. Only then debug NavigationAgent3D and the body.

Frequently asked questions

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

The map may not have published a usable iteration yet. Server setters enter a physics-frame synchronization phase, while asynchronous region and map work can take more frames. Defer setup, poll map_get_iteration_id(map) with a timeout, and confirm readiness with a query whose result you know.

How do I know if a NavigationServer3D path reached the target?

Snap the target with map_get_closest_point(), then compare the final path point with that snapped reference. The raw target can float above or beside the mesh and still be reachable. The snap is map-wide, so on layer-filtered queries inspect its owner as supporting evidence rather than proof of where the route failed.

Why does map_get_path() stop early on a large navmesh?

map_get_path() uses a default query object whose path_search_max_polygons is 4096. When the cap is reached, the search can return a route toward the closest polygon found so far. For diagnosis, use query_path() with polygon and distance limits set to 0.

Is NavigationServer3D.map_get_path() thread-safe?

Yes. Godot 4.7 documents navigation queries as thread-safe and able to run in true parallel, with additional callers waiting at the max_threads slot ceiling. Do not share one mutable parameter or result pair across in-flight queries. SceneTree parsing for baking remains main-thread only.

Should I call map_force_update() to query in the same frame?

No. Godot deprecated it in 4.5 because it is incompatible with asynchronous updates and safe only in a single-threaded context. Poll map_get_iteration_id() or observe map_changed, then confirm the expected result with a query.

Does map_changed mean the map is ready after a rebake?

Not by itself. Region and map updates are separate asynchronous stages, and an emission can belong to another mutation. Capture the region and map iteration IDs after bake_finished, wait for both to change, then run a query whose answer you know.