Skip to content

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.

Returns: Nav — An empty (unbaked) navmesh.

Returns: Booltrue 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 into verts, 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.

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.

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).

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 same id returns 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.

Frees id’s slot for another agent.

Static geometry helpers for enemy scripts. Pure math — no scene or device state.

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; ≥ 360 is 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 from Physics.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.tickInterval

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 return explicitly (Wren returns null — 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.

Returns: BehaviourTree — A new tree, auto-registered so the engine ticks it. Keep a reference (a game field) so it isn’t garbage-collected.

Sets the tree’s root node. Consumes the BtNode — reusing it afterward aborts.

Bool — pause (false) or resume (true) engine ticking of this tree.

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.

Returns: BtNode — Runs children left→right; stops at the first non-success (the “and” node). All succeed → success.

Returns: BtNode — Runs children left→right; stops at the first non-failure (the “or” / try-in-order node). All fail → failure.

Returns: BtNode — Flips successfailure (running passes through).

Returns: BtNode — Re-runs child until it succeeds count times, then success; a child failure aborts. count ≤ 0 repeats forever.

Returns: BtNode — After child succeeds, blocks (returns failure) for seconds before letting it run again — gates an ability by its cooldown.

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.

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.

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().

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()”

hasBool; remove drops one key; clear empties the board; size → the entry count.

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.

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) or losBreakGrace (tolerate a graceSeconds window first).
  • graceSeconds (Num) — Lost-contact time tolerated before searching (losBreakGrace only).
  • searchDuration (Num) — How long to search before giving up.
  • giveUpMode (GiveUpMode) — returnToSpawn or shambleHere.

Returns: TargetState — Advance the FSM with the current contact boolean.

Returns: TargetState — The current state without advancing.

Returns: Booltrue only on the tick contact was just lost — the moment to freeze the last-known position.

Returns: Num — Fraction of the search window elapsed, 0..1 (0 when not searching).

engage forces engaged (target reacquired by another sense); reset returns to gaveUp with timers cleared.

FSM states (constants): TargetState.engaged (0), searching (1), gaveUp (2).

LoseTargetMode.distanceLeash (0) — contact lost the instant it drops; losBreakGrace (1) — tolerate a grace window (LOS flicker).

GiveUpMode.returnToSpawn (0) — path back to spawn; shambleHere (1) — wander near where the search ended.

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.

Returns: AggroTable.

Add amount aggro to id (accumulates; floored at 0).

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.

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.

Distance-band tactic selection.

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).

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: GateEffecteffect if the gate triggers (statValue is below/above threshold per gateWhen), else GateEffect.none.

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).

Gate trigger direction (constants): GateWhen.below (0) triggers when the stat is below the threshold; above (1) when above.

Gate effects (constants): none (0), forceFlee (1), forceState (2), blockMelee (3), blockRanged (4), blockSummon (5).

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).

Returns: TensionModel — with the default pacing config.

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.

Tension phases (constants): TensionPhase.rest (0), buildUp (1), peak (2), relax (3).

A detect-by-sound registry: world sounds fan out to registered listeners (enemies) within earshot.

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.

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.

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).

Returns: AlertMemory.

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; activeBool; anchor[x, y, z]; remaining → seconds left; clear deactivates it.

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.

Returns: PainMemory.

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; validBool; attacker → the attacker id, or -1 when none / expired; clear forgets it.