Steering behaviours

Agents that seek, flee, wander, arrive and flock

▶ Run in browser

Tags: ai steering boids numpy

Forty autonomous agents steer with the classic Reynolds behaviours, all vectorised in numpy. Number keys 1-4 switch every agent between seek, flee, wander and arrive; B switches to a boids flock built from separation, alignment and cohesion. The mouse is the target, and the highlighted agent draws its per-behaviour steering forces as coloured debug lines.

What it demonstrates

  • Steering as “desired velocity minus current velocity”, clamped to a force budget, integrated per frame in on_update.

  • Whole-population numpy updates: one (N, 2) array each for position and velocity, no per-agent Python loop.

  • Boids from three pairwise components (separation / alignment / cohesion) computed with a single (N, N) distance matrix.

  • Immediate-mode debug drawing: force vectors on one highlighted agent, with a legend naming each component.

Controls: Mouse - Move the target 1/2/3/4 - Seek / Flee / Wander / Arrive B - Boids flock ESC - Quit

Run: uv run python examples/features/ai/steering.py Headless self-check: uv run python examples/features/ai/steering.py –test

Source

  1"""Steering behaviours: Agents that seek, flee, wander, arrive and flock
  2
  3Forty autonomous agents steer with the classic Reynolds behaviours, all
  4vectorised in numpy. Number keys 1-4 switch every agent between seek, flee,
  5wander and arrive; B switches to a boids flock built from separation,
  6alignment and cohesion. The mouse is the target, and the highlighted agent
  7draws its per-behaviour steering forces as coloured debug lines.
  8
  9# /// simvx
 10# tags = ["ai", "steering", "boids", "numpy"]
 11# ///
 12
 13## What it demonstrates
 14- Steering as "desired velocity minus current velocity", clamped to a force
 15  budget, integrated per frame in on_update.
 16- Whole-population numpy updates: one (N, 2) array each for position and
 17  velocity, no per-agent Python loop.
 18- Boids from three pairwise components (separation / alignment / cohesion)
 19  computed with a single (N, N) distance matrix.
 20- Immediate-mode debug drawing: force vectors on one highlighted agent, with
 21  a legend naming each component.
 22
 23Controls:
 24  Mouse   - Move the target
 25  1/2/3/4 - Seek / Flee / Wander / Arrive
 26  B       - Boids flock
 27  ESC     - Quit
 28
 29Run: uv run python examples/features/ai/steering.py
 30Headless self-check: uv run python examples/features/ai/steering.py --test
 31"""
 32
 33import numpy as np
 34
 35from simvx.core import Input, InputMap, Key, Node2D
 36from simvx.graphics import App
 37
 38WIDTH, HEIGHT = 960, 540
 39N_AGENTS = 40
 40MAX_SPEED = 220.0  # px/s
 41MAX_FORCE = 420.0  # px/s^2, per behaviour component
 42ARRIVE_RADIUS = 140.0  # slow-down radius around the target
 43
 44# Wander: a jittering point on a circle projected ahead of the agent.
 45WANDER_DIST = 60.0
 46WANDER_RADIUS = 40.0
 47WANDER_JITTER = 4.0  # rad/s of random drift in the wander angle
 48
 49# Boids: neighbourhood radii and component weights.
 50NEIGHBOUR_RADIUS = 80.0
 51SEPARATION_RADIUS = 30.0
 52BOID_WEIGHTS = {"separation": 1.6, "alignment": 1.0, "cohesion": 0.9}
 53
 54MODES = ("seek", "flee", "wander", "arrive", "boids")
 55COMPONENT_COLOURS = {
 56    "seek": (0.35, 0.9, 0.45, 1.0),
 57    "flee": (1.0, 0.45, 0.35, 1.0),
 58    "wander": (0.95, 0.8, 0.3, 1.0),
 59    "arrive": (0.45, 0.7, 1.0, 1.0),
 60    "separation": (1.0, 0.45, 0.35, 1.0),
 61    "alignment": (0.35, 0.9, 0.45, 1.0),
 62    "cohesion": (0.45, 0.7, 1.0, 1.0),
 63}
 64DEBUG_SCALE = 0.28  # px of debug line per px/s^2 of force
 65
 66
 67def _unit(v: np.ndarray) -> np.ndarray:
 68    """Row-wise normalise, mapping zero rows to zero rather than NaN."""
 69    mag = np.linalg.norm(v, axis=1, keepdims=True)
 70    return v / np.maximum(mag, 1e-9)
 71
 72
 73def _limit(v: np.ndarray, cap: float) -> np.ndarray:
 74    """Clamp each row's magnitude to cap without changing its direction."""
 75    mag = np.linalg.norm(v, axis=1, keepdims=True)
 76    return v * np.minimum(1.0, cap / np.maximum(mag, 1e-9))
 77
 78
 79class SteeringDemo(Node2D):
 80    """A flock of steering agents chasing (or dodging) the mouse."""
 81
 82    dynamic = True  # every agent moves every frame
 83
 84    def __init__(self, **kwargs):
 85        super().__init__(**kwargs)
 86        self.mode = "seek"
 87        self._rng = np.random.default_rng(7)
 88        self.pos = self._rng.uniform((60, 60), (WIDTH - 60, HEIGHT - 60), (N_AGENTS, 2))
 89        heading = self._rng.uniform(0.0, 2 * np.pi, N_AGENTS)
 90        self.vel = np.stack([np.cos(heading), np.sin(heading)], axis=1) * (MAX_SPEED * 0.5)
 91        self.wander_angle = self._rng.uniform(0.0, 2 * np.pi, N_AGENTS)
 92
 93    def on_ready(self):
 94        InputMap.add_action("quit", [Key.ESCAPE])
 95        InputMap.add_action("mode_seek", [Key.KEY_1])
 96        InputMap.add_action("mode_flee", [Key.KEY_2])
 97        InputMap.add_action("mode_wander", [Key.KEY_3])
 98        InputMap.add_action("mode_arrive", [Key.KEY_4])
 99        InputMap.add_action("mode_boids", [Key.B])
100
101    # --- behaviours -----------------------------------------------------
102    # Each returns an (N, 2) force array, already clamped to MAX_FORCE.
103
104    def _seek(self, target: np.ndarray) -> np.ndarray:
105        desired = _unit(target - self.pos) * MAX_SPEED
106        return _limit(desired - self.vel, MAX_FORCE)
107
108    def _flee(self, target: np.ndarray) -> np.ndarray:
109        desired = _unit(self.pos - target) * MAX_SPEED
110        return _limit(desired - self.vel, MAX_FORCE)
111
112    def _arrive(self, target: np.ndarray) -> np.ndarray:
113        offset = target - self.pos
114        dist = np.linalg.norm(offset, axis=1, keepdims=True)
115        speed = MAX_SPEED * np.clip(dist / ARRIVE_RADIUS, 0.0, 1.0)
116        desired = _unit(offset) * speed
117        return _limit(desired - self.vel, MAX_FORCE)
118
119    def _wander(self, dt: float) -> np.ndarray:
120        self.wander_angle += self._rng.uniform(-1.0, 1.0, N_AGENTS) * WANDER_JITTER * dt
121        heading = np.arctan2(self.vel[:, 1], self.vel[:, 0])
122        angle = heading + self.wander_angle
123        centre = self.pos + _unit(self.vel) * WANDER_DIST
124        point = centre + np.stack([np.cos(angle), np.sin(angle)], axis=1) * WANDER_RADIUS
125        desired = _unit(point - self.pos) * MAX_SPEED
126        return _limit(desired - self.vel, MAX_FORCE)
127
128    def _boids(self) -> dict[str, np.ndarray]:
129        diff = self.pos[:, None, :] - self.pos[None, :, :]  # (N, N, 2): j -> i
130        dist = np.linalg.norm(diff, axis=2)
131        np.fill_diagonal(dist, np.inf)
132        near = dist < NEIGHBOUR_RADIUS  # (N, N) neighbour mask
133        count = np.maximum(near.sum(axis=1, keepdims=True), 1)
134
135        # Separation: push away from crowding neighbours, harder when closer.
136        crowd = dist < SEPARATION_RADIUS
137        push = np.where(crowd[:, :, None], diff / np.maximum(dist, 1e-9)[:, :, None] ** 2, 0.0)
138        separation = _limit(_unit(push.sum(axis=1)) * MAX_SPEED - self.vel, MAX_FORCE)
139        separation[~crowd.any(axis=1)] = 0.0
140
141        # Alignment: match the average neighbour velocity.
142        mean_vel = np.where(near[:, :, None], self.vel[None, :, :], 0.0).sum(axis=1) / count
143        alignment = _limit(_unit(mean_vel) * MAX_SPEED - self.vel, MAX_FORCE)
144        alignment[~near.any(axis=1)] = 0.0
145
146        # Cohesion: seek the neighbour centroid.
147        centroid = np.where(near[:, :, None], self.pos[None, :, :], 0.0).sum(axis=1) / count
148        cohesion = _limit(_unit(centroid - self.pos) * MAX_SPEED - self.vel, MAX_FORCE)
149        cohesion[~near.any(axis=1)] = 0.0
150
151        return {"separation": separation, "alignment": alignment, "cohesion": cohesion}
152
153    def _components(self, target: np.ndarray, dt: float) -> dict[str, np.ndarray]:
154        """The active mode's named force components, each (N, 2)."""
155        if self.mode == "seek":
156            return {"seek": self._seek(target)}
157        if self.mode == "flee":
158            return {"flee": self._flee(target)}
159        if self.mode == "arrive":
160            return {"arrive": self._arrive(target)}
161        if self.mode == "wander":
162            return {"wander": self._wander(dt)}
163        return self._boids()
164
165    def _step(self, target: np.ndarray, dt: float) -> dict[str, np.ndarray]:
166        """Advance the whole population one frame; returns the components drawn."""
167        components = self._components(target, dt)
168        if self.mode == "boids":
169            total = sum(BOID_WEIGHTS[k] * f for k, f in components.items())
170        else:
171            total = next(iter(components.values()))
172        self.vel = _limit(self.vel + _limit(total, MAX_FORCE) * dt, MAX_SPEED)
173        self.pos = (self.pos + self.vel * dt) % (WIDTH, HEIGHT)  # toroidal wrap
174        return components
175
176    # --- frame loop -----------------------------------------------------
177
178    def on_update(self, dt: float):
179        if Input.is_action_just_pressed("quit"):
180            self.app.quit()
181        for mode in MODES:
182            if Input.is_action_just_pressed(f"mode_{mode}"):
183                self.mode = mode
184        mx, my = Input.mouse_position
185        self._debug = self._step(np.array([mx, my]), min(dt, 1 / 30))
186
187    def on_draw(self, renderer):
188        # Target crosshair at the mouse.
189        mx, my = Input.mouse_position
190        renderer.draw_circle((mx, my), 8, colour=(1.0, 1.0, 1.0, 0.6))
191        renderer.draw_line((mx - 14, my), (mx + 14, my), colour=(1.0, 1.0, 1.0, 0.6))
192        renderer.draw_line((mx, my - 14), (mx, my + 14), colour=(1.0, 1.0, 1.0, 0.6))
193
194        # Agents: small oriented triangles; agent 0 is the highlighted one.
195        heading = np.arctan2(self.vel[:, 1], self.vel[:, 0])
196        for i in range(N_AGENTS):
197            x, y = self.pos[i]
198            a = heading[i]
199            size = 13.0 if i == 0 else 8.0
200            c, s = np.cos(a), np.sin(a)
201            points = [
202                (x + c * size, y + s * size),
203                (x - c * size * 0.6 - s * size * 0.55, y - s * size * 0.6 + c * size * 0.55),
204                (x - c * size * 0.6 + s * size * 0.55, y - s * size * 0.6 - c * size * 0.55),
205            ]
206            colour = (1.0, 1.0, 1.0, 1.0) if i == 0 else (0.55, 0.75, 1.0, 0.9)
207            renderer.draw_lines(points, closed=True, colour=colour)
208
209        # Debug force vectors on the highlighted agent, one line per component.
210        hx, hy = self.pos[0]
211        for name, force in getattr(self, "_debug", {}).items():
212            fx, fy = force[0] * DEBUG_SCALE
213            renderer.draw_line((hx, hy), (hx + fx, hy + fy), colour=COMPONENT_COLOURS[name], thickness=2.0)
214
215        # HUD and legend.
216        renderer.draw_text("Steering Behaviours", (10, 10), colour=(1.0, 1.0, 1.0), scale=2)
217        renderer.draw_text(f"Mode: {self.mode.upper()}", (10, 56), colour=(0.85, 0.85, 0.85))
218        y = 78
219        for name in getattr(self, "_debug", {}):
220            renderer.draw_text(f"-- {name}", (10, y), colour=COMPONENT_COLOURS[name])
221            y += 18
222        renderer.draw_text(
223            "Mouse: target   1: seek  2: flee  3: wander  4: arrive  B: boids   ESC: quit",
224            (10, HEIGHT - 28),
225            colour=(0.6, 0.6, 0.6),
226        )
227
228
229def _selftest() -> bool:
230    """Logic-level checks on the vectorised behaviours, no window needed."""
231    ok = True
232
233    def check(label: str, passed: bool, detail: str) -> None:
234        nonlocal ok
235        ok = ok and passed
236        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
237
238    dt = 1 / 60
239    target = np.array([WIDTH / 2, HEIGHT / 2])
240
241    # Seek: the population closes on the target.
242    demo = SteeringDemo()
243    before = np.linalg.norm(demo.pos - target, axis=1).mean()
244    for _ in range(120):
245        demo._step(target, dt)
246    after = np.linalg.norm(demo.pos - target, axis=1).mean()
247    check("seek closes on the target", after < before * 0.5, f"mean distance {before:.0f} -> {after:.0f}")
248
249    # Flee: a force pointing away from the target for every agent near it.
250    demo = SteeringDemo()
251    demo.pos = target + demo._rng.uniform(-40, 40, (N_AGENTS, 2))
252    demo.vel[:] = 0.0
253    away = ((demo._flee(target)) * (demo.pos - target)).sum(axis=1)
254    check("flee pushes directly away", bool((away > 0).all()), f"min dot {away.min():.1f}")
255
256    # Arrive: agents settle near the target instead of orbiting at full speed.
257    demo = SteeringDemo()
258    demo.mode = "arrive"
259    for _ in range(600):
260        demo._step(target, dt)
261    speeds = np.linalg.norm(demo.vel, axis=1)
262    dists = np.linalg.norm(demo.pos - target, axis=1)
263    check(
264        "arrive settles (low speed near the target)",
265        float(speeds.mean()) < MAX_SPEED * 0.2 and float(dists.mean()) < ARRIVE_RADIUS,
266        f"mean speed {speeds.mean():.0f} px/s, mean distance {dists.mean():.0f} px",
267    )
268
269    # Wander: agents keep moving and their headings drift.
270    demo = SteeringDemo()
271    demo.mode = "wander"
272    h0 = np.arctan2(demo.vel[:, 1], demo.vel[:, 0])
273    for _ in range(300):
274        demo._step(target, dt)
275    h1 = np.arctan2(demo.vel[:, 1], demo.vel[:, 0])
276    turned = np.abs(np.angle(np.exp(1j * (h1 - h0))))
277    speeds = np.linalg.norm(demo.vel, axis=1)
278    check(
279        "wander keeps agents moving on drifting headings",
280        float(speeds.min()) > MAX_SPEED * 0.5 and float(turned.mean()) > 0.2,
281        f"min speed {speeds.min():.0f} px/s, mean turn {turned.mean():.2f} rad",
282    )
283
284    # Boids separation: two crowded agents are pushed apart.
285    demo = SteeringDemo()
286    demo.pos[:2] = [[300.0, 300.0], [310.0, 300.0]]
287    demo.vel[:2] = 0.0
288    sep = demo._boids()["separation"]
289    check(
290        "separation repels a crowded pair",
291        sep[0, 0] < 0 < sep[1, 0],
292        f"x-forces {sep[0, 0]:.0f} and {sep[1, 0]:.0f}",
293    )
294
295    # Boids cohesion + alignment: a loose flock tightens and aligns.
296    demo = SteeringDemo()
297    demo.mode = "boids"
298    for _ in range(400):
299        demo._step(target, dt)
300    mean_v = demo.vel.mean(axis=0)
301    alignment = float(np.linalg.norm(mean_v) / np.linalg.norm(demo.vel, axis=1).mean())
302    check("boids velocities align", alignment > 0.5, f"order parameter {alignment:.2f}")
303
304    # Global invariants: force and speed budgets are respected in every mode.
305    capped = True
306    for mode in MODES:
307        demo = SteeringDemo()
308        demo.mode = mode
309        for _ in range(120):
310            for f in demo._step(target, dt).values():
311                capped = capped and float(np.linalg.norm(f, axis=1).max()) <= MAX_FORCE * 1.001
312        capped = capped and float(np.linalg.norm(demo.vel, axis=1).max()) <= MAX_SPEED * 1.001
313        capped = capped and bool(np.isfinite(demo.pos).all() and np.isfinite(demo.vel).all())
314    check("forces and speeds stay within budget in all modes", capped, "MAX_FORCE / MAX_SPEED clamps hold")
315
316    print("SELFTEST:", "PASS" if ok else "FAIL")
317    return ok
318
319
320if __name__ == "__main__":
321    import sys
322
323    if "--test" in sys.argv:
324        sys.exit(0 if _selftest() else 1)
325    App(title="Steering Behaviours", width=WIDTH, height=HEIGHT).run(SteeringDemo())