Proposal: builtin NPC AI — perception/decision/steering, FSM+BT+utility, friendly & enemy, director (base + extensible) #61

Closed
opened 2026-09-01 05:13:42 +02:00 by orkun · 2 comments
Owner

Part of the builtin-controllers layer (#57). Conforms to the six-lever extensibility contract. Planning only — no implementation.

Context

Reusable NPC AI — both friendly (companions, shopkeepers, quest-givers, escortees) and enemy (patrol, chase, attack, flee, flock) — that plugs into the platformer/RPG/shooter bodies rather than reimplementing movement per game. The design separates perception → decision → action so each layer is independently swappable, and offers three decision models at increasing power (state machine, behavior tree, utility) because different games want different ones. Modeled on classic game-AI stacks: FSMs, behavior trees (Halo/UE), GOAP (F.E.A.R.), utility AI (big-brain, The Sims), and Reynolds steering behaviors.

Crucially: AI does not move bodies directly — it writes the same intent fields the player controllers read (move_x, jump, fire, aim_angle). So an enemy gunner reuses the shooter's esys_weapon/esys_projectile verbatim; a friendly follower reuses the platformer/topdown mover. One movement/combat implementation, driven by either input or AI.

Layered design

Layer 1 — Perception (what the NPC knows)

property Vision   { range, fov, angle }        # cone sight; LOS via Grid.los
property Hearing  { range }                    # noise events within radius
property Memory   { last_seen_x, last_seen_y, target, alertness, ttl }
property Faction  { id }                        # shared table: friend/enemy/neutral
  • esys_perception (throttled — re-scan every N frames via Cooldown, not every frame): uses Query.within/Query.nearest + Grid.los (line-of-sight already exists) + the faction table to populate Memory (target, last-seen, alertness). Emits TargetSpotted, TargetLost, NoiseHeard, AlertChanged.
  • Extend: stealth mechanics (light level, crouch, disguises) = feed Vision.range/alertness via @On; a "blind but hearing" enemy = drop Vision, keep Hearing (lever-2 composition).

Layer 2 — Decision (three models, pick per NPC)

All three write to the same intent output (a Brain { state, target, intent_x, intent_y, want_fire, ... } component), so downstream action layers don't care which was used.

  • (a) State machine — reuse Ludic's existing machine <reg> { state … become … }. Ship a Patrol/Chase/Attack/Flee/Return template as data-tunable states. Simplest, covers most enemies.
  • (b) Behavior tree — a data-defined tree (Selector/Sequence/Parallel/Decorator/Leaf) in a registry (Ai.tree("grunt", ...)), ticked by esys_bt. Leaves are named condition/action verbs (see_target, in_range, move_to, fire_at, flee) from a registry the game extends — the no-closure-safe way to make trees data. Modeled on UE/Halo BTs.
  • (c) Utility AI — a set of scored considerations (Ai.consider("attack", inputs..., curve)); esys_utility picks the highest-scoring action each re-plan. Great for companions and "sims"-like NPCs; modeled on big-brain/The Sims.
  • Selector field Brain.model ∈ {fsm, bt, utility} (lever 6). A game mixes: FSM for trash mobs, utility for the boss.
  • Events: cancellable DecisionMade {e, action} (override/veto the AI's choice), StateEntered/Exited, Replanned.

Layer 3 — Action / steering (how intent becomes motion)

Reynolds steering behaviors as composable, weighted forces writing the mover's intent:

property Steering { seek, flee, arrive, wander, separate, cohere, align, pursue, evade, weights }
  • esys_steering sums the enabled behaviors → intent_x/intent_y, which the platformer/topdown mover consumes. Flocking (boids) = separate+cohere+align. Path-following = Grid.a_star waypoints + arrive.
  • Combat actions map intent to the shooter's weapon (want_fire→"fire" intent) or RPG melee.
  • Extend: new steering force = add a weight + a handler @After(esys_steering); formation movement, cover-seeking = own behavior over the same intent field.

Layer 4 — Friendly vs. enemy (policy, not separate code)

Same stack; faction + goal differ:

  • Enemy: faction hostile to player; goals patrol/hunt/attack/flee-at-low-hp.
  • Friendly companion: faction allied; goals follow (arrive on the leader + separate), assist (target the player's target via shared perception), revive, carry. property Follower { leader, distance, mode } (follow/guard/wait/aggressive/defensive stances — the classic companion command wheel).
  • Neutral / vendor / quest-giver: no combat goals; Interacted (from RPG movement module) opens dialog/shop.
  • Escort missions, ally AI, and enemy AI are the same systems with different faction + goal data. That's the extensibility payoff.

Layer 5 — Director / spawning (macro AI)

  • property Spawner { table, rate, budget, cond } + esys_spawner — shared with the shooter wave director.
  • Optional AI director (Left 4 Dead-style pacing): a policy that scales budget/rate off a tension metric; DirectorBeat events. Entirely opt-in and replaceable.

How a developer extends each area

Want Lever How
Tune sight/aggression 1 data Vision.range/fov, Memory.alertness defaults
New enemy archetype 1 data new Ai.tree(...) / FSM template + faction — no code
Custom condition/action leaf reg + 2 register a named verb; own handler implements it
Override a decision 4 event @On(DecisionMade) → cancel / redirect
New steering force 2 compose add weight + @After(esys_steering) handler
Swap FSM→utility for one NPC 6 policy Brain.model field
Companion command (follow/guard) 1 data Follower.mode
Stealth / distraction 4 event feed NoiseHeard/AlertChanged listeners
Replace decision entirely 5 disable disable system esys_bt, write own; keep perception+steering

Determinism / Ludic ties

  • Perception re-scan and re-plan are frame-throttled + fixed-order → deterministic, replayable AI (no wall-clock, no float). Big deal for lockstep co-op and diffable headless tests.
  • Reuses Grid.los/Grid.a_star, Query.nearest/within, Angle.*, Cooldown, the machine statement, and the event bus. BT/utility/consideration registries reuse the Anim.clip name-table + Dict pattern.
  • AI writes intent, movement/weapons are the player controllers' systems → no duplicated enemy movement/gun code; an enemy is a body + a brain, nothing more.
  • Brain/memory state is components → snapshot/world_save captures it (save mid-fight, rollback).

Phasing

  1. Faction table + Vision/Memory + esys_perception (spot/lose target) + TargetSpotted/Lost events. Depends on #57 faction + Grid.los.
  2. FSM template (Patrol/Chase/Attack/Flee) writing intent; steering seek/flee/arrive/wander; enemy grunt end-to-end (drives shooter or platformer body).
  3. Behavior-tree model + verb registry; utility model + considerations.
  4. Friendly/companion Follower stances; neutral/vendor interaction; flocking (separate/cohere/align).
  5. Spawner/director; examples/ enemy + companion demos across a shooter and an RPG scene (proving the same stack drives both).

References

  • Reynolds steering behaviors (seek/flee/arrive/wander/flock/pursue-evade).
  • Behavior trees (Halo 2, Unreal BT), GOAP (F.E.A.R.), utility AI (The Sims, big-brain crate).
  • Left 4 Dead AI Director (macro pacing); Godot NavigationAgent2D + navigation demos.
  • Ludic internals: machine statement, Grid.los/a_star, Query.*, event bus, esys_* throttling via Cooldown.
_Part of the builtin-controllers layer (#57). Conforms to the six-lever extensibility contract. Planning only — no implementation._ ## Context Reusable NPC AI — both **friendly** (companions, shopkeepers, quest-givers, escortees) and **enemy** (patrol, chase, attack, flee, flock) — that plugs into the platformer/RPG/shooter bodies rather than reimplementing movement per game. The design separates **perception → decision → action** so each layer is independently swappable, and offers *three* decision models at increasing power (state machine, behavior tree, utility) because different games want different ones. Modeled on classic game-AI stacks: FSMs, behavior trees (Halo/UE), GOAP (F.E.A.R.), utility AI (`big-brain`, The Sims), and Reynolds steering behaviors. Crucially: AI **does not move bodies directly** — it writes the *same intent fields* the player controllers read (`move_x`, `jump`, `fire`, `aim_angle`). So an enemy gunner reuses the shooter's `esys_weapon`/`esys_projectile` verbatim; a friendly follower reuses the platformer/topdown mover. One movement/combat implementation, driven by either input or AI. ## Layered design ### Layer 1 — Perception (what the NPC knows) ``` property Vision { range, fov, angle } # cone sight; LOS via Grid.los property Hearing { range } # noise events within radius property Memory { last_seen_x, last_seen_y, target, alertness, ttl } property Faction { id } # shared table: friend/enemy/neutral ``` - `esys_perception` (throttled — re-scan every N frames via `Cooldown`, not every frame): uses `Query.within`/`Query.nearest` + `Grid.los` (line-of-sight already exists) + the faction table to populate `Memory` (target, last-seen, alertness). Emits `TargetSpotted`, `TargetLost`, `NoiseHeard`, `AlertChanged`. - **Extend:** stealth mechanics (light level, crouch, disguises) = feed `Vision.range`/`alertness` via `@On`; a "blind but hearing" enemy = drop `Vision`, keep `Hearing` (lever-2 composition). ### Layer 2 — Decision (three models, pick per NPC) All three write to the same **intent** output (a `Brain { state, target, intent_x, intent_y, want_fire, ... }` component), so downstream action layers don't care which was used. - **(a) State machine** — reuse Ludic's existing `machine <reg> { state … become … }`. Ship a `Patrol/Chase/Attack/Flee/Return` template as data-tunable states. Simplest, covers most enemies. - **(b) Behavior tree** — a data-defined tree (Selector/Sequence/Parallel/Decorator/Leaf) in a registry (`Ai.tree("grunt", ...)`), ticked by `esys_bt`. Leaves are **named condition/action verbs** (`see_target`, `in_range`, `move_to`, `fire_at`, `flee`) from a registry the game extends — the no-closure-safe way to make trees data. Modeled on UE/Halo BTs. - **(c) Utility AI** — a set of scored considerations (`Ai.consider("attack", inputs..., curve)`); `esys_utility` picks the highest-scoring action each re-plan. Great for companions and "sims"-like NPCs; modeled on `big-brain`/The Sims. - Selector field `Brain.model` ∈ {fsm, bt, utility} (lever 6). A game mixes: FSM for trash mobs, utility for the boss. - Events: `cancellable DecisionMade {e, action}` (override/veto the AI's choice), `StateEntered/Exited`, `Replanned`. ### Layer 3 — Action / steering (how intent becomes motion) Reynolds **steering behaviors** as composable, weighted forces writing the mover's intent: ``` property Steering { seek, flee, arrive, wander, separate, cohere, align, pursue, evade, weights } ``` - `esys_steering` sums the enabled behaviors → `intent_x/intent_y`, which the platformer/topdown mover consumes. Flocking (boids) = `separate`+`cohere`+`align`. Path-following = `Grid.a_star` waypoints + `arrive`. - Combat actions map intent to the shooter's weapon (`want_fire`→`"fire"` intent) or RPG melee. - **Extend:** new steering force = add a weight + a handler `@After(esys_steering)`; formation movement, cover-seeking = own behavior over the same intent field. ### Layer 4 — Friendly vs. enemy (policy, not separate code) Same stack; **faction + goal** differ: - **Enemy**: faction hostile to player; goals patrol/hunt/attack/flee-at-low-hp. - **Friendly companion**: faction allied; goals follow (`arrive` on the leader + `separate`), assist (target the player's target via shared perception), revive, carry. `property Follower { leader, distance, mode }` (follow/guard/wait/aggressive/defensive stances — the classic companion command wheel). - **Neutral / vendor / quest-giver**: no combat goals; `Interacted` (from RPG movement module) opens dialog/shop. - Escort missions, ally AI, and enemy AI are the *same systems* with different faction + goal data. That's the extensibility payoff. ### Layer 5 — Director / spawning (macro AI) - `property Spawner { table, rate, budget, cond }` + `esys_spawner` — shared with the shooter wave director. - Optional **AI director** (Left 4 Dead-style pacing): a policy that scales `budget`/`rate` off a tension metric; `DirectorBeat` events. Entirely opt-in and replaceable. ## How a developer extends each area | Want | Lever | How | |---|---|---| | Tune sight/aggression | 1 data | `Vision.range/fov`, `Memory.alertness` defaults | | New enemy archetype | 1 data | new `Ai.tree(...)` / FSM template + faction — no code | | Custom condition/action leaf | reg + 2 | register a named verb; own handler implements it | | Override a decision | 4 event | `@On(DecisionMade)` → `cancel` / redirect | | New steering force | 2 compose | add weight + `@After(esys_steering)` handler | | Swap FSM→utility for one NPC | 6 policy | `Brain.model` field | | Companion command (follow/guard) | 1 data | `Follower.mode` | | Stealth / distraction | 4 event | feed `NoiseHeard`/`AlertChanged` listeners | | Replace decision entirely | 5 disable | `disable system esys_bt`, write own; keep perception+steering | ## Determinism / Ludic ties - Perception re-scan and re-plan are **frame-throttled + fixed-order** → deterministic, replayable AI (no wall-clock, no float). Big deal for lockstep co-op and diffable headless tests. - Reuses `Grid.los`/`Grid.a_star`, `Query.nearest/within`, `Angle.*`, `Cooldown`, the `machine` statement, and the event bus. BT/utility/consideration registries reuse the `Anim.clip` name-table + `Dict` pattern. - AI writes **intent**, movement/weapons are the player controllers' systems → no duplicated enemy movement/gun code; an enemy is a body + a brain, nothing more. - Brain/memory state is components → snapshot/`world_save` captures it (save mid-fight, rollback). ## Phasing 1. Faction table + `Vision`/`Memory` + `esys_perception` (spot/lose target) + `TargetSpotted/Lost` events. Depends on #57 faction + `Grid.los`. 2. FSM template (Patrol/Chase/Attack/Flee) writing intent; steering `seek`/`flee`/`arrive`/`wander`; enemy grunt end-to-end (drives shooter or platformer body). 3. Behavior-tree model + verb registry; utility model + considerations. 4. Friendly/companion `Follower` stances; neutral/vendor interaction; flocking (`separate`/`cohere`/`align`). 5. Spawner/director; `examples/` enemy + companion demos across a shooter and an RPG scene (proving the same stack drives both). ## References - Reynolds **steering behaviors** (seek/flee/arrive/wander/flock/pursue-evade). - Behavior trees (Halo 2, Unreal BT), GOAP (F.E.A.R.), utility AI (The Sims, `big-brain` crate). - Left 4 Dead **AI Director** (macro pacing); Godot `NavigationAgent2D` + navigation demos. - Ludic internals: `machine` statement, `Grid.los`/`a_star`, `Query.*`, event bus, `esys_*` throttling via `Cooldown`.
Author
Owner

Amendment — ships as an external package

Per the decision on #57, this controller ships as an external, versioned source package (Ludic modules, compiled into the consumer's binary), not as compiler/runtime/native stdlib and not as a precompiled OS binary. Rationale (wasm target, determinism, compile-time ECS) and the binary-mod escape hatch are on #57.

Depends on #62 (package distribution + package-declarable namespaces/components/engine-systems) — the mechanism that lets this register its component schema and engine-owned systems without a compiler edit. The six-lever extensibility contract it conforms to stays in the language core.

## Amendment — ships as an external package Per the decision on #57, this controller ships as an **external, versioned source package** (Ludic modules, compiled into the consumer's binary), **not** as compiler/`runtime/native` stdlib and **not** as a precompiled OS binary. Rationale (wasm target, determinism, compile-time ECS) and the binary-mod escape hatch are on #57. **Depends on #62** (package distribution + package-declarable namespaces/components/engine-systems) — the mechanism that lets this register its component schema and engine-owned systems without a compiler edit. The six-lever extensibility contract it conforms to stays in the language core.
Author
Owner

Shipped — ludic.npcai (commit 9ce69d2)

A perception → decision → action AI source package on the six-lever contract (#57). The headline holds: the AI never moves a body directly — it writes the same intent fields the player controllers read (want_x/want_y/want_fire, want_jump), so an enemy gunner reuses the shooter's weapon/projectile/auto-aim verbatim and a companion reuses the mover. Friendly vs. enemy is faction + goal, not different code.

  • Perception — Vision + Memory, a throttled esys_perception with faction filtering and optional Grid line-of-sight; remembers the nearest hostile, emits TargetSpotted/TargetLost.
  • Decision — three models writing one Brain intent, selectable per-NPC via Brain.model (lever 6): a finite-state machine (patrol/chase/attack/flee), a utility scorer, and a canonical behaviour tree. Every decision is veto-able via cancellable DecisionMade.
  • Steering — Reynolds flocking (separate/cohere) in esys_steering.
  • Friendly — a Follower component with companion stances.
  • Action bridge — esys_ai_act writes the Brain intent onto whatever mover the body carries (TopDown and/or Platformer).

Reuses ludic.gameplay Faction (who is hostile) + Stats (hp for the flee behaviour). Deterministic: perception + re-plan are frame-throttled and fixed-order, no wall-clock, no float — diffable headless tests and lockstep co-op both hold.

Example examples/games/npcai_demo.ludic — 8 self-checks: an enemy perceives, chases and shoots a target through the shooter controller, flees at low hp under the utility model, and a companion follows its leader — a regression case in x test. Docs: docs/CONTROLLERS.md.

Closing as done.

## Shipped — `ludic.npcai` (commit 9ce69d2) A perception → decision → action AI source package on the six-lever contract (#57). The headline holds: **the AI never moves a body directly — it writes the same intent fields the player controllers read** (`want_x`/`want_y`/`want_fire`, `want_jump`), so an enemy gunner reuses the shooter's weapon/projectile/auto-aim verbatim and a companion reuses the mover. Friendly vs. enemy is faction + goal, not different code. - **Perception** — `Vision` + `Memory`, a throttled `esys_perception` with faction filtering and optional `Grid` line-of-sight; remembers the nearest hostile, emits `TargetSpotted`/`TargetLost`. - **Decision** — three models writing one `Brain` intent, selectable per-NPC via `Brain.model` (lever 6): a finite-state machine (patrol/chase/attack/flee), a utility scorer, and a canonical behaviour tree. Every decision is veto-able via `cancellable DecisionMade`. - **Steering** — Reynolds flocking (separate/cohere) in `esys_steering`. - **Friendly** — a `Follower` component with companion stances. - **Action bridge** — `esys_ai_act` writes the Brain intent onto whatever mover the body carries (TopDown and/or Platformer). Reuses `ludic.gameplay` Faction (who is hostile) + Stats (hp for the flee behaviour). Deterministic: perception + re-plan are frame-throttled and fixed-order, no wall-clock, no float — diffable headless tests and lockstep co-op both hold. Example `examples/games/npcai_demo.ludic` — 8 self-checks: an enemy perceives, chases and *shoots* a target through the shooter controller, flees at low hp under the utility model, and a companion follows its leader — a regression case in `x test`. Docs: `docs/CONTROLLERS.md`. Closing as done.
orkun closed this issue 2026-09-01 16:28:43 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: workshopsoft/ludic#61
No description provided.