Vav Labs
Back to blog

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.

Godot RVO tuning proof scene comparing preferred and safe velocity vectors across range, horizon, radius, neighbor cap, obstacle horizon, and priority.

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.

  1. Match radius to a deliberate round approximation of the body. Keep pathfinding clearance separate.
  2. Make max_speed cover the real movement envelope, including external motion the avoidance model is expected to handle.
  3. Pick a provisional time_horizon_agents that the controller can act on.
  4. Make neighbor_distance large enough to discover relevant conflicts inside that window.
  5. Raise max_neighbors only until the target density stops truncating relevant candidates.
  6. Tune static vertex obstacles with their own positive horizon.
  7. Replay the same sparse and dense cases while logging preferred, safe, and executed velocity, clearance, stalls, and completion.
What each avoidance property controls
PropertyQuestion it answersCommon low-value symptomCommon high-value symptom
radiusHow large is the avoidance body?Visual or physical overlapLost clearance and avoidable jams
neighbor_distanceHow far does the agent search?A conflict never becomes a candidateMore search work with no useful motion change
max_neighborsHow many nearest candidates survive?Relevant bodies are omitted in dense trafficMore constraints when the local set fills
time_horizon_agentsHow early do selected agents matter?Late, sharp correctionsEarly yielding and reduced velocity freedom
time_horizon_obstaclesHow early do vertex obstacles matter?Late wall responsePremature 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.

Godot 4.7 documented avoidance defaults
PropertyNavigationAgent2DNavigationAgent3D
radius10.0 px0.5 units
neighbor_distance500.0 px50.0 units
max_neighbors1010
time_horizon_agents1.0 s1.0 s
time_horizon_obstacles0.0 s0.0 s
max_speed100.0 px/s10.0 units/s
avoidance_enabledfalsefalse

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.

  1. A and B must share the relevant map and planar/full-3D simulation.
  2. A.avoidance_mask & B.avoidance_layers must be nonzero. Planar 3D also applies its elevation and height filters.
  3. 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.
  4. B's center must fall strictly inside A's neighbor_distance.
  5. B must survive A's nearest-neighbor cap.
  6. The retained pair contributes an agent-horizon constraint, which may still leave A's preferred velocity unchanged.
neighbor_distance > radius_self + radius_other
                   + (max_speed_self + max_speed_other)
                   * time_horizon_agents
Gate, cap, and horizon diagram for Godot RVO avoidance showing eligibility, neighbor distance, nearest-neighbor retention, and future-collision constraints.
Agent selection is a pipeline: eligibility and range decide who is visible, the cap decides who is retained, and the horizon decides which future conflicts bind.

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_obstacles to 0.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.

  1. Freeze the Godot build, physics rate, units, map, filters, initial state, preferred-velocity generator, body limits, and controller.
  2. Choose the smallest honest radius and verify pathfinding clearance separately.
  3. Make max_speed cover the actual movement envelope.
  4. Sweep a provisional agent horizon with relative changes such as half, baseline, and double.
  5. Audit range against the closing-speed case, then measure local candidate pressure before changing the cap.
  6. Sweep the positive obstacle horizon separately.
  7. Re-run every accepted profile after speed, density, footprint, physics rate, controller response, or traffic layers change.
Controlled RVO tuning scenarios
ScenarioWhat it isolatesUseful outputs
Offset head-onFirst reaction and passing sideCorrection distance, clearance, pass event
Perpendicular crossingRange/horizon agreementCorrection time, speed ratio, clearance
Two-stream mergeCap pressureCandidate upper bound, stalls, authored pass event
Two-point wall approachObstacle horizonWall clearance, first correction, stalls
Mixed radius/speed pairCombined geometry and closing speedClearance, per-agent correction
Dense shared destinationGoal pressure and infeasibility symptomsStalls, overlaps, remaining agents

Exact ZIP excerptscripts/articles/rvo_tuning_probe_2d.gd · compare(), sample(), summary()

class_name RvoTuningProbe2D
extends RefCounted

const MIN_PREFERRED_SPEED := 1.0
const MIN_DELTA_SPEED := 1.0
const DELTA_SPEED_RATIO := 0.01
const MIN_ANGLE_DEGREES := 1.0
const STALL_RATIO := 0.10

var sample_count := 0
var meaningful_sample_count := 0
var minimum_safe_speed_ratio := INF
var maximum_correction_angle_degrees := 0.0
var maximum_velocity_delta := 0.0
var maximum_continuous_stall_seconds := 0.0
var _continuous_stall_seconds := 0.0


static func compare(preferred_velocity: Vector2, safe_velocity: Vector2) -> Dictionary:
	var preferred_speed := preferred_velocity.length()
	var safe_speed := safe_velocity.length()
	var delta_speed := preferred_velocity.distance_to(safe_velocity)
	var angle_degrees := 0.0
	if preferred_speed > 0.0 and safe_speed > 0.0:
		angle_degrees = rad_to_deg(absf(preferred_velocity.angle_to(safe_velocity)))
	var delta_threshold := maxf(MIN_DELTA_SPEED, DELTA_SPEED_RATIO * preferred_speed)
	var meaningful := preferred_speed >= MIN_PREFERRED_SPEED and (
		delta_speed >= delta_threshold or angle_degrees >= MIN_ANGLE_DEGREES
	)
	return {
		"preferred_speed": preferred_speed,
		"safe_speed": safe_speed,
		"safe_speed_ratio": safe_speed / preferred_speed if preferred_speed > 0.0 else 1.0,
		"velocity_delta": delta_speed,
		"delta_threshold": delta_threshold,
		"correction_angle_degrees": angle_degrees,
		"meaningful": meaningful,
	}


func sample(preferred_velocity: Vector2, safe_velocity: Vector2, delta_seconds: float) -> Dictionary:
	var result := compare(preferred_velocity, safe_velocity)
	sample_count += 1
	if bool(result["meaningful"]):
		meaningful_sample_count += 1
	minimum_safe_speed_ratio = minf(minimum_safe_speed_ratio, float(result["safe_speed_ratio"]))
	maximum_correction_angle_degrees = maxf(
		maximum_correction_angle_degrees,
		float(result["correction_angle_degrees"])
	)
	maximum_velocity_delta = maxf(maximum_velocity_delta, float(result["velocity_delta"]))
	if float(result["preferred_speed"]) >= MIN_PREFERRED_SPEED \
			and float(result["safe_speed_ratio"]) <= STALL_RATIO:
		_continuous_stall_seconds += delta_seconds
		maximum_continuous_stall_seconds = maxf(
			maximum_continuous_stall_seconds,
			_continuous_stall_seconds
		)
	else:
		_continuous_stall_seconds = 0.0
	return result


func summary() -> Dictionary:
	return {
		"sample_count": sample_count,
		"meaningful_sample_count": meaningful_sample_count,
		"minimum_safe_speed_ratio": minimum_safe_speed_ratio if sample_count > 0 else null,
		"maximum_correction_angle_degrees": maximum_correction_angle_degrees,
		"maximum_velocity_delta": maximum_velocity_delta,
		"maximum_continuous_stall_seconds": maximum_continuous_stall_seconds,
	}

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 Godot 4.7.1 RVO counterfactuals
Frozen comparisonControlled changeFirst-batch observation
Agent range150 → 250 px, center distance 200.160 pxPreferred-to-safe delta 0 → 16.052 px/s; long-range deflection 9.237°
Agent horizon0.5 → 1.2 s, approximate TTC 0.801 sPreferred-to-safe delta 0 → 16.052 px/s
Combined radius8 + 16 versus 12 + 12, then total 24 → 48 pxSame-sum subject delta 0.000; larger-sum delta 12.167 px/s
Neighbor cap1 → 2, candidates at 120.000 and 133.522 pxCap one matched nearest-only; adding the second changed safe velocity by 118.516 px/s
Static-obstacle horizon0.5 → 1.5 s, segment distance 100 px, radius 12 pxSafe velocity changed from (80, 0) to (58.667, 0) px/s
Godot 4.7.1 RVO tuning proof scene with six panels for range, agent horizon, combined radius, neighbor cap, obstacle horizon, and priority.
The native Godot 4.7.1 scene freezes six comparisons and shows preferred and returned safe velocity vectors beside the controlled settings.

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-led RVO diagnosis
SymptomEvidence to collectCheck next
Open-space overlapCombined radius; preferred/safe/executed velocity; physics contactRadius, truthful speed, range, then horizon
Last-moment reactionFirst correction distance; candidate presenceRange, cap, then agent horizon
Some bodies vanish in dense crossingsApplication candidate upper bound versus capmax_neighbors, masks, priority, local density
Everyone brakes far awaySafe/preferred ratio over distanceHorizon, oversized radius, feasibility
Larger range changes cost but not motionCandidate count and capSaturated cap or nonbinding horizon
Agents avoid each other but touch a vertex wallWall correction distance and zero/positive runObstacle type, mask, positive obstacle horizon, physics wall
Agents leave the navmeshSafe velocity and path deviationClearance 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.

Godot avoidance profiles by simulation mode
ConcernNavigationAgent2DNavigationAgent3D planarNavigationAgent3D full 3D
SpaceXYXZ plus elevation/height filteringXYZ
BodyCircleXZ circle; height filters overlapSphere; height has no effect
Vertex obstaclesUsedUsedIgnored
Simulation group2D mapPlanar 3DSeparate full-3D simulation
Obstacle horizonUsed for vertex obstaclesUsed for vertex obstaclesNo 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.

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.