Godot pathfinding / 2026-06-27 / 12 min read
Verified as of Godot 4.7 stable docs and Godot 4.7-stable proof workspace on 2026-06-27
TileMap to Navigation Grid in Godot
Build an AStarGrid2D from a Godot TileMapLayer using get_used_rect(), tile custom data, and set_point_solid(). Verified in Godot 4.7.
The tilemap is painted. The grid is not.
You painted a level in TileMapLayer. The walls are there. The water is there. The little bridge that only your game rules understand is there too.
If you came here searching for TileMap, use TileMapLayer in Godot 4.x; the old TileMap node has been deprecated since Godot 4.3.
Then you ask AStarGrid2D for a path and it walks straight through the scenery like nothing happened.
That is not a Godot bug. It is a missing bridge.
TileMapLayer stores your painted cells. AStarGrid2D stores a separate grid of pathfinding points. A navigation layer on a tile map belongs to Godot's navigation system, not to AStarGrid2D. If you want a grid pathfinder to respect the level you painted, you have to build that grid from the tile data.
This article is that bridge.
The short answer
To build an AStarGrid2D from a TileMapLayer, set grid.region = tile_layer.get_used_rect(), match grid.cell_size to the TileSet tile size, call grid.update() once, then mark blocked cells with set_point_solid().
TileMapLayer cell coordinates can stay as AStarGrid2D point IDs. You usually do not need to normalize the map to (0, 0).
The basic pipeline is deliberately small:
- Read the used rectangle from a
TileMapLayer. - Create an
AStarGrid2Dwith the same region and tile size. - Decide which cells are solid.
- Write those cells with
set_point_solid(). - Ask for a path using tile coordinates as point IDs.
The negative-origin check
The important part is that you do not need to normalize the tile map to (0, 0).
I verified this in Godot 4.7 with a deliberately awkward map. AStarGrid2D accepted the negative region origin, accepted set_point_solid(Vector2i(-4, -3)), and returned paths using native negative point IDs.
That one detail removes a lot of coordinate bookkeeping. The local verification artifact for this article is available as tilemap-to-navigation-grid-verification.json. It is a correctness proof with pass/fail cases, not a performance benchmark.
Rect2i(-4, -3, 18, 12)TileMapLayer navigation is not AStarGrid2D
Godot gives you more than one pathfinding system.
TileMapLayer can participate in Godot's navigation stack through navigation layers and NavigationServer2D. That is useful when you are using the engine navigation system.
AStarGrid2D is different. It is a grid pathfinder object. It does not inspect your tile map. It does not read your navigation polygons. It does not know that a wall tile means solid unless you tell it.
That separation is useful once you accept it. It means your grid can be driven by the rules of your game, not only by collision polygons or editor navigation data.
- A wall tile can be blocked in every mode.
- A water tile can be physically open but gameplay-blocked.
- A bridge tile can have collision but still be walkable by your units.
- Empty cells can be treated as walkable or solid depending on the game.
The minimal build step
Here is the shape of the build step. The two quiet decisions are grid.region = tile_layer.get_used_rect() and using tile coordinates directly as point IDs.
func build_grid_from_tilemap(tile_layer: TileMapLayer) -> AStarGrid2D:
var used_rect := tile_layer.get_used_rect()
var tile_size := tile_layer.tile_set.tile_size
var grid := AStarGrid2D.new()
grid.region = used_rect
grid.cell_size = Vector2(tile_size)
grid.offset = Vector2(tile_size) / 2.0
grid.update()
for y in range(used_rect.position.y, used_rect.position.y + used_rect.size.y):
for x in range(used_rect.position.x, used_rect.position.x + used_rect.size.x):
var cell := Vector2i(x, y)
var solid := is_cell_solid(tile_layer, cell)
grid.set_point_solid(cell, solid)
return gridDo not normalize unless you have to
A common instinct is to normalize the used rectangle to (0, 0). That gives the grid point IDs from (0, 0) to (width - 1, height - 1), while the tile map still has cells from used_rect.position to used_rect.end.
Every path request now needs translation. Every debug overlay needs translation. Every click-to-cell edit needs translation. And eventually one of those conversions gets missed.
The cleaner version is just this:
grid.region = tile_layer.get_used_rect()The offset is a different problem
The grid's cell_size should match the TileSet tile size. The offset controls where the point sits inside the cell when converting between grid IDs and positions. For a normal square tile grid, center the point:
grid.cell_size = Vector2(tile_layer.tile_set.tile_size)
grid.offset = Vector2(grid.cell_size) / 2.0Keep point IDs and world positions separate
The origin belongs in grid.region. The intra-cell center belongs in grid.offset.
If your TileMapLayer node itself is moved, scaled, or nested under a transformed parent, include that transform when converting cell IDs to world positions. The grid point ID can still be the tile cell. The drawn position is where transforms enter the picture.
For an untransformed layer, tile_layer.map_to_local(cell) should agree with an AStarGrid2D point position when grid.offset = cell_size / 2.
Deciding walkability
The grid does not care why a point is solid. It only needs a boolean. The question is where that boolean comes from.
| Source | Best for | Pitfall |
|---|---|---|
| Custom data | Gameplay walkability rules: water, bridges, terrain types, movement profiles | Needs TileSet discipline; every relevant tile should define the rule |
| Physics layer | Prototypes where collision and movement mean the same thing | Collision is often not navigation; projectiles, cover, placement, and units may need different rules |
| Atlas IDs | Tiny prototypes where one tile art asset maps to one rule | Brittle when art changes or multiple tiles share the same movement behavior |
Custom data is usually the game rule
For most game rules, custom data is the best source. In the TileSet, add a custom data layer such as walkable: bool, then each tile decides its gameplay meaning. The focused version of that rule lives in TileMapLayer custom data for walkability in Godot.
Water can be non-walkable without needing collision. A bridge can be walkable even if it has collision for some other system. Terrain cost, faction rules, unit movement type, and temporary blockers can all grow from the same idea.
func is_cell_solid(tile_layer: TileMapLayer, cell: Vector2i) -> bool:
var data := tile_layer.get_cell_tile_data(cell)
if data == null:
return true
if not data.has_custom_data("walkable"):
return true
return not bool(data.get_custom_data("walkable"))Physics and atlas IDs are narrower tools
Physics-derived walkability can work when collision means cannot walk here. It is convenient for prototypes, but it is easy to over-trust. Collision is not always navigation. A tile may have collision for projectiles, placement, cover, selection, or visual reasons.
Atlas IDs can also work for tiny prototypes where the art and the rule are genuinely the same thing. They get brittle once tile art changes or several tiles share the same movement behavior.
func is_cell_solid_by_physics(tile_layer: TileMapLayer, cell: Vector2i) -> bool:
var data := tile_layer.get_cell_tile_data(cell)
if data == null:
return true
return data.get_collision_polygons_count(0) > 0Empty cells are a design choice
get_used_rect() gives you the rectangle that encloses used cells. Inside that rectangle, some cells may be empty.
For an authored dungeon, empty may mean solid void. For an overworld map, empty may mean open ground. Pick the policy explicitly. Do not let null tile data become accidental pathfinding behavior.
Call update() for shape, not for every solid cell
AStarGrid2D.update() rebuilds the grid after shape-related properties change. Use it after setting the region, cell size, offset, diagonal mode, and similar grid-shape configuration.
Then write solidity. Do not call update() again after every set_point_solid().
Solidity is point data. The grid shape did not change. Calling update() repeatedly there is the kind of small waste that hides in beginner code until the map gets bigger. This is the same point-data boundary used in the tower-defense path validation proof.
grid.update()
for cell in cells:
grid.set_point_solid(cell, is_cell_solid(tile_layer, cell))Put the conversion in one helper
In a real project, put this conversion in one small helper instead of spreading it across your scene script. The downloadable proof workspace includes a fuller version with skipped-cell reporting, point-write counts, custom-data mode, physics-layer mode, and empty-cell policy.
enum WalkabilitySource {
CUSTOM_DATA,
PHYSICS_LAYER,
}
enum EmptyCellPolicy {
SOLID,
WALKABLE,
}
func build_from_tilemap_layer(
tile_layer: TileMapLayer,
walkability_source: WalkabilitySource,
empty_policy: EmptyCellPolicy) -> Dictionary:
var used_rect := tile_layer.get_used_rect()
var cell_size := tile_layer.tile_set.tile_size
var grid := AStarGrid2D.new()
grid.region = used_rect
grid.cell_size = Vector2(cell_size)
grid.offset = Vector2(cell_size) / 2.0
grid.update()
for y in range(used_rect.position.y, used_rect.position.y + used_rect.size.y):
for x in range(used_rect.position.x, used_rect.position.x + used_rect.size.x):
var cell := Vector2i(x, y)
var solid := _is_solid(tile_layer, cell, walkability_source, empty_policy)
grid.set_point_solid(cell, solid)
return {
"grid": grid,
"used_rect": used_rect,
"region": grid.region,
"cell_size": cell_size,
"offset": grid.offset,
}Asking for a path
Once the grid is built, path requests are simple. Because the region kept the TileMapLayer origin, start and goal are tile coordinates.
The path returned by get_id_path() is also a list of tile coordinates. If you need world positions for drawing or movement, convert with the layer transform.
func get_tile_path(grid: AStarGrid2D, start: Vector2i, goal: Vector2i) -> Array[Vector2i]:
if not grid.is_in_boundsv(start) or not grid.is_in_boundsv(goal):
return []
if grid.is_point_solid(start) or grid.is_point_solid(goal):
return []
return grid.get_id_path(start, goal)
func cell_center(tile_layer: TileMapLayer, cell: Vector2i) -> Vector2:
return tile_layer.map_to_local(cell)
var world_pos := tile_layer.to_global(tile_layer.map_to_local(cell))Runtime changes
If the tile map changes at runtime, rebuild the affected navigation data. For a small map, rebuilding the whole AStarGrid2D is often fine. That is exactly what the demo does: you paint terrain, the grid rebuilds, and the walker reroutes.
For a larger game, rebuilding the whole grid on every door, bridge, explosion, or tower placement is not the end state. You will want runtime blocker updates without a full rebuild. Track which cells changed. Rewrite only those points. Re-query only the agents or routes that depend on them.
PathForge is built around that kind of work: dynamic blockers, movement ranges, editor diagnostics, and dirty navigation updates for games where movement is part of the design, not a decorative line on the floor.
What the proof demo shows
The interactive demo for this article is deliberately small. It is a paint sandbox, not a benchmark.
Measurement-gap: the published artifact is a correctness verification harness, not a timing dataset. Add Dataset schema only after reproducible performance captures exist.
The default Mismatch preset has water and bridge tiles outlined in magenta. Switch between Custom data and Physics layer and the derived grid changes. Switch from Native (-4,-3) to Ignore offset and the demo stamps MISALIGNED across the board.
- A painted
TileMapLayercan be converted into anAStarGrid2D. get_used_rect()can be used directly as the grid region.- Negative tile coordinates can remain native grid point IDs.
- Custom-data walkability and physics-layer walkability can disagree.
set_point_solid()writes point data after one shapeupdate().

The offset still
The offset issue does not read well in motion, so the proof package captures it as a separate still. The left side keeps the native region. The right side ignores the origin and shows the misaligned result.

Common failure modes
These are the boring ones to check before rewriting the pathfinder.
The grid starts at zero, the tile map does not
Symptom: the path appears shifted, skipped, or empty when the map has negative coordinates or a non-zero used rectangle.
Fix: use the used rectangle as the grid region: grid.region = tile_layer.get_used_rect().
The path ignores walls
Symptom: get_id_path() returns a route through blocked tiles.
Fix: make sure you call set_point_solid() after grid.update(), and make sure your walkability rule reads the correct tile data.
Physics and custom data disagree
Symptom: physics-layer mode and custom-data mode produce different routes.
Fix: decide which source represents gameplay. If collision and movement are not the same rule, use custom data for movement.
Empty cells behave strangely
Symptom: paths cut through holes in the authored map, or refuse to use unpainted ground.
Fix: choose an empty-cell policy. Make it visible in code.
update() is inside the cell loop
Symptom: rebuilding is slower than it needs to be, or point data disappears after you write it.
Fix: configure shape, call update() once, then write point solidity.
Frequently asked questions
Does TileMapLayer have built-in pathfinding?
TileMapLayer can participate in Godot's navigation system through navigation layers and NavigationServer2D. That is separate from AStarGrid2D. If you want AStarGrid2D to use tile data, build the grid manually from the tile map.
How do I build an AStarGrid2D from a TileMapLayer?
Create a new AStarGrid2D, set region to tile_layer.get_used_rect(), set cell_size from the TileSet tile size, set offset to half a cell, call update(), then iterate the used rectangle and write blocked cells with set_point_solid().
Do I need to normalize TileMapLayer coordinates to zero?
No, not for the normal case. The proof for this article verifies that AStarGrid2D.region can keep a negative origin in Godot 4.7. That means TileMapLayer cell coordinates can be used directly as AStarGrid2D point IDs.
Why does my AStarGrid2D not line up with my tilemap?
The usual cause is mixing coordinate systems. Keep grid.region equal to tile_layer.get_used_rect(), use tile coordinates as point IDs, and only use offset to center points inside cells. If the TileMapLayer node is transformed, include that transform when converting cells to world positions.
Should walkability come from physics or custom data?
Use custom data when movement is a gameplay rule. Use physics only if collision and movement mean the same thing in your project. They often start the same and then diverge.
Do I call update() after set_point_solid()?
No. Call update() after grid shape changes, such as region or cell size. Write solidity with set_point_solid() after that. The verification harness for this article checks that point solidity works without another update().