Skip to content

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.

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.

Everything below is exactly the module surface — there are no other node kinds, and an unknown Kind errors at tick.

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.

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.
Constructor Semantics
BehaviorTree.Invert(child) Flips SuccessFailure. 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 cooldownFailure and Running leave the window open, so a failing check retries next tick instead of locking itself out for the interval.
local Status = BehaviorTree.tick(Tree, Blackboard, Dt, Clock)
  • dt is forwarded into every Action — the module does not measure time between ticks for you.
  • clock is an optional () -> number threaded into Cooldown nodes. 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, Cooldown reads a module-level clock that defaults to os.clock (swappable globally via BehaviorTree._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.ChloeKernelServer
local 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)
end

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

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:

  1. Build one tree per npc at spawn; drop it on Npc.Died.
  2. Tick from the same loop that calls kit:update(), passing the kit clock: BehaviorTree.tick(Tree, Board, Dt, Npcs.Clock).
  3. reset on 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.

  • Root combat brains on ReactiveSelector. Higher-priority branches (“under fire”, “enemy visible”) must preempt lower Running plans (“investigate”, “patrol”). Use plain Selector only where committing to a started plan is correct — one-shot sequences you never want interrupted.
  • Conditions gate, actions work. A Condition runs every time its composite scans it; keep checks cheap (field reads, distance compares). Expensive queries (kit:findCover) belong in an Action that caches its result on the blackboard.
  • Return Running from anything that takes time. An action that issues moveTowards and returns Success immediately tells its Sequence to advance — the npc starts the next step while still walking. Running is how the tree waits.
  • Place Cooldown above the whole attempt, not just the effect. Since only Success arms the timer, wrapping Cooldown around a Sequence of check-then-act gives “retry until it works, then hold off” for free — the spec-verified pattern.
  • Name the nodes you’ll debug. name is optional metadata on every constructor; it costs nothing and turns a tree dump into a readable plan.
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.