Godot pathfinding / 2026-08-22 / 16 min read
Verified as of Godot 4.7.2 stable, build ed1daf0bf. Official docs, tagged source, and native proof checked on 2026-08-22
TileMapLayer Custom Data for Movement Costs in Godot
Store terrain costs in TileSet custom data, validate missing values, and import safe movement weights from TileMapLayer into AStarGrid2D.

Painted terrain still needs a cost model
Painting mud, road, and forest tiles does not give a pathfinder movement costs. The visual tile tells the player what the ground looks like. The navigation model still needs a stable fact it can read.
Godot's TileSet custom-data layers are a good place for stable authored facts. They are typed, belong to the tile definition, and can be read through the TileData associated with a placed TileMapLayer atlas cell.
The quiet failure is numeric. A typed float custom-data layer can return its default 0.0. AStarGrid2D accepts zero as a legal point weight, so an untouched terrain tile can become free to enter without raising an engine error.
The short answer
For one universal movement model, author a typed float custom-data layer named movement_cost, require every traversable atlas tile to have an explicit value of at least 1.0, normalize the placed cells, and apply the complete accepted batch after AStarGrid2D.update().
For infantry, vehicles, and other profiles that interpret the same terrain differently, author a stable terrain_id instead. Let each unit profile own the mapping from terrain identity to cost.
Keep walkable separate. Walkability answers whether a cell may be entered. Movement cost answers how expensive a legal entry is.
Custom data belongs to the tile definition
Custom data is attached to a tile definition in the TileSet. Every placed instance of that base or alternative tile reads the same authored value.
That is the right ownership model for stable terrain facts, but not for occupants, temporary fire, faction danger, action points, or other runtime state.
| Fact | Good TileSet custom data? | Reason |
|---|---|---|
walkable: bool | Yes | The base terrain decides whether it may be entered. |
terrain_id: String | Yes | Grass, road, water, and swamp identities are authored with the tile. |
movement_cost: float | Yes, for one shared model | Every query agrees on the same base number. |
| Current occupant | No | Occupancy belongs to placed runtime state. |
| Temporary fire penalty | No | A transient overlay should not rewrite the shared TileSet asset. |
| Unit action points | No | AP belongs to the unit and movement transaction. |
Choose the schema before writing the importer
Use a float layer when every path query agrees on the terrain cost. The safe baseline in this importer is movement_cost >= 1.0: grass and road may be 1.0, forest 2.0, and mud 4.0.
Use a string layer when different actors read the same terrain differently. The tile owns a stable ID such as grass, forest, mud, or road. A profile-owned table resolves the ID into a cost.
| Schema | Tile owns | Use when |
|---|---|---|
| Scalar cost | movement_cost: float | Every unit shares one terrain interpretation. |
| Terrain identity | terrain_id: String | Infantry, vehicles, and other profiles need different costs. |
One terrain identity can feed several profiles
This table is an article-level data shape rather than a verbatim package excerpt. It shows the ownership boundary. The TileSet names terrain, while each profile defines what that terrain costs.
A valid float layer can still hold an unpainted zero
The walkability guide owns the generic rule. TileData.has_custom_data() checks whether the named layer exists in the TileSet, not whether this tile was deliberately painted.
Movement costs add a more dangerous consequence. A typed float defaults to 0.0, while AStarGrid2D.set_point_weight_scale() rejects negative values but accepts zero. Under the ordinary weighted-grid model, entering a zero-weight destination costs zero.
The exact 4.7.2 source diagnostic for a negative value starts with Can't set point's weight scale less than 0.0. Its full format is Can't set point's weight scale less than 0.0: %f. An untouched zero does not trigger it, so enforce the minimum in the importer.
Validate the TileSet schema once
Resolve the layer ID before the cell loop. The packaged helper separates a missing node, missing TileSet, missing named layer, and wrong Variant type into structured results.
Call it with movement_cost and TYPE_FLOAT, or with terrain_id and TYPE_STRING.
Import placed atlas tiles into a normalized map
TileMapLayer.get_used_cells() returns coordinates containing a tile. get_cell_tile_data(cell) returns the associated TileData, or null for absent and non-atlas cells.
Validate and normalize without mutating the pathfinder. Each error includes the placed cell and the source identifiers needed to find the authored tile.
Where the importer plugs in
The full region, offset, and coordinate contract belongs to TileMap to Navigation Grid. Once that shape is prepared, preload the packaged helper and apply only a complete accepted import result.
Walkability and other point data are replayed in the same post-update() phase, but remain separate rules.
Validate the whole batch before the first write
The helper rejects a null or dirty grid, invalid baseline, non-Vector2i keys, out-of-bounds cells, non-finite values, and negative costs before it mutates the grid.
Only after that validation loop succeeds does it bulk-fill the baseline and write the per-cell overrides. Invalid input returns false without a partial mutation. The is_dirty() guard remains active in release builds.
Bulk-fill the baseline after update()
fill_weight_scale_region() fills the intersection of the requested rectangle and the grid region. It takes effect without another update().
Starting every prepared point at the explicit baseline 1.0 makes replay deterministic and covers grid cells without a per-cell override. A later structural update() clears point weights and solidity, so replay both layers afterward.
Weight is charged on entry
AStarGrid2D multiplies its computed step cost by the weight scale of the neighboring point being entered. A route total therefore skips the starting cell and includes every later destination cell.
This entry-cost rule keeps the importer and any displayed movement quote consistent. Returned-route cost reconstruction, sub-1.0 heuristics, and diagonal math belong to the weighted terrain guide.
Several unit profiles need terrain identity
One AStarGrid2D point has one current weight_scale. If infantry and vehicles need different preferences, one shared mutable grid cannot express both profiles simultaneously.
The packaged terrain importer accepts a known-ID vocabulary, rejects empty IDs, and reports unknown_terrain_id before a profile query begins.
Resolve one selected profile
After import, the packaged resolve_profile_costs() helper maps each normalized terrain_id to the selected profile's cost. It rejects missing IDs, non-finite costs, and values below the package's 1.0+ minimum-cost policy.
The search architecture can use one prepared grid per stable profile, serialized weight application, or a custom profile-aware query layer. That route-selection architecture remains with the weighted terrain guide.
Runtime modifiers belong outside the TileSet
A rainstorm may make mud slower, fire may add a temporary penalty, and a unit may occupy a cell for one turn. Those values change independently from the shared TileSet asset.
Compose runtime overlays with the authored base in gameplay state. Do not use TileMapLayer.notify_runtime_tile_data_update() as an AStarGrid2D refresh button. It updates TileMapLayer internals and does not write a separate grid for you.
For structural grid changes and replay ownership, continue with runtime AStarGrid2D updates.
Keep Jump Point Search off for weighted terrain
Godot 4.7 documents that enabling jumping_enabled disables consideration of weight scaling. The tagged 4.7.2 source shows the same branch. Ordinary expansion reads the destination point's weight, while the jumping branch keeps the local scale at 1.0.
An importer cannot repair that mismatch. If movement costs must affect route choice, keep jumping off and use the weighted terrain guide for the complete heuristic tradeoff.
Failure modes worth naming
These checks catch the common cases where the importer appears to work but the resulting routes are wrong.
| Symptom | Likely cause | First check |
|---|---|---|
| Unassigned tiles are strongly preferred | Typed float default became 0.0 | Reject values below the declared minimum. |
| Every tile reports custom data exists | has_custom_data() checked the TileSet layer | Validate the returned value or use an invalid sentinel. |
| A used cell has no TileData | It is empty or not backed by an atlas source | Reject it or route that source type to another policy. |
| An alternative tile has the wrong cost | Its own value was never authored | Inspect its alternative tile ID and custom data. |
| Weights disappear after a shape change | update() cleared point data | Replay normalized solids and weights. |
| Weights change but the route does not | Jumping is enabled or the other route still costs more | Disable jumping, then reconstruct route cost. |
| Infantry and vehicles prefer the same cells | One scalar cost was shared across profiles | Store terrain identity and resolve a selected profile. |
What the native artifact proves
The MIT-licensed source package contains the importer, a real serialized TileSet, its reproducible fixture generator, and the verifier. The machine-readable receipt reports 14/14 checks, including a clean extracted-package rerun on Godot 4.7.2.
The untouched fixture is atlas tile 2:0/0. The verifier opens the .tres as text, proves that 2:0/0/custom_data_0 is absent, then loads the TileSet and observes the typed runtime value 0.0. This distinguishes a genuine default from an explicit zero serialized by an editor interaction.
The receipt also covers schema errors, atlas alternatives, scene-collection rejection, baseline fill, apply-after-update, clear-and-replay, JPS interaction, terrain-ID validation, and two profile resolutions. The ZIP is 8,996 bytes with SHA-256 09431778306a12ba8e54b17510791e369335038807951d1b760b221593f5b695. Measurement-gap: This is a correctness proof, not a performance benchmark.
- 13 deterministic workspace checks passed.
- The extracted ZIP returned the same 13 check IDs.
- The packaged fixture retained the untouched-property absence check.
- No Web-export, throughput, allocation, or memory claim is made.
The complete workflow
Keep the importer boring and make invalid authoring visible before it becomes a plausible but wrong route.
- Add typed custom-data layers to the TileSet.
- Paint explicit values on base and alternative atlas tiles.
- Validate layer names and Variant types once.
- Read used cells and reject unsupported sources.
- Normalize costs or terrain IDs without mutating the grid.
- Stop on authoring errors and report the tile identifiers to repair.
- Build the grid using the coordinate and empty-cell policy from the TileMap-to-grid guide.
- Apply walkability after the shape update.
- Bulk-fill the movement baseline and apply the complete accepted override batch.
- Keep jumping off when weights must affect route choice.
- Replay point data after any later structural update.
- Keep occupants, temporary penalties, and AP outside the TileSet asset.
Frequently asked questions
How do I store movement costs in a Godot TileMapLayer?
Create a typed custom-data layer in the TileSet, assign an explicit value to each atlas tile, read placed TileData through TileMapLayer.get_cell_tile_data(), validate the complete batch, and copy accepted values into the pathfinding model after AStarGrid2D.update().
Why does an unpainted movement_cost return 0.0?
Typed custom-data values are default-constructed when needed, and the documented float default is 0.0. If zero is not a valid authored cost, reject it explicitly or use a terrain ID with an empty-string sentinel.
Can an alternative tile have a different movement cost?
Yes. Custom data belongs to the tile definition, and an alternative atlas tile can carry values different from its base tile.
Do I call AStarGrid2D.update() after setting movement weights?
No. Set or fill weight calls take effect without update(). Call update() first after shape changes, then replay solidity, baseline weights, and per-cell overrides. A later update clears point data.
Why does AStarGrid2D ignore my TileMap movement costs?
First confirm that weights were applied after update(). Then check jumping_enabled: Godot currently ignores weight scaling while Jump Point Search is enabled.
Should I use TileSet navigation layers or NavigationRegion travel cost instead?
Use those for Godot's NavigationServer2D and navigation-polygon pipeline. TileSet navigation layers, NavigationRegion2D.enter_cost, and travel_cost do not write AStarGrid2D point weights. Choose between a grid and navmesh.
How should different unit types read the same terrain?
Store a stable terrain ID on the tile, validate it against a known vocabulary, then resolve it through the selected unit profile's cost table.
Should runtime blockers or action points live in TileSet custom data?
No. TileSet custom data is shared tile-definition data. Occupancy, temporary penalties, buffs, faction danger, and AP belong to runtime systems that compose with the authored base.