Steering behaviours¶
Agents that seek, flee, wander, arrive and flock
▶ Run in browserTags: 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())