doc 2 of 6

Part 01 — Seek, flee, arrive

You will build: One CharacterBody2D scene that chases the mouse pointer with a visible turning arc, a second instance that runs away from it, and a third that coasts to a stop on a fixed marker instead of buzzing back and forth across it. All three are the same script with one exported value changed.

Everything else on this shelf — flocking, obstacle dodging, crowds that do not walk through each other — is this part's eight lines with a different vector plugged into one slot. Get the eight lines right and the rest is arithmetic.

Set up the scene#

Make a scene called agent.tscn:

  • CharacterBody2D, named Agent
  • CollisionShape2D child, shape set to a CircleShape2D with radius 12
  • Polygon2D child, so there is something with a visible front end

For the polygon, set its Polygon property to three points: (14, 0), (-10, -8), (-10, 8). That draws an arrowhead pointing along positive X, which is to the right. Rotation zero means "pointing right" everywhere in Godot 2D, so a shape drawn pointing right is a shape whose rotation you can set to an angle and trust.

Put an instance of agent.tscn into a Node2D scene called main.tscn. Run it once to confirm nothing errors. It will sit there. That is correct so far.

Write the version that works#

Attach this to Agent:

extends CharacterBody2D

@export var speed: float = 220.0

func _physics_process(_delta: float) -> void:
	var target: Vector2 = get_global_mouse_position()
	velocity = global_position.direction_to(target) * speed
	move_and_slide()

Run it and move the mouse around.

It works. The agent reaches the pointer every time, from any angle, at exactly the speed you asked for. There is no bug to find here, and that is exactly the trap: a correct program that produces a dead result.

Watch it for thirty seconds and three things surface.

It has no turning circle. Flick the mouse to the opposite side of the screen and the agent reverses through 180 degrees in a single frame. Nothing physical does that. A moth, a fish, a guard, a homing missile — all of them have to spend time turning, and that spent time is most of what makes motion read as a creature rather than a cursor.

It has no momentum. Stop moving the mouse and the agent stops the instant it arrives — full speed to zero in one frame. Start moving again and it is at full speed on the next frame.

It vibrates when it arrives. Park the pointer and look closely. The agent overshoots by a fraction of a pixel, direction_to flips 180 degrees, it overshoots back. It sits on the target shivering forever.

Now try to fix it by tuning. Lower speed and the shivering shrinks, but the agent takes forever to cross the screen and still snaps instantly when it turns. Raise speed and the shivering gets worse. There is no value of speed that fixes either problem, because speed is not the problem. The line is.

What that line actually says#

velocity = global_position.direction_to(target) * speed

Read it as a sentence: this frame, the agent's velocity is whatever I say it is. The agent's current motion is not an input to that sentence. It is overwritten and discarded, sixty times a second.

An agent that cannot see its own velocity cannot have inertia, cannot have a turn rate, and cannot decelerate — those are all statements about how velocity changes, and nothing here is looking at how velocity changes.

The repair is one word: velocity stops being something you assign and becomes something you nudge.

The steering equation#

Three terms. This is the whole shelf.

Desired velocity — the velocity the agent would have if it could teleport its motion to whatever it wants, right now, with no physical limits. For chasing a target: point at the target, travel at full speed.

Steering — the difference between what it wants and what it has: desired - velocity. This is a correction, not a destination. If the agent is already moving exactly as it wants, steering is the zero vector and nothing changes.

Integration — add a limited slice of that steering to velocity, every physics frame.

var desired: Vector2 = global_position.direction_to(target) * max_speed
var steering: Vector2 = (desired - velocity).limit_length(max_force * delta)
velocity = (velocity + steering).limit_length(max_speed)
move_and_slide()

The agent no longer teleports to the velocity it wants. It leans toward it, and how hard it can lean is capped. That cap is the turning circle. It was never a smoothing filter or a lerp — it falls out of clamping a correction.

Everything remaining on this shelf changes exactly one line of that block: how desired is computed. Flee negates it. Flocking sums three of them. Obstacle avoidance bends it. The integration never changes.

Two caps doing two different jobs#

There are two limit_length calls and they are not redundant.

limit_length(max_force * delta) clamps the correction. It answers: how much can this agent's velocity change in one second? That is acceleration, and acceleration is what a turn is made of. Turning from north to east is not a rotation, it is a change of velocity, and the size of the change you can afford per frame is the radius of your turning circle.

limit_length(max_speed) clamps the result. It answers: how fast can this thing go, once all the corrections have been applied? Nothing about turning; only about top speed.

Try it. Set max_speed to 220 and run with max_force at 300, then 900, then 6000.

  • 300 — a barge. Enormous arcs, wide overshoots, it circles the pointer several times before it converges.
  • 900 — a bird. It visibly banks into turns and settles.
  • 6000 — back to the cursor you started with. 6000 * 0.0166 is 100 pixels-per-second of velocity change available every single frame, which is enough to reverse a 220 px/s agent in three frames. The cap stops biting and the behaviour collapses into the version you already rejected.

Then hold max_force at 900 and move max_speed between 80 and 600. The arc shape barely changes; the agent covers ground faster and the arc gets physically larger in pixels for the same reason a faster car needs more road to turn. Two knobs, two feelings. Keep them separate.

Why max_force * delta and not bare max_force#

Plenty of steering references clamp with a raw limit_length(max_force), with no delta anywhere. That code says "at most this much velocity change per frame", which means a machine running at 144 Hz gives its agents nearly two and a half times the turning ability of a machine at 60 Hz. The behaviour changes with the hardware.

Multiplying by delta re-reads max_force as pixels-per-second-per-second — an acceleration — and takes one frame's worth of it. Same agent on every machine. That single * delta is the only place delta belongs in this code, which brings up the two places it does not.

Delta, and the two ambushes#

Ambush one: multiplying velocity by delta#

Anyone arriving from a Node2D position += direction * speed * delta background will reach for the same shape here and write velocity = direction * speed * delta. The result is an agent that creeps across the screen at roughly one sixtieth of the speed you configured, and the usual response is to multiply speed by 60 and move on, which then breaks the moment the frame rate changes.

move_and_slide() applies the physics step itself. The velocity property is documented in pixels per second, and the CharacterBody2D class reference names this exact error in the property description: setting velocity to the desired velocity multiplied by delta produces a motion vector in pixels rather than a velocity. Feed it a per-second value and leave the timing to it.

Ambush two: move_toward#

Vector2.move_toward is the natural tool for "nudge this vector toward that one", and it is a legitimate alternative to the limit_length clamp. Its signature is the trap:

Vector2 move_toward(to: Vector2, delta: float) const

The second parameter is named delta, so the first thing nearly everyone writes is:

velocity = velocity.move_toward(desired, delta)   # frozen

Run that and the agent will barely move. That parameter is a distance in the vector's own units, not a frame time. Passing 0.0166 moves the velocity vector 0.0166 pixels-per-second closer to the desired velocity, each frame. Reaching 220 px/s from a standstill takes about 13,000 frames — three and a half minutes of drifting. It looks like the steering code is broken. It is doing exactly what it was told.

The correct form supplies a distance:

velocity = velocity.move_toward(desired, acceleration * delta)

acceleration * delta is a distance in velocity-space — the same quantity max_force * delta was.

So which one? move_toward will not overshoot its target, which is a genuine convenience, and for a single behaviour it reads more plainly. The limit_length form keeps steering alive as a vector you can hold in a variable before you commit it — and the moment you have three behaviours to combine, you need to add three steering vectors together and clamp the sum. move_toward gives you no such vector; it hands back a finished velocity.

The deciding question: will this agent ever run more than one behaviour at once? If no, move_toward is fine and shorter. If yes — and on this shelf it is yes by Part 02 — build the steering vector.

Why limit_length and not normalized() * max_speed#

The obvious way to cap a vector's length is to normalize it and rescale:

velocity = (velocity + steering).normalized() * max_speed   # wrong here

This does cap the length. It also sets the length, on every single vector that goes through it. Every agent is permanently at full throttle. No agent can drift, coast, ease off, or slow down, which quietly makes arrive impossible to write later — a target-approach behaviour whose whole content is "travel slower near the target" cannot exist downstream of a line that forces full speed.

limit_length(max_speed) leaves short vectors alone and shortens only the long ones. That is the difference between a cap and an assignment, and it is the same distinction the whole part turns on.

There is a second reason, and it is nastier. From the Vector2 reference: normalized() returns (0, 0) when the vector's length is zero. No error, no NaN, no warning. Feed it a degenerate case and it silently reports "no direction", which downstream reads as "no steering", which on screen reads as this one agent has mysteriously decided not to move. The same applies to direction_to when both points coincide.

That is not hypothetical — Part 03 has agents standing on each other, which is precisely a zero-length difference vector. Handle it deliberately rather than inheriting a silent zero:

var to_target: Vector2 = target - global_position
var distance: float = to_target.length()
if distance < 0.001:
	return Vector2.ZERO
var direction: Vector2 = to_target / distance

Computing the length once and dividing by it gives you the direction and the distance for the price of a single square root. direction_to() followed by distance_to() costs two. In a per-frame loop over every agent, that adds up, and this version is the one you can also test for degeneracy.

Turn the floor off#

Add this to _ready:

motion_mode = CharacterBody2D.MOTION_MODE_FLOATING

move_and_slide() behaves differently depending on motion_mode. The default, MOTION_MODE_GROUNDED, exists for platformers: it sorts collisions into floors, walls and ceilings relative to up_direction, applies floor snapping, and gives you is_on_floor(). Every one of those is a lie in a top-down scene, where there is no floor and no gravity direction. Leave it on and agents behave strangely against certain collision angles for reasons that have nothing to do with your steering.

MOTION_MODE_FLOATING treats every surface as just a surface. That is what a top-down agent wants. You can also set it in the Inspector under Motion Mode; setting it in _ready keeps the requirement visible in the file that depends on it.

Flee is one minus sign#

Behaviour.FLEE:
	return -direction * max_speed

That is the entire behaviour. Desired velocity points away from the target instead of toward it; the steering equation is untouched.

Run a flee agent alongside a seek agent and watch what one character does to the read. The seeker looks purposeful. The fleeing one looks panicked — it banks hard when the pointer swings around, and because the same max_force cap applies, it sometimes cannot turn fast enough and skims past the threat before it manages to get away. Nobody wrote panic. Panic is what a limited turn rate looks like when the desired direction changes faster than the agent can follow.

This is the payoff for building the equation instead of building the behaviour. A codebase full of chase_player() and run_away() functions has two behaviours. This has one equation and a sign.

One honest limitation: flee at exactly zero distance has no direction to flee in, and the guard above returns a zero desired velocity. An agent standing precisely on the thing it fears will stand there. In practice something is always a fraction of a pixel off, but if you ever spawn a fleeing agent exactly on its threat, that is the frame it does nothing.

Arrive#

Set a seek agent's target to a fixed marker instead of the mouse and watch it fail in a new way. It sprints at the marker at full speed, reaches it, cannot stop, overshoots, turns around at its capped turn rate, sprints back, overshoots the other way. It orbits forever. Higher max_force tightens the orbit into a jitter but never resolves it, because desired velocity is max_speed right up to the last pixel — the agent is being told to travel at full speed at the exact moment it needs to be stopped.

Arrive fixes the desire, not the integration. Inside a slowing radius, scale desired speed down in proportion to how close you are:

Behaviour.ARRIVE:
	if distance < arrive_tolerance:
		return Vector2.ZERO
	var ramp: float = minf(distance / slowing_radius, 1.0)
	return direction * max_speed * ramp

Outside the radius, distance / slowing_radius is greater than one, minf clamps it to one, and arrive is ordinary seek. Inside, the ratio falls linearly to zero, so desired speed falls with it, so the steering starts pointing backwards along the direction of travel and the agent brakes. It settles onto the point and stays there.

Why not distance_squared_to here#

The inner loops in Part 02 use distance_squared_to on purpose — it skips a square root, and for the question "is this neighbour within range" a squared comparison against a squared radius answers exactly the same question for less work.

That reasoning does not transfer to this line. The ramp is not a comparison, it is a proportion, and squared distance is not proportional to distance. Substitute it and at half the slowing radius the ramp reads 0.25 instead of 0.5, so the agent dumps three quarters of its speed the moment it enters the zone and then crawls the last stretch. The stop is not wrong, it is badly shaped — which is the worst kind of bug, because it looks like a tuning problem and no amount of tuning fixes the curve.

Rule of thumb: squared distances are for thresholds. The instant a distance enters an arithmetic expression whose output you care about, it has to be a real distance.

Point it where it is going#

func _face_travel_direction() -> void:
	if velocity.length_squared() < 1.0:
		return
	body_shape.rotation = velocity.angle()

Vector2.angle() returns the vector's angle measured from the positive X axis, in radians — which is why the polygon was drawn pointing right.

The guard matters. At a dead stop, velocity is near zero and its angle is numerically meaningless; without the guard, a resting agent spins to face whatever noise is left in the last hundredth of a pixel. Comparing length_squared() against 1.0 avoids the square root and reads as "faster than one pixel per second".

Note that this rotates the Polygon2D child, not the body. Rotating the body would rotate its collision shape too — harmless for a circle, but the moment the shape is a capsule the physics footprint starts spinning, and any _draw debug output ends up in a rotating coordinate space. Keeping the body axis-aligned and rotating only the visual is the cheaper habit.

Y is down, so the mirror is real#

Godot 2D puts positive Y downward on screen. Angles are still measured counter-clockwise in the mathematical sense, but with the Y axis flipped, that renders as clockwise on screen. An angle of PI / 2 points down, not up.

Nothing in this code needs correcting for that — angle() and rotated() agree with each other and with the renderer. The mirror bites when you port formulas from a reference written for Y-up: a wander offset that circles one way in the diagram circles the other way in your game, and a heading built from sin/cos by hand ends up flipped vertically. If a ported behaviour looks like a mirror image of the reference, this is why, and negating an angle is usually the whole fix.

The finished agent#

class_name SteeringAgent
extends CharacterBody2D

enum Behaviour { SEEK, FLEE, ARRIVE }

@export var behaviour: Behaviour = Behaviour.SEEK
@export var max_speed: float = 220.0
@export var max_force: float = 900.0
@export var slowing_radius: float = 160.0
@export var arrive_tolerance: float = 4.0
@export var target_node: Node2D
@export var draw_debug: bool = false

@onready var body_shape: Polygon2D = $Polygon2D

var _debug_desired: Vector2 = Vector2.ZERO

func _ready() -> void:
	motion_mode = CharacterBody2D.MOTION_MODE_FLOATING

func _physics_process(delta: float) -> void:
	var desired: Vector2 = _desired_velocity(_target_position())
	var steering: Vector2 = (desired - velocity).limit_length(max_force * delta)
	velocity = (velocity + steering).limit_length(max_speed)
	move_and_slide()
	_face_travel_direction()
	if draw_debug:
		_debug_desired = desired
		queue_redraw()

func _target_position() -> Vector2:
	if target_node != null:
		return target_node.global_position
	return get_global_mouse_position()

func _desired_velocity(target: Vector2) -> Vector2:
	var to_target: Vector2 = target - global_position
	var distance: float = to_target.length()
	if distance < 0.001:
		return Vector2.ZERO
	var direction: Vector2 = to_target / distance
	match behaviour:
		Behaviour.SEEK:
			return direction * max_speed
		Behaviour.FLEE:
			return -direction * max_speed
		Behaviour.ARRIVE:
			if distance < arrive_tolerance:
				return Vector2.ZERO
			var ramp: float = minf(distance / slowing_radius, 1.0)
			return direction * max_speed * ramp
	return Vector2.ZERO

func _face_travel_direction() -> void:
	if velocity.length_squared() < 1.0:
		return
	body_shape.rotation = velocity.angle()

func _draw() -> void:
	if not draw_debug:
		return
	draw_line(Vector2.ZERO, velocity * 0.25, Color(0.4, 0.9, 1.0), 2.0)
	draw_line(Vector2.ZERO, _debug_desired * 0.25, Color(1.0, 0.7, 0.2), 2.0)

Now build the scene that proves the point. In main.tscn, place three instances of agent.tscn and a Marker2D somewhere in open space:

Instancebehaviourtarget_node
AgentSEEKempty (falls through to the mouse)
Agent2FLEEempty
Agent3ARRIVEthe Marker2D

Three characters that read completely differently, from one script, with one dropdown changed. Turn on draw_debug on any of them and the two lines tell you everything: orange is what the agent wants, blue is what it has, and the gap between them is the steering. Watch the orange line snap instantly while the blue one swings around to meet it — that gap, and the time it takes to close, is the entire illusion of life.

One detail about velocity after move_and_slide()#

move_and_slide() does not only read velocity, it writes it. After a collision, the property holds the post-slide velocity, not what you assigned. Next frame's steering therefore starts from where the agent actually ended up rather than from a fantasy.

That is what you want. An agent grinding along a wall genuinely has lost the velocity component that went into the wall, and its next correction should account for that. If you ever need the distinction explicitly — for an animation that reacts to being blocked, say — get_real_velocity() returns the velocity actually achieved by the last move. velocity is intention; get_real_velocity() is outcome.

Checkpoint — definition of done#

  • The seek agent visibly arcs when you flick the mouse across the screen; it never reverses direction within a single frame.
  • Dropping max_force to 300 widens the arcs and makes the agent circle the pointer before settling; raising it to 6000 returns it to the snappy cursor behaviour from the start of this part.
  • Changing max_speed alone changes how quickly the agent covers ground without changing how tight the turns feel.
  • The flee agent runs away and banks hard when the pointer swings past it, using the same max_force value as the seeker.
  • The arrive agent stops on its Marker2D and stays stopped — no orbiting, no shivering — while a SEEK agent pointed at the same marker orbits it forever.
  • The arrow visibly points along the direction of travel while moving and holds still when stopped.
  • With draw_debug on, the orange desired line jumps instantly and the blue velocity line lags behind it, closing the gap over several frames.
  • All three behaviours run from one script, and switching between them is a dropdown, not an edit.
  • You can explain: why does the seeking agent turn in an arc when nothing in the code rotates it?

Stretch (no instructions)#

  • Make the flee agent wrap to the opposite edge of the screen instead of escaping forever.
  • Add a PURSUE behaviour that aims where the target will be, using the target's velocity and the time it would take to close the current gap.
  • Give the arrive agent an ease-in as well as an ease-out, so it accelerates smoothly off the mark instead of at maximum force.
  • Feed max_force from the agent's current speed so a fast agent turns worse than a slow one, the way a vehicle does.

If you get stuck#

  • Agent crawls across the screen at about a sixtieth of the speed you configured → something multiplied velocity by delta before move_and_slide(). The only delta in the movement block belongs on max_force.
  • Agent takes minutes to reach full speed, or never seems to move at all → you swapped in velocity.move_toward(desired, delta). That second argument is a distance, not a frame time. It needs acceleration * delta.
  • Agent still snaps direction instantly → either the assignment version is still in _physics_process, or max_force * delta exceeds twice max_speed, in which case the clamp never triggers. Print steering.length() and compare it to max_force * delta; if they are never equal, the cap is doing nothing.
  • Agent never slows for ARRIVE → check the instance, not the scene file. Exported values live on the instance, and it is a common slip to set behaviour on agent.tscn while the placed node still says SEEK. Also confirm target_node is assigned; an empty one falls through to the mouse.
  • ARRIVE agent stops well short of the markerarrive_tolerance is too large, or slowing_radius is bigger than the distance it started from, so the agent has been braking since frame one.
  • Agent behaves oddly against certain collision angles, or sticks to a surfacemotion_mode is still MOTION_MODE_GROUNDED. Confirm the _ready line runs and is not shadowed by an inspector value.
  • Arrow points sideways relative to travel → the Polygon2D points is not drawn along positive X. Rotation zero means right; a shape drawn pointing up is permanently 90 degrees off.
  • Rotation sweeps the opposite way from a diagram you are following → Y is down in Godot 2D, so positive angles read clockwise on screen. Any Y-up reference is mirrored.
  • One agent freezes for no reason → it is sitting exactly on its target, so the difference vector is zero-length. normalized() and direction_to() return (0, 0) there without complaint. The distance < 0.001 guard is what catches it.
  • Two agents shove each other around → their collision shapes are on the same layer and move_and_slide() is resolving the overlap physically. That is expected for now; making a crowd hold its own spacing is a later part of this shelf, and it is not a physics job.

Next: Part 02 — Three rules that look like a flock. Read it once the seek agent's arc is tuned to your taste, because flocking is three desired velocities summed and the tuning instinct you built here is what keeps the sum from tearing itself apart.