Skip to content

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

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 Bt
Bt.registerLeaf("MoveToTarget", ["speed", "number"]) { |bb, dt, params|
// move at params["speed"] …
return BtStatus.success
}

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.

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.

Returns: the last Bt.load diagnostic ("" if it succeeded).

root = 0
[[node]]
id = 0
kind = "selector" # sequence | selector | parallel | inverter | repeat | cooldown | succeeder | leaf | phase_switch
children = [1, 2]
[[node]]
id = 1
kind = "leaf"
leaf = "IsTargetVisible" # a registered leaf name
params = { } # inline table of typed values
[[node]]
id = 2
kind = "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 }, …].

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% HP
tree.blackboard().setInt("phase", _phase) // the phase_switch node routes to it

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