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 browser

Tags: ai state-machine 2d

The machine is plain Python with no engine types in it:

  • Mode is a StrEnum naming the states, and each state is a small class with enter(enemy) and update(enemy, dt). update returns the Mode to switch to, or None to stay, so every transition is an explicit return value.

  • Enemy holds its states in a dict keyed by Mode, runs the current one each frame, calls enter on the next, and reports each edge through an on_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_RADIUS but only gives up beyond the larger LOSE_RADIUS, or after 1.5 seconds without line of sight, and leaves ATTACK only past ATTACK_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())