Part 04 — Godot's avoidance, and when to use it
You will build: The same flock running on NavigationAgent2D RVO avoidance, where agents predict each other and slip past instead of shoving. A key toggles between your hand-rolled separation and the built-in solver, so both run in the same scene against the same crowd and you can judge them side by side.
The one thing separation cannot do#
Your separation force reads positions. For each neighbour inside a radius, it pushes away from where that neighbour is right now. That is a complete description of the technique and also its ceiling.
Consider two agents walking straight at each other down a corridor. Separation fires when they get close. Both compute a push directly away from the other — which means both push along the same line, in exactly opposite directions, straight backwards. Neither one steps aside, because "aside" is not information that exists in a position-only calculation. They slow, they close again, they slow again. In a doorway with a dozen agents you get a plug.
RVO — reciprocal velocity obstacles — uses a different input. It reads both agents' velocities and asks a predictive question: given where you are both heading and how fast, will you overlap within the next second or so? If yes, it computes a new velocity for each that avoids the predicted overlap. The word that matters is reciprocal: both agents solve at the same time, each assuming the other is also solving and will take half the correction. That shared assumption is what breaks the symmetry. One goes left, one goes right, and neither has to be told which.
That is the trade you are evaluating in this part. Separation is a force you author, weight and debug. RVO is a solver you configure and then trust.
Wire it up#
Add a NavigationAgent2D as a child of your agent's CharacterBody2D. Then, in the inspector, turn avoidance_enabled on.
Do that now, before writing any code, because the default is false. The agent registers with the avoidance simulation only when that flag is true, and until then the velocity_computed signal never fires. This is the first place people lose an afternoon: they connect the signal correctly, run the scene, and nothing happens, because the node they connected to was never in the simulation to begin with.
Here is the naive version. Type it, run it, and watch it fail — the failure is the lesson.
extends CharacterBody2D
@export var movement_speed: float = 90.0
@onready var navigation_agent: NavigationAgent2D = $NavigationAgent2D
func _ready() -> void:
navigation_agent.velocity_computed.connect(_on_velocity_computed)
func _physics_process(_delta: float) -> void:
var desired: Vector2 = Vector2.RIGHT * movement_speed
navigation_agent.set_velocity(desired)
func _on_velocity_computed(safe_velocity: Vector2) -> void:
velocity = safe_velocity
move_and_slide()
Run it. Every agent sits perfectly still. No error in the output, no warning in the debugger, no red text anywhere.
Failure one: the frozen flock#
The reason is one sentence in the navigation documentation that most tutorials skip:
The NavigationAgent must be supplied with a
target_positionattribute, even if you are only using the agent for avoidance. Otherwise, thesafe_velocityreceived from thevelocity_computedsignal will always be the zero vector.
You wanted avoidance only. You did not want pathfinding. But the agent is a pathfinding node that happens to also do avoidance, and with no target it considers itself already arrived — so the safe velocity it hands back is zero, every frame, forever. The signal is firing. It is firing with Vector2.ZERO.
There are three preconditions, and they bite in this order:
target_positionmust be set. Even for avoidance-only use. Set it somewhere far away in the direction you want to travel, or update it each frame to sit ahead of the agent.- The agent needs a navigation map. The signal's own description is explicit: it is emitted as long as
avoidance_enabledis true and the agent has a navigation map. A scene with noNavigationRegion2Dgives you no signal at all — not a zero vector, nothing. - On the first frame, the server has not synchronised. From the 2D navigation introduction: on the first frame the NavigationServer map has not synchronised region data and any path query returns empty. Setting a target in
_readylands on an unsynchronised map. The documented fix is to wait one physics frame first.
That third one produces the most confusing symptom of the set — the agent does nothing until you poke it at runtime, at which point it works fine forever. Nothing is broken. You asked a question before the map existed.
So add a navigation region to the scene, and set the target after a physics frame:
func _ready() -> void:
navigation_agent.velocity_computed.connect(_on_velocity_computed)
_set_up_target.call_deferred()
func _set_up_target() -> void:
await get_tree().physics_frame
navigation_agent.target_position = movement_target
await get_tree().physics_frame is the current spelling. If you have seen yield(get_tree(), ...) in a tutorial, that tutorial is Godot 3 and the rest of its advice deserves the same suspicion.
Failure two: the shudder#
Fix the target and the agents move. Now make them move somewhere useful — toward a path — and you will probably write this:
func _physics_process(_delta: float) -> void:
var next: Vector2 = navigation_agent.get_next_path_position()
var desired: Vector2 = global_position.direction_to(next) * movement_speed
navigation_agent.set_velocity(desired)
velocity = desired
move_and_slide() # <-- the bug
It is a reasonable-looking line. set_velocity() is a request, so surely you still need to actually move? The agents do move. They also vibrate, stutter, and grind against each other in a way that looks like a physics bug.
It is not a physics bug. move_and_slide() is being called twice per frame: once here with your raw desired velocity, and once inside _on_velocity_computed with the corrected one. The body integrates twice. Your uncorrected movement fights the avoidance correction every single frame, and what you see is the average of two disagreeing solvers.
set_velocity() does not move anything. Read its description literally: it sets the wanted velocity, and the simulation will try to fulfil it but will modify it to avoid collisions. Nothing happens until the answer comes back through the signal. It is a request with a callback, not an assignment.
The shape worth memorising#
The official example's structure exists precisely to make both of these failures impossible, and it is worth internalising rather than copying:
extends CharacterBody2D
@export var movement_speed: float = 90.0
@export var movement_target: Vector2 = Vector2(600.0, 300.0)
@onready var navigation_agent: NavigationAgent2D = $NavigationAgent2D
func _ready() -> void:
navigation_agent.velocity_computed.connect(_on_velocity_computed)
_set_up_target.call_deferred()
func _set_up_target() -> void:
await get_tree().physics_frame
navigation_agent.target_position = movement_target
func _physics_process(_delta: float) -> void:
if NavigationServer2D.map_get_iteration_id(navigation_agent.get_navigation_map()) == 0:
return
if navigation_agent.is_navigation_finished():
return
var next_path_position: Vector2 = navigation_agent.get_next_path_position()
var new_velocity: Vector2 = global_position.direction_to(next_path_position) * movement_speed
if navigation_agent.avoidance_enabled:
navigation_agent.set_velocity(new_velocity)
else:
_on_velocity_computed(new_velocity)
func _on_velocity_computed(safe_velocity: Vector2) -> void:
velocity = safe_velocity
move_and_slide()
Three things earn their place here.
move_and_slide() appears exactly once, inside the callback. There is one movement path in this script and it runs through the callback whether or not avoidance is involved. That is what makes the double-integration bug structurally unreachable rather than merely absent.
The branch calls the callback directly when avoidance is off. This looks redundant until you toggle avoidance_enabled at runtime and the agent drops dead — because velocity_computed only fires while the flag is true. Anyone who wires up only the signal path has built a script that stops working the moment avoidance is disabled. The else branch is not a stylistic nicety; it is the reason the toggle works at all.
The iteration-id guard is the standing form of the first-frame problem. map_get_iteration_id() == 0 means the map has never synchronised. Returning early is cheaper and more honest than querying a map that has nothing in it.
One caution about the callback: do not call path-related methods on the agent from inside _on_velocity_computed. The agent documentation warns this can cause infinite recursion and directs you to use deferred calls instead. Keep the callback to assigning velocity and moving.
Now build the toggle#
You want both systems in the same scene so the comparison is honest. Add an input action — call it toggle_avoidance — and flip the flag on every agent:
extends Node2D
@export var agents: Array[NodePath] = []
func _unhandled_input(event: InputEvent) -> void:
if not event.is_action_pressed("toggle_avoidance"):
return
for path: NodePath in agents:
var agent: Node = get_node(path)
var nav: NavigationAgent2D = agent.get_node("NavigationAgent2D")
nav.avoidance_enabled = not nav.avoidance_enabled
Your hand-rolled separation from the earlier parts should apply only when avoidance_enabled is false — otherwise you are running two avoidance systems that disagree, which produces its own shudder. Guard it inside the agent script:
var new_velocity: Vector2 = global_position.direction_to(next_path_position) * movement_speed
if not navigation_agent.avoidance_enabled:
new_velocity += _separation() * separation_weight
new_velocity = new_velocity.limit_length(movement_speed)
Run the scene with thirty agents converging on one point and press the key back and forth. What you are looking for is not "which is better" but what each one does wrong. Watch the moment of convergence in both modes.
radius versus neighbor_distance#
These two properties are the most commonly conflated pair on the node, and the defaults will burn you.
radiusdefaults to10.0. This is the agent's body — how fat it is in the simulation. The documentation states outright that it is not the avoidance-manoeuvre starting radius.neighbor_distancedefaults to500.0. This is how far the agent looks.
Body versus search. If your sprites are sixty pixels across and you leave radius at ten, the simulation is negotiating between ten-pixel discs while your art overlaps by fifty pixels. It looks like avoidance is failing. It is working perfectly on a body you never told it about.
Then there is max_neighbors, defaulting to 10. This is a hard cap on how many agents each solve considers, regardless of how many are inside the search distance. It is a k-nearest cutoff — the same idea you would hand-roll with a spatial hash — and it is why RVO degrades gracefully instead of quadratically in a crowd.
But it is also a behavioural cliff. In a dense flock, each agent negotiates with ten neighbours and is genuinely blind to the rest. Agents at the edge of a clump ignore the far side entirely and the flock splits into pockets that behave independently. That fragmentation looks exactly like a bug in your grouping logic. It is max_neighbors doing its documented job.
Set radius to roughly half your sprite width. Then decide whether neighbor_distance at 500 is really what you want — a 500-pixel search means agents start manoeuvring around things half a screen away, which reads as skittishness.
max_speed will lie to you quietly#
max_speed defaults to 100.0 and the simulation clamps the returned safe_velocity to it.
If your movement_speed is 150 and max_speed is 100, your agents move at 100. Your code says 150. Nothing in the output disagrees with you. You will re-read your steering maths three times before checking the inspector.
Set max_speed at or above whatever peak speed your movement code can produce.
Obstacles, and the winding that leaks#
NavigationObstacle2D comes in two flavours and the flavour is decided by which property you populate.
Dynamic: set radius above zero. The class description calls it a soft please-move-away-from-me object. Its great virtue is that it can change position every frame at no extra cost, which makes it right for a moving hazard, a rolling boulder, a patrolling guard the crowd should part around.
Static: populate vertices with an outline. These are hard do-not-cross boundaries for avoidance-using agents. Outlines cannot cross or overlap each other.
Do not set both on one node. The obstacle tutorial recommends against it for performance reasons and suggests toggling between the two depending on whether the obstacle is currently moving.
For static obstacles, winding order decides direction:
If the vertices are winded in clockwise order agents will be pushed in by the obstacle, else they will be pushed out.
Wind a fence counter-clockwise when you meant to pen agents inside, and they leak out through it while the outline sits there looking correct in the editor. Wind a room clockwise and the crowd gets sucked into the wall. Neither produces an error. Build the outline, run it, and check which side the agents end up on — that is faster than reasoning about it.
Static obstacles also cannot be moved cheaply. Warping a vertices-based obstacle means agents cannot predict the movement and may end up trapped inside it; the fix is to warp and rebuild from scratch, which the docs describe as having a high performance cost if done every frame. If a thing moves, give it a radius instead.
For dynamic obstacles that move, feed the velocity property yourself each frame. The node does not derive it from position changes, and without it neighbouring agents predict a stationary object:
extends Node2D
@export var drift: Vector2 = Vector2(40.0, 0.0)
@onready var obstacle: NavigationObstacle2D = $NavigationObstacle2D
func _physics_process(delta: float) -> void:
global_position += drift * delta
obstacle.velocity = drift
One asymmetry catches nearly everyone: obstacles ship with avoidance_enabled set to true, agents ship with it set to false. Obstacles are ready to work out of the box. Agents are not. If your obstacle appears to do nothing, the flag to check is on the agent.
Note also that a fresh obstacle has radius at 0.0, so it is neither static nor dynamic until you set something. And affect_navigation_mesh and carve_navigation_mesh — both default false — belong to the mesh-baking half of the class, which is a separate job from avoidance entirely. One node, two unrelated responsibilities.
Teleports#
If you warp an agent across the map, call set_velocity_forced() in the same frame.
set_velocity() submits a request to the solver. set_velocity_forced() overwrites the simulation's internal velocity directly. Without it, the simulation still believes the agent is travelling along its pre-teleport trajectory, so for the next fraction of a second every nearby agent is dodging a ghost — swerving around a predicted path from a position the agent no longer occupies.
func teleport_to(destination: Vector2) -> void:
global_position = destination
velocity = Vector2.ZERO
navigation_agent.set_velocity_forced(Vector2.ZERO)
The honest comparison#
Two facts about RVO shape the whole decision, and both are stated plainly in the documentation.
Avoidance does not affect pathfinding. The path is computed once and the avoidance simulation neither reads it nor updates it — the solver only bends the per-frame velocity. An agent shoved sideways into a wall by its neighbours does not get a new route. It keeps steering toward the same next path position from a worse place. NavigationObstacle2D likewise does not affect pathfinding at all.
Obstacles are not collision. The obstacle tutorial says it outright: dynamic obstacles are not a reliable way to constrain agents in crowded or narrow spaces. A crowd pressed into a corridor squeezes through. If you need a hard boundary, that is a physics body's job.
So the deciding question is not "which is more advanced". It is:
Is non-interpenetration the requirement, or is the look the requirement?
If bodies must not overlap — units in a strategy game, NPCs in a marketplace where a stack of three villagers reads as a glitch — RVO wins, and it wins clearly. It is a proper solver doing predictive geometry with mutual assumptions, and you will not out-implement it with a weighted force in an afternoon.
If what you want is character — a school of fish that swirls, a crowd that panics with a visible wave, birds that break and reform — hand-rolled forces win. Not because they avoid better. Because you can weight them, draw them, exaggerate them, and tune them until the motion reads the way you want. RVO gives you correct movement with no dial for "more nervous". Your separation force has that dial, because you wrote it.
There is a third answer that is often the right one: run both. RVO for non-interpenetration, your own cohesion and alignment for the look. They are not competing — separation is the only one of the three flocking forces RVO replaces.
Checkpoint — definition of done#
- With
avoidance_enabledon and notarget_positionset, agents sit motionless — and you have watched it happen rather than taken it on faith. - After setting a target behind
await get_tree().physics_frame, the same agents move. - Two agents sent head-on at each other pass on opposite sides under RVO, and stall against each other under hand-rolled separation alone.
- Pressing your toggle key switches modes mid-run with no freeze, no stutter and no teleport in either direction.
-
move_and_slide()appears exactly once in your agent script, and you have confirmed by search rather than memory. - With
radiusraised to half your sprite width, visible overlap between agents disappears. - With
max_neighborsdropped to 3 in a crowd of thirty, the flock visibly fragments into pockets — and you can point at which agents stopped seeing each other. - Setting
max_speedbelowmovement_speedmakes agents move slower than your code says, with nothing in the output to explain it. - A static obstacle wound the wrong way leaks agents through it; reversing the vertex order seals it.
- You can explain: why does RVO break the head-on deadlock that position-based separation cannot, when both agents are running identical code?
Stretch (no instructions)#
- Give the player character a higher
avoidance_priorityand walk it through a standing crowd. - Use
avoidance_layersandavoidance_maskso two factions ignore each other entirely while both avoid the player. - Compare
velocityagainstget_real_velocity()on an agent squeezed between neighbours, and drive a squash animation from the difference. - Raise
time_horizon_agentsfrom its default and find the value where the flock starts looking paranoid.
If you get stuck#
- Every agent frozen, no errors →
target_positionwas never set. Avoidance-only use still requires a target, orsafe_velocityisVector2.ZEROevery frame. velocity_computednever fires at all → eitheravoidance_enabledis stillfalse(it defaults to false) or the scene has no navigation map for the agent to belong to. Both are required; the signal description names them together.- Works after the first click, never on startup → the target was set on the first frame, before the server synchronised. Wrap it in
await get_tree().physics_frame. - Agents jitter or grind while moving →
move_and_slide()is being called in both_physics_processand the callback. Search the file; it belongs only in the callback. - Agent stops dead the instant you toggle avoidance off → your
_physics_processhas noelsebranch calling_on_velocity_computed()directly. The signal only fires while the flag is true. - Sprites visibly overlap despite avoidance working →
radiusis still at its default10.0, which is the body, not the search distance. Raise it toward half your sprite width. - Flock splits into independent pockets in a crowd →
max_neighbors(default10) is capping the solve. Not a logic bug in your grouping. - Agents slower than
movement_speedwith no explanation →max_speed(default100.0) is clamping the returned safe velocity. - Agents drift through a static obstacle → check winding. Clockwise pushes agents in, anything else pushes them out. Reverse the vertex order and re-run.
- An agent trapped inside a moved obstacle → the obstacle uses
verticesand was warped. Static obstacles must be rebuilt after moving; useradiusfor anything that moves regularly. - Neighbours swerve around empty space after a teleport →
set_velocity_forced()was not called in the teleport frame, so the simulation is still predicting the old trajectory. - Crash or hang inside the callback → path-related agent methods called from
_on_velocity_computedcan recurse. Defer them. - An agent pushed into a wall and stuck there → expected. Avoidance does not affect pathfinding, so the route is never recomputed. Handle wall recovery yourself, or re-issue the target.
Next: Part 05 — When the flock gets big. Read it once thirty agents run smoothly and you want to know why three hundred do not.