Quick answer: set_cell only marks a TileMap chunk visually dirty — physics shapes and navigation polygons stay cached until the next physics frame, and only if the layer knows the data changed. Call notify_runtime_tile_data_update(layer) after each batch of edits, follow with update_internals() if you need the shapes available in the same frame, and rebake any overlapping NavigationRegion2D manually.
Here is how to fix a Godot TileMap whose collisions stay stale after you edit cells with set_cell at runtime. The visuals update instantly — the new tile sprite appears, the old one vanishes — but the player keeps walking on air where a deleted tile used to be, or bumps into invisible walls where a new tile has not finished baking. Bullets pass through new walls. Enemies path through cells you just placed. The fundamental confusion is that TileMap is a rendering layer with a side-effect physics layer, and those two layers are updated by different systems on different schedules.
The Symptom
You call set_cell in response to a player action — mining a block, placing a wall, opening a door — and observe one or more of the following:
Visible tile changes, invisible collisions. The sprite updates this frame, but a raycast from the player’s feet still reports the old tile as solid. The player floats above a hole that visually exists, or stands inside a wall that visually does not exist.
Collision updates one frame late. The new collision shape appears next physics frame, which is fine for static edits but causes a one-frame jump when the player is standing on the edited cell. They snap up or down by a tile width and the camera follows, producing a visible jolt.
Navigation paths are wrong. AI characters continue to path through tiles you just placed, or refuse to path through tiles you just removed. The TileMap’s baked navigation polygon is still the pre-edit version.
Edits in _physics_process work, edits in signals do not. Calling set_cell from inside _physics_process happens to align with the physics rebake schedule and just barely works. Calling it from a button-press signal or a UI callback puts you on the input frame instead, and the rebake misses the next physics step.
What Causes This
TileMap caches physics shapes per chunk. A TileMap layer divides the world into rendering chunks (16x16 cells by default). Each chunk caches its CanvasItem mesh, its physics body shapes, and its navigation polygons. When you call set_cell, the layer marks the chunk dirty for rendering purposes, but the physics and navigation rebakes are deferred to either the next physics frame or until you explicitly request them.
No automatic dirty tracking for physics. The renderer can afford to rebuild a chunk every frame; the physics server cannot. Rebaking collision shapes involves allocating new Shape2D resources, registering them with PhysicsServer2D, and updating spatial indexes. To avoid doing that work for every cell change, the layer waits for an explicit signal — notify_runtime_tile_data_update — before considering a rebake.
Deferred physics processing. Even after the layer is notified, the actual rebuild happens during the layer’s _physics_process. If you call set_cell after that has already run for the current frame, the rebuild waits until the next one. For edits that need to be queryable immediately, update_internals() is the synchronous version.
NavigationRegion2D treats the TileMap as static geometry. If you placed a separate NavigationRegion2D over the TileMap to drive a NavigationAgent2D, the region baked the polygon at scene load and does not observe tile changes. The TileMap’s built-in navigation will update when notified; the overlay region will not.
The Fix
Step 1: Notify the layer after every batch of edits. Group your set_cell calls and follow them with one notify per affected layer. Notifying once per cell is wasteful; notifying once per batch is correct.
# Place a wall and refresh collisions immediately
func place_wall(layer: int, cells: Array) -> void:
for cell in cells:
$TileMap.set_cell(layer, cell, 0, Vector2i(0, 0))
# One notify per layer, after the batch
$TileMap.notify_runtime_tile_data_update(layer)
The notify is virtually free — it just flips a boolean. The next physics frame walks the dirty chunks and rebuilds them. For 99% of TileMap edits this is enough.
Step 2: Force a synchronous rebuild when you need it now. If your code edits a tile and then immediately raycasts against it (e.g., a destroy-then-explode sequence), follow the notify with update_internals so the new shapes exist before your raycast.
func destroy_and_check(cell: Vector2i) -> void:
var layer := 0
$TileMap.set_cell(layer, cell, -1) # erase
$TileMap.notify_runtime_tile_data_update(layer)
$TileMap.update_internals() # synchronous rebuild
# Now the raycast sees the post-edit world
var space := get_world_2d().direct_space_state
var q := PhysicsRayQueryParameters2D.create(
global_position, global_position + Vector2(0, 64))
var hit := space.intersect_ray(q)
print("hit: ", hit)
update_internals is heavier — it rebuilds every dirty chunk synchronously — so do not call it on every cell. Use it as a fence right before a query that depends on the new state.
Defer to the Next Physics Frame
If you are editing tiles from a signal that fires during the input frame (a button press, a mouse click), the safest pattern is to defer the edit to the next physics frame. This guarantees the rebake schedule lines up with the engine’s expectations and avoids order-of-operations bugs where your edit happens between rendering and physics.
func _on_button_pressed() -> void:
# Don't edit on the input frame — defer to physics
call_deferred("_apply_edit", Vector2i(3, 5))
func _apply_edit(cell: Vector2i) -> void:
$TileMap.set_cell(0, cell, 0, Vector2i(0, 0))
$TileMap.notify_runtime_tile_data_update(0)
You can also use await get_tree().physics_frame if you are inside a coroutine. Both achieve the same alignment.
Rebaking Overlapping Navigation Regions
The TileMap’s built-in navigation rebakes itself when notified, but a standalone NavigationRegion2D that overlays the TileMap does not. After a TileMap edit, ask the navigation server to rebake the region’s polygon:
func _refresh_navigation() -> void:
var region: NavigationRegion2D = $NavigationRegion2D
# Bake on a worker thread; result arrives via signal
NavigationServer2D.region_bake_navigation_polygon(
region.get_rid(), true)
The bake is asynchronous by default. Listen for the bake_finished signal on the region if you need to know when the new polygon is live. For small TileMaps the bake completes within a frame; for large procedural worlds it may take several.
“A TileMap is two systems wearing one node’s clothing. The renderer reacts to
set_cellon its own; physics needs a tap on the shoulder.”
Related Issues
If TileMap collisions are correct but your character slides off slope tiles, see TileMap Slope Snap Jitter. For navigation pathing failures unrelated to baking, check NavigationAgent2D Stuck on Corners.
Notify after the batch, update_internals before the query — that’s the whole rule.