Quick answer: Add a NavigationObstacle2D child to each moving obstacle and enable avoidance_enabled on the agent. The static NavigationPolygon only knows about geometry present at bake time; dynamic obstacles must use the avoidance system at runtime.

A unit using NavigationAgent2D sets a target across the room, plots a path, and walks into the side of a crate that wandered into the corridor. It freezes there, body pressed against the obstacle, because every frame the agent says it has reached the next path point and tries to advance into geometry the path believes is empty.

Why Static Bakes Miss Dynamic Obstacles

When you bake a NavigationPolygon, Godot subtracts obstacle source geometry from the walkable region. Moving sprites and physics-driven bodies are not part of that snapshot. The bake produces a static polygon that knows the layout of walls and props that existed at bake time, nothing else.

The avoidance system is a separate runtime layer built on RVO2 (Reciprocal Velocity Obstacles). Each NavigationAgent2D contributes its position and velocity; each NavigationObstacle2D contributes a circular or polygonal exclusion zone. The agent’s requested velocity is filtered through this layer to produce a safe velocity that respects both static geometry (the polygon) and dynamic obstacles (other agents and obstacle nodes).

The Fix: Add NavigationObstacle2D Nodes

For each moving obstacle — a patrolling guard, a sliding crate, a destructible barrel — add a NavigationObstacle2D child:

# crate.gd
extends CharacterBody2D

func _ready():
    var obstacle = NavigationObstacle2D.new()
    obstacle.radius = 16.0   # half the crate’s width plus a 2px buffer
    obstacle.avoidance_enabled = true
    add_child(obstacle)

On the agent side, ensure avoidance is enabled and a callback handler updates the body velocity:

# enemy.gd
@onready var agent: NavigationAgent2D = $NavigationAgent2D

func _ready():
    agent.avoidance_enabled = true
    agent.radius = 14.0
    agent.velocity_computed.connect(_on_velocity_computed)

func _physics_process(delta):
    var next = agent.get_next_path_position()
    var desired = (next - global_position).normalized() * speed
    agent.set_velocity(desired)   # goes through avoidance

func _on_velocity_computed(safe_velocity):
    velocity = safe_velocity
    move_and_slide()

The critical detail: do not assign velocity = desired directly. Pass it to agent.set_velocity() and apply the value you receive in velocity_computed. That callback is where avoidance happens.

When the Geometry Itself Changes

If a wall is destroyed at runtime — not just moved — the static polygon needs a rebake. Don’t do this every frame; only after the change is committed:

func on_wall_destroyed(wall):
    wall.queue_free()
    await get_tree().process_frame   # let the node leave the tree
    $NavigationRegion2D.bake_navigation_polygon(true)   # on_thread=true

The on_thread=true argument moves the bake off the main thread. The agent continues using the previous polygon until the bake finishes and the new one is swapped in atomically.

Verifying

Enable Debug → Visible Navigation in the editor to see the walkable polygon highlighted in cyan. Run the game and watch agents reroute around obstacle nodes in real time. If an agent still walks into a crate, the crate likely lacks a NavigationObstacle2D child — or the obstacle’s radius is too small for the agent’s radius plus a margin.

“Static polygon for walls, obstacle nodes for movers, avoidance callback for velocity. Three layers, each with one job.”

Always wire velocity_computed — bypassing it means avoidance never runs.