Part 03 — Steering around what you can see
You will build: A flock that crosses a room full of static walls and pillars by casting a speed-scaled probe ahead of itself and turning sideways before it makes contact, plus a runtime toggle that drops back to plain physical collision so you can compare the two in a single keypress.
You finished Part 02 with three rules and a weighted sum. The flock holds together, spreads out, and drifts. It also has no idea the world contains anything except other boids.
Time to put a wall in front of it.
First, let the wall win#
Open your flock scene. Add a StaticBody2D with a CollisionShape2D and a RectangleShape2D sized to something like 400 by 40 pixels. Drop it across the middle of the room. Add three or four more — a long wall, a couple of square pillars, one corner made of two walls meeting at a right angle.
Your boid is already a CharacterBody2D calling move_and_slide(), so this works with no code changes at all. Run it.
The boids stop passing through the walls.
That is the entire result, and it is worth staring at for thirty seconds, because it is going to feel like a solved problem for about the length of one lap around the room. Watch what actually happens:
- A boid travelling at full speed hits the wall at full speed. There is no anticipation, no slowdown, no turn. It arrives like a dropped crate.
- After impact it slides along the surface.
move_and_slide()kills the component of velocity going into the wall and keeps the rest. The boid does not decide to follow the wall; it is what remains of the boid after the wall subtracted a direction from it. - Cohesion is still pulling the boid toward the flock centre, which may well be on the other side of the wall. So it keeps pressing. It hugs the surface for as long as the flock stays over there.
- Feed the flock into the right-angle corner and it stops permanently. Two walls remove two components. Nothing is left. The boids pile up and vibrate.
This is the trap, and it is a good one because it does not look like a bug. Nothing passes through anything. A screenshot would pass review.
The distinction to hold onto:
Collision is a consequence. Avoidance is a behaviour.
Physical collision runs after your agent has already decided where to go. It edits the outcome. It never touches the decision. An agent that looks alive makes the decision differently when there is a wall ahead — it commits to a turn while it still has room to turn. Everything in this part is about moving the wall from the outcome stage to the decision stage.
Keep the walls. They are not wrong; they are the safety net for when avoidance fails. You are adding a layer above them, not replacing them.
Measure the gap between what you asked for and what you got#
Before writing the fix, make the failure visible in numbers. CharacterBody2D gives you both halves:
velocityis what your steering code asked for. It is also, per the docs, "used and modified during calls tomove_and_slide()" — after the call it holds the post-slide result, so read it before if you want the raw request.get_real_velocity()is what the body actually achieved last frame, after collision resolution.
Cache the request before you move, then compare.
extends CharacterBody2D
class_name Boid
@export var max_speed: float = 160.0
var _requested_velocity: Vector2 = Vector2.ZERO
var crush: float = 0.0
func _apply_movement() -> void:
_requested_velocity = velocity
move_and_slide()
var achieved: Vector2 = get_real_velocity()
var lost: float = (_requested_velocity - achieved).length()
crush = clampf(lost / maxf(max_speed, 1.0), 0.0, 1.0)
crush is now a 0-to-1 number meaning "how much of what I wanted was taken away from me". A boid in open water sits near 0. A boid grinding along a wall sits around 0.5 — half its intent is being deleted every frame. A boid wedged in a corner sits at 1.0 and stays there.
That number is worth spending. Tint the sprite by it, squash the scale, or print the flock average once a second. Two reasons:
- It is the honest debugging signal for this whole part. When avoidance is working, flock-average crush drops toward zero even though the walls have not moved. That is your proof, and it is more trustworthy than "it looks better now".
- It reads as life. An agent visibly straining against a crowd is doing something a viewer understands without being told.
One caution: crush will spike for a single frame any time a boid legitimately turns hard, because a sharp turn also produces a large difference. It is the sustained value that means something. If you drive visuals from it, smooth it:
crush = lerpf(crush, clampf(lost / maxf(max_speed, 1.0), 0.0, 1.0), 0.15)
Look ahead with a probe#
The naive version reacts on contact. The fix is to detect the wall while there is still room to do something about it, which means asking a question about a place the boid is not yet standing in.
Add a RayCast2D as a child of your boid scene. Name it Probe. In the inspector:
- Set its Target Position to something non-zero —
(60, 0)is fine as a starting value; the script overwrites it every frame anyway. - Leave Enabled on.
- Set its Collision Mask to the layer your walls live on, and only that layer. If the probe can see other boids, every neighbour becomes an obstacle and the flock tears itself apart. Give walls their own physics layer now if you have not already.
RayCast2D runs its query during the physics step and caches the result, so you read is_colliding(), get_collision_point(), and get_collision_normal() from the cached data rather than paying for a query per call. That also means the result you read in _physics_process reflects the position the node had at the start of the step. For a probe that extends tens of pixels ahead, one frame of staleness is irrelevant. Do not reach for force_raycast_update() unless you have moved the node yourself mid-frame and need the answer immediately.
Now the length. A fixed probe length is the obvious first choice and it is wrong in a specific way: a boid drifting at 20 px/s and a boid sprinting at 200 px/s need wildly different amounts of warning. Give the slow one a 100 px probe and it swerves around a pillar it would never have reached. Give the fast one the same 100 px and it commits to the turn 0.5 seconds before impact, which is not enough.
Tie length to speed. What you actually want to express is time, not distance:
@export var look_ahead_time: float = 0.65
@export var look_ahead_minimum: float = 24.0
@onready var probe: RayCast2D = $Probe
func _aim_probe() -> void:
var speed: float = velocity.length()
if speed < 0.01:
probe.enabled = false
return
probe.enabled = true
var length: float = maxf(speed * look_ahead_time, look_ahead_minimum)
probe.target_position = velocity.normalized() * length
Read that middle line as the design decision it is: the boid looks 0.65 seconds into its own future. Faster means further, automatically, with no second tuning knob. The minimum keeps a nearly-stopped boid from going blind and drifting into a wall it can no longer see.
target_position is in the raycast's local space, so this only points where you think it does if the boid's own rotation is zero. If you rotate the sprite to face travel — and you probably will, using velocity.angle() — rotate a child Node2D holding the sprite, not the body. Keep the CharacterBody2D itself unrotated and the probe maths stays honest. (Remember from the class reference that Vector2.angle() measures from the positive X axis and Y points down in 2D, so increasing angles sweep clockwise on screen. Sprites ported from a Y-up reference come out mirrored.)
The zero-speed guard matters more than it looks. velocity.normalized() on a zero vector returns (0, 0) — it does not error and it does not produce NaN. Without the guard you would silently set target_position to (0, 0), the ray would have zero length, is_colliding() would return false forever, and you would have a boid that is provably not detecting a wall it is standing against. Silent zeroes are this whole subject's favourite way to waste an afternoon.
The one line that is the whole part#
You have a hit. You have get_collision_normal() — a unit vector pointing out of the surface, back toward you. The instinct is overwhelming:
# Do not do this.
var avoid: Vector2 = probe.get_collision_normal() * avoid_strength
Push away from the wall. Directly away. It is what a physical repulsion would do, it is one line, and it is the reason so many hand-rolled flocks end up glued to their level geometry.
Think about the geometry. You are flying at a wall close to head-on. The normal points straight back at you. Adding it to your velocity subtracts from your forward speed. Add enough and you stop. Now you are a stationary boid pressed against a wall, with cohesion still pulling you into it and a repulsion force cancelling exactly the part of your motion that could get you out. The two forces balance and you sit there. Increase avoid_strength and it gets worse — you bounce straight back, cohesion turns you around, you fly at the wall again. Head-on oscillation.
The fix is to stop thinking about escape and start thinking about steering. You do not want to reverse. You want to turn. So push sideways:
func _obstacle_force() -> Vector2:
if not probe.is_colliding():
return Vector2.ZERO
var normal: Vector2 = probe.get_collision_normal()
var heading: Vector2 = velocity.normalized()
# The component of the wall normal that is perpendicular to travel.
var lateral: Vector2 = normal - heading * normal.dot(heading)
if lateral.length_squared() < 0.0001:
# Dead-on hit: the normal has no sideways component at all.
# Pick a side deterministically so the boid does not dither.
lateral = Vector2(-heading.y, heading.x)
return _steer_towards(lateral.normalized() * max_speed)
That subtraction is the trick, and it is worth naming what it does. normal.dot(heading) is how much of the normal points back along your travel direction. Subtracting heading * that removes exactly that component. What remains is the part of the normal that is at right angles to where you are going — the sideways nudge, with the useless braking part stripped out.
Consequences of that one line:
- Glancing hit on a wall you are travelling almost parallel to: the normal is already nearly perpendicular to travel, so
lateralis nearly the whole normal. Strong sideways push, no speed lost. The boid peels off the wall instead of grinding along it. - Head-on hit: the normal is nearly antiparallel to travel, so almost all of it gets subtracted and
lateralcollapses toward zero. That is what the guard handles.Vector2(-heading.y, heading.x)is the heading rotated a quarter turn, which gives a consistent choice of side. Consistent matters: if you picked randomly per frame, the boid would flicker left-right-left and go nowhere. If you want the flock to look less mechanical, seed a per-boid_turn_biasof1.0or-1.0once in_ready()and multiply by it, so different boids peel off in different directions and the flock splits around a pillar instead of all queueing on one side. - Speed is preserved. Nothing in
lateralopposes forward travel, by construction. The boid arcs. That arc is what "alive" looks like.
Note the last line. lateral.normalized() * max_speed is a desired velocity — "travel sideways at full speed" — and every rule in Part 02 ends by handing exactly that through _steer_towards, which returns (desired - velocity).limit_length(max_force): a steering force. The obstacle rule goes through the same funnel for the same reason. A weighted sum is only meaningful if every term is the same kind of quantity. Mixing "desired velocity" terms with "raw acceleration" terms is how weight tuning turns into superstition — the numbers still respond to being dragged, so it feels like tuning, but what you are actually adjusting is a unit conversion nobody wrote down.
Give it authority#
Now fold it into the same weighted sum from Part 02.
@export var weight_separation: float = 1.5
@export var weight_alignment: float = 1.0
@export var weight_cohesion: float = 0.8
@export var weight_obstacle: float = 6.0
@export var max_force: float = 480.0
func _steer() -> Vector2:
var steering: Vector2 = Vector2.ZERO
steering += _separation() * weight_separation
steering += _alignment() * weight_alignment
steering += _cohesion() * weight_cohesion
steering += _obstacle_force() * weight_obstacle
return steering.limit_length(max_force)
Those first three are Part 02's functions under their own names, unedited. The fourth is new. All four return forces from _steer_towards, all four get clamped together, and the weights stay pure ratios.
weight_obstacle at 6.0 is not a rounding error, and it is not "6 feels good". It has to dominate, and the reason is structural rather than aesthetic.
Obstacle force is intermittent. It is zero on almost every frame, for almost every boid, and it exists only in the narrow window between "I can see the wall" and "I have hit the wall". Cohesion, meanwhile, is on constantly. If you weight avoidance to merely tie with the flocking rules, then in the exact moment it matters — a boid closing on a wall while the flock centre sits beyond it — you have a tie. A tie means the boid goes straight. A boid that goes straight hits the wall.
The rule: a force that only speaks occasionally has to be listened to when it does.
Tune it by lowering, not raising. Start at 6.0, watch it work, then drop it until you see a boid clip a corner. Whatever value that is, go back up by half again. Raising from a broken state teaches you nothing because "it stopped hitting walls" has a hundred possible causes, most of them accidental.
Use limit_length() for the final clamp rather than normalized() * max_force. limit_length() leaves shorter vectors untouched, which preserves the difference between "gently drifting" and "hard turn". normalized() would force every boid to full steering effort on every frame, and a flock where nothing is ever relaxed does not read as a flock.
The toggle#
Add an input action named toggle_avoidance in Project Settings and bind it to a key.
Then read that key on the flock manager, not on the boid. This is worth a paragraph, because the obvious placement is a trap with no error message attached.
extends Node2D
# flock.gd — the manager that spawned the boids.
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("toggle_avoidance"):
Boid.look_ahead_enabled = not Boid.look_ahead_enabled
get_viewport().set_input_as_handled()
look_ahead_enabled is a static var: one value shared by the whole class, which is what you want for a comparison switch. Now put Input.is_action_just_pressed("toggle_avoidance") inside the boid's _physics_process instead and watch what happens. _physics_process runs once per boid. Input.is_action_just_pressed() is a global query — the docs describe it as true "in the current frame or physics tick", and it answers the same for every caller in that tick. So one keypress runs look_ahead_enabled = not look_ahead_enabled once per boid. Sixty boids, sixty flips, and you land on the value you started with. The key does nothing, forever, and nothing in the output says why.
One node owns the input. Every boid reads the flag. That split is the general shape: shared state gets written from one place and read from many.
Here is the whole thing assembled.
extends CharacterBody2D
class_name Boid
const GROUP: StringName = &"boids"
@export var max_speed: float = 160.0
@export var max_force: float = 480.0
@export var neighbour_radius: float = 90.0
@export var weight_separation: float = 1.5
@export var weight_alignment: float = 1.0
@export var weight_cohesion: float = 0.8
@export var weight_obstacle: float = 6.0
@export var look_ahead_time: float = 0.65
@export var look_ahead_minimum: float = 24.0
@onready var probe: RayCast2D = $Probe
@onready var body_shape: Polygon2D = $Polygon2D
static var look_ahead_enabled: bool = true
var crush: float = 0.0
var _neighbours: Array[Boid] = []
var _turn_bias: float = 1.0
var _requested_velocity: Vector2 = Vector2.ZERO
func _ready() -> void:
add_to_group(GROUP)
motion_mode = CharacterBody2D.MOTION_MODE_FLOATING
_turn_bias = 1.0 if randf() < 0.5 else -1.0
velocity = Vector2.RIGHT.rotated(randf() * TAU) * max_speed
func _physics_process(delta: float) -> void:
_gather_neighbours()
_aim_probe()
velocity = (velocity + _steer() * delta).limit_length(max_speed)
_requested_velocity = velocity
move_and_slide()
var lost: float = (_requested_velocity - get_real_velocity()).length()
crush = lerpf(crush, clampf(lost / max_speed, 0.0, 1.0), 0.15)
if velocity.length_squared() > 1.0:
body_shape.rotation = velocity.angle()
func _gather_neighbours() -> void:
_neighbours.clear()
var radius_squared: float = neighbour_radius * neighbour_radius
for node: Node in get_tree().get_nodes_in_group(GROUP):
var other: Boid = node as Boid
if other == null or other == self:
continue
if global_position.distance_squared_to(other.global_position) <= radius_squared:
_neighbours.append(other)
func _steer_towards(desired: Vector2) -> Vector2:
return (desired - velocity).limit_length(max_force)
func _aim_probe() -> void:
var speed: float = velocity.length()
if speed < 0.01 or not look_ahead_enabled:
probe.enabled = false
return
probe.enabled = true
probe.target_position = velocity.normalized() * maxf(speed * look_ahead_time, look_ahead_minimum)
func _steer() -> Vector2:
var steering: Vector2 = Vector2.ZERO
steering += _separation() * weight_separation
steering += _alignment() * weight_alignment
steering += _cohesion() * weight_cohesion
if look_ahead_enabled:
steering += _obstacle_force() * weight_obstacle
return steering.limit_length(max_force)
func _obstacle_force() -> Vector2:
if not probe.is_colliding():
return Vector2.ZERO
var normal: Vector2 = probe.get_collision_normal()
var heading: Vector2 = velocity.normalized()
if heading == Vector2.ZERO:
return Vector2.ZERO
var lateral: Vector2 = normal - heading * normal.dot(heading)
if lateral.length_squared() < 0.0001:
lateral = Vector2(-heading.y, heading.x) * _turn_bias
return _steer_towards(lateral.normalized() * max_speed)
Paste _cohesion(), _alignment() and _separation() in from Part 02 with no edits. They read _neighbours and return through _steer_towards, both of which are above, which is the only reason they transplant cleanly. _gather_neighbours(), neighbour_radius, GROUP and the add_to_group(GROUP) call are not optional plumbing you can trim — they are the three rules' entire input.
Three details in there that will cost you an evening if you get them wrong:
_gather_neighbours() runs first, or the flock is not a flock. All three Part 02 rules return Vector2.ZERO on an empty _neighbours, and an empty list is what you get if the gather is missing, or if add_to_group(GROUP) never ran because you rewrote _ready() for the probe and dropped the line. The failure is silent and specific: obstacle avoidance works beautifully, boids arc around every wall, and they completely ignore each other. It looks like a flocking bug. It is a plumbing bug.
Never multiply velocity by delta before move_and_slide(). The class reference calls this out by name: it produces a motion vector in pixels rather than pixels per second, and move_and_slide() applies the physics step's delta itself. delta belongs on the steering term, which is an acceleration, and nowhere else. The symptom is a flock crawling at roughly one-sixtieth speed, which reads as "my max_speed export is broken".
MOTION_MODE_FLOATING. The grounded default applies floor, wall and ceiling semantics with a gravity direction. For top-down flocking there is no floor, and grounded mode will produce snagging and stair-step behaviour on your walls that has nothing to do with your steering code. Set it once in _ready() or in the inspector.
Now run it and hit the key.
With look-ahead off: crates. Impacts, wall-hugging, the corner jam. With it on: the flock notices the wall a beat early, splits, arcs around either side, and rejoins. The walls have not changed. The physics has not changed. Only the moment of the decision moved.
Watch the crush average across the toggle. That is the part worth writing down.
Where a single probe honestly fails#
One ray forward handles convex obstacles well. A pillar, the outer corner of a building, a wall you approach at an angle — all fine, because at every point along the approach there is a sideways direction that leads to open space, and the lateral force finds it.
Concave geometry breaks it, and you should go build the failure rather than take this on faith. Make a U: three walls forming a dead-end pocket, opening toward the flock. Drive them in.
What happens: a boid enters the pocket, sees the back wall, turns sideways, sees the side wall, turns back, sees the back wall again. It ping-pongs. Sometimes it works loose. Often it does not, and the flock accumulates in the pocket like water in a cup.
The reason is not tuning. A single probe answers exactly one question — is there something in front of me right now — and the answer inside a pocket is yes in every direction the boid can turn. There is no local information that distinguishes "turn left, which leads out" from "turn left, which leads to another wall". Getting out requires knowing about space the boid cannot see from where it stands, which is a statement about the map, not about the boid.
So do not raise weight_obstacle to fix it. That makes the ping-pong faster and more violent, which some people mistake for progress.
The honest answer is that concave navigation is pathfinding, and pathfinding is a separate system: bake a navigation mesh, query a path, and follow it while steering handles the local, per-frame corrections between waypoints. Godot's navigation server is built exactly around that split — the docs are blunt that avoidance "does not affect the pathfinding", because the two solve different problems at different time scales. Part 04 covers the built-in avoidance side of that; the pathfinding side is its own subject.
For a lot of games this never comes up, because you control the level. If your flock lives in open water, or a courtyard, or a field with scattered trees, a probe is the whole answer and adding navigation would be cost with no gain. The deciding question is not "which technique is better" — it is does my level contain a pocket a boid can enter and not see its way out of? If yes, you need a map. If no, you do not.
Whiskers: wider vision, at a price#
There is a cheaper improvement available first, and it addresses a different failure than the pocket.
A single centre ray has a blind spot: an obstacle can be off to one side, not intersecting the ray at all, and the boid will sail past close enough to clip it with its collision shape. It also means the boid learns about a corner very late, because the ray only catches the wall once it is nearly head-on. The turn ends up sharp and abrupt.
Widen the vision with two angled probes — whiskers — alongside the centre ray. Add two more RayCast2D children named WhiskerLeft and WhiskerRight, same collision mask.
@export var whisker_angle: float = 0.5 # radians, roughly 29 degrees
@export var whisker_scale: float = 0.6 # whiskers are shorter than the centre ray
@onready var probe: RayCast2D = $Probe
@onready var whisker_left: RayCast2D = $WhiskerLeft
@onready var whisker_right: RayCast2D = $WhiskerRight
func _aim_probe() -> void:
var speed: float = velocity.length()
if speed < 0.01 or not look_ahead_enabled:
probe.enabled = false
whisker_left.enabled = false
whisker_right.enabled = false
return
probe.enabled = true
whisker_left.enabled = true
whisker_right.enabled = true
var heading: Vector2 = velocity.normalized()
var length: float = maxf(speed * look_ahead_time, look_ahead_minimum)
probe.target_position = heading * length
whisker_left.target_position = heading.rotated(-whisker_angle) * length * whisker_scale
whisker_right.target_position = heading.rotated(whisker_angle) * length * whisker_scale
func _obstacle_force() -> Vector2:
var heading: Vector2 = velocity.normalized()
if heading == Vector2.ZERO:
return Vector2.ZERO
var total: Vector2 = Vector2.ZERO
total += _lateral_from(probe, heading) * 1.0
total += _lateral_from(whisker_left, heading) * 0.7
total += _lateral_from(whisker_right, heading) * 0.7
if total.length_squared() < 0.0001:
return Vector2.ZERO
return _steer_towards(total.normalized() * max_speed)
func _lateral_from(ray: RayCast2D, heading: Vector2) -> Vector2:
if not ray.is_colliding():
return Vector2.ZERO
var normal: Vector2 = ray.get_collision_normal()
var lateral: Vector2 = normal - heading * normal.dot(heading)
if lateral.length_squared() < 0.0001:
lateral = Vector2(-heading.y, heading.x) * _turn_bias
return lateral.normalized()
The whiskers are shorter and weighted lower on purpose. A whisker hit means "there is something over there", which deserves a gentle correction. A centre hit means "I am going to run into this", which deserves the full force. Same weights on all three and the boid overreacts to obstacles it was never going to touch.
The cornering gets noticeably smoother because the boid starts its turn while the wall is still off to the side, instead of waiting until it is directly ahead.
The trade is exact and you should say it out loud before adopting this: you tripled your raycast queries per agent per frame. One boid, three casts. Two hundred boids, six hundred casts per physics frame — and physics runs 60 times a second by default, so 36,000 queries a second before you have drawn anything. On a small flock this is invisible. On a large one it is the first thing that shows up in a profile.
Part 05 is where that bill comes due and where you will learn to measure it rather than guess. For now, take the whiskers, and remember that you took them. Cost you accepted knowingly is fine. Cost you cannot account for is how projects get slow without anyone knowing which change did it.
If you want a middle option: run the centre ray every frame and alternate the whiskers, left on even physics frames, right on odd. Halves the whisker cost. Slightly less responsive. Whether that trade is worth it depends on flock size, which is exactly the kind of thing you should not decide without a profiler open.
The hole that is left#
One more experiment, and it sets up the next part.
Turn weight_separation up. Keep going until the boids genuinely stop overlapping — until you can freeze the frame and see clear space between every pair of sprites.
Watch what you get. The flock starts to vibrate. Boids twitch, jitter, buzz against each other like a jar of flies. Push it further and the flock blows itself apart entirely.
Separation was never designed for this. It is a positional rule: it reads where neighbours are right now and pushes away from them, proportional to closeness. That gives you comfortable spacing, and it is excellent at that. But turning it into a collision guarantee means it has to produce a force large enough to stop a boid from ever entering another boid's space — and it only ever finds out about the intrusion after the boid has already got close. It corrects too late, so it has to correct too hard, and a too-hard correction overshoots and needs correcting the other way. That feedback loop is the vibration you are watching.
Look at what you just built for the walls, and notice you solved this exact problem — by predicting. The probe does not ask "is there a wall touching me", it asks "will there be a wall where I am headed". That question can be answered early enough for a gentle correction.
So the question for Part 04 is the obvious one: what if agents asked that about each other? Not "where is my neighbour" but "where will my neighbour be, and does that intersect where I will be?" Two agents on a collision course could each yield slightly, right now, and pass cleanly — no vibration, because neither ever had to make a violent correction.
That is reciprocal velocity obstacles, and Godot ships it. NavigationAgent2D has an avoidance mode that runs this simulation for you, with the usual bargain: less code, less control, and a set of defaults and preconditions that will silently produce a motionless agent if you get them wrong. There is a specific sentence in the navigation docs that has cost people entire evenings, and it is the first thing Part 04 puts on the table.
Turn your separation weight back down to something sane before you move on.
Checkpoint — definition of done#
- With look-ahead disabled, a boid steered into a wall arrives at full speed and slides along it, and boids driven into a right-angle corner stay stuck there.
- With look-ahead enabled, boids visibly begin turning before contact and arc around a pillar instead of touching it.
- The toggle key flips between the two behaviours at runtime with no restart, and the difference is obvious to someone watching who was not told what changed.
- The probe visibly gets longer as a boid speeds up and shorter as it slows — check with Debug > Visible Collision Shapes turned on.
- A boid with near-zero velocity does not throw errors and does not steer into a wall it is drifting toward.
- Flock-average crush drops measurably when look-ahead is on, compared to the same scene with it off.
- Replacing the lateral force with the raw collision normal reproduces the failure: boids stall against the wall or bounce head-on instead of turning.
- The probe's collision mask excludes other boids — confirmed by putting two boids on a collision course in open space and seeing no obstacle force fire.
- A U-shaped dead end still traps the flock. You have seen this and did not try to fix it by raising the weight.
- With whiskers added, a boid approaching a wall at a shallow angle starts its turn earlier than it did with the centre ray alone.
- You can explain: why steering along the collision normal pins an agent to the wall, while steering perpendicular to its own heading carries it around.
Stretch (no instructions)#
- Make the probe length depend on the flock's local density as well as speed, so a boid in a crowd commits to its turn earlier than a lone one.
- Give each boid a
crush-driven visual — colour, squash, a strain particle — and see whether a viewer can tell where the level's pinch points are without being shown the geometry. - Log a warning when a boid's crush stays above 0.8 for more than a second, and use it to find the bad corners in a level you did not design.
- Replace the two fixed whiskers with a small fan of probes sampling a spread of headings, pick the clearest direction, and decide honestly whether the result is better enough to justify the queries.
If you get stuck#
- Boids ignore walls entirely, look-ahead on or off → the probe's collision mask does not include the wall's collision layer. Layers and masks are different fields; a probe with mask 1 does not see a body on layer 2.
- Every boid is constantly avoiding, even in open space → the probe can see other boids. Move walls to their own physics layer and set the probe's mask to that layer alone.
- Boids freeze against walls instead of turning → you are using the raw collision normal rather than its lateral component, or the
lateralguard is returning zero. Printlateral.length()on a head-on hit; if it is near zero and no fallback fires, the guard threshold is wrong. - Boids flicker left and right against a flat wall and make no progress → the fallback side is being chosen fresh each frame. Set
_turn_biasonce in_ready()and never reassign it. - The flock crawls at a fraction of
max_speed→velocityis being multiplied bydeltabeforemove_and_slide(). The call applies the physics step itself;deltabelongs on the steering term alone. - Boids avoid walls perfectly but drift past each other as though the flock rules were deleted →
_gather_neighbours()is not being called, oradd_to_group(GROUP)is missing from_ready(). An empty_neighboursmakes all three Part 02 rules return zero, and the only force left is the obstacle one. - Boids snap to new headings in a single frame instead of arcing → the
* deltais missing from the steering term, so one frame applies a full second of acceleration. - The toggle key does nothing at all → the keypress is being read inside the boid rather than on the manager. Every boid flips the shared
static varon the same tick, and an even flock cancels itself out. Move the check to the manager and setBoid.look_ahead_enabledthere once. - The probe points in the wrong direction, especially after a turn →
target_positionis local space and the body is rotated. Rotate a child sprite node instead of theCharacterBody2D. - The probe never reports a hit even though it visibly crosses a wall → confirm it is
enabled, and confirmtarget_positionis not(0, 0). A zero-length ray hits nothing and reports nothing. - Boids snag on wall edges or stair-step along flat surfaces →
motion_modeis still grounded. SetMOTION_MODE_FLOATINGfor top-down movement. - Avoidance works alone but boids still clip walls in a dense flock →
weight_obstacleis losing the weighted sum. Raise it until clipping stops, then reduce until it starts, then split the difference upward. - Boids escape single walls but pile up in a U-shaped pocket → working as designed. A single-ray probe cannot solve concave geometry. This one needs a path, not a bigger weight.
- Frame rate drops sharply after adding whiskers → three casts per boid per physics frame at 60 Hz adds up fast. Reduce the flock temporarily to confirm the cause before optimising anything.
is_colliding()returns stale results after teleporting a boid → the raycast caches its result from the start of the physics step. Callforce_raycast_update()after moving the node manually if you need the answer in the same frame.
Next: Part 04 — Godot's avoidance, and when to use it. Read it once your probe handles walls cleanly and the only remaining problem is agents colliding with each other.