BehaviorTree
BehaviorTree is a behavior-tree helper with no internal scheduling — no runner, no per-tree loop, no agent registry. You build one tree per agent and tick it from your own loop. That is the same stance NPCKit takes for everything else: the kernel ships mechanisms, and when a brain thinks is a game decision. The payoff is that trees are plain tables, ticks are synchronous function calls, and everything is deterministic under spec with an injected clock.
Mental model
Section titled “Mental model”A tree is a nested table of nodes. Each tick, BehaviorTree.tick(root, blackboard, dt, clock?) walks the tree top-down and every visited node returns one of three statuses:
| Status | Meaning |
|---|---|
Success |
Finished. |
Failure |
Cannot run — condition false, no path, no target. |
Running |
In progress. Composites remember the running child and resume it. |
The statuses are exported as constants: BehaviorTree.Success, BehaviorTree.Failure, BehaviorTree.Running.
Running-resume memory is the whole point. A Sequence that reached its third child does not re-run children one and two next tick — it stores the running index in the node (_resume) and continues there. That memory lives in the node tables themselves, which drives the one hard rule of this module: build one tree per agent. Two npcs sharing a tree instance share plan state, and one npc’s progress teleports the other mid-plan.
The blackboard is any caller-defined value threaded into every Condition check and Action run. It carries no schema and the module never touches it — an npc handle, or a table wrapping one plus your own scratch (patrol index, last target), are the usual shapes.
Node types
Section titled “Node types”Everything below is exactly the module surface — there are no other node kinds, and an unknown Kind errors at tick.
Composites
Section titled “Composites”| Constructor | Semantics |
|---|---|
BehaviorTree.Selector(name?, children) |
First child returning Success or Running wins; Failure falls through to the next. A Running child is resumed directly next tick — earlier siblings are not re-scanned. Returns Failure when every child fails. |
BehaviorTree.ReactiveSelector(name?, children) |
Re-evaluates from the first child every tick. A higher-priority child returning non-Failure preempts a Running lower one and resets the preempted child’s memory. When everything fails, the previously running child is also reset. |
BehaviorTree.Sequence(name?, children) |
Children run in order; all must succeed. Any Failure fails the whole plan and clears its memory. Running pauses the plan at that child and resumes there next tick. |
The Selector / ReactiveSelector split matters for combat brains. A plain Selector commits: once “investigate the noise” is Running, nothing above it gets a look-in until it finishes or fails. A ReactiveSelector re-asks the higher priorities every tick, so “enemy appeared” interrupts “investigate” mid-plan — which is why combat brains root on it.
Leaves
Section titled “Leaves”| Constructor | Semantics |
|---|---|
BehaviorTree.Condition(name?, check) |
Instant boolean gate: check(blackboard) → boolean maps to Success/Failure. Never Running. |
BehaviorTree.Action(name?, run) |
Work leaf: run(blackboard, dt) → Status?. A nil return counts as Success, so fire-and-forget actions need no explicit return. |
Decorators
Section titled “Decorators”| Constructor | Semantics |
|---|---|
BehaviorTree.Invert(child) |
Flips Success ↔ Failure. Running passes through unchanged. |
BehaviorTree.Succeed(child) |
Any non-Running result becomes Success. Running passes through. |
BehaviorTree.Cooldown(seconds, child) |
Gates a subtree to once per interval. Returns Failure while cooling down. Only a child Success starts the cooldown — Failure and Running leave the window open, so a failing check retries next tick instead of locking itself out for the interval. |
Ticking
Section titled “Ticking”local Status = BehaviorTree.tick(Tree, Blackboard, Dt, Clock)dtis forwarded into everyAction— the module does not measure time between ticks for you.clockis an optional() -> numberthreaded intoCooldownnodes. Pass the NPCKit kit clock (Npcs.Clock) so cooldowns run on the same injectable time source as the rest of the fight and stay deterministic under spec. Without it,Cooldownreads a module-level clock that defaults toos.clock(swappable globally viaBehaviorTree._setClock(fn)— a spec convenience; prefer the per-tick parameter, which needs no global swap).
BehaviorTree.reset(node) recursively drops all resume memory below a node. Call it whenever the plan’s premise dies — the target changed, the tactic was aborted — because a Sequence resuming mid-plan against stale state is a bug: half a plan built against the old target is wrong against the new one. (reset clears _resume everywhere; Cooldown timers are not cleared.)
A complete brain: patrol → chase → attack
Section titled “A complete brain: patrol → chase → attack”One tree per wolf, the npc handle rides the blackboard, and the loop resets the tree on target change. Paste into a Bootstrap:
local ServerScriptService = game:GetService("ServerScriptService")local ServerStorage = game:GetService("ServerStorage")
local Root = ServerScriptService.ChloeKernelServerlocal BehaviorTree = require(Root.Kits.BehaviorTree)local NPCKit = require(Root.Kits.NPCKit)
return function(kernel: any) local Npcs = NPCKit.attach(kernel, { Seed = 7 })
Npcs:define("Wolf", { Model = ServerStorage.NPC.Wolf, -- a Humanoid rig; movement rides Humanoid:MoveTo Difficulty = "Novice", Moveset = { Bite = { Kind = "Melee", Range = 6, Damage = 15, Cooldown = 1, }, }, })
local Sequence = BehaviorTree.Sequence local Condition = BehaviorTree.Condition local Action = BehaviorTree.Action
local function buildBrain() -- ReactiveSelector root: a target appearing preempts patrol mid-plan return BehaviorTree.ReactiveSelector("Brain", { Sequence("Attack", { Condition("TargetInReach", function(Board) local Npc = Board.Npc local Position = Npc:targetPosition(Npc.Target) return Position ~= nil and (Position - Npc:position()).Magnitude <= 8 end), Action("Bite", function(Board) local Npc = Board.Npc local Position = Npc:targetPosition(Npc.Target) if Position then Npc:faceTowards(Position) end if Npc:canReact() then Npc:act("Bite", Npc.Target) end return BehaviorTree.Success end), }), Sequence("Chase", { Condition("HasTarget", function(Board) return Board.Npc.Target ~= nil end), Action("Close", function(Board) local Npc = Board.Npc local Position = Npc:targetPosition(Npc.Target) if Position == nil then return BehaviorTree.Failure end Npc:moveTowards(Position) return BehaviorTree.Running -- still closing; resume here next tick end), }), Action("Patrol", function(Board) local Npc = Board.Npc local Goal = Board.Route[Board.RouteIndex] if (Npc:position() - Goal).Magnitude < 4 then Board.RouteIndex = Board.RouteIndex % #Board.Route + 1 return BehaviorTree.Success -- leg done; next tick starts the next leg end Npc:moveTowards(Goal) return BehaviorTree.Running end), }) end
local Boards = {}
local function spawnWolf(at: Vector3) local Npc = Npcs:spawn("Wolf", at) Boards[Npc.Id] = { Npc = Npc, Tree = buildBrain(), -- one tree per npc: resume memory is per-instance Route = { Vector3.new(0, 0, 0), Vector3.new(40, 0, 0), Vector3.new(40, 0, 40) }, RouteIndex = 1, LastTarget = nil, } end
kernel.Bus:subscribe("Npc.Died", function(_, npc) Boards[npc.Id] = nil end)
spawnWolf(Vector3.new(0, 5, 0)) spawnWolf(Vector3.new(20, 5, 0))
kernel.Scheduler:every(0.15, function() Npcs:update() for _, Npc in Npcs:all() do local Board = Boards[Npc.Id] if Board == nil then continue end Npc:updateTarget(50, 80) if Npc.Target ~= Board.LastTarget then -- Half a plan against the old target is a bug against the new one BehaviorTree.reset(Board.Tree) Board.LastTarget = Npc.Target end BehaviorTree.tick(Board.Tree, Board, 0.15, Npcs.Clock) end end, kernel.Priority.Low)endWalk the behavior through the semantics: with no target, Attack and Chase fail their conditions and Patrol runs (Running leg by leg). When updateTarget acquires, the ReactiveSelector re-evaluates from the top on the very next tick — Chase preempts the Running patrol, whose resume memory resets. When the wolf closes inside 8 studs, Attack preempts Chase the same way. If the target dies or drops, both upper branches fail again and patrol restarts from its top — which is exactly what the loop’s reset-on-target-change guarantees for stale in-branch state too.
How NPCKit runs trees
Section titled “How NPCKit runs trees”It doesn’t — and that is the design. NPCKit has no tree slot, no tree tick, and no opinion about your strategic layer. The integration contract is three lines of convention:
- Build one tree per npc at spawn; drop it on
Npc.Died. - Tick from the same loop that calls
kit:update(), passing the kit clock:BehaviorTree.tick(Tree, Board, Dt, Npcs.Clock). reseton target change.
Everything the tree does is the NPCKit helper vocabulary — updateTarget, canSee, act, defend, moveTowards, kit:findCover, squad:role — so the tree stays pure decision structure while the kit stays pure mechanism.
Authoring guidance
Section titled “Authoring guidance”- Root combat brains on
ReactiveSelector. Higher-priority branches (“under fire”, “enemy visible”) must preempt lowerRunningplans (“investigate”, “patrol”). Use plainSelectoronly where committing to a started plan is correct — one-shot sequences you never want interrupted. - Conditions gate, actions work. A
Conditionruns every time its composite scans it; keep checks cheap (field reads, distance compares). Expensive queries (kit:findCover) belong in anActionthat caches its result on the blackboard. - Return
Runningfrom anything that takes time. An action that issuesmoveTowardsand returnsSuccessimmediately tells itsSequenceto advance — the npc starts the next step while still walking.Runningis how the tree waits. - Place
Cooldownabove the whole attempt, not just the effect. Since onlySuccessarms the timer, wrappingCooldownaround aSequenceof check-then-act gives “retry until it works, then hold off” for free — the spec-verified pattern. - Name the nodes you’ll debug.
nameis optional metadata on every constructor; it costs nothing and turns a tree dump into a readable plan.
API reference
Section titled “API reference”| Member | Description |
|---|---|
BehaviorTree.Selector(name?, children) → Node |
First non-failing child wins; resumes a Running child directly. |
BehaviorTree.ReactiveSelector(name?, children) → Node |
Re-evaluates from the top every tick; preempts and resets lower Running children. |
BehaviorTree.Sequence(name?, children) → Node |
In order, all must succeed; pauses at a Running child. |
BehaviorTree.Condition(name?, check) → Node |
check(blackboard) → boolean as Success/Failure. |
BehaviorTree.Action(name?, run) → Node |
run(blackboard, dt) → Status?; nil counts as Success. |
BehaviorTree.Invert(child) → Node |
Flips Success/Failure; Running passes. |
BehaviorTree.Succeed(child) → Node |
Everything non-Running becomes Success. |
BehaviorTree.Cooldown(seconds, child) → Node |
Failure while cooling; only child Success starts the timer. |
BehaviorTree.tick(node, blackboard, dt, clock?) → Status |
One synchronous walk. clock feeds Cooldown nodes. |
BehaviorTree.reset(node) |
Recursively drop resume memory (target died, tactic aborted). |
BehaviorTree.Success / .Failure / .Running |
Status constants. |
BehaviorTree._setClock(fn) |
Global default-clock swap for specs; prefer the tick parameter. |
BehaviorTree publishes no bus topics and defines no hook points — it is a pure data-structure module with no kernel dependencies.