Steering and agent movement
Movement that reads as a creature rather than a cursor: arrive, flee, pursue and separation as summable forces, blending vs priority.
How to move an AI character so it reads as a creature rather than a cursor. Read
game-patterns.md for scene composition and gdscript-style.md for language idioms.
Verify signatures against the live engine (engine class-info --class CharacterBody2D,
engine class-info --class NavigationAgent2D). The patterns here are stable, but the API is
what the running binary says it is.
The one idea#
Naive chase code assigns velocity:
velocity = global_position.direction_to(target) * speed # snaps, orbits, jittersSteering accelerates toward a desired velocity instead:
var steering := desired_velocity - velocityvelocity = (velocity + steering.limit_length(max_force) * delta).limit_length(max_speed)Turn rate then falls out of max_force, and top speed out of max_speed. A heavy tank
gets a low force and a high speed; a wasp gets the reverse. That single substitution is
most of what separates convincing AI motion from the version that snaps to the player and
vibrates on arrival.
Two numbers per agent, both @exported so they are tunable from the inspector:
max_speedsets how fast it may travel.max_forcesets how hard it may change direction. This is the feel dial.
Steering vs NavigationAgent#
They solve different problems and compose.
| Need | Use |
|---|---|
| Route around static level geometry | NavigationAgent2D/3D pathfinding |
| Smooth, weighty local motion | Hand-rolled steering (this doc) |
| Keep a crowd from stacking up | Separation (below), or agent avoidance |
The normal shape is both: ask the navigation agent for the next path point, then steer toward it rather than assigning velocity straight at it. Pathfinding chooses the route, steering decides how the body gets there.
Build: a steering agent#
Verified end to end on 4.7. Creates the scene, the body, and the script:
scene create --path res://ai/steer_demo.tscn --root-type Node2D --root-name SteerDemoscene open --path res://ai/steer_demo.tscnscene2d add-body --type CharacterBody2D --name Agent --shape circle --radius 12scene2d add-body --type StaticBody2D --name Target --shape circle --radius 8 --position "Vector2(400, 200)"script create --path res://ai/steering_agent.gd --extends CharacterBody2Dscript attach --node-path Agent --script-path res://ai/steering_agent.gdnode set --node-path Agent --property target_path --value "../Target"scene save--property target_path --value "../Target" is not a typo. get_node_or_null(target_path)
resolves relative to the node holding it, so a sibling needs the ../. Passing the bare
name silently yields null, _physics_process returns early, and the agent sits still with
no error anywhere. That is the exact failure this line exists to prevent.
The script (compiles clean, script lint reports zero findings):
class_name SteeringAgent2Dextends CharacterBody2D
@export var max_speed := 300.0@export var max_force := 1200.0@export var arrive_radius := 96.0@export var target_path: NodePath
var _target: Node2D
func _ready() -> void: _target = get_node_or_null(target_path) as Node2D
func _physics_process(delta: float) -> void: if _target == null: return var steering := arrive(_target.global_position).limit_length(max_force) velocity = (velocity + steering * delta).limit_length(max_speed) move_and_slide()The behaviours#
Each returns a steering force (desired - velocity), never a velocity. That is what
lets them be summed.
Arrive#
Full speed toward the target, easing to a stop inside arrive_radius. Most chase,
escort, and patrol code ends up calling this one.
func arrive(target: Vector2) -> Vector2: var offset := target - global_position var distance := offset.length() if is_zero_approx(distance): return -velocity var speed := max_speed if distance < arrive_radius: speed = max_speed * distance / arrive_radius return offset / distance * speed - velocitySeek is this without the taper. Seek overshoots the target, turns around, overshoots again, and orbits forever, which is the classic “enemy vibrating on the player” bug. Reach for arrive by default and only drop the taper when the agent should not slow down.
Flee#
func flee(threat: Vector2, panic_distance := 200.0) -> Vector2: var offset := global_position - threat var distance := offset.length() if is_zero_approx(distance) or distance > panic_distance: return Vector2.ZERO return offset / distance * max_speed - velocitypanic_distance is what stops every enemy on the map fleeing forever once the player
picks up the scary weapon.
Pursue leads the target#
func pursue(target: Node2D, target_velocity: Vector2) -> Vector2: var lead := global_position.distance_to(target.global_position) / max_speed return arrive(target.global_position + target_velocity * lead)Without the lead the chaser aims at where the target was and permanently tails anything moving across its path. Scaling the lead by distance means a far-off interceptor predicts further ahead, which is also what looks intelligent.
Separation keeps a crowd from stacking#
func separation(radius := 80.0) -> Vector2: var push := Vector2.ZERO for other in get_tree().get_nodes_in_group("flock"): if other == self or not other is Node2D: continue var offset: Vector2 = global_position - other.global_position var distance := offset.length() if distance > radius or is_zero_approx(distance): continue push += offset / distance * (1.0 - distance / radius) * max_speed if push == Vector2.ZERO: return Vector2.ZERO return push - velocityThe 1.0 - distance / radius term is the important part: a neighbour at arm’s length
pushes gently, one that is overlapping pushes hard. A flat repulsion makes the crowd
twitch.
Build it (group membership is what the loop reads):
node set-groups --node-path F1 --groups '["flock"]'Blending#
Sum the forces, weight them, clamp once at the end. Weights are the tuning surface:
func _physics_process(delta: float) -> void: var steering := seek(goal) + separation() * separation_weight velocity = (velocity + steering.limit_length(max_force) * delta).limit_length(max_speed) move_and_slide()Clamp the sum, not each term, or the weights stop meaning anything. When two behaviours conflict (flee-from-fire vs seek-objective) prefer priority over blending. Priority takes the first behaviour that returns a non-zero force; blending averages the two into walking straight into the fire.
Facing#
Movement direction and facing are separate. Turn toward travel rather than snapping:
if velocity.length_squared() > 1.0: rotation = lerp_angle(rotation, velocity.angle(), turn_speed * delta)In 3D use look_at with an up vector, or Basis.looking_at, and interpolate with
Quaternion.slerp. Assigning a basis every frame produces the same snap that assigning
velocity does.
3D#
Identical maths on Vector3 with CharacterBody3D. Two differences:
- Keep steering on the horizontal plane and let gravity own Y, or the agent will steer itself into the floor. Zero the Y component of the steering force before applying it.
- Seat spawns on real geometry with
spatial place-on/spatial raycastrather than guessing heights, and read bounds back withspatial bounds.
Crowd avoidance via NavigationAgent#
Godot ships RVO avoidance on NavigationAgent2D/3D. The wiring:
@onready var _agent: NavigationAgent2D = $NavigationAgent2D
func _ready() -> void: _agent.velocity_computed.connect(_on_velocity_computed)
func _physics_process(_delta: float) -> void: _agent.set_velocity(global_position.direction_to(goal) * speed)
func _on_velocity_computed(safe_velocity: Vector2) -> void: velocity = safe_velocity move_and_slide()avoidance_enabled, radius, neighbor_distance, max_neighbors, and the
velocity_computed(safe_velocity) signal all exist on 4.7 and the signal fires once per
physics frame. Verify the returned safe_velocity numerically before building on it.
In a two-agent head-on test here it came back (0, 0) on every frame in both headless and
windowed editors, with the agent registered on a valid map, so the wiring firing is not
evidence that avoidance is steering anything. Check with runtime eval and read the value:
runtime eval --code 'var a = get_tree().current_scene.get_node("A")emit("safe=%s pos=%s" % [a.last_safe, a.global_position])'Hand-rolled separation has no such dependency and is the dependable option when a crowd just needs to not stack.
Verify numerically, not by screenshot#
A screenshot of a moving agent proves nothing. Play the scene and read the actual numbers:
scene play --mode res://ai/steer_demo.tscnruntime get --node-path Agent --properties '["position","velocity"]'What a correct arrive looks like, from a real run with the agent starting at (0, 0) and the
target at (400, 200):
| Time | position | velocity |
|---|---|---|
| ~2 s | (124.4, 62.2) | (182.8, 91.4) |
| ~6 s | (420.0, 200.9) | (0.0, -18.2) |
Two things to read off that. The heading is right, since 124:62 is the same 2:1 ratio as
400:200. And it settles with velocity near zero instead of orbiting, which is the arrive
taper doing its job. The final x of 420 rather than 400 is the two collision radii
touching (12 + 8), not an error.
For separation, check pair distances rather than positions. Three agents all seeking
(0, 0) from overlapping starts settled at 56.0, 57.0, and 57.7 units apart. That is a
stable triangle, the equilibrium between seek pulling in and separation pushing
out. All three converging on one number is the signal that it works; a collapsing set
means separation_weight is too low.
Pitfalls#
- A sibling
NodePathneeds../. Silent null, no error, agent never moves. - Steer in
_physics_process, not_process.move_and_slideis physics-frame work. - Clamp the summed force, not each behaviour, or blending weights do nothing.
max_forcetoo high erases the effect, leaving velocity assignment again with extra steps. If motion looks snappy, lower it before adding more behaviours.- Separation over a whole-tree group scan is O(n²). Fine for a squad; for hundreds,
bucket by grid cell or use an
Area2Dneighbour list. get_nodes_in_groupreturns freed nodes’ peers mid-frame. Guard withis_instance_valid(other)when agents die during iteration.