Proposal: builtin NPC AI — perception/decision/steering, FSM+BT+utility, friendly & enemy, director (base + extensible) #61
Labels
No labels
area:ci
area:docs
area:input
area:net
area:rendering
area:repo
area:stdlib
area:tooling
area:types
cleanup
dx
priority:high
priority:low
priority:medium
proposal
status:in-progress
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: workshopsoft/ludic#61
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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'sesys_weapon/esys_projectileverbatim; 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)
esys_perception(throttled — re-scan every N frames viaCooldown, not every frame): usesQuery.within/Query.nearest+Grid.los(line-of-sight already exists) + the faction table to populateMemory(target, last-seen, alertness). EmitsTargetSpotted,TargetLost,NoiseHeard,AlertChanged.Vision.range/alertnessvia@On; a "blind but hearing" enemy = dropVision, keepHearing(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.machine <reg> { state … become … }. Ship aPatrol/Chase/Attack/Flee/Returntemplate as data-tunable states. Simplest, covers most enemies.Ai.tree("grunt", ...)), ticked byesys_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.Ai.consider("attack", inputs..., curve));esys_utilitypicks the highest-scoring action each re-plan. Great for companions and "sims"-like NPCs; modeled onbig-brain/The Sims.Brain.model∈ {fsm, bt, utility} (lever 6). A game mixes: FSM for trash mobs, utility for the boss.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:
esys_steeringsums the enabled behaviors →intent_x/intent_y, which the platformer/topdown mover consumes. Flocking (boids) =separate+cohere+align. Path-following =Grid.a_starwaypoints +arrive.want_fire→"fire"intent) or RPG melee.@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:
arriveon 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).Interacted(from RPG movement module) opens dialog/shop.Layer 5 — Director / spawning (macro AI)
property Spawner { table, rate, budget, cond }+esys_spawner— shared with the shooter wave director.budget/rateoff a tension metric;DirectorBeatevents. Entirely opt-in and replaceable.How a developer extends each area
Vision.range/fov,Memory.alertnessdefaultsAi.tree(...)/ FSM template + faction — no code@On(DecisionMade)→cancel/ redirect@After(esys_steering)handlerBrain.modelfieldFollower.modeNoiseHeard/AlertChangedlistenersdisable system esys_bt, write own; keep perception+steeringDeterminism / Ludic ties
Grid.los/Grid.a_star,Query.nearest/within,Angle.*,Cooldown, themachinestatement, and the event bus. BT/utility/consideration registries reuse theAnim.clipname-table +Dictpattern.world_savecaptures it (save mid-fight, rollback).Phasing
Vision/Memory+esys_perception(spot/lose target) +TargetSpotted/Lostevents. Depends on #57 faction +Grid.los.seek/flee/arrive/wander; enemy grunt end-to-end (drives shooter or platformer body).Followerstances; neutral/vendor interaction; flocking (separate/cohere/align).examples/enemy + companion demos across a shooter and an RPG scene (proving the same stack drives both).References
big-braincrate).NavigationAgent2D+ navigation demos.machinestatement,Grid.los/a_star,Query.*, event bus,esys_*throttling viaCooldown.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/nativestdlib 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.
Shipped —
ludic.npcai(commit9ce69d2)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.Vision+Memory, a throttledesys_perceptionwith faction filtering and optionalGridline-of-sight; remembers the nearest hostile, emitsTargetSpotted/TargetLost.Brainintent, selectable per-NPC viaBrain.model(lever 6): a finite-state machine (patrol/chase/attack/flee), a utility scorer, and a canonical behaviour tree. Every decision is veto-able viacancellable DecisionMade.esys_steering.Followercomponent with companion stances.esys_ai_actwrites the Brain intent onto whatever mover the body carries (TopDown and/or Platformer).Reuses
ludic.gameplayFaction (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 inx test. Docs:docs/CONTROLLERS.md.Closing as done.