AI State Machine.¶
A finite-state machine drives an enemy through four states: PATROL walks a loop of waypoints, CHASE closes in once the player is inside its vision radius with a clear line of sight, ATTACK strikes on a cooldown at close range, and RETURN walks back to the nearest waypoint when the player escapes. The grey smoke cloud blocks sight but not movement: a patrolling enemy cannot see the player through it, and a chase that loses sight of the player for 1.5 seconds gives up. The live state floats above the enemy, and every transition is written to the log at the top right.
▶ Run in browserTags: ai state-machine 2d
The machine is plain Python with no engine types in it:
Modeis aStrEnumnaming the states, and each state is a small class withenter(enemy)andupdate(enemy, dt).updatereturns theModeto switch to, orNoneto stay, so every transition is an explicit return value.Enemyholds its states in a dict keyed byMode, runs the current one each frame, callsenteron the next, and reports each edge through anon_transition(old, new)callback. Nothing in it draws or reads input, so it can be driven and checked without a window.Perception uses hysteresis: the enemy acquires the player inside
VISION_RADIUSbut only gives up beyond the largerLOSE_RADIUS, or after 1.5 seconds without line of sight, and leaves ATTACK only pastATTACK_EXIT. Line of sight is a segment-against-rectangle test through the smoke.
StateMachineDemo moves the player, feeds the player’s position to
Enemy.update in on_update, and draws the world in on_draw. The title, hit
count, transition log and help line are Label nodes on a CanvasLayer that
draw themselves, and log_transition rewrites the log Label on each edge.
Move the player with WASD, the arrow keys or the left stick, or hold the mouse button or a finger down and the player walks toward it.
Source¶
1# /// simvx
2# tags = ["ai", "state-machine", "2d"]
3# web = { width = 960, height = 540 }
4# ///
5"""AI State Machine.
6
7A finite-state machine drives an enemy through four states: PATROL walks a loop
8of waypoints, CHASE closes in once the player is inside its vision radius with a
9clear line of sight, ATTACK strikes on a cooldown at close range, and RETURN
10walks back to the nearest waypoint when the player escapes. The grey smoke cloud
11blocks sight but not movement: a patrolling enemy cannot see the player through
12it, and a chase that loses sight of the player for 1.5 seconds gives up. The live
13state floats above the enemy, and every transition is written to the log at the
14top right.
15
16The machine is plain Python with no engine types in it:
17
18- `Mode` is a `StrEnum` naming the states, and each state is a small class with
19 `enter(enemy)` and `update(enemy, dt)`. `update` returns the `Mode` to switch
20 to, or `None` to stay, so every transition is an explicit return value.
21- `Enemy` holds its states in a dict keyed by `Mode`, runs the current one each
22 frame, calls `enter` on the next, and reports each edge through an
23 `on_transition(old, new)` callback. Nothing in it draws or reads input, so it
24 can be driven and checked without a window.
25- Perception uses hysteresis: the enemy acquires the player inside
26 `VISION_RADIUS` but only gives up beyond the larger `LOSE_RADIUS`, or after
27 1.5 seconds without line of sight, and leaves ATTACK only past `ATTACK_EXIT`.
28 Line of sight is a segment-against-rectangle test through the smoke.
29
30`StateMachineDemo` moves the player, feeds the player's position to
31`Enemy.update` in `on_update`, and draws the world in `on_draw`. The title, hit
32count, transition log and help line are `Label` nodes on a `CanvasLayer` that
33draw themselves, and `log_transition` rewrites the log `Label` on each edge.
34
35Move the player with WASD, the arrow keys or the left stick, or hold the mouse
36button or a finger down and the player walks toward it.
37"""
38
39from collections import deque
40from enum import StrEnum
41
42from simvx.core import Anchor, Axis2D, CanvasLayer, Controller, Input, Label, Node2D, Pointer, Vec2
43from simvx.graphics import App
44
45WAYPOINTS = [Vec2(140, 140), Vec2(420, 140), Vec2(420, 420), Vec2(140, 420)]
46SMOKE = (560.0, 180.0, 120.0, 200.0) # x, y, w, h
47
48VISION_RADIUS = 190.0
49LOSE_RADIUS = 250.0
50ATTACK_RANGE = 44.0
51ATTACK_EXIT = ATTACK_RANGE + 12.0
52ATTACK_COOLDOWN = 0.8
53SIGHT_PATIENCE = 1.5 # seconds a chase survives without line of sight
54FLASH_TIME = 0.18
55ARRIVE_DIST = 4.0
56
57PATROL_SPEED = 90.0
58CHASE_SPEED = 165.0
59RETURN_SPEED = 115.0
60PLAYER_SPEED = 220.0
61PLAYER_R = 12.0
62ENEMY_R = 14.0
63
64
65class Mode(StrEnum):
66 PATROL = "PATROL"
67 CHASE = "CHASE"
68 ATTACK = "ATTACK"
69 RETURN = "RETURN"
70
71
72MODE_COLOURS = {
73 Mode.PATROL: (0.35, 0.8, 0.4, 1.0),
74 Mode.CHASE: (1.0, 0.6, 0.15, 1.0),
75 Mode.ATTACK: (1.0, 0.25, 0.25, 1.0),
76 Mode.RETURN: (0.4, 0.6, 1.0, 1.0),
77}
78
79
80def segment_crosses_rect(a: Vec2, b: Vec2, rect) -> bool:
81 """True if the segment a-b passes through the axis-aligned rect (x, y, w, h)."""
82 x, y, w, h = rect
83 dx, dy = b.x - a.x, b.y - a.y
84 t0, t1 = 0.0, 1.0
85 for p, q in ((-dx, a.x - x), (dx, x + w - a.x), (-dy, a.y - y), (dy, y + h - a.y)):
86 if p == 0.0:
87 if q < 0.0:
88 return False # parallel to this edge and outside it
89 elif p < 0.0:
90 t0 = max(t0, q / p)
91 else:
92 t1 = min(t1, q / p)
93 if t0 > t1:
94 return False
95 return True
96
97
98class State:
99 def enter(self, e: Enemy) -> None:
100 pass
101
102 def update(self, e: Enemy, dt: float) -> Mode | None:
103 return None
104
105
106class Patrol(State):
107 def update(self, e, dt):
108 if e.sees_player():
109 return Mode.CHASE
110 e.walk_route(PATROL_SPEED * dt)
111 return None
112
113
114class Chase(State):
115 def enter(self, e):
116 self.unseen = 0.0
117
118 def update(self, e, dt):
119 dist = e.pos.distance_to(e.player_pos)
120 if dist <= ATTACK_RANGE:
121 return Mode.ATTACK
122 self.unseen = 0.0 if e.has_line_of_sight() else self.unseen + dt
123 if dist > LOSE_RADIUS or self.unseen > SIGHT_PATIENCE:
124 return Mode.RETURN
125 e.pos = e.pos.move_toward(e.player_pos, CHASE_SPEED * dt)
126 return None
127
128
129class Attack(State):
130 def enter(self, e):
131 self.cooldown = 0.0 # the first strike lands at once
132
133 def update(self, e, dt):
134 if e.pos.distance_to(e.player_pos) > ATTACK_EXIT:
135 return Mode.CHASE
136 self.cooldown -= dt
137 if self.cooldown <= 0.0:
138 e.hits += 1
139 e.flash = FLASH_TIME
140 self.cooldown = ATTACK_COOLDOWN
141 return None
142
143
144class Return(State):
145 def enter(self, e):
146 e.wp_index = min(range(len(e.waypoints)), key=lambda i: e.pos.distance_to(e.waypoints[i]))
147
148 def update(self, e, dt):
149 if e.sees_player():
150 return Mode.CHASE
151 if e.walk_route(RETURN_SPEED * dt):
152 return Mode.PATROL
153 return None
154
155
156class Enemy:
157 """A four-state agent in plain Python: no node, no drawing, no input."""
158
159 def __init__(self, waypoints, on_transition=None):
160 self.waypoints = list(waypoints)
161 self.pos = Vec2(self.waypoints[0])
162 self.wp_index = 1
163 self.player_pos = Vec2()
164 self.hits = 0
165 self.flash = 0.0
166 self.on_transition = on_transition
167 self.states = {Mode.PATROL: Patrol(), Mode.CHASE: Chase(), Mode.ATTACK: Attack(), Mode.RETURN: Return()}
168 self.mode = Mode.PATROL
169 self.states[self.mode].enter(self)
170
171 def has_line_of_sight(self) -> bool:
172 return not segment_crosses_rect(self.pos, self.player_pos, SMOKE)
173
174 def sees_player(self) -> bool:
175 return self.pos.distance_to(self.player_pos) <= VISION_RADIUS and self.has_line_of_sight()
176
177 def walk_route(self, step: float) -> bool:
178 """Step toward the current waypoint; on arrival target the next one and return True."""
179 target = self.waypoints[self.wp_index]
180 self.pos = self.pos.move_toward(target, step)
181 if self.pos.distance_to(target) >= ARRIVE_DIST:
182 return False
183 self.wp_index = (self.wp_index + 1) % len(self.waypoints)
184 return True
185
186 def update(self, dt: float, player_pos: Vec2) -> None:
187 self.player_pos = player_pos
188 self.flash = max(0.0, self.flash - dt)
189 new = self.states[self.mode].update(self, dt)
190 if new is not None and new != self.mode:
191 if self.on_transition:
192 self.on_transition(self.mode, new)
193 self.mode = new
194 self.states[new].enter(self)
195
196
197class StateMachineDemo(Node2D):
198 dynamic = True
199
200 input_context = {
201 "move": [Axis2D.WASD, Axis2D.ARROWS, Controller.LEFT_STICK],
202 "steer": [Pointer.PRIMARY],
203 }
204
205 def on_ready(self):
206 self.player = Vec2(790, 420)
207 self.log = deque(maxlen=8)
208 self.enemy = Enemy(WAYPOINTS, on_transition=self.log_transition)
209
210 hud = self.add_child(CanvasLayer())
211 hud.add_child(Label("AI State Machine", anchor=Anchor.TOP_LEFT, inset=16, font_size=28))
212 self.hits_label = hud.add_child(Label("", anchor=Anchor.TOP_LEFT, inset=(16, 56), font_size=16))
213 self.log_label = hud.add_child(Label("Transitions", anchor=Anchor.TOP_RIGHT, inset=16, font_size=15))
214 self.hits_label.text_colour = (1.0, 0.6, 0.6, 1.0)
215 self.add_help(hud)
216
217 def log_transition(self, old: Mode, new: Mode) -> None:
218 self.log.append(f"{self.tree.now:5.1f}s {old} -> {new}")
219 self.log_label.text = "\n".join(["Transitions", *self.log])
220
221 def on_update(self, dt: float):
222 move = Input.value("move")
223 if Input.pressed("steer"):
224 to_pointer = Input.mouse_position - self.player
225 if to_pointer.length() > PLAYER_R: # dead zone: stop on the pointer, do not jitter
226 move = to_pointer.normalized()
227 size = self.viewport_size
228 self.player = (self.player + move * (PLAYER_SPEED * dt)).clamped(
229 Vec2(PLAYER_R, PLAYER_R), size - Vec2(PLAYER_R, PLAYER_R)
230 )
231 self.enemy.update(dt, self.player)
232 self.hits_label.text = f"Hits taken: {self.enemy.hits}"
233
234 def on_draw(self, renderer):
235 e = self.enemy
236 colour = MODE_COLOURS[e.mode]
237
238 x, y, w, h = SMOKE
239 renderer.rect((x, y), (w, h), fill=(0.55, 0.55, 0.6, 0.35), stroke=(0.7, 0.7, 0.75, 0.6))
240 renderer.text("smoke", (x + 8, y + 8), fill=(0.75, 0.75, 0.8))
241
242 renderer.polygon(e.waypoints, stroke=(0.4, 0.45, 0.55, 0.8))
243 for i, wp in enumerate(e.waypoints):
244 active = i == e.wp_index and e.mode in (Mode.PATROL, Mode.RETURN)
245 renderer.circle(wp, 6, fill=(0.9, 0.9, 0.5, 1.0) if active else (0.5, 0.55, 0.65, 1.0))
246
247 renderer.circle(e.pos, VISION_RADIUS, stroke=(*colour[:3], 0.25), segments=64)
248 if e.mode in (Mode.CHASE, Mode.ATTACK):
249 renderer.circle(e.pos, ATTACK_RANGE, stroke=(1.0, 0.4, 0.4, 0.5))
250 sight = (1.0, 0.8, 0.3, 0.5) if e.has_line_of_sight() else (0.5, 0.5, 0.5, 0.35)
251 renderer.line(e.pos, self.player, stroke=sight)
252
253 if e.flash > 0.0:
254 renderer.circle(e.pos, ENEMY_R + 14 * (1 - e.flash / FLASH_TIME), stroke=(1.0, 0.3, 0.2, 0.9))
255 renderer.circle(e.pos, ENEMY_R, fill=colour)
256 renderer.text(e.mode, (e.pos.x, e.pos.y - ENEMY_R - 24), fill=colour, scale=1.4, align="centre")
257 renderer.circle(self.player, PLAYER_R, fill=(0.3, 0.85, 1.0, 1.0))
258
259 # Example help overlay: not part of the technique shown.
260 def add_help(self, hud):
261 help_text = "WASD / arrows / stick: move hold mouse or finger: walk toward it"
262 hud.add_child(Label(help_text, anchor=Anchor.BOTTOM_LEFT, inset=16, font_size=14))
263
264
265if __name__ == "__main__":
266 App(title="AI State Machine", width=960, height=540).run(StateMachineDemo())