Godot pathfinding / 2026-08-07 / 16 min read
Verified as of Godot 4.7.1 stable
Tuning RVO Avoidance in Godot: Radius, Neighbors, and Time Horizons
Tune Godot RVO avoidance by matching radius, neighbor distance, neighbor caps, and time horizons to your agents, then verify desired and safe velocity.

The short answer
Many RVO tuning problems look like one bad number. Usually two settings disagree.
An agent reacts late, so radius gets inflated. The larger body hides the late reaction but jams a doorway. A crowd ignores someone, so neighbor_distance gets raised across the arena. Nothing changes because max_neighbors was already full. Five knobs aren't five independent strength sliders.
This guide starts after the movement loop works. If the body doesn't submit a preferred velocity and consume velocity_computed(safe_velocity), use the NavigationAgent setup guide. If the question is whether an object should change the route, start with avoidance versus pathfinding.
For agents and radius-based dynamic obstacles, range decides who can be seen, the nearest-neighbor cap decides who counts, and time_horizon_agents decides how soon a future collision constrains velocity. radius changes the geometry under all three. Static vertex obstacles use a separate uncapped query and time_horizon_obstacles.
One Godot 4.7.1 edge needs to be visible up front. Both NavigationAgent classes default time_horizon_obstacles to 0.0, while their property prose says the value must be positive. The setter accepts zero, the 2D obstacle query shrinks to the radius band, and the solver later takes the reciprocal. So zero isn't a documented disable switch. In the packaged wall snapshot it returned a finite, unchanged velocity; a positive 1.5 s horizon corrected the same state. That's one authored observation, not a general meaning for zero.
Use this order:
The table describes tendencies. A map, mask, mode, priority, speed, or controller mismatch can produce the same visible symptom.
- Match
radiusto a deliberate round approximation of the body. Keep pathfinding clearance separate. - Make
max_speedcover the real movement envelope, including external motion the avoidance model is expected to handle. - Pick a provisional
time_horizon_agentsthat the controller can act on. - Make
neighbor_distancelarge enough to discover relevant conflicts inside that window. - Raise
max_neighborsonly until the target density stops truncating relevant candidates. - Tune static vertex obstacles with their own positive horizon.
- Replay the same sparse and dense cases while logging preferred, safe, and executed velocity, clearance, stalls, and completion.
| Property | Question it answers | Common low-value symptom | Common high-value symptom |
|---|---|---|---|
radius | How large is the avoidance body? | Visual or physical overlap | Lost clearance and avoidable jams |
neighbor_distance | How far does the agent search? | A conflict never becomes a candidate | More search work with no useful motion change |
max_neighbors | How many nearest candidates survive? | Relevant bodies are omitted in dense traffic | More constraints when the local set fills |
time_horizon_agents | How early do selected agents matter? | Late, sharp corrections | Early yielding and reduced velocity freedom |
time_horizon_obstacles | How early do vertex obstacles matter? | Late wall response | Premature wall avoidance |
Start with truthful geometry and velocity
Godot 4.7 documents very different starting scales for 2D and 3D. The values come from the NavigationAgent2D and NavigationAgent3D references and the exact 2D and 3D 4.7.1 constants. They establish an initial state. They don't know your sprite scale, corridor width, speed, acceleration, density, or controller.
radius is the avoidance circle in 2D. For a pair of discs, the 4.7.1 pairwise source uses radius_self + radius_other. Two radius-16 discs are tangent at 32 px of center separation. That is modeled contact geometry, not a promised clearance for an arbitrary physics controller.
It also isn't navmesh clearance. NavigationPolygon.agent_radius or a baked navmesh radius changes where a route can exist. Runtime avoidance radius changes the local velocity constraints. Start from the actor's ordinary physical footprint, add only the margin the game needs, and test the narrowest legal passage.
The frozen receipt makes the combined-radius rule concrete. Radius pairs 8 + 16 and 12 + 12 produced exactly the same first safe velocity for the subject. Increasing the combined radius from 24 to 48 px changed that safe velocity by 12.167 px/s in the same state.
max_speed needs the same honesty. Godot's avoidance guide warns that an agent moving faster than its configured maximum can receive an avoidance velocity that isn't accurate enough. In isolation, the artifact submitted (100, 0) with max_speed = 60 and received (60, 0). That's the clamp working; it doesn't rescue a controller whose real velocity exceeds the model.
The ORCA analysis assumes disc-like holonomic agents, accurate modeled state, reciprocal participation, and immediate execution of the selected velocity. Post-clamping for kinematic or dynamic limits removes its strict guarantee. If your character eases toward safe_velocity, record the executed velocity and test that controller separately. Physics collision remains the hard boundary.
| Property | NavigationAgent2D | NavigationAgent3D |
|---|---|---|
radius | 10.0 px | 0.5 units |
neighbor_distance | 500.0 px | 50.0 units |
max_neighbors | 10 | 10 |
time_horizon_agents | 1.0 s | 1.0 s |
time_horizon_obstacles | 0.0 s | 0.0 s |
max_speed | 100.0 px/s | 10.0 units/s |
avoidance_enabled | false | false |
Treat agent selection as gate, cap, then horizon
The 4.7.1 source begins an agent search with neighbor_distance², keeps eligible neighbors nearest-first, and contracts the remaining search range once the list reaches max_neighbors. See computeNeighbors() and insertAgentNeighbor().
For agent A evaluating B, the useful order is:
That gives you two hard limits. A long horizon can't predict someone outside the range. A large range can't retain the eleventh-nearest qualifying body when the cap is ten.
For center distance d, combined radius R, and horizon τ, the code block is a useful worst-case head-on audit. It is a derived sufficient discovery bound, not a Godot formula or preset. It doesn't account for masks, cap saturation, controller lag, or arbitrary trajectories. Godot uses a strict distance comparison, so don't balance a test on exact equality.
The artifact froze one pair at 200.160 px center distance. With neighbor_distance = 150, safe velocity stayed equal to preferred. Raising the range to 250 produced a 16.052 px/s correction and a 9.237° deflection. Keeping that pair in range but changing time_horizon_agents from 0.5 to 1.2 s produced the same unchanged-versus-corrected relationship. The modeled time to contact was about 0.801 s.
The cap counterfactual used a nearer threat at 120 px and a non-collinear second threat at 133.522 px. With max_neighbors = 1, the result matched the nearest-only control exactly. With a cap of two, the subject's safe velocity changed by 118.516 px/s. That's why an application-side proximity count is only a warning signal. Godot doesn't expose its private selected-neighbor list.
A longer horizon activates future conflicts earlier and leaves less velocity freedom. A shorter one leaves the controller less time to act. If raising the horizon changes nothing, check the gate and cap before inventing another ratio. The RVO2 parameter reference summarizes the same range, cap, and horizon tradeoffs.
- A and B must share the relevant map and planar/full-3D simulation.
A.avoidance_mask & B.avoidance_layersmust be nonzero. Planar 3D also applies its elevation and height filters.- A ignores a matching agent whose priority is lower than A's. B may still select A. Priority is directional eligibility, not a sorting weight.
- B's center must fall strictly inside A's
neighbor_distance. - B must survive A's nearest-neighbor cap.
- The retained pair contributes an agent-horizon constraint, which may still leave A's preferred velocity unchanged.
Tune vertex obstacles on their own branch
time_horizon_obstacles applies to static vertex obstacles in the planar solver. Radius-based dynamic obstacles don't use that branch. Godot 4.7.1 backs them with the agent-neighbor path, so they consume max_neighbors slots and use time_horizon_agents.
The zero default is awkward for a real reason:
At zero, the candidate range reduces to the radius band, with a strict boundary comparison. Source inspection still doesn't define zero as “off.”
So I tested it. The subject was 100 px from a two-point vertical segment and approached the segment interior, with radius = 12 and preferred velocity (80, 0). Horizons 0.0 and 0.5 s both returned (80, 0). At 1.5 s, safe velocity became (58.667, 0). All callbacks were finite. This establishes that exact 4.7.1 snapshot. It doesn't test a convex endpoint, contact, or a general meaning for zero.
Use an explicit positive obstacle horizon when vertex walls matter. Compare wall distance at first correction, safe speed, minimum clearance, and stalls. And keep a physics wall for geometry that must never be crossed. The avoidance system constrains local velocity; it is not physics geometry.
- Both agent classes default
time_horizon_obstaclesto0.0. - The class prose says the value must be positive.
- The setters reject negatives but accept zero.
- The 2D query range becomes
(time_horizon_obstacles * max_speed + radius)². - The obstacle solver later takes the reciprocal of the horizon.
Run a controlled tuning matrix
Change one parameter family while everything else stays fixed. A useful pass looks like this:
Use more than a perfect head-on pair. Godot's guide warns that perfectly symmetric agents can fail to acquire a natural passing side. The packaged suite uses authored offsets and covers the cases below.
Record preferred, safe, and executed velocity separately. Add the first meaningful correction time and distance, safe/preferred speed ratio, correction angle, minimum signed clearance, maximum continuous stalled time, and the authored pass or arrival result. If your controller has collision shapes, log physics contacts in that separate integration test too.
The exact packaged velocity probe is below. It doesn't count engine-selected neighbors and it doesn't prove collision-free motion. The trajectory runner owns executed velocity and clearance.
- Freeze the Godot build, physics rate, units, map, filters, initial state, preferred-velocity generator, body limits, and controller.
- Choose the smallest honest radius and verify pathfinding clearance separately.
- Make
max_speedcover the actual movement envelope. - Sweep a provisional agent horizon with relative changes such as half, baseline, and double.
- Audit range against the closing-speed case, then measure local candidate pressure before changing the cap.
- Sweep the positive obstacle horizon separately.
- Re-run every accepted profile after speed, density, footprint, physics rate, controller response, or traffic layers change.
| Scenario | What it isolates | Useful outputs |
|---|---|---|
| Offset head-on | First reaction and passing side | Correction distance, clearance, pass event |
| Perpendicular crossing | Range/horizon agreement | Correction time, speed ratio, clearance |
| Two-stream merge | Cap pressure | Candidate upper bound, stalls, authored pass event |
| Two-point wall approach | Obstacle horizon | Wall clearance, first correction, stalls |
| Mixed radius/speed pair | Combined geometry and closing speed | Clearance, per-agent correction |
| Dense shared destination | Goal pressure and infeasibility symptoms | Stalls, overlaps, remaining agents |
What the verified artifact showed
I ran the standalone project with Godot 4.7.1-stable (official), build a13da4feb, at 60 Hz. The strongest evidence is the frozen A/B set: every row changes one controlled input and records the first complete safe-velocity batch after two warm-up batches.
| Frozen comparison | Controlled change | First-batch observation |
|---|---|---|
| Agent range | 150 → 250 px, center distance 200.160 px | Preferred-to-safe delta 0 → 16.052 px/s; long-range deflection 9.237° |
| Agent horizon | 0.5 → 1.2 s, approximate TTC 0.801 s | Preferred-to-safe delta 0 → 16.052 px/s |
| Combined radius | 8 + 16 versus 12 + 12, then total 24 → 48 px | Same-sum subject delta 0.000; larger-sum delta 12.167 px/s |
| Neighbor cap | 1 → 2, candidates at 120.000 and 133.522 px | Cap one matched nearest-only; adding the second changed safe velocity by 118.516 px/s |
| Static-obstacle horizon | 0.5 → 1.5 s, segment distance 100 px, radius 12 px | Safe velocity changed from (80, 0) to (58.667, 0) px/s |

Repeatability and evidence boundaries
For reproducible counterfactuals, the harness disables avoidance multithreading before it creates its explicit maps, disables asynchronous map iterations, and restores the prior project setting afterward. That is a measurement control, not a performance recommendation or a claim about the default multithreaded scheduler. All 17 snapshot profiles ran three times on fresh maps. The final receipt records 0.0 px/s maximum subject-vector drift across 34 repeat comparisons and stable meaningful/no-meaningful classifications.
The trajectories are separate, single controlled observations rather than a numeric repeatability suite. In the offset head-on case both authored pass planes were reached in 211 ticks; first correction was attributed to the first of two qualifying samples at 0.833 s and 267.770 px center distance. Its minimum 60 Hz point-sampled signed clearance was -0.017 px, a slight modeled-disc overlap, not collision-free evidence. The dense shared-destination case did not reach every goal before the 720-tick guard and recorded a -0.420 px minimum sampled clearance. The two-point wall case reached only its authored approach event; its first correction occurred at 1.133 s and 117.348 px signed wall clearance, with a 2.010 px minimum sampled wall clearance.
The verifier passed 20 core checks. The separate scene smoke passed seven. The clean-extracted source package returned the same check IDs and matched every manifest hash, adding the twenty-first publication-candidate check; it does not claim raw trajectory metrics were identical across processes. The behavior proof has no collision shapes, so physics contacts are not measured and the receipt stores physics_contact_count as null. Clearance is sampled once per physics tick before integration, not swept continuously.
The ZIP contains ten allowlisted files, is 20,906 bytes, and has SHA-256 e816502ee348c5735f6d178264629bb9c9fccb9bda783699aba9d85f739f84f2. The six-panel scene compares preferred and safe vectors for range, horizon, radius, cap, obstacle horizon, and priority.
Measurement-gap: This evidence doesn't establish a universal preset, collision freedom outside the authored states, deadlock freedom, subjective smoothness, throughput, a supported crowd count, 3D behavior, or the engine's private selected-neighbor list. If any named result can't be reproduced from the package, I want to know.
Diagnose the symptom before changing a value
Jitter doesn't identify one knob either. Neighbor-set churn, changing preferred velocity, overlapping starts, discrete updates, smoothing, and an infeasible local configuration can all alter the callback. Change one cause and replay the same log.
| Symptom | Evidence to collect | Check next |
|---|---|---|
| Open-space overlap | Combined radius; preferred/safe/executed velocity; physics contact | Radius, truthful speed, range, then horizon |
| Last-moment reaction | First correction distance; candidate presence | Range, cap, then agent horizon |
| Some bodies vanish in dense crossings | Application candidate upper bound versus cap | max_neighbors, masks, priority, local density |
| Everyone brakes far away | Safe/preferred ratio over distance | Horizon, oversized radius, feasibility |
| Larger range changes cost but not motion | Candidate count and cap | Saturated cap or nonbinding horizon |
| Agents avoid each other but touch a vertex wall | Wall correction distance and zero/positive run | Obstacle type, mask, positive obstacle horizon, physics wall |
| Agents leave the navmesh | Safe velocity and path deviation | Clearance and path_max_distance, not a larger RVO radius |
Keep 2D and 3D profiles separate
Godot documents that planar and full-3D avoidance agents don't interact. Full 3D also ignores vertex obstacles; radius-based obstacles use the agent-neighbor path and time_horizon_agents. Match the mode before adjusting range or horizon. No number repairs two actors living in separate simulations.
| Concern | NavigationAgent2D | NavigationAgent3D planar | NavigationAgent3D full 3D |
|---|---|---|---|
| Space | XY | XZ plus elevation/height filtering | XYZ |
| Body | Circle | XZ circle; height filters overlap | Sphere; height has no effect |
| Vertex obstacles | Used | Used | Ignored |
| Simulation group | 2D map | Planar 3D | Separate full-3D simulation |
| Obstacle horizon | Used for vertex obstacles | Used for vertex obstacles | No effect |
Know when RVO isn't the owner
RVO owns local velocity selection. Route legality, path-query scheduling, formation identity, and shared global route work belong to other layers.
- If the body doesn't move or receive a callback, fix the NavigationAgent lifecycle.
- If a wall must change the legal route, update navigation data and use the avoidance/pathfinding boundary.
- If the agent repaths, jitters at arrival, or gets pushed off its path, use the stuck-agent probes.
- If one order creates hundreds of target writes, bound the request side with the hundreds-of-units guide.
- If a squad must keep named ranks, use the formation controller. RVO doesn't own slot identity.
- If a huge crowd shares global route work, the 10,000-agent article covers a measured shared-field architecture that explicitly excludes RVO.
Frequently asked questions
What is the best NavigationAgent2D radius?
There isn't a universal value. Start from a circular approximation of the actor's physical footprint, add only the visual or safety margin the game needs, and test the narrowest legal passage. NavigationAgent2D.radius affects avoidance, not normal pathfinding clearance.
What is the difference between neighbor_distance and max_neighbors?
neighbor_distance is the center-distance search gate. max_neighbors is the maximum number of qualifying agent-like neighbors retained nearest-first. A large range with a saturated small cap still omits bodies; a large cap with a short range has nothing extra to select.
Should neighbor_distance equal speed times time_horizon_agents?
No. A conservative head-on audit also includes both radii and combined closing speed: r_self + r_other + (v_self,max + v_other,max) * horizon. That is a derived worst-case discovery check, not an official Godot formula or preset.
Why do agents slow down when I increase time_horizon_agents?
A longer horizon activates future conflicts earlier. With several retained neighbors, the combined constraints can move safe velocity farther from preferred motion, so the result may slow down or turn away sooner.
Does time_horizon_obstacles = 0 disable obstacle avoidance?
Don't treat it as a documented disable. Godot 4.7.1 defaults to zero and accepts it even though the property prose says the value must be positive. In the packaged wall snapshot, zero returned finite unchanged velocity while a 1.5 s horizon corrected the same state. That result is scenario-local. Use an explicit positive value when static vertex obstacles matter.
How many max_neighbors should a Godot crowd use?
Enough to cover the relevant local-density peak for that traffic class, and no more without evidence. Count nearby application bodies as an upper bound, watch for cap saturation in crossings and merges, then measure. Whole-level agent count isn't local neighbor count.
Can a larger radius keep an agent on the navmesh?
No. A larger avoidance radius changes local collision geometry; it doesn't make the solver understand navmesh boundaries. Use correct pathfinding clearance, physics collision, and off-path diagnostics instead.