Part 03 — off the grid, into the mesh
You will build: The same level made walkable by a NavigationRegion2D whose polygon is baked from the static collision shapes you already have, queried directly through NavigationServer2D.map_get_path() with no agent node anywhere in the scene. The mesh route draws on screen next to the grid route from part 02, so you can see where the two disagree.
Part 02 left you with a grid that knows where the walls are. Every cell is a point, solid cells are switched off, and a query returns a list of cells to walk through. That model has a hard cost: it charges you for area. This part replaces the area model with a shape model, and the swap is not a straight upgrade — it wins some situations and loses others. You will end up owning both.
Two ways to describe "where can I walk"#
A grid quantises space. You choose a cell size, you slice the world into a lattice, and walkability becomes a per-cell boolean. Nothing about that depends on the level's shape. An empty room and a maze cost exactly the same, because the cost is the number of cells.
Do the arithmetic on a real number. A 4000-by-4000 pixel field with 32-pixel tiles is 125 by 125 cells: about 15,600 points, fine. Drop to 4-pixel precision on the same field and it is 1000 by 1000 — a million points to allocate and search, for a field with nothing in it.
A navigation mesh describes the same field differently. It stores walkable space as a set of convex polygons stitched together along shared edges. An empty 4000-by-4000 field is one polygon: four vertices. Add a round pillar in the middle and it becomes a handful. The cost tracks how complicated the shape is, not how large the area is or how finely you want to measure it.
That is the whole difference, and it decides more projects than any benchmark will:
- A grid pays for area and resolution. Precision is free to describe and expensive to store.
- A mesh pays for geometric complexity. Area is nearly free; a thousand fiddly little obstacles is not.
There is a second difference that shows up the first time you look at a mesh path. Grid paths are made of cell steps, so they are stair-shaped and have as many points as cells crossed. Mesh paths cross large polygons in one hop, so a route across an open room is often two points: start and end. You will see this directly in a few minutes.
The fork, stated honestly#
Neither model is the correct one. Here is where each earns its place.
A grid wins when a cell is already a concept your game's rules use. If your gameplay code asks "which tile is that", "what terrain is on this tile", "can I build here", "is this tile flooded" — you already have a grid, and pathfinding on it costs you nothing extra in concepts. Per-tile movement cost is a one-line change (set_point_weight_scale()), and it is genuinely awkward to express on a mesh. Tile-based building games, tactics grids, farming sims, tower defense: grid.
A mesh wins when geometry is free-form. Angled walls, curved caves, hand-placed rocks at arbitrary rotations, large outdoor areas, anything where snapping the world to a lattice would be a lie you then have to maintain. Mesh paths also come out looking like something a person would walk, rather than something that only turns at 45 degrees.
The deciding question is not "which is faster". It is: does your design already talk about cells? If your rules reference tiles, use the grid you already have. If your level is drawn rather than laid out, bake a mesh. If you genuinely need both — grid for building rules, mesh for how the villager walks there — running both is normal and not a design failure.
Build the region#
Open the level scene from part 02. Add a NavigationRegion2D as a child of the level root. Its navigation_polygon slot is empty in the inspector; create a new NavigationPolygon resource there.
Then move your wall bodies so they sit under the NavigationRegion2D. That reparenting is not tidying. The default parse mode reads the region's own subtree and nothing outside it, so walls left as siblings of the region are invisible to the bake and you get a polygon that covers them as though they were floor.
Now set three properties, and understand what each one decides.
parsed_geometry_type — what kind of nodes count as source geometry. Set it to Static Colliders. The default is Both, which also pulls in mesh instances; in a 2D project built on collision shapes, Both gives the parser more to chew on for no gain.
parsed_collision_mask — which physics layers count. It defaults to every layer on. Narrow it to the layer your walls live on. If you skip this, every static body in the scene carves the mesh, including trigger volumes and one-way ledges you never meant to be walls.
source_geometry_mode — where the parser starts walking the tree. Root Node Children (the default) recursively parses the NavigationRegion2D itself and everything beneath it. Nothing outside that subtree is seen, which is the reason the walls had to move a moment ago. The two group modes are the alternative: tag nodes into a group, name that group in source_geometry_group_name, and parse only those, leaving the scene arranged however you like. That is also what you want once a level has multiple regions that must not eat each other's geometry.
Then press Bake NavigationPolygon in the toolbar and look at the result. You should see a translucent polygon covering the floor and stopping at your walls.
Why bake from collision shapes rather than art#
You have two candidate sources for "where is a wall": the sprites, and the colliders. Bake from the colliders.
The walls the agent must respect are the walls physics already knows about. If the mesh comes from the same shapes that stop a CharacterBody2D, the two can never disagree — a path is never routed through something the body will collide with, because both read the same truth. Bake from art instead and you now maintain two definitions of solidity, and they will drift the first time someone nudges a sprite without moving its collider. That bug is miserable, because the agent walks confidently into a wall and everything looks right.
There is a performance reason too. The docs are direct about per-cell tile geometry being heavy to parse, and recommend physics shapes as the source when you rebake at runtime. So the choice that is harder to get wrong is also the cheaper one.
agent_radius, and the corridor that vanishes#
Look at the NavigationPolygon inspector again and find agent_radius. It defaults to 10.0.
That number shrinks the baked mesh inward from every wall, so that an agent of that radius has room to stand anywhere the mesh exists. It is doing you a favour: without it, paths would hug walls exactly, and any agent with a body wider than a point would grind along them.
Now make it hurt, because reading about this does not stick.
Build a corridor exactly one tile wide — 16 pixels, if that is your tile size — connecting two rooms. Bake. Then look at the corridor.
There is no mesh in it. None. The corridor is plainly, visibly walkable in the editor, your eyes can see the gap, and the navigation polygon stops at each end. agent_radius = 10.0 removes 10 pixels from each side of a 16-pixel corridor, which is minus four pixels of remaining space.
No error is printed. Nothing is red. Every path from one room to the other takes the long way around, or returns nothing at all if there is no long way.
This is the failure that sends people back to re-baking, adjusting outlines, deleting and recreating the region — an hour of work on a mesh that was correct the entire time. The bake did exactly what you configured it to do.
Your options, and they are real trade-offs:
- Lower
agent_radiusto something under half your narrowest corridor, then re-bake. Cheap, but you have told the navigation system your agents are smaller than they are, and a wide agent will now clip corners. - Widen the corridor to at least twice the radius plus a margin. Costs you level design, gives you honest clearance.
- Use separate regions with different radii if you have both a fat agent and a thin one. More setup, correct results.
Whichever you pick, the durable habit is this: when a route takes a strange detour, look at the mesh under the shortcut before you look at your code.
While you are in this inspector, note cell_size (default 1.0). It sets the rasterisation grid the baker uses, and the docs say it should match the navigation map's cell size. A mismatch produces subtly ragged or missing edges — another silent one.
Query it with no agent at all#
NavigationAgent2D is coming in part 04. Skipping it now is deliberate: the agent node wraps a query, a path cursor, arrival logic, and optional avoidance into one object, and if that object is the first thing you meet you will not be able to tell which of the four is misbehaving. Learn the query alone first.
Add a Node2D to the level, name it MeshProbe, and attach this script. It is the naive version. Type it as written.
extends Node2D
func _ready() -> void:
var map: RID = get_world_2d().get_navigation_map()
var path: PackedVector2Array = NavigationServer2D.map_get_path(map, Vector2(64.0, 64.0), Vector2(900.0, 600.0), true)
print("points in path: ", path.size())
Set those two positions to points that are obviously inside your baked mesh. Run the scene.
points in path: 0
Empty. The mesh is visibly correct in the editor. The coordinates are inside it. The call did not error.
Why it is empty#
Navigation lives on a server, and the server does not apply changes the instant you ask. From the class reference: most NavigationServer2D changes take effect after the next physics frame, not immediately. The server records your calls and executes them during its sync phase.
Your region registered itself during scene setup. _ready() runs before the first physics frame. So at the moment you queried, the map on the server was still empty — you asked a blank map for a route and it correctly told you there is no route.
There is a documented way to detect exactly this state:
if NavigationServer2D.map_get_iteration_id(map) == 0:
return
map_get_iteration_id() returns 0 when the map has never synchronised. That is not a heuristic; it is the guard the docs point you at. Treat it as a precondition on every navigation query you write, not as a one-time fix, because the same condition happens again any time you load a level and something queries early.
The other half of the fix is not querying that early in the first place. Waiting one physics frame is enough:
func _ready() -> void:
await get_tree().physics_frame
_run_query()
Use both. The await handles the normal case; the iteration-id guard catches the case where something else queries before you expected. (The server also emits a signal when a map changes — check the NavigationServer2D class reference if you want to react to synchronisation rather than wait for it.)
The probe, properly#
Replace the script with this. It queries on click, snaps off-mesh clicks onto the mesh, and draws both routes.
class_name MeshProbe
extends Node2D
## Where routes start from. Drop any Node2D in the level here.
@export var start_marker: Node2D
## Optional: the part 02 pathfinder, for side-by-side comparison.
@export var grid_source: Node
## Passed straight to map_get_path(). Toggle it and watch the shape change.
@export var optimize_path: bool = true
var _map: RID
var _mesh_route: PackedVector2Array = PackedVector2Array()
var _grid_route: PackedVector2Array = PackedVector2Array()
func _ready() -> void:
_map = get_world_2d().get_navigation_map()
await get_tree().physics_frame
print("map edge connection margin: ", NavigationServer2D.map_get_edge_connection_margin(_map))
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
var button_event: InputEventMouseButton = event
if button_event.pressed and button_event.button_index == MOUSE_BUTTON_LEFT:
_route_to(get_global_mouse_position())
func _route_to(target: Vector2) -> void:
if start_marker == null:
push_warning("MeshProbe has no start_marker assigned.")
return
if NavigationServer2D.map_get_iteration_id(_map) == 0:
push_warning("Navigation map has not synchronised yet — query skipped.")
return
# A click can land on a wall or outside the level. Snapping both ends onto
# the mesh turns "no route" into "the nearest sensible route".
var origin: Vector2 = NavigationServer2D.map_get_closest_point(_map, start_marker.global_position)
var destination: Vector2 = NavigationServer2D.map_get_closest_point(_map, target)
_mesh_route = NavigationServer2D.map_get_path(_map, origin, destination, optimize_path)
_grid_route = _query_grid(start_marker.global_position, target)
print("mesh points: ", _mesh_route.size(), " grid points: ", _grid_route.size())
queue_redraw()
func _query_grid(from_world: Vector2, to_world: Vector2) -> PackedVector2Array:
if grid_source == null or not grid_source.has_method("path_between"):
return PackedVector2Array()
return grid_source.path_between(from_world, to_world)
func _draw() -> void:
_draw_route(_grid_route, Color(1.0, 0.55, 0.1), 2.0, 3.0)
_draw_route(_mesh_route, Color(0.2, 0.85, 1.0), 4.0, 6.0)
func _draw_route(points: PackedVector2Array, color: Color, line_width: float, dot_radius: float) -> void:
if points.size() < 2:
return
# _draw() works in this node's local space; the routes are in global space.
var local_points: PackedVector2Array = PackedVector2Array()
for point: Vector2 in points:
local_points.append(to_local(point))
draw_polyline(local_points, color, line_width)
for point: Vector2 in local_points:
draw_circle(point, dot_radius, color)
Two things worth pausing on.
optimize has no default. map_get_path(map, origin, destination, optimize) requires the fourth argument. That is unusual enough to trip people, and the compiler will tell you. What it does is worth seeing rather than reading: run with optimize_path on, then off, and click the same target. Optimised routes cut across polygons and hug corners; unoptimised routes are more conservative and have more points. Look at both before you decide which your game wants.
map_get_closest_point() is the difference between a demo and a toy. Players click walls. Enemies get told to chase a target standing in a doorway. Without snapping, every one of those is an empty array and an agent that shrugs. Snapping first turns "invalid destination" into "as close as I can get", which is nearly always what the game meant.
Feeding it the grid route#
If you want the comparison drawn, add this method to your part 02 pathfinder script and assign that node to grid_source. It assumes your AStarGrid2D had its cell_size and offset set from the tile layer, which puts its points in the layer's local space — rename astar_grid and tile_layer to whatever you called them.
func path_between(from_world: Vector2, to_world: Vector2) -> PackedVector2Array:
var from_id: Vector2i = tile_layer.local_to_map(tile_layer.to_local(from_world))
var to_id: Vector2i = tile_layer.local_to_map(tile_layer.to_local(to_world))
if not astar_grid.is_in_boundsv(from_id) or not astar_grid.is_in_boundsv(to_id):
return PackedVector2Array()
var local_points: PackedVector2Array = astar_grid.get_point_path(from_id, to_id)
var world_points: PackedVector2Array = PackedVector2Array()
for point: Vector2 in local_points:
world_points.append(tile_layer.to_global(point))
return world_points
That conversion at the end is not decoration. get_point_path() returns positions in the grid's own space; the mesh route is in global space. Draw them together without converting and the two routes sit on top of each other only while the tile layer happens to be at the origin, then silently separate the day someone moves it.
Run it. Click around the level and read the printed counts. Across an open room the mesh route will often be two points against the grid's twenty. Through a twisty corridor the counts converge. That ratio is the mesh's advantage, made visible.
Two rooms that refuse to connect#
Regions are not global. Each NavigationRegion2D bakes its own polygon, and a path can only cross from one region into another where they are joined.
Build the failure on purpose, because you will hit it accidentally later otherwise. Make two NavigationRegion2D nodes for two rooms and arrange them so their polygons overlap by a comfortable margin. Bake both. Query from one room to the other.
Nothing. No route.
Overlap does not connect regions. From the class reference, plainly: overlapping two regions' navigation polygons is not enough for connecting them. Regions join along shared edges — an edge counts as connected to another when both of its vertices sit within edge_connection_margin of the corresponding vertices on the other edge.
So the fix is geometric, not numeric. Make the two polygons meet along a shared boundary rather than bleeding through each other. Line up the doorway edges.
The margin is the tolerance on "line up", and you can read the current value — the probe script prints it in _ready() — and adjust it:
NavigationServer2D.map_set_edge_connection_margin(_map, 4.0)
Raise it when your rooms are hand-placed and their edges are close but not exact. Do not raise it to paper over regions that are genuinely far apart: a large margin will happily connect edges across a wall you meant to be solid, and that bug is much harder to see than a missing connection.
Each region also has use_edge_connections, and the map has map_set_use_edge_connections(). Turning connections off for one region is the deliberate way to say "this island is reachable only through a link", rather than a thing you flip while guessing.
"My two rooms won't path into each other" is almost always this. Check edges before you check anything else.
The tilemap shortcut, and what it hides#
TileMapLayer.navigation_enabled is true by default. That means a TileMapLayer whose TileSet has a navigation layer feeds the navigation map on its own, with no NavigationRegion2D in the scene at all. Paint floor tiles, get a mesh.
For a tile-based level that is the least machinery for the result, and it updates when tiles change. If your level is a tilemap and you have no free-form geometry, take it.
Now the part that costs people an evening. If the TileSet has no navigation layer defined, the tiles contribute nothing. Navigation geometry is stored per tile, indexed by the TileSet's navigation layer index — TileData.get_navigation_polygon(0) needs a layer 0 to exist. No layer, no polygon, no mesh, no error message.
On screen this is indistinguishable from a failed bake: a level that looks walkable and every query returning empty. The tell is in the TileSet resource, not in the scene. Open the TileSet, confirm a navigation layer exists, then confirm the individual tiles actually have a navigation polygon painted on that layer. Both steps fail silently.
If you edit cells from code and need the change reflected immediately rather than next frame, notify_runtime_tile_data_update() and update_internals() are the methods to reach for — and the server sync rule still applies on top of that.
Rebaking at runtime#
Doors open. Walls get destroyed. When the level changes shape, the mesh has to change with it.
The editor button has a script equivalent, region.bake_navigation_polygon(), and it has a trap sitting in its default argument: on_thread defaults to true. The call returns immediately and the polygon is not ready yet. Query in the next line and you get the old mesh or no mesh. Wait for the bake_finished signal, or poll is_baking().
The lower-level path is the two-step one, and it is the current API:
func rebake_region(region: NavigationRegion2D) -> void:
var polygon: NavigationPolygon = region.navigation_polygon
var source_data: NavigationMeshSourceGeometryData2D = NavigationMeshSourceGeometryData2D.new()
# Walks the scene tree, so it must run on the main thread.
NavigationServer2D.parse_source_geometry_data(polygon, source_data, region)
NavigationServer2D.bake_from_source_geometry_data(polygon, source_data)
region.navigation_polygon = polygon
await get_tree().physics_frame
Parsing is separated from baking because only the bake is safe to move off the main thread — parsing reads nodes. That separation is the whole reason the old single-call API was replaced.
The await at the end is the same lesson as before: you have changed the map, and the change lands after the next physics frame. Anything that queries immediately after calling this will read the state from before your rebake.
The dead APIs you will meet in the next ten minutes#
This corner of Godot has more dead tutorials than live ones. Every one of these appears in results that look current:
Navigation2D— removed in Godot 4 entirely. Godot 3 tutorials build the whole scene around this node. If a tutorial adds one, close it.Navigation2DServer— renamed toNavigationServer2D. The dimension suffix moved to the end across the board in Godot 4.NavigationPolygonInstance— renamed toNavigationRegion2D. Very common in old screenshots and in the old Add Node dialog.get_simple_path()— gone. Its replacement isNavigationServer2D.map_get_path().make_polygons_from_outlines()— deprecated, replaced byparse_source_geometry_data()plusbake_from_source_geometry_data(). It is in nearly every runtime-navmesh tutorial written for Godot 4.0 through 4.2, which is why those tutorials look modern and still mislead.NavigationRegion2D.get_region_rid()— deprecated. Do not learn it as the way to reach a region's RID.TileMap(the node) — deprecated in favour ofTileMapLayer. Anything callingTileMap.get_navigation_map(layer)is targeting the old node.NavigationMeshInstance— that is 3D (NavigationRegion3Dnow). Worth flagging because 3D navigation tutorials get cross-applied to 2D constantly, and most of the concepts survive the trip while none of the class names do.
Checkpoint — definition of done#
- A
NavigationRegion2Din your level bakes a polygon that covers the floor and stops at the walls, withparsed_geometry_typeset to Static Colliders andparsed_collision_masknarrowed to your wall layer. - The naive
_ready()version of the probe printedpoints in path: 0on a mesh you could see was correct, and the deferred version printed a non-zero count with no other change. - You built a corridor narrower than twice
agent_radius, baked, and confirmed by eye that no mesh exists inside it — then made it appear by changing one number and re-baking. - Clicking inside a wall still produces a route, because
map_get_closest_point()snapped the destination onto the mesh first. - Both routes draw at once, and across an open room the mesh route visibly has far fewer points than the grid route. You have read the printed counts.
- Toggling
optimize_pathchanges the shape of the drawn mesh route, and you can say which setting your game should ship with and why. - Two regions whose polygons merely overlap refuse to path into each other, and aligning their edges fixes it.
- You can explain: why does a 4000-by-4000 empty field cost a mesh almost nothing and a fine-grained grid almost everything — and what would have to be true about your game for you to pay the grid's price anyway?
Stretch (no instructions)#
- Colour each mesh route segment by its length, so long open-space hops read differently from tight corner turns.
- Print
map_get_closest_point_owner()for a click, and use it to display which region a point belongs to. - Give one region a non-zero
travel_costand find a level layout where the path chooses the longer route through the cheaper region. - Time a thousand
map_get_path()calls against a thousand grid queries on the same level, then repeat it on a level four times the size, and see which one's timing changed.
If you get stuck#
- Every query returns an empty array, mesh looks fine in the editor → you queried before the map synchronised. Print
NavigationServer2D.map_get_iteration_id(map); if it is0, the map has never synced. Move the query behindawait get_tree().physics_frame. - Paths take a long detour around an obviously walkable corridor →
agent_radiusshrank the mesh out of it. Look at the baked polygon over that corridor, not at your code. Nothing errors when this happens. - The polygon bakes as empty or nearly empty →
parsed_collision_maskdoes not include the layer your walls are on, orparsed_geometry_typeis set to Mesh Instances in a project with no mesh instances. Also confirm the walls are actually children of theNavigationRegion2D— with the defaultsource_geometry_mode, anything outside the region's own subtree is never parsed. - Routes work inside each room but never between them → the regions overlap instead of sharing an edge. Align the doorway edges; check
map_get_edge_connection_margin()before changing it. - Tilemap-driven navigation contributes nothing → open the
TileSetand confirm a navigation layer exists and that individual tiles have a polygon painted on it. Both failures look identical to a broken bake from the scene view. - Both routes draw but they are offset from each other by a constant amount → the grid route was never converted out of the tile layer's local space, or the probe node is not at the origin. The
to_local()in_draw_route()handles the probe;to_global()inpath_between()handles the grid. map_get_path()errors about argument count →optimizehas no default value. Pass it explicitly.- A runtime rebake has no effect → either
bake_navigation_polygon()is still running on its thread (wait forbake_finished), or you queried in the same frame you rebaked. Both produce "my rebake did nothing". - A
NavigationObstacle2Ddoes not block anything → it is not supposed to by default. It affects avoidance velocity, not pathfinding. That distinction is part 04's problem.
Next: Part 04 — the agent that follows. Read it once your mesh routes are correct — the agent node wraps the query you wrote by hand, and its failures are much quicker to diagnose when you already know what a working query looks like.