Steering behaviours

agents that seek, flee, wander, arrive and flock.

▶ Run in browser

Tags: ai steering boids numpy

Steering is how an autonomous agent chooses its motion one small push at a time. Each behaviour names a desired velocity, and the steering force is the desired velocity minus the agent’s current velocity, clamped to MAX_FORCE. The force changes the velocity, the velocity is clamped to MAX_SPEED, and the velocity moves the agent. Swap the desired velocity and the same three lines give every behaviour:

  • Seek heads straight for the target at full speed.

  • Flee heads straight away from it.

  • Arrive is seek with a brake: inside ARRIVE_RADIUS the desired speed falls with distance, so agents settle on the target instead of orbiting it.

  • Wander seeks a point on a circle projected ahead of the agent, and nudges that point around the circle at random, so the heading drifts smoothly rather than jittering.

  • Boids sums three weighted forces over each agent’s neighbours: separation pushes away from agents that are too close, alignment matches their average velocity and cohesion seeks their centroid.

Every behaviour is a plain function over the whole population: position and velocity are one (N, 2) NumPy array each, so there is no per-agent Python loop, and boids finds every neighbour pair at once from one (N, N) distance matrix. The node integrates in on_fixed_update, wraps agents around the edges of viewport_size, and draws in on_draw with dynamic = True. The pointer is the target. The larger white agent draws the steering forces acting on it as coloured lines, and the legend names each colour.

Controls: Pointer: the target 1 / 2 / 3 / 4 or buttons: seek / flee / wander / arrive B or button: boids flock

Source

  1# /// simvx
  2# tags = ["ai", "steering", "boids", "numpy"]
  3# ///
  4"""Steering behaviours: agents that seek, flee, wander, arrive and flock.
  5
  6Steering is how an autonomous agent chooses its motion one small push at a
  7time. Each behaviour names a *desired velocity*, and the steering force is the
  8desired velocity minus the agent's current velocity, clamped to `MAX_FORCE`.
  9The force changes the velocity, the velocity is clamped to `MAX_SPEED`, and the
 10velocity moves the agent. Swap the desired velocity and the same three lines
 11give every behaviour:
 12
 13- **Seek** heads straight for the target at full speed.
 14- **Flee** heads straight away from it.
 15- **Arrive** is seek with a brake: inside `ARRIVE_RADIUS` the desired speed
 16  falls with distance, so agents settle on the target instead of orbiting it.
 17- **Wander** seeks a point on a circle projected ahead of the agent, and
 18  nudges that point around the circle at random, so the heading drifts
 19  smoothly rather than jittering.
 20- **Boids** sums three weighted forces over each agent's neighbours:
 21  separation pushes away from agents that are too close, alignment matches
 22  their average velocity and cohesion seeks their centroid.
 23
 24Every behaviour is a plain function over the whole population: position and
 25velocity are one `(N, 2)` NumPy array each, so there is no per-agent Python
 26loop, and boids finds every neighbour pair at once from one `(N, N)` distance
 27matrix. The node integrates in `on_fixed_update`, wraps agents around the edges
 28of `viewport_size`, and draws in `on_draw` with `dynamic = True`. The pointer is
 29the target. The larger white agent draws the steering forces acting on it as
 30coloured lines, and the legend names each colour.
 31
 32Controls:
 33    Pointer: the target
 34    1 / 2 / 3 / 4 or buttons: seek / flee / wander / arrive
 35    B or button: boids flock
 36"""
 37
 38from enum import StrEnum
 39
 40import numpy as np
 41
 42from simvx.core import (
 43    Anchor,
 44    Button,
 45    ButtonGroup,
 46    CanvasLayer,
 47    HBoxContainer,
 48    Input,
 49    Key,
 50    Label,
 51    Node2D,
 52    VBoxContainer,
 53    on_input,
 54)
 55from simvx.graphics import App
 56
 57N_AGENTS = 40
 58MAX_SPEED = 220.0  # px/s
 59MAX_FORCE = 420.0  # px/s^2, per behaviour
 60ARRIVE_RADIUS = 140.0
 61WANDER_DIST = 60.0  # how far ahead the wander circle sits
 62WANDER_RADIUS = 40.0
 63WANDER_JITTER = 4.0  # rad/s of random drift around the wander circle
 64NEIGHBOUR_RADIUS = 80.0
 65SEPARATION_RADIUS = 30.0
 66BOID_WEIGHTS = {"separation": 1.6, "alignment": 1.0, "cohesion": 0.9}
 67DEBUG_SCALE = 0.28  # px of force line per px/s^2 of force
 68
 69RED, GREEN, YELLOW, BLUE = (1.0, 0.45, 0.35, 1.0), (0.35, 0.9, 0.45, 1.0), (0.95, 0.8, 0.3, 1.0), (0.45, 0.7, 1.0, 1.0)
 70COLOURS = {
 71    "seek": GREEN,
 72    "flee": RED,
 73    "wander": YELLOW,
 74    "arrive": BLUE,
 75    "separation": RED,
 76    "alignment": GREEN,
 77    "cohesion": BLUE,
 78}
 79
 80
 81class Mode(StrEnum):
 82    SEEK = "seek"
 83    FLEE = "flee"
 84    WANDER = "wander"
 85    ARRIVE = "arrive"
 86    BOIDS = "boids"
 87
 88
 89KEYS = {Mode.SEEK: Key.KEY_1, Mode.FLEE: Key.KEY_2, Mode.WANDER: Key.KEY_3, Mode.ARRIVE: Key.KEY_4, Mode.BOIDS: Key.B}
 90
 91
 92def unit(v):
 93    """Row-wise normalise, mapping zero rows to zero rather than NaN."""
 94    return v / np.maximum(np.linalg.norm(v, axis=1, keepdims=True), 1e-9)
 95
 96
 97def limit(v, cap):
 98    """Clamp each row's magnitude to cap without changing its direction."""
 99    return v * np.minimum(1.0, cap / np.maximum(np.linalg.norm(v, axis=1, keepdims=True), 1e-9))
100
101
102def steer(desired, vel):
103    """The force that turns vel toward desired, within the force budget."""
104    return limit(desired - vel, MAX_FORCE)
105
106
107def seek(pos, vel, target):
108    return steer(unit(target - pos) * MAX_SPEED, vel)
109
110
111def flee(pos, vel, target):
112    return steer(unit(pos - target) * MAX_SPEED, vel)
113
114
115def arrive(pos, vel, target):
116    offset = target - pos
117    speed = MAX_SPEED * np.clip(np.linalg.norm(offset, axis=1, keepdims=True) / ARRIVE_RADIUS, 0.0, 1.0)
118    return steer(unit(offset) * speed, vel)
119
120
121def wander(pos, vel, wander_angle):
122    angle = np.arctan2(vel[:, 1], vel[:, 0]) + wander_angle
123    point = pos + unit(vel) * WANDER_DIST + np.stack([np.cos(angle), np.sin(angle)], axis=1) * WANDER_RADIUS
124    return steer(unit(point - pos) * MAX_SPEED, vel)
125
126
127def boids(pos, vel):
128    """Separation, alignment and cohesion forces, each zero for an agent with nobody near."""
129    diff = pos[:, None, :] - pos[None, :, :]  # (N, N, 2): from j to i
130    dist = np.linalg.norm(diff, axis=2)
131    np.fill_diagonal(dist, np.inf)
132    near = dist < NEIGHBOUR_RADIUS
133    crowd = dist < SEPARATION_RADIUS
134    count = np.maximum(near.sum(axis=1, keepdims=True), 1)
135    has_near = near.any(axis=1, keepdims=True)
136
137    # Each crowding neighbour pushes along diff / dist^2: away from it, harder the closer it is.
138    push = np.where(crowd[:, :, None], diff / np.maximum(dist, 1e-9)[:, :, None] ** 2, 0.0).sum(axis=1)
139    mean_vel = np.where(near[:, :, None], vel[None], 0.0).sum(axis=1) / count
140    centroid = np.where(near[:, :, None], pos[None], 0.0).sum(axis=1) / count
141    return {
142        "separation": steer(unit(push) * MAX_SPEED, vel) * crowd.any(axis=1, keepdims=True),
143        "alignment": steer(unit(mean_vel) * MAX_SPEED, vel) * has_near,
144        "cohesion": steer(unit(centroid - pos) * MAX_SPEED, vel) * has_near,
145    }
146
147
148class SteeringDemo(Node2D):
149    dynamic = True
150    input_context = {mode: [key] for mode, key in KEYS.items()}
151
152    def on_ready(self):
153        self.rng = np.random.default_rng(7)
154        w, h = self.viewport_size
155        self.pos = self.rng.uniform((60, 60), (w - 60, h - 60), (N_AGENTS, 2))
156        heading = self.rng.uniform(0.0, 2 * np.pi, N_AGENTS)
157        self.vel = np.stack([np.cos(heading), np.sin(heading)], axis=1) * MAX_SPEED * 0.5
158        self.wander_angle = self.rng.uniform(0.0, 2 * np.pi, N_AGENTS)
159        self.forces = {}
160
161        hud = self.add_child(CanvasLayer())
162        header = hud.add_child(VBoxContainer(anchor=Anchor.TOP_LEFT, inset=12))
163        header.add_child(Label("Steering Behaviours", font_size=28))
164        self.legend = header.add_child(VBoxContainer())
165        bar = hud.add_child(HBoxContainer(anchor=Anchor.BOTTOM_LEFT, inset=12))
166        group = ButtonGroup()
167        # An action button prints its key beside the text ("Seek (1)") and follows a rebind or a device change.
168        self.buttons = {mode: bar.add_child(Button(mode.title(), action=mode, group=group)) for mode in Mode}
169        self.set_mode(Mode.SEEK)
170
171    # @on_input refuses a StrEnum member as an action name, so the modes are passed as plain strings.
172    @on_input(tuple(map(str, Mode)))
173    def on_mode(self, event):
174        self.set_mode(Mode(event.action))
175
176    def set_mode(self, mode: Mode):
177        self.mode = mode
178        self.buttons[mode].checked = True
179        self.legend.clear_children()
180        for name in BOID_WEIGHTS if mode is Mode.BOIDS else [mode]:
181            label = self.legend.add_child(Label(name))
182            label.text_colour = COLOURS[name]
183
184    def on_fixed_update(self, dt):
185        target = np.asarray(Input.mouse_position, dtype=float)
186        match self.mode:
187            case Mode.SEEK:
188                self.forces = {"seek": seek(self.pos, self.vel, target)}
189            case Mode.FLEE:
190                self.forces = {"flee": flee(self.pos, self.vel, target)}
191            case Mode.ARRIVE:
192                self.forces = {"arrive": arrive(self.pos, self.vel, target)}
193            case Mode.WANDER:
194                self.wander_angle += self.rng.uniform(-1.0, 1.0, N_AGENTS) * WANDER_JITTER * dt
195                self.forces = {"wander": wander(self.pos, self.vel, self.wander_angle)}
196            case Mode.BOIDS:
197                self.forces = boids(self.pos, self.vel)
198        total = sum(BOID_WEIGHTS.get(name, 1.0) * force for name, force in self.forces.items())
199        self.vel = limit(self.vel + limit(total, MAX_FORCE) * dt, MAX_SPEED)
200        self.pos = (self.pos + self.vel * dt) % self.viewport_size  # agents wrap around the edges
201
202    def on_draw(self, renderer):
203        mx, my = Input.mouse_position
204        white = (1.0, 1.0, 1.0, 0.6)
205        renderer.circle((mx, my), 8, stroke=white)
206        renderer.line((mx - 14, my), (mx + 14, my), stroke=white)
207        renderer.line((mx, my - 14), (mx, my + 14), stroke=white)
208
209        # Each agent is a triangle pointing along its velocity; agent 0 is drawn larger, in white.
210        size = np.full((N_AGENTS, 1), 8.0)
211        size[0] = 13.0
212        forward = unit(self.vel) * size
213        side = np.stack([-forward[:, 1], forward[:, 0]], axis=1) * 0.55  # forward turned 90 degrees
214        back = self.pos - forward * 0.6
215        for i, triangle in enumerate(np.stack([self.pos + forward, back - side, back + side], axis=1)):
216            renderer.polygon(triangle, stroke=(1.0, 1.0, 1.0, 1.0) if i == 0 else (0.55, 0.75, 1.0, 0.9))
217
218        for name, force in self.forces.items():
219            renderer.line(self.pos[0], self.pos[0] + force[0] * DEBUG_SCALE, stroke=COLOURS[name], width=2.0)
220
221
222if __name__ == "__main__":
223    App(title="Steering Behaviours", width=960, height=540).run(SteeringDemo())