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