---
title: Game AI with state machines
summary: Read before writing enemies, companions or any character logic — perception, a squad blackboard, the brain as one state table, weapons and the game mode as machines, the player on the same input seam as the bots.
area: core
globals: []
sources: [src/core/StateMachine.ts, src/core/Aspect.ts, src/gl/nav/NavAgent.ts, src/gl/nav/NavMesh.ts, src/runtime/input.ts]
---

# Game AI with state machines

Enemies and allied bots that patrol, notice you, shoot, dive behind cover, chase and search — built
from `StateMachine` + `Aspect`/`System` + `NavAgent`, with the player on the same input path as the
bots. The `StateMachine` API itself is [state-machine.md](/docs/api/core/state-machine.md); how a whole game
is laid out is [Project architecture](https://le.codes/docs/api/cookbook/project-architecture.md).

- make enemies patrol, notice the player, chase, shoot and search
- make a bot take cover and peek out to shoot
- share what bots see across a squad so they act as a team
- give a character sight and hearing: field of view, line of sight, memory
- write a weapon as states: ready, fire, cooldown, reload
- run rounds and respawns as a game-mode state machine
- drive the player and the bots through the same intent
- debug a bot by reading its state transitions

## When

- Any character that "does something depending on what it is doing" — enemy, companion, turret,
  boss phase; also weapons (ready → fire → cooldown → reload) and the game mode itself
  (warmup → round → over). If you are about to write `if (this.mode === 'chase')` chains or a
  hand-rolled `tick()` with timers, reach for this recipe.
- Several bots that should act as a team (share what they see, not all dive behind one crate).
- A game that may go multiplayer later: the recipe's INTENT seam is the shape `Net.input`
  replicates ([multiplayer](/docs/api/runtime/multiplayer.md)), so bots and remote players stay
  interchangeable.

## The shape

One rule decides where everything goes: **the machine decides, the aspects act, the systems share.**

| Part | Kind | Owns | Ticks |
|---|---|---|---|
| `Intent` | plain object on the character | `{ moveX, moveZ, aim, fire, melee, crouch }` — the ONLY input to a character | — |
| `Character` | `Aspect` on the node | body, health, crouch pose, facing, applying the intent | `updateFixed`, after `NavAgent` |
| `Senses` | `Aspect` | what this character sees / heard / remembers | after `Character` |
| `Squad` | `System` | per-team blackboard: merged contacts, target pick, cover claims | after `Senses` |
| `Brain` | `Aspect` | the `StateMachine` + per-step facts + actions; WRITES the intent | after `Squad`, before weapons |
| `Gun`, `Melee` | `Aspect` | a small machine each, READ the intent | after `Brain` |
| `PlayerControl` | `System` | keyboard/mouse → the player's intent | before `Character` |
| `GameMode` | `System` | rounds, spawns, score — a machine too | last |
| `World` | `System` | level geometry queries (LOS, hitscan, cover points) + the sim clock | first |

Order is declared, never assumed: every class names what it follows with `static after = [...]`
([aspects.md](/docs/api/core/aspects.md#order-within-a-phase)). Everything runs in **`updateFixed`** — the fixed
1/60 step, the same dt on every host — so a fight replays exactly on its host (see
[Verify](#verify)).

What is SDK and what is project: `StateMachine`, `Aspect`/`System`, `NavAgent`/`NavMesh`, `Input`
are SDK. `Intent`, `Character`, `Senses`, `Squad`, `Brain`, weapons, the world queries are the
project's — copy them from the reference project and change the numbers.

## Step 1 — the intent seam

Nothing below the intent knows who is in control.

```ts
export interface Intent {
  moveX: number; moveZ: number
  aim: Vec3 | null          // a world point to face and shoot at; null = face the way you walk
  fire: boolean; melee: boolean; crouch: boolean
}

export class Character extends Aspect<'character', Node, { hit: (amount: number, from: Character | null) => void; die: (by: Character | null) => void; spawn: () => void }> {
  static after = [NavAgent]
  team: 'blue' | 'red' = 'red'
  hp = 100
  controlled = false              // true = the intent's move axes steer the crowd (a player)
  intent: Intent = { moveX: 0, moveZ: 0, aim: null, fire: false, melee: false, crouch: false }
  alive = true
  readonly facing = new Vec3(0, 0, -1)

  get agent(): NavAgent { return this.node.get(NavAgent)! }

  updateFixed(): void {
    if (!this.alive) return
    const it = this.intent
    if (this.controlled) this.agent.move(it.moveX * 3.4, it.moveZ * 3.4)   // velocity drive: stays on the mesh, avoids the bots
    const p = this.node.position
    let fx = 0, fz = 0
    if (it.aim) { fx = it.aim.x - p.x; fz = it.aim.z - p.z }
    else { const v = this.agent.velocity; if (v.x * v.x + v.z * v.z > 0.04) { fx = v.x; fz = v.z } }
    const len = Math.hypot(fx, fz)
    if (len > 1e-3) { this.facing.set(fx / len, 0, fz / len); this.node.lookAt([p.x + this.facing.x, p.y, p.z + this.facing.z], '-z') }
  }

  damage(amount: number, from: Character | null): void {
    if (!this.alive) return
    this.hp = Math.max(0, this.hp - amount)
    this.emit('hit', amount, from)
    if (this.hp === 0) { this.alive = false; this.agent.stop(); this.emit('die', from) }
  }
}
```

Two ways to move and both are the crowd's: a bot's brain calls `agent.goTo(p)` (a path), the
player calls `agent.move(vx, vz)` every step (a velocity). Set `turnSpeed: 0` on the `NavAgent` —
facing belongs to the character (the aim point wins, else the velocity).

The player is just a `System` that fills one character's intent from `Input` before `Character`
runs (`static before = [Character]`): WASD → `moveX/moveZ`, the mouse ray dropped on a chest-high
plane (`scene.camera.getRay(x, y)`, solve `y = 0.9`) → `aim`, buttons → `fire/melee/crouch`.

## Step 2 — the brain: patrol, chase, cover and search as one state table

The `Brain` aspect holds three things: **facts** refreshed every step before the machine runs,
**actions** the states call, and the **table**. Keep the table readable top to bottom — it IS the
behaviour, and the transition log is your debugger.

```ts
export class Brain extends Aspect<'brain', Node> {
  static after = [Squad]
  static before = [Gun, Melee]
  role: 'enemy' | 'ally' = 'enemy'
  leader: Character | null = null      // an ally follows this one
  fsm!: StateMachine<Brain>

  // facts (refreshed in sense(), read by guards)
  target: Character | null = null
  targetPos: Vec3 | null = null
  visible = false
  dist = Infinity
  lastSeen = -Infinity
  cover: Cover | null = null
  coverRetryAt = 0

  get me(): Character { return (this.node as With<Node, Character>).character }
  get lostFor(): number { return Sim.time - this.lastSeen }

  updateFixed(dt: number): void {
    if (!this.fsm) this.init()
    this.sense()            // refresh the facts; clear the one-shot intent flags (fire/melee) — states re-assert them
    this.fsm.step(dt)
  }

  private init(): void {
    this.fsm = new StateMachine(BotStates, this)
    this.fsm.on('change', (to, from) => console.log(`[fsm] ${this.me.label} ${from}>${to}`))
    this.me.on('hit', () => this.fsm.send('hit'))
    this.me.on('spawn', () => this.fsm.go('idle'))
  }

  // actions — small, idempotent, safe to call every step
  hasTarget(): boolean { return this.target !== null }
  home(): string { return this.role === 'ally' ? 'follow' : 'patrol' }
  moveTo(p: Vec3, tolerance = 0.6): void { /* agent.goTo(p) only when the goal moved > tolerance */ }
  hold(): void { /* agent.stop() once */ }
  arrived(): boolean { return this.me.agent.state !== 'moving' }
  aimAtTarget(): void { if (this.targetPos) this.me.intent.aim = this.visible ? this.target!.center : this.targetPos }
  fireAt(): boolean { /* visible, in range, the first thing on the eye→target line is an enemy → intent.fire = true */ return false }
  seekCover(): boolean { /* world.findCover(me, target.eye(), 12 m, not claimed by a team-mate) → this.cover */ return false }
  coverBlown(): boolean { /* no cover, target within 3 m, or the cover no longer hides a crouched eye */ return true }
}

const BotStates = defineStates<Brain>({
  initial: 'idle',
  to: { dead: (b) => !b.me.alive },                                        // from ANY state, tested first
  on: { hit: (b) => (b.fsm.is('cover') ? undefined : 'flinch') },          // an event from any state
  states: {
    idle:   { enter: (b) => b.hold(), to: { combat: (b) => b.hasTarget() }, update: (b, _dt, t) => (t >= 0.3 ? b.home() : undefined) },
    patrol: {
      initial: 'walk',
      to: { combat: (b) => b.hasTarget() },
      states: {
        walk: { enter: (b) => b.nextWaypoint(), to: { wait: (b, t) => t > 0.2 && b.arrived() } },
        wait: { enter: (b) => b.hold(), after: [1.5, 'walk'] },
      },
    },
    follow: { to: { combat: (b) => b.hasTarget() }, update: (b) => b.keepNear(b.leader!, 4.5, 2.5) },
    combat: {
      initial: 'engage',
      exit: (b) => { b.me.intent.crouch = false; b.me.intent.aim = null; b.squad.release(b.me); b.cover = null },
      to: { idle: (b) => !b.hasTarget(), search: (b) => !b.visible && b.lostFor > 3.5 },
      states: {
        engage: {
          update: (b) => { b.aimAtTarget(); if (b.visible) { b.dist > 9 ? b.moveTo(b.targetPos!) : b.hold(); b.fireAt() } else if (b.targetPos) b.moveTo(b.targetPos) },
          to: {
            melee: (b) => b.visible && b.dist <= 1.9,
            cover: (b) => b.visible && b.dist >= 4 && b.me.hp <= 60 && Sim.time >= b.coverRetryAt && b.seekCover(),
          },
        },
        melee: { update: (b) => { b.aimAtTarget(); b.moveTo(b.targetPos!, 0.3); if (b.dist <= 1.7) b.me.intent.melee = true }, to: { engage: (b) => b.dist > 2.8 } },
        cover: {
          initial: 'go',
          enter: (b) => { b.squad.claim(b.cover!, b.me); b.moveTo(b.cover!.pos, 0.2) },
          exit: (b) => { b.coverRetryAt = Sim.time + 2 },
          to: { engage: (b) => b.coverBlown() },
          states: {
            go:   { update: (b) => b.aimAtTarget(), to: { hide: (b, t) => t > 0.2 && b.arrived() } },
            hide: { enter: (b) => { b.hold(); b.me.intent.crouch = true }, exit: (b) => { b.me.intent.crouch = false }, after: [1.6, 'peek'] },
            peek: { update: (b) => { b.aimAtTarget(); b.fireAt() }, after: [1.0, 'hide'] },
          },
        },
      },
    },
    search: { enter: (b) => { if (b.targetPos) b.moveTo(b.targetPos, 0.5) }, to: { combat: (b) => b.visible, idle: (b) => !b.hasTarget() }, after: [6, 'idle'] },
    flinch: { after: [0.12, '$back'] },
    dead:   { enter: (b) => b.hold(), on: { hit: () => undefined } },
  },
})
```

Reading rules that make the table behave (see [state-machine.md](/docs/api/core/state-machine.md)):
outer guards win (`combat.to.search` fires whatever child is active), a guard never re-enters a
state that is already active, `'$back'` returns to the leaf we came from (flinch), and a
compound `exit` is the one place to undo what any child did (crouch, aim, the cover claim).

## Step 3 — perception and the squad board

Bots decide on FACTS, never on the raw scene. `Senses` produces them; `Squad` shares them.

```ts
export class Senses extends Aspect<'senses', Node> {
  static after = [Character]
  fov = 140; range = 20; memory = 6
  readonly visible: Character[] = []
  readonly known = new Map<Character, { pos: Vec3; at: number; seen: boolean }>()

  updateFixed(): void {
    const me = this.me
    this.visible.length = 0
    if (!me.alive) { this.known.clear(); return }
    const eye = me.eye(), cosHalf = Math.cos((this.fov / 2) * Math.PI / 180)
    for (const c of Aspect.all(Character)) {
      if (c === me || !c.alive || c.team === me.team) continue
      const d = c.position.sub(me.position); d.y = 0
      const len = d.length()
      if (len > this.range) continue
      if (len > 1.5 && d.dot(me.facing) / len < cosHalf) continue      // outside the cone (very close = always noticed)
      if (!this.world.los(eye, c.eye())) continue                       // eye to eye: a crouched eye hides behind low cover
      this.visible.push(c)
      this.known.set(c, { pos: c.position, at: Sim.time, seen: true })
    }
    for (const [c, s] of this.known) if (!c.alive || Sim.time - s.at > this.memory) this.known.delete(c)
  }

  /** A gunshot / a blow: enemies of `source` within `radius` learn where it came from. */
  static noise(pos: Vec3, radius: number, source: Character): void {
    for (const s of Aspect.all(Senses)) {
      const me = s.me
      if (me === source || !me.alive || me.team === source.team || me.position.distanceTo(pos) > radius) continue
      const cur = s.known.get(source)
      if (!(cur && cur.seen && Sim.time - cur.at < 0.5)) s.known.set(source, { pos: pos.clone(), at: Sim.time, seen: false })
    }
  }
}
```

The `Squad` system (one instance; a board per team) merges every member's `known` map into the
team's contacts each step, answers `pick(me, senses)` — nearest visible, else nearest remembered,
else the squad's nearest contact — and holds cover **claims** (`claim`/`release`/`claimedByOther`)
so two bots never dive behind the same crate. Brains read the board; they never read each other.

`Sim` is a plain `{ time, step }` object advanced by the `World` system (`static before =
[NavCrowd]`): every memory is stamped with the SIMULATION clock, not `Date.now()`.

## Step 4 — weapons and the game mode are machines too

The same tool, two more tables. A weapon reads the intent its owner wrote; it does not care who.

```ts
const GunStates = defineStates<Gun>({
  initial: 'ready',
  states: {
    ready:    { to: { reload: (g) => g.me.alive && g.ammo === 0, fire: (g) => g.me.alive && g.me.intent.fire && g.ammo > 0 } },
    fire:     { enter: (g) => g.shoot(), to: { cooldown: () => true } },      // a pass-through: enter, then chain out the same step
    cooldown: { to: { ready: (g, t) => t >= g.cooldown } },                   // per-instance timing → a guard on t, not `after`
    reload:   { to: { ready: (g, t) => t >= g.reloadTime }, exit: (g) => { g.ammo = g.magazine } },
  },
})

const GameStates = defineStates<GameMode>({
  initial: 'warmup',
  states: {
    warmup: { enter: (g) => { g.round++; g.resetAll(); console.log(`[evt] round ${g.round} start`) }, after: [1, 'round'] },
    round:  { update: (g) => g.respawns(), to: { over: (g) => g.winner !== null } },
    over:   { enter: (g) => console.log(`[evt] round-over ${g.round} winner=${g.winner}`), to: { done: (g, t) => t >= 2 && g.round >= 2 }, after: [2, 'warmup'] },
    done:   { enter: () => console.log('[evt] match-over') },
  },
})
```

`shoot()` is a hitscan from the eye toward `intent.aim` with a little seeded spread: the first box
or character on the ray takes it (`world.trace`), the shot is a `Senses.noise`, a team-mate on the
line still stops the bullet. Melee is `ready → windup → strike → recover` with a reach + a
"in front of me" dot-product test.

## Verify

Reference project: `arena-cubes` (not published — the steps above are self-contained; built
2026-08-29, re-verified 2026-09-30 on SDK 2.x, headless and `--sim`, with the flow below).
Three console channels, and every test is a substring match on them:

- `[evt] …` — game events: `round 1 start`, `shot red1>blue2 d=7.9`, `hit`, `kill`, `respawn`, `round-over`.
- `[fsm] red1 patrol.walk>combat.engage` — EVERY brain transition. This is the trace to read when a
  bot does something odd; count transitions per state to spot a thrash (`grep -o '>combat.cover.go' | wc -l`).
- `[dbg] t=14 round you:85@-6.5,9.8 blue1:combat.cover.hide/40 red1:combat.engage/70 …` — every 2 s,
  every bot's state and hp.

```json
{ "name": "skirmish", "steps": [
  { "frames": 10,   "expect": { "log": "[nav] loaded" } },
  { "frames": 90,   "expect": { "log": "[evt] round 1 start" } },
  { "key": "KeyW", "ms": 2000 },
  { "frames": 900,  "expect": { "log": "combat.engage" } },
  { "frames": 900,  "expect": { "log": "[evt] shot" } },
  { "frames": 1800, "expect": { "log": "[evt] kill" } },
  { "frames": 4200, "expect": { "log": "[evt] round-over 1" } }
] }
```

```
lecodes navmesh bake                                           # once, and after every layout change
lecodes render --script tests/skirmish.flow.json               # headless: the logic
lecodes render --sim --script tests/skirmish.flow.json         # the native engines, no window
lecodes desktop run                                            # play it
```

The flow asserts logs only, so it runs on every host. For pixels, add `"screenshot": "fight.png"` to
a step and run it with `--desktop` — `--sim` refuses screenshot steps and the headless renderer does
not draw 3D.

Determinism is what makes a run trustworthy: everything in `updateFixed`, a seeded RNG for every
random choice (spread, patrol points via `nav.randomPoint`, respawn slots), the `Sim` clock for
every timestamp. Run the flow twice on the same host and `diff` the `[evt]`/`[fsm]` lines — they
must be identical (measured on `--sim`: 972 identical lines). Two **different** hosts need not agree
— the same flow played out differently headless and on `--sim` (another team won the first round) —
so compare a run with a run on its own host. The fight itself uses no rigid-body simulation (static
bodies exist only for the navmesh bake; sight and hits are the project's own ray tests over the
level's boxes); if yours does, check that part on `--sim` — the headless renderer's physics is a
separate build ([physics.md](/docs/api/3d/physics.md#hosts-without-physics-physicssupported)).

## Traps

- **A cover guard without hysteresis thrashes.** `engage → cover.go → engage` every step (508
  entries in one run) when the "leave cover" test can be true the moment cover is chosen (target
  closer than 3 m) or when the target flips to another visible enemy. Fix: preconditions on the
  guard (`dist >= 4`), a cooldown stamped on `exit` (`coverRetryAt`), and target stickiness (keep
  a target seen within the last second).
- **A shared contact is not "just seen".** A bot entering combat on a team-mate's report had
  `lastSeen = -Infinity` → `search` on the very next step. Stamp `lastSeen` with the contact's
  report time when adopting a target.
- **`'$back'` into a compound state re-runs its `enter`.** Flinching out of `cover` and back would
  re-enter `cover` with `cover = null` (cleared on `combat.exit`). Either don't flinch there
  (`on: { hit: (b) => b.fsm.is('cover') ? undefined : 'flinch' }`) or make `enter` re-derive what it needs.
- **Per-instance timings can't use `after`** (the table is shared): `to: { ready: (g, t) => t >= g.cooldown }`.
- **Friendly fire without a rule kills your own squad** — allies bunch up behind the leader. A
  team-mate on the line blocks the shot; `fireAt()` only pulls the trigger when the first thing on
  the line is an enemy.
- **Headless PNGs of a 3D scene are blank** — pixels need `--desktop`.
- **Bodies keep their crowd slot.** A dead character that only `agent.stop()`s is still an obstacle
  for the crowd; fine for a 3 s respawn, wrong for permanent corpses (detach the `NavAgent`).

## See also

- [state-machine.md](/docs/api/core/state-machine.md) — the `StateMachine` reference
- [aspects.md](/docs/api/core/aspects.md), [systems.md](/docs/api/core/systems.md) — phases and ordering
- [navmesh.md](/docs/api/3d/navmesh.md) — baking and `NavAgent`
- [input.md](/docs/api/runtime/input.md) — the player's keys and mouse
