AI
The scripted surface for enemy AI. You author a behaviour tree in Wren and the engine ticks it; underneath sit a walkable-surface navmesh for pathfinding, a surround-ring solver so a pack encircles a target instead of stacking on it, a lost-target → search → give-up FSM, aggro / tactic / stat-gate selection, an L4D-style tension director, a noise bus for detect-by-sound, and perception helpers. These are thin bindings over device-free, unit-tested C++ cores.
Everything here is genre-general — the navmesh models walkable surfaces (top-down, third-person, or FPS), and perception/combat range are plain geometry, not a top-down- or swim-specific abstraction. Pair these with the perception primitives on Physics (overlapSphere, Raycast.fromPointMasked) for the full see → decide → move loop. See the AI Hunters example, which drives the whole surface from one Wren tree.
The behaviour-tree surface (BehaviourTree / Bt / BtNode / Blackboard / BtStatus), the decision cores (LostTargetTracker, AggroTable, Tactic, StatGate, TensionModel and their constants), and the noise bus (NoiseBus / AlertMemory / PainMemory) are frozen contracts (ADR 0065); Nav / SurroundRing / Ai by ADR 0057.
A Recast/Detour navmesh. Bake once from level geometry, then query a waypoint corridor between two points. Re-bake when the walkable geometry changes.
Nav.new()
Section titled “Nav.new()”Returns: Nav — An empty (unbaked) navmesh.
nav.bake(verts, tris)
Section titled “nav.bake(verts, tris)”Returns: Bool — true on success, false if the input produced no walkable polygons.
Parameters:
verts(List) — Flat list of vertex coordinates[x0, y0, z0, x1, y1, z1, …].tris(List) — Flat list of integer indices intoverts, three per triangle[i0, i1, i2, …].
Baking replaces any previous mesh. A block or wall carved out of the floor becomes unwalkable, so paths route around its footprint.
nav.findPath(fx, fy, fz, tx, ty, tz)
Section titled “nav.findPath(fx, fy, fz, tx, ty, tz)”Returns: List of [x, y, z] — A waypoint corridor from start to goal, or an empty list if there is no path (or the mesh is unbaked). The endpoints are snapped onto the mesh.
Parameters:
fx,fy,fz(Num) — Start position in world space.tx,ty,tz(Num) — Goal position in world space.
Each element is a 3-element list [x, y, z]. Steer an agent toward the next waypoint (path[1]) and re-query each frame as it moves.
SurroundRing
Section titled “SurroundRing”An encounter-spacing solver: it assigns agents to concentric slots around a target so they encircle it without clumping. A fresh solver has three rings (inner melee / mid / outer standby).
SurroundRing.new()
Section titled “SurroundRing.new()”Returns: SurroundRing — A solver with the default three rings.
ring.assign(id, weight, preferredRange, isMelee, desiredAngleDeg)
Section titled “ring.assign(id, weight, preferredRange, isMelee, desiredAngleDeg)”Returns: List [angleDeg, radius, standby] — The slot for agent id. The slot world position is target + (cos(angleDeg°), 0, sin(angleDeg°)) * radius. standby is 1 when the agent is parked on the outer ring (no inner slot free), else 0.
Parameters:
id(Num) — Stable agent id. Re-assigning the sameidreturns the same held slot (assignments are sticky — agents don’t thrash between bearings frame to frame).weight(Num) — Priority for claiming a close slot when the ring is contested (higher wins).preferredRange(Num) — The agent’s ideal engagement distance.isMelee(Bool) — Whether the agent wants an inner (melee) ring slot.desiredAngleDeg(Num) — Preferred bearing (degrees) around the target; the solver spreads agents near their preferred angles.
ring.release(id)
Section titled “ring.release(id)”Frees id’s slot for another agent.
Static geometry helpers for enemy scripts. Pure math — no scene or device state.
Ai.stopDistance(attackRange, bodyRadius)
Section titled “Ai.stopDistance(attackRange, bodyRadius)”Returns: Num — The center-to-center distance at which a body’s leading edge parks inside its attack range: max(0, attackRange − bodyRadius − 0.2) (0.2 m margin).
Parameters:
attackRange(Num) — The attack’s reach.bodyRadius(Num) — The agent’s body radius.
Ai.inFieldOfView(ex, ey, ez, fx, fy, fz, tx, ty, tz, fovDeg)
Section titled “Ai.inFieldOfView(ex, ey, ez, fx, fy, fz, tx, ty, tz, fovDeg)”Returns: Bool — Whether target lies inside a view cone of full angle fovDeg at eye facing forward.
Parameters:
ex,ey,ez(Num) — Eye (observer) position.fx,fy,fz(Num) — Forward direction (need not be normalized).tx,ty,tz(Num) — Target position.fovDeg(Num) — Full cone angle in degrees;≥ 360is omnidirectional.
Use it as the perception gate for a chase, and pair it with Raycast.fromPointMasked for a line-of-sight check.
Ai.nearestVisibleTarget(scene, ex, ey, ez, fx, fy, fz, radius, fovDeg, candidates, mask)
Section titled “Ai.nearestVisibleTarget(scene, ex, ey, ez, fx, fy, fz, radius, fovDeg, candidates, mask)”Returns: Num — The id of the nearest candidate that is within range, inside the field of view, and has line of sight; or -1 if none. This is the “acquire nearest visible target” primitive — range + FoV + LOS + nearest, in one native call.
Parameters:
scene(Scene) — The scene whose physics world the line-of-sight raycast is cast in.ex,ey,ez(Num) — Eye (observer) position.fx,fy,fz(Num) — Forward direction (need not be normalized).radius(Num) — Maximum acquire distance.fovDeg(Num) — Full cone angle in degrees (≥ 360= omnidirectional).candidates(List) — A flat list of[id0, x0, y0, z0, id1, x1, y1, z1, …]— the candidate targets (e.g. gathered fromPhysics.overlapSphere).mask(Num) — The obstruction collision-layer bitmask the line-of-sight ray tests against (0= all layers). Line of sight is a native masked raycast — no Wren callback — so this is safe to call from inside a behaviour-tree leaf.
Ai.tickInterval / Ai.tickInterval=(seconds)
Section titled “Ai.tickInterval / Ai.tickInterval=(seconds)”The engine’s behaviour-tree tick cadence, in seconds. 0 ticks every frame; the default 0.1 ticks at 10 Hz. Global — it applies to every registered BehaviourTree.
Ai.tickInterval = 0 // tick trees every frame (smooth motion in a small demo)var hz = 1 / Ai.tickIntervalBehaviourTree
Section titled “BehaviourTree”Author an enemy’s decision logic as a tree, in Wren. You build the tree once from the Bt factories with closure leaves, then the engine ticks it every AI step — there is deliberately no tree.tick method (a game-called tick would re-enter the Wren VM, which is forbidden). Leaves share state through the tree’s typed Blackboard.
A leaf is a closure { |bb, dt| … } that returns a BtStatus. bb is the tree’s blackboard; dt is the AI-tick delta in seconds. Inside a leaf you may freely call other AI/physics methods (Ai, Nav, SurroundRing, Physics) — those are host calls, not VM re-entry.
A single-expression leaf returns its value implicitly; a multi-statement leaf must
returnexplicitly (Wren returnsnull— decoded as failure — otherwise).
import "engine" for BehaviourTree, Bt, BtStatus
_tree = BehaviourTree.new()_tree.setRoot(Bt.selector([ Bt.sequence([ Bt.leaf { |bb, dt| canSee(bb) ? BtStatus.success : BtStatus.failure }, Bt.leaf { |bb, dt| chaseToSlot(bb, dt) return BtStatus.running }, Bt.cooldown(1.5, Bt.leaf { |bb, dt| attack(bb) }) ]), Bt.leaf { |bb, dt| returnToSpawn(bb, dt) } // fallback]))// The engine ticks _tree each frame — do not call _tree.tick.BehaviourTree.new()
Section titled “BehaviourTree.new()”Returns: BehaviourTree — A new tree, auto-registered so the engine ticks it. Keep a reference (a game field) so it isn’t garbage-collected.
tree.setRoot(node)
Section titled “tree.setRoot(node)”Sets the tree’s root node. Consumes the BtNode — reusing it afterward aborts.
tree.enabled = (flag)
Section titled “tree.enabled = (flag)”Bool — pause (false) or resume (true) engine ticking of this tree.
tree.blackboard()
Section titled “tree.blackboard()”Returns: Blackboard — this tree’s companion blackboard (the same bb passed to its leaves).
Static factories for behaviour-tree nodes. Composites take a List of BtNode; leaf takes a closure.
Bt.sequence(children)
Section titled “Bt.sequence(children)”Returns: BtNode — Runs children left→right; stops at the first non-success (the “and” node). All succeed → success.
Bt.selector(children)
Section titled “Bt.selector(children)”Returns: BtNode — Runs children left→right; stops at the first non-failure (the “or” / try-in-order node). All fail → failure.
Bt.inverter(child)
Section titled “Bt.inverter(child)”Returns: BtNode — Flips success ↔ failure (running passes through).
Bt.repeat(count, child)
Section titled “Bt.repeat(count, child)”Returns: BtNode — Re-runs child until it succeeds count times, then success; a child failure aborts. count ≤ 0 repeats forever.
Bt.cooldown(seconds, child)
Section titled “Bt.cooldown(seconds, child)”Returns: BtNode — After child succeeds, blocks (returns failure) for seconds before letting it run again — gates an ability by its cooldown.
Bt.leaf(action)
Section titled “Bt.leaf(action)”Returns: BtNode — A leaf backed by a closure action = { |bb, dt| … } (arity 2, checked when built). The closure returns a BtStatus (or a Bool: true = success). Called each AI tick in native context.
BtNode
Section titled “BtNode”An opaque behaviour-tree node handle, produced by the Bt factories. A node is consumed (moved) when placed into a parent composite or passed to setRoot; reusing a consumed node aborts with a clear message. It has no methods of its own — you only pass it to another factory or to setRoot.
Blackboard
Section titled “Blackboard”A typed per-agent key/value store (bool / int / number / string — the same value set as SaveState) shared by a tree’s leaves. A tree’s companion board is the bb argument; a standalone board comes from Blackboard.new().
Blackboard.new()
Section titled “Blackboard.new()”Returns: Blackboard — A new, empty store.
bb.setBool(key, value) · setInt(key, value) · setNumber(key, value) · setString(key, value)
Section titled “bb.setBool(key, value) · setInt(key, value) · setNumber(key, value) · setString(key, value)”Store a typed value under key (String).
bb.getBool(key, fallback) · getInt(key, fallback) · getNumber(key, fallback) · getString(key, fallback)
Section titled “bb.getBool(key, fallback) · getInt(key, fallback) · getNumber(key, fallback) · getString(key, fallback)”Returns: the stored value, or fallback if the key is absent or holds a different type (a wrong-type read returns the fallback — it does not coerce).
bb.has(key) · remove(key) · clear() · size()
Section titled “bb.has(key) · remove(key) · clear() · size()”has → Bool; remove drops one key; clear empties the board; size → the entry count.
BtStatus
Section titled “BtStatus”Leaf return values (integer constants): BtStatus.running (0), BtStatus.success (1), BtStatus.failure (2). A leaf may also return a Bool (true → success, false → failure); anything else is treated as failure.
LostTargetTracker
Section titled “LostTargetTracker”A pure FSM that turns “chase” into believable “lost you → search → give up”. Feed it each AI tick with whether the target is currently in contact.
LostTargetTracker.new(loseMode, graceSeconds, searchDuration, giveUpMode)
Section titled “LostTargetTracker.new(loseMode, graceSeconds, searchDuration, giveUpMode)”Returns: LostTargetTracker.
Parameters:
loseMode(LoseTargetMode) —distanceLeash(lose the instant contact drops) orlosBreakGrace(tolerate agraceSecondswindow first).graceSeconds(Num) — Lost-contact time tolerated before searching (losBreakGraceonly).searchDuration(Num) — How long to search before giving up.giveUpMode(GiveUpMode) —returnToSpawnorshambleHere.
tracker.tick(dt, inContact)
Section titled “tracker.tick(dt, inContact)”Returns: TargetState — Advance the FSM with the current contact boolean.
tracker.state()
Section titled “tracker.state()”Returns: TargetState — The current state without advancing.
tracker.justLostContact()
Section titled “tracker.justLostContact()”Returns: Bool — true only on the tick contact was just lost — the moment to freeze the last-known position.
tracker.searchProgress()
Section titled “tracker.searchProgress()”Returns: Num — Fraction of the search window elapsed, 0..1 (0 when not searching).
tracker.engage() · tracker.reset()
Section titled “tracker.engage() · tracker.reset()”engage forces engaged (target reacquired by another sense); reset returns to gaveUp with timers cleared.
TargetState
Section titled “TargetState”FSM states (constants): TargetState.engaged (0), searching (1), gaveUp (2).
LoseTargetMode
Section titled “LoseTargetMode”LoseTargetMode.distanceLeash (0) — contact lost the instant it drops; losBreakGrace (1) — tolerate a grace window (LOS flicker).
GiveUpMode
Section titled “GiveUpMode”GiveUpMode.returnToSpawn (0) — path back to spawn; shambleHere (1) — wander near where the search ended.
AggroTable
Section titled “AggroTable”Per-source threat accumulation plus a commitment gate, so a boss holds its target instead of flipping to whoever last hit it. Ids are opaque integers.
AggroTable.new()
Section titled “AggroTable.new()”Returns: AggroTable.
table.add(id, amount)
Section titled “table.add(id, amount)”Add amount aggro to id (accumulates; floored at 0).
table.addInitialContact(id, amount, cap)
Section titled “table.addInitialContact(id, amount, cap)”First contact shouldn’t let the first-hitter dominate: add, but clamp id’s total to at most cap.
table.decay(dt, ratePerSec) · table.prune(floor)
Section titled “table.decay(dt, ratePerSec) · table.prune(floor)”decay drops every entry by ratePerSec * dt; prune removes entries at or below floor.
table.top()
Section titled “table.top()”Returns: Num — The highest-aggro source id, or -1 if empty.
table.value(id) · remove(id) · clear() · size()
Section titled “table.value(id) · remove(id) · clear() · size()”value → the aggro held by id (0 if absent); the rest are housekeeping.
AggroTable.shouldSwitchFocus(currentId, currentScore, candidateId, candidateScore, now, lastSwitchTime, minDwell, switchMargin)
Section titled “AggroTable.shouldSwitchFocus(currentId, currentScore, candidateId, candidateScore, now, lastSwitchTime, minDwell, switchMargin)”Returns: Bool — Whether to switch focus to candidateId. It may switch only after minDwell has passed since lastSwitchTime and the candidate beats the current score by more than switchMargin. Acquiring from no current target skips the dwell gate. A static helper — the game owns now / lastSwitchTime / the scores.
Tactic
Section titled “Tactic”Distance-band tactic selection.
Tactic.select(bands, distance, fallback)
Section titled “Tactic.select(bands, distance, fallback)”Returns: Tactic — The tactic for the current distance: the band with the smallest maxDistance that still covers it (tightest enclosing). fallback if no band covers distance.
Parameters:
bands(List) — A flat list[maxDist0, tactic0, maxDist1, tactic1, …](order-independent).distance(Num) — Current distance to the target.fallback(Tactic) — Returned when the target is farther than every band.
Tactic constants: ignore (0), approach (1), melee (2), strafe (3), kite (4), rangedAttack (5), flee (6), hold (7), summon (8).
StatGate
Section titled “StatGate”Data-driven self-preservation / ability gating: read a stat and, when it crosses a threshold, force or block behaviour.
StatGate.eval(gateWhen, threshold, effect, statValue)
Section titled “StatGate.eval(gateWhen, threshold, effect, statValue)”Returns: GateEffect — effect if the gate triggers (statValue is below/above threshold per gateWhen), else GateEffect.none.
StatGate.combine(effects)
Section titled “StatGate.combine(effects)”Returns: GateEffect — The single highest-priority effect from a flat List of effect ints (forceFlee > forceState > the block* effects). none if the list is empty.
GateWhen: below (0), above (1). GateEffect: none (0), forceFlee (1), forceState (2), blockMelee (3), blockRanged (4), blockSummon (5).
GateWhen
Section titled “GateWhen”Gate trigger direction (constants): GateWhen.below (0) triggers when the stat is below the threshold; above (1) when above.
GateEffect
Section titled “GateEffect”Gate effects (constants): none (0), forceFlee (1), forceState (2), blockMelee (3), blockRanged (4), blockSummon (5).
TensionModel
Section titled “TensionModel”An optional strategic director (Left-4-Dead-style): it walks rest → buildUp → peak → relax driven by an aggregate threat signal, so a spawn director can pace encounters (hold back during a peak, push during rest).
TensionModel.new()
Section titled “TensionModel.new()”Returns: TensionModel — with the default pacing config.
model.tick(dt, threat01)
Section titled “model.tick(dt, threat01)”Returns: TensionPhase — Advance one tick with the current aggregate threat in 0..1.
model.phase() · model.level() · model.reset()
Section titled “model.phase() · model.level() · model.reset()”phase → the current TensionPhase; level → the smoothed internal tension 0..1; reset returns to rest.
TensionModel.threatScoring(samples, maxRange)
Section titled “TensionModel.threatScoring(samples, maxRange)”Returns: Num — An unnormalized tier-weighted proximity sum from a flat List [distance, tierWeight, engaged(0|1), …] (one triple per enemy). Proximity is 1 at point-blank and 0 at maxRange; engaged samples count 1.5×. Divide by an expected-max for the scene to feed tick’s threat01.
TensionPhase
Section titled “TensionPhase”Tension phases (constants): TensionPhase.rest (0), buildUp (1), peak (2), relax (3).
NoiseBus
Section titled “NoiseBus”A detect-by-sound registry: world sounds fan out to registered listeners (enemies) within earshot.
NoiseBus.new()
Section titled “NoiseBus.new()”Returns: NoiseBus.
bus.registerListener(id, x, y, z, hearingRadius) · updatePosition(id, x, y, z) · unregister(id) · clear() · size()
Section titled “bus.registerListener(id, x, y, z, hearingRadius) · updatePosition(id, x, y, z) · unregister(id) · clear() · size()”Manage the listener set. hearingRadius is how far that listener can hear.
bus.emit(x, y, z, noiseRadius)
Section titled “bus.emit(x, y, z, noiseRadius)”Returns: List of Num — the ids of every listener that heard a sound at (x,y,z) with noiseRadius (raise each one’s AlertMemory).
NoiseBus.isAudible(nx, ny, nz, noiseRadius, lx, ly, lz, hearingRadius)
Section titled “NoiseBus.isAudible(nx, ny, nz, noiseRadius, lx, ly, lz, hearingRadius)”Returns: Bool — Audible iff the listener is within both radii (i.e. within min(noiseRadius, hearingRadius)). Fully 3D, hard cutoff. A static helper.
AlertMemory
Section titled “AlertMemory”A per-agent alert: a world position to investigate, held for a linger time then abandoned (so a heard gunshot doesn’t park an enemy forever).
AlertMemory.new()
Section titled “AlertMemory.new()”Returns: AlertMemory.
alert.raise(x, y, z, lingerSeconds)
Section titled “alert.raise(x, y, z, lingerSeconds)”Move the anchor to (x,y,z) and reset the linger timer.
alert.tick(dt) · alert.active() · alert.anchor() · alert.remaining() · alert.clear()
Section titled “alert.tick(dt) · alert.active() · alert.anchor() · alert.remaining() · alert.clear()”tick counts the timer down; active → Bool; anchor → [x, y, z]; remaining → seconds left; clear deactivates it.
PainMemory
Section titled “PainMemory”A per-agent pain memory: getting hit leashes the agent onto its attacker for a while, so perception falls back to the shooter when sight and hearing find nothing.
PainMemory.new()
Section titled “PainMemory.new()”Returns: PainMemory.
pain.remember(attackerId, seconds)
Section titled “pain.remember(attackerId, seconds)”Leash onto attackerId for seconds.
pain.tick(dt) · pain.valid() · pain.attacker() · pain.clear()
Section titled “pain.tick(dt) · pain.valid() · pain.attacker() · pain.clear()”tick counts down; valid → Bool; attacker → the attacker id, or -1 when none / expired; clear forgets it.