Skip to content

AI Hunters

App: apps/ai_hunters/

The AI Foundation full loop (engine PLM-189 / ADR 0065). Four hunters run a complete enemy-AI loop authored as a behaviour tree in Wren — the engine ticks each tree at AI cadence, with no per-frame plumbing:

perceive (sight: range + field-of-view + masked-raycast line-of-sight; and sound: the noise bus) → chase the target around a central obstacle via the navmesh, taking a distinct surround-ring slot and picking a tactic by range → when the target escapes and goes silent, lose it → search its last-known point → give up and return to spawn.

It wires the whole scriptable-AI surface: BehaviourTree / Bt / Blackboard, Ai.nearestVisibleTarget, LostTargetTracker, NoiseBus / AlertMemory, Tactic, Nav, and SurroundRing.

Terminal window
% ./plume3d ai_hunters

The demo self-screenshots the encircle, search, and give-up frames, then exits.

Each hunter builds its tree once, in Hunter.new. The game never ticks it — the engine drives every registered tree each frame (Ai.tickInterval = 0 here, for smooth motion). Leaves are closures over self; they return a BtStatus.

_tree = BehaviourTree.new()
_tree.setRoot(Bt.selector([
Bt.sequence([
Bt.leaf { |bb, dt| perceive_(bb, dt) }, // updates the FSM; success only while Engaged
Bt.leaf { |bb, dt| chase_(bb, dt) } // navmesh-chase to the ring slot + pick a tactic
]),
Bt.sequence([
Bt.leaf { |bb, dt| searching_(bb, dt) }, // success only while Searching
Bt.leaf { |bb, dt| search_(bb, dt) } // head for the last-known point
]),
Bt.leaf { |bb, dt| giveUp_(bb, dt) } // return to spawn
]))

Ai.nearestVisibleTarget does range + field-of-view + line-of-sight + nearest in one native call; its LOS is a masked raycast against the obstacle’s static physics body (so a target behind the block is unseen). Noise comes from the bus. The result feeds the lost-target FSM, and the last-known point tracks the last real perception:

perceive_(bb, dt) {
var seen = Ai.nearestVisibleTarget(_scene, _x, 0.5, _z, _facingX, 0, _facingZ,
35.0, 220.0, [0, _target[0], 0.5, _target[2]], _mask) >= 0
var heard = _alert.active()
if (seen) {
bb.setNumber("lkx", _target[0]); bb.setNumber("lkz", _target[2])
} else if (heard) {
var a = _alert.anchor(); bb.setNumber("lkx", a[0]); bb.setNumber("lkz", a[2])
}
var st = _fsm.tick(dt, seen || heard) // LostTargetTracker → TargetState
bb.setInt("state", st)
return st == TargetState.engaged ? BtStatus.success : BtStatus.failure
}

The obstacle is a static physics body on an obstruction layer, so the LOS raycast has something to hit:

Physics.addStaticBox(_scene, _obstNode, 3, 1.5, 3)
Physics.setCollisionLayer(_obstNode, _mask)

The world sends footstep noise through the bus each few ticks; every hunter that hears it raises its AlertMemory:

var heard = _noise.emit(_target[0], 0.5, _target[2], 6.0) // -> ids that heard it
for (id in heard) _hunters[id].hear(_target[0], _target[2])

The engaged branch claims a distinct SurroundRing slot, navmesh-paths to it (routing around the obstacle), and records a tactic for the current range:

var slot = _ring.assign(_id, 1.0, 1.0, true, _id * 90.0) // [angleDeg, radius, standby]
// ... convert (angleDeg, radius) to a world point around the target, then Nav.findPath to it ...
bb.setInt("tactic", Tactic.select([2, Tactic.melee, 6, Tactic.strafe, 30, Tactic.kite],
dist, Tactic.approach))

When the target flees far away and goes silent, sight and sound both drop; the LostTargetTracker moves engaged → searching (the hunters head for the last-known point) and, after the search window, → gaveUp (they return to spawn). The hunters are coloured by FSM state — their own colour while engaged, amber while searching, dim blue once they give up — so the three screenshots read as the loop’s three phases. Placement is a seeded, navmesh-snapped scatter, so the run is deterministic.