Vav Labs
Back to blog

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.

A Godot TileSet terrain grid imported as validated AStarGrid2D movement weights.

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.

FactGood TileSet custom data?Reason
walkable: boolYesThe base terrain decides whether it may be entered.
terrain_id: StringYesGrass, road, water, and swamp identities are authored with the tile.
movement_cost: floatYes, for one shared modelEvery query agrees on the same base number.
Current occupantNoOccupancy belongs to placed runtime state.
Temporary fire penaltyNoA transient overlay should not rewrite the shared TileSet asset.
Unit action pointsNoAP 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.

SchemaTile ownsUse when
Scalar costmovement_cost: floatEvery unit shares one terrain interpretation.
Terrain identityterrain_id: StringInfantry, 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.

Production adaptationIllustrative profile data shape, not a verbatim ZIP excerpt

const PROFILE_COSTS := {
	&"infantry": {
		&"grass": 1.0,
		&"forest": 2.0,
		&"mud": 3.0,
		&"road": 1.0,
	},
	&"vehicle": {
		&"grass": 2.0,
		&"forest": 5.0,
		&"mud": 8.0,
		&"road": 1.0,
	},
}

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.

Production adaptationIntentional anti-pattern shown for diagnosis. Do not paste it as the importer

# Incomplete guard: layer existence does not prove deliberate authoring.
if tile_data.has_custom_data("movement_cost"):
	var cost := float(tile_data.get_custom_data("movement_cost"))

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.

Exact ZIP excerptscripts/articles/tilemap_movement_cost_importer.gd · require_custom_data_layer() and _schema_error()

static func require_custom_data_layer(
		layer: TileMapLayer,
		layer_name: StringName,
		expected_type: Variant.Type
) -> Dictionary:
	if layer == null:
		return _schema_error(&"missing_tilemap_layer", layer_name)
	if layer.tile_set == null:
		return _schema_error(&"missing_tileset", layer_name)

	var layer_id := layer.tile_set.get_custom_data_layer_by_name(layer_name)
	if layer_id < 0:
		return _schema_error(&"missing_custom_data_layer", layer_name)

	var observed_type := layer.tile_set.get_custom_data_layer_type(layer_id)
	if observed_type != expected_type:
		return {
			"ok": false,
			"layer_id": -1,
			"errors": [{
				"reason": &"wrong_custom_data_type",
				"layer": layer_name,
				"expected_type": expected_type,
				"observed_type": observed_type,
			}],
		}

	return {"ok": true, "layer_id": layer_id, "errors": []}


static func _schema_error(reason: StringName, layer_name: StringName) -> Dictionary:
	return {
		"ok": false,
		"layer_id": -1,
		"errors": [{"reason": reason, "layer": layer_name}],
	}

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.

Exact ZIP excerptscripts/articles/tilemap_movement_cost_importer.gd · import_scalar_costs() and _cell_error()

static func import_scalar_costs(
		layer: TileMapLayer,
		layer_id: int,
		minimum_cost: float = 1.0
) -> Dictionary:
	var costs: Dictionary[Vector2i, float] = {}
	var errors: Array[Dictionary] = []

	if layer == null or layer.tile_set == null:
		return {
			"ok": false,
			"costs": costs,
			"errors": [{"reason": &"missing_tileset"}],
		}
	if layer_id < 0 or layer_id >= layer.tile_set.get_custom_data_layers_count():
		return {
			"ok": false,
			"costs": costs,
			"errors": [{"reason": &"invalid_layer_id", "layer_id": layer_id}],
		}
	if not is_finite(minimum_cost) or minimum_cost < 0.0:
		return {
			"ok": false,
			"costs": costs,
			"errors": [{"reason": &"invalid_minimum_cost", "observed": minimum_cost}],
		}

	for cell in layer.get_used_cells():
		var tile_data := layer.get_cell_tile_data(cell)
		if tile_data == null:
			errors.append(_cell_error(layer, cell, &"no_atlas_tile_data"))
			continue

		var value: Variant = tile_data.get_custom_data_by_layer_id(layer_id)
		var cost := float(value)
		if not is_finite(cost) or cost < minimum_cost:
			var error := _cell_error(layer, cell, &"invalid_movement_cost")
			error["observed"] = value
			error["minimum_cost"] = minimum_cost
			errors.append(error)
			continue

		costs[cell] = cost

	return {
		"ok": errors.is_empty(),
		"costs": costs,
		"errors": errors,
	}


static func _cell_error(
		layer: TileMapLayer,
		cell: Vector2i,
		reason: StringName
) -> Dictionary:
	return {
		"cell": cell,
		"reason": reason,
		"source_id": layer.get_cell_source_id(cell),
		"atlas_coords": layer.get_cell_atlas_coords(cell),
		"alternative_tile": layer.get_cell_alternative_tile(cell),
	}

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.

Production adaptationPaste context for the packaged static helper. Grid construction remains owned by the TileMap-to-grid guide

const Importer := preload(
	"res://scripts/articles/tilemap_movement_cost_importer.gd"
)

grid.region = layer.get_used_rect()
grid.update()

# Apply walkability and other point data here.
Importer.apply_movement_costs(grid, import_result.costs, 1.0)

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.

Exact ZIP excerptscripts/articles/tilemap_movement_cost_importer.gd · apply_movement_costs()

static func apply_movement_costs(
		grid: AStarGrid2D,
		costs: Dictionary,
		baseline: float = 1.0
) -> bool:
	if grid == null or grid.is_dirty():
		return false
	if not is_finite(baseline) or baseline < 0.0:
		return false

	for cell_value in costs:
		if not cell_value is Vector2i:
			return false
		var cell: Vector2i = cell_value
		var cost := float(costs[cell])
		if not grid.is_in_boundsv(cell) or not is_finite(cost) or cost < 0.0:
			return false

	grid.fill_weight_scale_region(grid.region, baseline)
	for cell_value in costs:
		var cell: Vector2i = cell_value
		grid.set_point_weight_scale(cell, float(costs[cell]))
	return true

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.

Production adaptationEntry-cost illustration, not executable GDScript

start -> grass -> forest -> mud
cost  =          1.0   + 2.0    + 4.0

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.

Exact ZIP excerptscripts/articles/tilemap_movement_cost_importer.gd · import_terrain_ids()

static func import_terrain_ids(
		layer: TileMapLayer,
		layer_id: int,
		known_ids: Dictionary = {}
) -> Dictionary:
	var terrain_by_cell: Dictionary[Vector2i, StringName] = {}
	var errors: Array[Dictionary] = []

	if layer == null or layer.tile_set == null:
		return {
			"ok": false,
			"terrain_by_cell": terrain_by_cell,
			"errors": [{"reason": &"missing_tileset"}],
		}

	for cell in layer.get_used_cells():
		var tile_data := layer.get_cell_tile_data(cell)
		if tile_data == null:
			errors.append(_cell_error(layer, cell, &"no_atlas_tile_data"))
			continue

		var terrain_id := StringName(
			String(tile_data.get_custom_data_by_layer_id(layer_id))
		)
		if terrain_id.is_empty():
			errors.append(_cell_error(layer, cell, &"missing_terrain_id"))
			continue
		if not known_ids.is_empty() and not known_ids.has(terrain_id):
			var error := _cell_error(layer, cell, &"unknown_terrain_id")
			error["observed"] = terrain_id
			errors.append(error)
			continue

		terrain_by_cell[cell] = terrain_id

	return {
		"ok": errors.is_empty(),
		"terrain_by_cell": terrain_by_cell,
		"errors": errors,
	}

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.

Production adaptationComposition rule, not executable package source

effective cost = authored base x profile multiplier + runtime penalty

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.

SymptomLikely causeFirst check
Unassigned tiles are strongly preferredTyped float default became 0.0Reject values below the declared minimum.
Every tile reports custom data existshas_custom_data() checked the TileSet layerValidate the returned value or use an invalid sentinel.
A used cell has no TileDataIt is empty or not backed by an atlas sourceReject it or route that source type to another policy.
An alternative tile has the wrong costIts own value was never authoredInspect its alternative tile ID and custom data.
Weights disappear after a shape changeupdate() cleared point dataReplay normalized solids and weights.
Weights change but the route does notJumping is enabled or the other route still costs moreDisable jumping, then reconstruct route cost.
Infantry and vehicles prefer the same cellsOne scalar cost was shared across profilesStore 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.

  1. Add typed custom-data layers to the TileSet.
  2. Paint explicit values on base and alternative atlas tiles.
  3. Validate layer names and Variant types once.
  4. Read used cells and reject unsupported sources.
  5. Normalize costs or terrain IDs without mutating the grid.
  6. Stop on authoring errors and report the tile identifiers to repair.
  7. Build the grid using the coordinate and empty-cell policy from the TileMap-to-grid guide.
  8. Apply walkability after the shape update.
  9. Bulk-fill the movement baseline and apply the complete accepted override batch.
  10. Keep jumping off when weights must affect route choice.
  11. Replay point data after any later structural update.
  12. 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.