Vav Labs
Back to blog

Godot pathfinding / 2026-06-23 / 18 min read

Updated 2026-07-30 · Verified as of Godot 4.7.1 stable, checked 2026-07-30

NavigationServer2D in Godot 4: setup, path queries, and debugging

Set up NavigationServer2D in Godot 4, validate reachable paths, connect regions and links, handle sync and rebakes, and diagnose empty or partial paths.

NavigationServer2D diagnostic illustration showing a path that stops at the edge of one navigation region before a disconnected target region.

Start here: symptom to cause to fix

Start with the symptom you can reproduce, then jump to the check that produces an observable verdict. Run the direct checked query before changing NavigationAgent2D movement settings; otherwise path-data failures and movement failures look identical.

A useful query has three verdicts: unsynced or empty, partial, and reached. A non-empty path is not automatically a success because its final point can stop on the closest reachable part of the start island.

Observable tell: every router destination ends in a status, count, iteration, metadata value, or debug-render feature that can confirm or reject the suspected cause.

What you seeMost likely causeGo here
Empty path on the first frameThe map has not synchronized, or the region contributes no usable polygonCheck map synchronization
Path has points but ends before the targetThe target is disconnected, excluded by layers, or outside the reachable islandRun the checked query
Regions overlap but the path stops at the seamTheir polygons do not have compatible connected edgesInspect region connections
A door, swim area, or special route is ignoredThe query mask, region layers, or route costs do not match the intended ruleCheck layers and costs
The path uses a link but the actor stops therePathfinding found the link, but gameplay code did not perform the traversalHandle the link segment
An avoidance obstacle does not reroute the pathAvoidance changed local steering, not the global navigation meshSeparate avoidance from rebaking
The route is stale after a runtime rebakeThe bake finished but the updated map iteration is not query-readyWait for a new iteration
Large request bursts spike the frameToo many searches, fragmented polygons, or repeated unreachable targetsApply the performance guardrails

Get one direct query working

The smallest scene needs a NavigationRegion2D with an assigned NavigationPolygon, plus start and target positions in global coordinates. If the polygon uses source geometry, bake it. If it is drawn or built manually, assign its vertices and polygon indices. In both cases the region must join the same navigation map that you query.

Make the first setup call deferred, wait for a physics frame, and confirm that map_get_iteration_id(map) is greater than zero. Then call the pasteable checked-query helper in the next section instead of treating any non-empty array as success.

Observable tell: the map iteration is greater than zero and the helper prints exactly one status=... verdict. If the iteration remains zero, do not tune the agent yet.

@onready var region: NavigationRegion2D = $NavigationRegion2D
@onready var start_marker: Marker2D = $Start
@onready var target_marker: Marker2D = $Target

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

func _first_query() -> void:
    await get_tree().physics_frame

    if region.navigation_polygon == null:
        push_error("NavigationRegion2D has no NavigationPolygon.")
        return

    var map: RID = get_world_2d().get_navigation_map()
    if NavigationServer2D.map_get_iteration_id(map) == 0:
        push_error("Navigation map has not synchronized yet.")
        return

    query_path_checked(
        start_marker.global_position,
        target_marker.global_position
    )

Paste this first: a checked path query

Paste this function into a Node2D that belongs to the world you are querying, then call it with global start and target positions. It prints one console verdict and returns the same evidence as a dictionary for your game or debug UI.

The last returned point is the reachability test. start_snap exposes a start far outside the mesh; target_error separates a reached target from a partial path that merely ended as close as the connected map allowed.

Observable tell: the console prints status=unsynced, empty, partial, or reached, followed by the reason and the values needed for the next check.

func query_path_checked(
    start_position: Vector2,
    target_position: Vector2,
    tolerance: float = 4.0,
    navigation_layers: int = 1
) -> Dictionary:
    var map: RID = get_world_2d().get_navigation_map()
    var iteration := NavigationServer2D.map_get_iteration_id(map)
    var report: Dictionary

    if iteration == 0:
        report = {
            "status": "unsynced",
            "reason": "The navigation map has never synchronized.",
            "path": PackedVector2Array(),
            "map_iteration": iteration,
        }
        print("[NavigationServer2D] status=unsynced reason=%s iteration=%d" % [
            report.reason, iteration
        ])
        return report

    var path := NavigationServer2D.map_get_path(
        map,
        start_position,
        target_position,
        true,
        navigation_layers
    )

    if path.is_empty():
        report = {
            "status": "empty",
            "reason": "A synchronized map returned no usable path; inspect regions, polygons, and layers.",
            "path": path,
            "map_iteration": iteration,
        }
        print("[NavigationServer2D] status=empty reason=%s iteration=%d" % [
            report.reason, iteration
        ])
        return report

    var start_snap := path[0].distance_to(start_position)
    var target_error := path[path.size() - 1].distance_to(target_position)
    var status := "reached" if target_error <= tolerance else "partial"
    var reason := (
        "The final point is inside the target tolerance."
        if status == "reached"
        else "The final point is outside tolerance; inspect connections and layers."
    )

    report = {
        "status": status,
        "reason": reason,
        "path": path,
        "map_iteration": iteration,
        "start_snap": start_snap,
        "target_error": target_error,
    }
    print(
        "[NavigationServer2D] status=%s reason=%s iteration=%d points=%d start_snap=%.2f target_error=%.2f"
        % [status, reason, iteration, path.size(), start_snap, target_error]
    )
    return report

The mental model

Think in layers. A navigation map is an isolated navigation world identified by an RID. Regions contribute NavigationPolygon geometry to a map. Direct queries read the synchronized map. Links add deliberate graph connections. Agents follow paths and optionally participate in avoidance. Obstacles affect future baking or local avoidance depending on how they are configured.

Those jobs are independent. Visible art and physics collision are not automatically navigation data. Avoidance does not prove that the global route is valid. A region can exist on the server while contributing no polygons. Two valid regions can still be disconnected.

Scene-authored games can use NavigationRegion2D, NavigationLink2D, and NavigationAgent2D nodes. Use the server RID API directly for procedural regions, additional maps, reusable query objects, or custom resource lifetimes. Any RID you create with the server must eventually be released with NavigationServer2D.free_rid().

Observable tell: if the direct query reports reached but the actor still does not move, the map and route are no longer the first suspect; inspect the agent-following and body-movement layer.

PartOwnsDoes not do
NavigationServer2DMaps, regions, queries, links, obstacles, and avoidance dataMove a game body for you
NavigationRegion2DOne region and its NavigationPolygonTurn visible floor art into walkable data automatically
NavigationPathQueryParameters2DLayer mask, metadata, post-processing, region filters, and search limitsSynchronize pending map changes
NavigationAgent2DPath-following state and optional avoidance for one actorRepair missing or disconnected map geometry
NavigationLink2DA route connection between two navmesh positionsPlay the jump, teleport, ladder, or door interaction

Map synchronization and rebake timing

NavigationServer setters are queued. Scene nodes and scripts can change maps, regions, links, and agents during a frame, but path queries see the synchronized server state. For initial scene setup, a deferred function followed by the next physics frame is the normal pattern.

For a runtime rebake, wait for both operations: the background bake must finish, then the navigation map must synchronize the updated polygon. Capture the map iteration after bake_finished and wait for the matching map_changed signal until that iteration changes.

Do not treat one map_changed emission by itself as proof that query data is ready. Confirmed issue #112652 demonstrates the first-emission trap in tested 4.5+ NavigationServer3D builds. The defensive 2D pattern here therefore does not rely on the signal alone. Keep the map_get_iteration_id() > 0 gate for initial setup. After a rebake, require the iteration to advance beyond the value captured after baking.

Do not build production flow around map_force_update(). Since Godot 4.4 moved map synchronization to an asynchronous process, it no longer reliably forces a synchronous update. Issue #104671 documents a freshly created NavigationServer3D map remaining unsynchronized after the call. Prefer map_get_iteration_id() together with map_changed.

Observable tell: the rebake is query-ready only when the map iteration is greater than the value captured after bake_finished. A map_changed signal with the same iteration is not the finish line.

func rebake_and_wait(region: NavigationRegion2D) -> void:
    if region.navigation_polygon == null:
        push_error("Assign a NavigationPolygon before baking.")
        return

    var map: RID = region.get_navigation_map()
    region.bake_navigation_polygon(true)

    if region.is_baking():
        await region.bake_finished

    var previous_iteration := NavigationServer2D.map_get_iteration_id(map)

    while NavigationServer2D.map_get_iteration_id(map) == previous_iteration:
        var changed_map: RID = await NavigationServer2D.map_changed
        if changed_map != map:
            continue

Paste this second: inspect region connections

Use this helper after the checked query reports partial and the final point stops at a region seam. It prints the connection count and every pathway door registered for that region in the synchronized map.

Overlapping regions are not automatically connected. Their navigation polygons need compatible shared edges, or nearly parallel edges close enough for the map's edge_connection_margin. Partial overlap and small floating-point differences are common reasons for a path that stops at a region boundary.

Turn on Visible Navigation and edge-connection debug rendering, then inspect the server instead of judging the artwork. region_get_connections_count() reports how many connections a synchronized region has, and the pathway start/end getters expose each connection door.

Use NavigationLink2D when the gap is intentional. Do not use a link to hide a missing polygon, wrong transform, mismatched layer, or broken edge.

Observable tell: connections=0 together with no edge-connection line in the debug render confirms that the server did not join that seam.

func print_region_connections(region: NavigationRegion2D) -> void:
    var region_rid: RID = region.get_rid()
    var count := NavigationServer2D.region_get_connections_count(region_rid)

    print("[NavigationServer2D] region=%s connections=%d" % [region.name, count])

    for index in range(count):
        print(
            NavigationServer2D.region_get_connection_pathway_start(region_rid, index),
            " -> ",
            NavigationServer2D.region_get_connection_pathway_end(region_rid, index)
        )

Use query objects when the result needs evidence

map_get_path() is the shortest API. Use NavigationPathQueryParameters2D and NavigationPathQueryResult2D when you need path length, region or link metadata, raw corridor inspection, simplification, search limits, or included/excluded region filters.

Create the parameter and result objects once and reuse them. Metadata is enabled by default; disable flags you do not consume when query volume matters. Raw-corridor output is useful for debugging, while corridor-funnel output is normally the better movement path.

Observable tell: path_types, path_rids, and path_owner_ids line up with the returned path points and identify the exact region or link used by the suspect segment.

OptionUse it whenTrade-off
PATH_POSTPROCESSING_CORRIDORFUNNELActors move freely inside irregular polygonsShortest movement path, but shaped by the polygon corridor
PATH_POSTPROCESSING_NONEYou need to see the raw selected corridorDiagnostic output, not usually the movement path
simplify_pathToo many minor points cause steering jitterAdditional query-time processing
included_regions / excluded_regionsA large map is partitioned into known region chunksThe caller must maintain correct RID filters
path_search_max_polygonsUnreachable searches need a bounded costA low limit can return a poor partial path
var query_parameters := NavigationPathQueryParameters2D.new()
var query_result := NavigationPathQueryResult2D.new()

func query_with_metadata(
    start_position: Vector2,
    target_position: Vector2
) -> NavigationPathQueryResult2D:
    query_parameters.map = get_world_2d().get_navigation_map()
    query_parameters.start_position = start_position
    query_parameters.target_position = target_position
    query_parameters.navigation_layers = 1
    query_parameters.metadata_flags = (
        NavigationPathQueryParameters2D.PATH_METADATA_INCLUDE_ALL
    )
    query_parameters.path_postprocessing = (
        NavigationPathQueryParameters2D.PATH_POSTPROCESSING_NONE
    )
    query_parameters.path_search_max_polygons = 4096

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

Obstacles and runtime rebaking

A NavigationObstacle2D has two separate jobs. With affect_navigation_mesh, its geometry removes walkable area the next time a navigation polygon is baked. With avoidance_enabled, it participates in local steering. Avoidance alone does not change a direct path query.

For runtime baking, source-geometry parsing and polygon baking are different costs. Parsing the SceneTree must happen on the main thread. The bake can run on a background thread. Reuse NavigationMeshSourceGeometryData2D when the same parsed geometry feeds multiple bakes, and prefer simple collision shapes over detailed visual geometry.

Large dynamic worlds should update bounded region chunks instead of rebaking one giant polygon. Align chunk baking rectangles and borders so neighboring region edges can connect consistently.

Observable tell: enabling avoidance changes the agent's safe velocity while the direct path stays identical. The path changes only after obstruction geometry participates in a bake and the map publishes a newer iteration.

MechanismChanges future path queries?Runtime note
affect_navigation_meshYes, after another bake and map syncRemoves geometry; it does not trigger the rebake itself
Static avoidance verticesNoHard local avoidance boundary; moving it requires a rebuild
Dynamic avoidance radius and velocityNoCheap to move and predictable, but unreliable as a hard blocker in narrow crowds
Region or polygon updateYes, after map syncUse bounded chunks and async baking where possible

Performance guardrails

Path-search cost follows polygon and edge count, not the visual size of the world. A large clean polygon map can be cheaper than a small TileMap-derived navmesh fragmented into thousands of tiny polygons.

Unreachable targets are a worst case because the search may explore every connected polygon before proving that the target cannot be reached. Do not run a separate reachability query before the real path query; inspect the last returned point once. Use search limits only when a bounded partial answer is acceptable.

Avoid resetting every agent target each frame, spread large request bursts across frames, and disable metadata you do not consume. Measure map_get_path() or query_path() call sites directly—for example, with Time.get_ticks_usec() around representative queries—and correlate those measurements with frame time. Use the Editor's Navigation Process counter for map updates and avoidance work, not path-query cost. If repeated path requests are the problem, continue with the pathfinding frame-spike guide.

Measurement-gap note: the downloadable proof for this guide verifies query correctness, map synchronization, region connections, and link metadata; it is not a performance benchmark. Profile your own polygon count, query mix, and target hardware before choosing a path-request budget.

Observable tell: custom timings around the query caller rise with request bursts or repeated unreachable targets, then fall when the same requests are budgeted across frames. A flat Navigation Process monitor does not rule out query cost.

Implementation checklist

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

Observable tell: the same reproducible query now prints reached, its final point is inside tolerance, and any remaining failure occurs in movement or special traversal rather than map data.

  1. Decide whether the game is navmesh-shaped or grid-shaped.
  2. Assign and, when source geometry is involved, bake a NavigationPolygon on a NavigationRegion2D.
  3. Wait for NavigationServer synchronization and confirm the map iteration is greater than zero.
  4. Query the map directly with NavigationServer2D.map_get_path().
  5. Classify the result as empty, partial, or reached by comparing the returned endpoints with the requested positions.
  6. If the path ends early, inspect region connections and navigation layers.
  7. If the path crosses a NavigationLink2D, hand that segment to gameplay code.
  8. Only debug NavigationAgent2D movement after the direct path verdict is correct.

Frequently asked questions

Why does NavigationServer2D return a path that does not reach the target?

Godot snaps the query endpoints to navigation mesh positions. If the requested target is outside the connected start island, the path can end at the closest reachable point. Compare the final path position with the requested target and apply an explicit gameplay tolerance.

How do I know when a NavigationServer2D map has synchronized?

NavigationServer2D.map_get_iteration_id(map) returns 0 before the map has ever synchronized. For initial setup, use a deferred function and wait for a physics frame. After runtime changes, watch the map_changed signal and confirm that the iteration ID increased.

Why do two overlapping NavigationRegion2D nodes not connect?

Overlap alone is not enough. Their polygons need compatible shared edges or nearly parallel edges within the map's edge_connection_margin. Use navigation edge debug rendering and region_get_connections_count() to verify the connection.

Does NavigationLink2D move or teleport the actor?

No. It adds a connection to pathfinding. Use path metadata to detect the link segment, then let gameplay code perform the jump, ladder, door interaction, platform ride, or teleport.

Does NavigationObstacle2D make path queries reroute?

Only after affect_navigation_mesh contributes obstruction geometry to a new bake and the map synchronizes. Runtime avoidance_enabled steers avoidance agents locally but does not rewrite the global path by itself.

Should a tile-based game use NavigationServer2D or AStarGrid2D?

Use AStarGrid2D or a custom grid when cells, blockers, weights, movement ranges, and dirty updates define the rules. Use NavigationServer2D when the world is better represented as continuous navigation mesh regions.