Behaviour-Tree Authoring
Author enemy / mini-boss / boss behaviour trees as data and load them at runtime. The
behaviour-tree runtime uses Wren closures for leaves — perfect for
hand-written AI, but a GUI can’t draw a closure. So the data path (this page, ADR 0068) adds named
leaves the editor lists and a .bt.toml references. It is additive — Bt.leaf { |bb, dt| } is
unchanged. See the BT editor.
As with combat and light/sound/vision: the engine runs the tree; your game owns the policy — HP, damage, and phase thresholds are all game Wren. A boss phase is just a blackboard int your game sets.
Named leaves
Section titled “Named leaves”Bt.registerLeaf(name, paramSpec) { |bb, dt, params| }
Section titled “Bt.registerLeaf(name, paramSpec) { |bb, dt, params| }”Register a leaf the editor lists and a .bt.toml references by name. paramSpec is a flat List
[name, type, …] (type ∈ "bool"/"int"/"number"/"string") declaring its typed params — the editor
inspector + the loader validate against it. The arity-3 action receives the blackboard, the tick delta,
and params (a Map of the values bound in the asset), and returns a BtStatus.
import "engine" for BtBt.registerLeaf("MoveToTarget", ["speed", "number"]) { |bb, dt, params| // move at params["speed"] … return BtStatus.success}Bt.leafCatalog
Section titled “Bt.leafCatalog”Returns: a List of per-leaf sublists [name, pname0, ptype0, …] — the registered names + schemas.
The editor palette is populated from this (the live game), not a hand-kept list.
Loading a tree
Section titled “Loading a tree”Bt.load(path)
Section titled “Bt.load(path)”Returns: an engine-ticked BehaviourTree built from a .bt.toml, or null
on a parse/validation failure (see Bt.loadError). Leaves resolve against the registry; a failed load
never disturbs an existing tree (atomic). The loader is hardened — it bounds node count + depth and rejects
cycles, duplicate/dangling ids, unknown leaves, and mistyped params.
Bt.loadError
Section titled “Bt.loadError”Returns: the last Bt.load diagnostic ("" if it succeeded).
The .bt.toml schema
Section titled “The .bt.toml schema”root = 0[[node]]id = 0kind = "selector" # sequence | selector | parallel | inverter | repeat | cooldown | succeeder | leaf | phase_switchchildren = [1, 2][[node]]id = 1kind = "leaf"leaf = "IsTargetVisible" # a registered leaf nameparams = { } # inline table of typed values[[node]]id = 2kind = "leaf"leaf = "MoveToTarget"params = { speed = 3.0 }parallel takes policy = "all" (Success iff all succeed; Failure if any fails) or "one" (Success if
any succeeds; Failure iff all fail). repeat takes count; cooldown takes seconds. phase_switch
takes a phase_key + cases = [{ phase = 0, child = 1 }, …].
Boss phases
Section titled “Boss phases”PhaseEvaluator.evaluate(currentPhase, health01, elapsedSeconds, thresholds)
Section titled “PhaseEvaluator.evaluate(currentPhase, health01, elapsedSeconds, thresholds)”Returns: the phase index for this tick. The game feeds a pushed-in health01 (0..1) + elapsed time and
gets a forward-only phase (never regresses on a heal; the opener has no trigger). thresholds is a flat
List [healthThresh0, timeThresh0, …] (a negative value disables that axis; index 0 is the opener). The
engine never reads an HP field — you write the result to a blackboard int a phase_switch node reads.
import "engine" for PhaseEvaluator_phase = PhaseEvaluator.evaluate(_phase, myHp01, 0, [-1, -1, 0.3, -1]) // enter phase 1 at ≤30% HPtree.blackboard().setInt("phase", _phase) // the phase_switch node routes to itA regular enemy is one root (no phases); a mini-boss is ~2 phase cases; a boss is 4+ — the same schema, loader, and editor, differing only in the data.