doc 3 of 6

Part 02 — Three rules that look like a flock

You will build: Sixty boids in a bounded arena that travel as one body — turning together, holding spacing, and reforming into a flock after you scatter them with a keypress. Three exported weights let you retune the whole personality while the game is running.

Part 01 gave you one agent chasing one point. The steering equation there was (desired - velocity), clamped, applied as acceleration. Nothing in this part replaces that equation. You are going to run it three times per frame against three different desires and add the results.

That is the entire trick. There is no fourth algorithm called flocking.


The failure you are being set up to avoid#

The diagrams for boids always show three arrows of equal length: cohesion, alignment, separation. So the obvious move is to write all three, weight them 1.0 each, and run.

What you get is a shivering blob. Sixty agents packed into a disc about two body-widths across, jittering, not travelling anywhere, not dispersing. No error. No warning. The output looks like a bug in alignment — everyone is clearly not agreeing on a heading — so that is where people go looking.

Alignment is fine. The blob is separation's fault, and specifically it is what happens when separation is written as a plain sum of normalized vectors. Every neighbour inside the radius shoves with the same authority, so a boid pressed against your flank and a boid eighty pixels away vote equally. Sum sixty of those and they cancel into noise. Cohesion wins uncontested, the flock fuses, and then it fights itself at close range forever.

You will not be told which line to write differently. You are going to build each rule alone, run it, and watch it fail in its own distinct way. Three separate failures is what makes the weighted sum legible instead of magic.


Scene setup#

Two scenes and two scripts.

boid.tscn:

  • Root: CharacterBody2D, named Boid, script boid.gd.
    • Set Motion Mode to Floating in the inspector. The default, Grounded, applies floor and wall and ceiling semantics that mean nothing in a top-down arena and will quietly change how the body slides.
  • Child: CollisionShape2D with a CircleShape2D, radius 6.
  • Child: Polygon2D — a triangle pointing along +X, for example the points (10, 0), (-6, 5), (-6, -5).

The triangle must point right, not up. Vector2.angle() measures against the positive X axis, so a sprite drawn pointing up will render permanently rotated a quarter turn off its heading. And because Y is down in 2D, increasing angles sweep clockwise on screen — the opposite of the maths textbook diagram you probably have in your head.

On the CharacterBody2D, set Collision → Layer and Mask both to zero for now. You want physical collision off. If boids bump each other through the physics server, you cannot tell which of the shapes you are seeing is your separation rule and which is the solver pushing bodies apart. Turn it off, make the rules do the work, and you will know exactly what you built.

flock.tscn:

  • Root: Node2D, named Flock, script flock.gd.

flock.gd — spawn them and give them a shove#

extends Node2D

@export var boid_scene: PackedScene
@export var count: int = 60
@export var arena: Rect2 = Rect2(0.0, 0.0, 1152.0, 648.0)

func _ready() -> void:
	for i: int in count:
		var boid: Boid = boid_scene.instantiate() as Boid
		add_child(boid)
		boid.arena = arena
		boid.global_position = Vector2(
			randf_range(arena.position.x + 60.0, arena.end.x - 60.0),
			randf_range(arena.position.y + 60.0, arena.end.y - 60.0)
		)
		boid.velocity = Vector2.RIGHT.rotated(randf() * TAU) * boid.max_speed

The random initial velocity is load-bearing, not decoration. Every rule you are about to write returns Vector2.ZERO when a boid has no neighbours, and cohesion and alignment both read other boids' velocities. Spawn the whole flock at rest and the entire system's fixed point is "everyone stays at rest" — sixty motionless dots and no error to explain them.

arena defaults to Godot's default viewport size. If your project uses a different resolution, change it here; the manager pushes the same rectangle down to every boid so there is one source of truth.

Assign boid.tscn to Boid Scene in the inspector before running.


The neighbourhood comes first#

Every rule that follows consumes the same list. Build it once.

Start boid.gd:

extends CharacterBody2D
class_name Boid

const GROUP: StringName = &"boids"

@export var max_speed: float = 220.0
@export var max_force: float = 700.0
@export var neighbour_radius: float = 90.0
@export var arena: Rect2 = Rect2(0.0, 0.0, 1152.0, 648.0)

var _neighbours: Array[Boid] = []

func _ready() -> void:
	add_to_group(GROUP)

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)

Three decisions in there worth defending.

distance_squared_to against radius * radius. A square root per neighbour per boid per frame is a cost you never need to pay for a threshold test, because squaring preserves ordering for non-negative numbers. The rule is narrow, though: a squared distance is only ever valid for comparison. The moment you want a falloff curve, you need real distance again — and you will, in separation.

Skipping yourself. get_nodes_in_group returns you as well. Leave yourself in and every boid counts itself as a neighbour sitting at distance zero, which poisons cohesion's average and detonates separation's divisor.

_steer_towards is Part 01, unchanged. It takes a desired velocity and returns the force that bends current velocity toward it, capped at max_force. limit_length leaves shorter vectors alone, which is why it is the right tool here and normalized() * max_force is not — the latter would force every steering vector to full strength even when the boid is already going almost exactly where it wants.

Nothing moves yet. Add the driver:

func _physics_process(delta: float) -> void:
	_gather_neighbours()
	var steering: Vector2 = _cohesion()
	velocity = (velocity + steering * delta).limit_length(max_speed)
	move_and_slide()

Two multiplications, and only one of them is by delta. Steering is an acceleration in pixels per second squared, so it needs delta to become a velocity change. velocity itself must not be scaled by deltamove_and_slide() applies the physics step internally. Scale it yourself and the flock crawls at roughly one sixtieth speed, which reads as "my max_speed export is being ignored".

One more thing about velocity: after move_and_slide() returns, the property holds the post-collision velocity, not what you assigned. With collision off in this part it does not bite, but remember it exists.


Cohesion alone#

func _cohesion() -> Vector2:
	if _neighbours.is_empty():
		return Vector2.ZERO
	var centre: Vector2 = Vector2.ZERO
	for other: Boid in _neighbours:
		centre += other.global_position
	centre /= float(_neighbours.size())
	return _steer_towards(global_position.direction_to(centre) * max_speed)

Average the neighbours' positions, steer toward that point at full speed.

Run it.

The flock collapses. Sixty boids converge on a single pixel and sit there quivering, each one overshooting the centre, turning around, overshooting again. It looks broken and it is not — that is cohesion doing precisely and only what you asked. "Move toward where your neighbours are" has exactly one stable answer, and it is a point.

Watch the quiver specifically. That is the steering equation oscillating around a target it cannot stop on, the same overshoot you saw before you added arrive in Part 01. Nothing here will damp it. Separation will.


Alignment alone#

Swap the driver's one line to var steering: Vector2 = _alignment() and add:

func _alignment() -> Vector2:
	if _neighbours.is_empty():
		return Vector2.ZERO
	var average: Vector2 = Vector2.ZERO
	for other: Boid in _neighbours:
		average += other.velocity
	average /= float(_neighbours.size())
	if average.length_squared() == 0.0:
		return Vector2.ZERO
	return _steer_towards(average.normalized() * max_speed)

Run it.

Consensus, then exodus. Within a couple of seconds every boid agrees on a heading, and the whole population drifts off one edge of the window in a loose smear, spacing unchanged from wherever they spawned. They never group up. Again: exactly its job. "Match your neighbours' heading" says nothing about where anyone should be.

The length_squared() guard matters. If the averaged velocity is the zero vector — everyone stationary, or two neighbours travelling exactly opposite — then normalized() returns (0, 0) rather than erroring or producing NaN. That is a genuine kindness from Godot in most code and a trap here, because _steer_towards(Vector2.ZERO) is not "no steering", it is "steer hard toward being stopped". Return zero explicitly and mean it.

Two rules, two failure modes, and they are complements: cohesion produces position agreement with no heading, alignment produces heading agreement with no position. Neither is close to a flock on its own.


Separation, and why the falloff is the whole rule#

Swap the driver to _separation() and write this:

func _separation() -> Vector2:
	var push: Vector2 = Vector2.ZERO
	for other: Boid in _neighbours:
		var offset: Vector2 = global_position - other.global_position
		var distance: float = offset.length()
		if distance < 0.01:
			push += Vector2.RIGHT.rotated(randf() * TAU)
			continue
		push += (offset / distance) / distance
	if push.length_squared() == 0.0:
		return Vector2.ZERO
	return _steer_towards(push.normalized() * max_speed)

offset / distance is the unit vector pointing away from that neighbour. Dividing by distance a second time is the falloff: a boid at 20 pixels contributes four times the vote of one at 80. That second division is the difference between a flock and a blob, and it is a single character on the page.

Delete it — write push += offset.normalized() — and run both versions back to back. Do this. It takes ten seconds and it is the whole point of the part. Without falloff, forty distant neighbours drown out the two that are actually about to collide with you, and the flock reads as a solid disc with vibrating edges.

Note the honest cost of distance = offset.length(): you just paid for the square root you skipped in _gather_neighbours. You skipped it there because a threshold does not need magnitude. You pay it here because a falloff does. Same operation, different justification, and being able to say which situation you are in is the actual skill.

The two boids that never come apart#

Now the guard.

Two boids at identical positions produce an offset of (0, 0). Not an infinite shove — nothing. normalized() on a zero vector returns (0, 0), and so does direction_to() between coincident points. Godot will not raise anything. The two boids weld together and travel as a single unit for the rest of the session, and because they move identically they will never drift apart on their own.

You will see this. Sixty randomly spawned agents under strong cohesion find exact coincidence more often than intuition suggests, and once found it is permanent.

The fix is to guard the divisor rather than assume the case never happens: below a hundredth of a pixel, push in an arbitrary direction and let the next frame sort it out. randf() * TAU gives a full-circle random angle. A fixed direction would work too, but then every stacked pair separates along the same axis, which is visible.

Run separation alone. The flock expands smoothly to an even lattice and stops interacting once everyone is outside everyone else's radius. Boids near the edges of the pack wander off. No cohesion, no shape.

Normalized push, or raw push?#

The code above ends with push.normalized() * max_speed, which throws away the accumulated magnitude — a boid squeezed by six close neighbours steers exactly as hard as one with a single close neighbour. That is the classic formulation and it is stable and predictable.

The alternative keeps the magnitude, so density becomes pressure:

	return _steer_towards((push * crowd_scale).limit_length(max_speed))

push is in units of one-over-pixels, so crowd_scale has to convert it into pixels per second. Pick the distance at which you want a single neighbour to produce a full-strength shove and multiply: at max_speed 220 and a distance of 18 pixels, crowd_scale lands near 4000.0.

Both are defensible. The deciding question: does crowding mean anything in your game? For birds and fish, no — spacing is spacing, and the normalized version gives you a calmer flock that is far easier to tune. For a panicking crowd, a queue, or anything where being pressed should feel different from being near, the raw magnitude is the feature you are looking for, and you accept the tuning cost.


Adding them up#

Now the driver becomes the real one. Add the weights:

@export_range(0.0, 4.0, 0.05) var cohesion_weight: float = 0.8
@export_range(0.0, 4.0, 0.05) var alignment_weight: float = 1.0
@export_range(0.0, 4.0, 0.05) var separation_weight: float = 1.5
func _physics_process(delta: float) -> void:
	_gather_neighbours()

	var steering: Vector2 = Vector2.ZERO
	steering += _cohesion() * cohesion_weight
	steering += _alignment() * alignment_weight
	steering += _separation() * separation_weight
	steering = steering.limit_length(max_force)

	velocity = (velocity + steering * delta).limit_length(max_speed)
	move_and_slide()

Each rule already clamps itself to max_force inside _steer_towards. The second clamp, after summation, is what stops three simultaneously-maxed rules from producing a force three times anything the boid can physically apply. Without it, weights above 1.0 silently grant extra acceleration and your tuning becomes a fight between "how strong is this rule relative to the others" and "how strong is this rule in absolute terms". Clamp the total and the weights become pure ratios, which is what you want them to be.

@export_range with a minimum, maximum and step gives you a slider. Run the scene, open the boid instances in the remote scene tree while the game is playing, and drag. Tuning a flock by reading numbers is close to impossible; tuning it by dragging while watching is a couple of minutes.

Ratios that read as something#

FeelSeparationAlignmentCohesionWhat you see
Birds1.51.00.8Loose, wide, strong shared heading; the shape stretches and folds
Fish1.21.41.2Tight ball, fast synchronized turns, the flock reads as one object
Queueing crowd2.50.60.2Personal space defended hard, everyone mostly pursuing their own business

These are taste, not physics. There is no correct triple and nobody derived these from bird data. They are starting points that land in recognisable territory; the real values are whatever looks right in your game at your max_speed, your neighbour_radius and your sprite size.

neighbour_radius is the hidden fourth weight. A radius smaller than the flock's natural spacing means most boids see nobody most frames, and the flock fragments into pairs — which looks like a logic bug and is a tuning problem. A radius covering the whole arena means every boid sees every other, all three averages converge on the global average, and the flock becomes one rigid mass. Start around three or four body-widths.


Keeping them onscreen#

Alignment will happily march the whole flock into the void. Two ways to stop it, and they look completely different.

Wrap. Teleport a boid across when it exits.

func _wrap_position() -> void:
	if global_position.x < arena.position.x:
		global_position.x += arena.size.x
	elif global_position.x > arena.end.x:
		global_position.x -= arena.size.x
	if global_position.y < arena.position.y:
		global_position.y += arena.size.y
	elif global_position.y > arena.end.y:
		global_position.y -= arena.size.y

Wrap preserves flock shape perfectly — the flock crosses the seam and comes out the other side identical, because nothing about its internal geometry changed. The cost is that the neighbourhood does not wrap with it. A boid one pixel from the left edge cannot see the boid one pixel from the right edge, so the flock tears in half at the seam for a second or two while it crosses, then heals. You can fix that by making _gather_neighbours test wrapped offsets too, at roughly quadruple the cost.

Turn back. Apply a steering force near the margins.

@export var margin: float = 80.0
@export_range(0.0, 8.0, 0.1) var boundary_weight: float = 3.0

func _stay_inside() -> Vector2:
	var desired: Vector2 = Vector2.ZERO
	if global_position.x < arena.position.x + margin:
		desired.x = max_speed
	elif global_position.x > arena.end.x - margin:
		desired.x = -max_speed
	if global_position.y < arena.position.y + margin:
		desired.y = max_speed
	elif global_position.y > arena.end.y - margin:
		desired.y = -max_speed
	if desired == Vector2.ZERO:
		return Vector2.ZERO
	return _steer_towards(desired.limit_length(max_speed))

Add it to the sum as a fourth term, steering += _stay_inside() * boundary_weight, before the final clamp.

Turn-back deforms the flock, and that deformation is the interesting part. The leading edge of the flock turns first, the trailing boids keep coming, and the whole body rolls over on itself. Real schools of fish do this at tank walls and reef edges — it is called milling, and you get it free the moment you stop teleporting and start steering. If you are building an aquarium, a bird sim, or anything where the boundary is a place in the world rather than a screen artefact, take the turn-back. If the boundary is only "the window", take the wrap.

The margin matters more than the weight. Too thin and the flock hits the wall before the force can turn 220 px/s of momentum around; boids visibly exit and get yanked back. Make margin at least the distance a boid covers while decelerating, which at these numbers is comfortably under 80 pixels.


Scatter, to prove it reforms#

A flock that only ever coheres is not obviously working. Break it.

In flock.gd:

func _unhandled_key_input(event: InputEvent) -> void:
	var key: InputEventKey = event as InputEventKey
	if key == null or not key.pressed or key.echo:
		return
	if key.keycode == KEY_SPACE:
		_scatter()

func _scatter() -> void:
	var boids: Array[Node] = get_tree().get_nodes_in_group(&"boids")
	if boids.is_empty():
		return
	var centre: Vector2 = Vector2.ZERO
	for node: Node in boids:
		centre += (node as Boid).global_position
	centre /= float(boids.size())
	for node: Node in boids:
		var boid: Boid = node as Boid
		boid.velocity = centre.direction_to(boid.global_position) * boid.max_speed

Press space. The flock bursts outward, and then — with no reforming code anywhere — pulls itself back together and resumes travelling. That reforming is the actual deliverable of this part. The three rules are not a description of a flock; they are a rule for becoming a flock from any starting state, which is why the same code handles spawn, scatter, and steady state without special cases.

One boid sits exactly at the computed centre roughly never, but when it does, direction_to returns (0, 0) and it does not scatter. Same zero-length behaviour as separation. Once you have seen the pattern it stops surprising you.


Order of operations, and a debt you are taking on#

_gather_neighbours() is called once per frame per boid, and all three rules read _neighbours. Do not be tempted to inline the radius test into each rule — three passes over the same neighbours costs three times as much for identical results, and it lets the three rules disagree about who counts as a neighbour, which is a genuinely miserable bug to chase.

Even done right, look at what this costs. Every boid asks the scene tree for every other boid and measures distance to all of them. Sixty boids means 3,540 distance checks per frame, and the count grows with the square of the population — two hundred boids is 39,800. On a desktop at sixty boids you will not notice. At three hundred you will.

That is the seed of Part 05's problem, and the fix is not micro-optimisation, it is refusing to ask about boids that could not possibly be in range. Leave it quadratic for now. You cannot optimise a spatial query well until you have felt why the naive one is slow.


The whole file#

Check yours against this.

extends CharacterBody2D
class_name Boid

const GROUP: StringName = &"boids"

@export var max_speed: float = 220.0
@export var max_force: float = 700.0
@export var neighbour_radius: float = 90.0
@export var arena: Rect2 = Rect2(0.0, 0.0, 1152.0, 648.0)
@export var margin: float = 80.0

@export_range(0.0, 4.0, 0.05) var cohesion_weight: float = 0.8
@export_range(0.0, 4.0, 0.05) var alignment_weight: float = 1.0
@export_range(0.0, 4.0, 0.05) var separation_weight: float = 1.5
@export_range(0.0, 8.0, 0.1) var boundary_weight: float = 3.0

@onready var body_shape: Polygon2D = $Polygon2D

var _neighbours: Array[Boid] = []

func _ready() -> void:
	add_to_group(GROUP)

func _physics_process(delta: float) -> void:
	_gather_neighbours()

	var steering: Vector2 = Vector2.ZERO
	steering += _cohesion() * cohesion_weight
	steering += _alignment() * alignment_weight
	steering += _separation() * separation_weight
	steering += _stay_inside() * boundary_weight
	steering = steering.limit_length(max_force)

	velocity = (velocity + steering * delta).limit_length(max_speed)
	move_and_slide()

	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 _cohesion() -> Vector2:
	if _neighbours.is_empty():
		return Vector2.ZERO
	var centre: Vector2 = Vector2.ZERO
	for other: Boid in _neighbours:
		centre += other.global_position
	centre /= float(_neighbours.size())
	return _steer_towards(global_position.direction_to(centre) * max_speed)

func _alignment() -> Vector2:
	if _neighbours.is_empty():
		return Vector2.ZERO
	var average: Vector2 = Vector2.ZERO
	for other: Boid in _neighbours:
		average += other.velocity
	average /= float(_neighbours.size())
	if average.length_squared() == 0.0:
		return Vector2.ZERO
	return _steer_towards(average.normalized() * max_speed)

func _separation() -> Vector2:
	var push: Vector2 = Vector2.ZERO
	for other: Boid in _neighbours:
		var offset: Vector2 = global_position - other.global_position
		var distance: float = offset.length()
		if distance < 0.01:
			push += Vector2.RIGHT.rotated(randf() * TAU)
			continue
		push += (offset / distance) / distance
	if push.length_squared() == 0.0:
		return Vector2.ZERO
	return _steer_towards(push.normalized() * max_speed)

func _stay_inside() -> Vector2:
	var desired: Vector2 = Vector2.ZERO
	if global_position.x < arena.position.x + margin:
		desired.x = max_speed
	elif global_position.x > arena.end.x - margin:
		desired.x = -max_speed
	if global_position.y < arena.position.y + margin:
		desired.y = max_speed
	elif global_position.y > arena.end.y - margin:
		desired.y = -max_speed
	if desired == Vector2.ZERO:
		return Vector2.ZERO
	return _steer_towards(desired.limit_length(max_speed))

func _steer_towards(desired: Vector2) -> Vector2:
	return (desired - velocity).limit_length(max_force)

The body_shape.rotation = velocity.angle() line sits after move_and_slide() deliberately, so the sprite points along the velocity the body actually ended up with. The length_squared guard stops a near-stationary boid from spinning wildly as tiny velocity components flip sign.

Note which node is being turned. The Polygon2D rotates; the CharacterBody2D stays axis-aligned, the same habit Part 01 settled on. It costs nothing here, and it is the difference between Part 03 working and not: the obstacle probe you are about to bolt on is a RayCast2D child whose target_position is measured in its own local space. Rotate the body and every direction you hand that probe arrives pre-rotated by the boid's heading, twice over. The probe then points somewhere no one intended, and the symptom only shows up after a turn.


Checkpoint — definition of done#

  • With cohesion_weight at 1.0 and the other two at 0.0, the flock collapses to a single quivering point.
  • With only alignment_weight non-zero and boundary_weight at 0.0, the flock agrees on a heading and leaves the arena without ever grouping.
  • With only separation_weight non-zero, the boids spread to roughly even spacing and stop reacting to each other.
  • With all three at the "birds" ratio, the flock travels as a body — you can point at it and say where it is going.
  • Replacing (offset / distance) / distance with offset.normalized() visibly fuses the flock into a shivering disc, and restoring it un-fuses it.
  • Pressing space bursts the flock apart, and it reassembles without any reforming code.
  • No two boids are ever permanently welded together, even after a minute of running with high cohesion.
  • Dragging separation_weight from 0.5 to 3.0 during play changes the flock's spacing live, without a restart.
  • Every boid stays inside the arena for a full minute under whichever boundary method you chose.
  • You can explain: why does removing the distance falloff from separation make the flock look like alignment is broken, when alignment is untouched?

Stretch (no instructions)#

  • Give each boid a slightly different max_speed and neighbour_radius at spawn. Watch what a few percent of variation does to how organic the flock reads.
  • Add a fourth term: a weak seek toward the mouse position, weighted well below separation. The flock should follow without piling onto the cursor.
  • Make one boid a leader — zero cohesion and alignment weights, its own wander — and let the rest have a strong alignment weight. Note how little you had to change.
  • Colour each boid by _neighbours.size(). The map of who can see whom explains more tuning problems than any print statement will.
  • Wrap the neighbourhood as well as the position, so the flock does not tear at the seam.

If you get stuck#

  • All sixty boids fuse into one shivering dot → separation is either weighted at zero, or written without the second / distance. Set cohesion and alignment to 0.0 and confirm separation alone spreads the flock; if it does not, the rule is the problem, not the weights.
  • Two boids stuck together permanently, moving as one → the distance < 0.01 guard is missing or the threshold is smaller than your float error. A zero-length offset produces zero push, not infinite push.
  • Nothing moves at all, no errors → the flock manager is not assigning an initial velocity, or it is assigning it before add_child. Every rule returns Vector2.ZERO from a standing start with no neighbour motion to copy.
  • Boids completely ignore each other, each drifting in a straight line → they are not in the boids group. Confirm add_to_group(GROUP) is in _ready on the boid script, not on the manager.
  • The whole flock crawls at a fraction of max_speedvelocity is being multiplied by delta before move_and_slide(). That call applies the physics step itself. delta belongs on steering, nowhere else.
  • Boids accelerate instantly to full speed and turn on a coinmax_force is far too high relative to max_speed, so every steering vector is achievable in one frame. Drop max_force until turns take visible time.
  • Violent frame-to-frame jitter across the whole flock → the summed steering is exceeding what the boid can apply, usually because the post-sum limit_length(max_force) was skipped while weights are above 1.0.
  • The flock splits into stable pairs and small clumps that ignore each otherneighbour_radius is smaller than the spacing separation settles at. The two are in a tug of war; raise the radius or lower the separation weight.
  • The flock is one rigid mass that cannot change shapeneighbour_radius covers most of the arena, so every boid averages the same global values and the three rules have nothing local to respond to.
  • Boids exit the arena and get snapped backmargin is too small for their momentum, or the arena Rect2 on the boid does not match the actual viewport because the manager is not pushing its value down.
  • Triangles point ninety degrees off their heading → the Polygon2D is drawn pointing up. Vector2.angle() measures from the positive X axis, so the art must point right.
  • Sprites spin frantically when the flock slows → the velocity.length_squared() > 1.0 guard before setting body_shape.rotation is missing.
  • body_shape is null on the first frame → the Polygon2D child is named something other than Polygon2D, so $Polygon2D resolves to nothing. @onready runs once and does not retry.
  • Framerate drops as you raise count → expected, and the subject of Part 05. Everything here is quadratic on purpose.

Godot's Vector2 reference, for the exact behaviour of limit_length, normalized and direction_to at zero length: docs.godotengine.org/en/stable/classes/class_vector2.html


Next: Part 03 — Steering around what you can see. Read it once your flock reforms reliably — the next rule needs a boid that already knows how to negotiate with its neighbours, because obstacle avoidance is the same weighted-sum machinery pointed at something that will not negotiate back.