BehaviorTree
A behavior tree helper with Running-resume memory, reactive preemption and an injectable clock, built once per agent and ticked from your own loop.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Kits .BehaviorTree - Since
- v0.3.0
- Source
- Kits/BehaviorTree.luau
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. Trees are plain tables, a tick is a synchronous function call, and everything is deterministic under specs with an injected clock. It is neither an attach kit nor a service kit: require it and call its functions.
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 from the top, and every visited node returns one of three statuses:
| Status | Meaning |
|---|---|
Success |
Finished. |
Failure |
Cannot run: a condition is false, there is no path, there is no target. |
Running |
In progress. Composites remember the running child and resume it. |
The statuses are exported as BehaviorTree.Success, BehaviorTree.Failure and BehaviorTree.Running.
Running-resume memory is what the module is for. A Sequence that reached its third child does not re-run the first two next tick: it stores the running index in the node (_resume) and continues there. That memory lives in the node tables themselves, which gives the one hard rule: build one tree per agent. Two NPCs sharing a tree share plan state, and one NPC’s progress moves the other mid-plan.
The blackboard is any value you choose, passed to every Condition check and Action run. The module never reads or writes it. An NPC handle, or a table that wraps one with your own scratch (patrol index, last target), are the usual shapes.
Node types
Section titled “Node types”This is the whole 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) |
The first child that returns Success or Running wins. Failure moves on to the next child. A Running child is resumed directly next tick, and the siblings before it are not checked again. Returns Failure when every child fails. |
BehaviorTree.ReactiveSelector(name?, children) |
Evaluates from the first child every tick. A higher-priority child that returns anything but Failure preempts a Running lower one and resets the preempted child’s memory. When every child fails, the previously running child is reset too. |
BehaviorTree.Sequence(name?, children) |
Children run in order and 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 difference between the two selectors matters for combat brains. A plain Selector commits: once “investigate the noise” is Running, nothing above it runs until it finishes or fails. A ReactiveSelector asks the higher priorities again every tick, so “enemy appeared” interrupts “investigate” mid-plan. Combat brains usually root on it.
Leaves
Section titled “Leaves”| Constructor | Semantics |
|---|---|
BehaviorTree.Condition(name?, check) |
Instant boolean gate: check(blackboard) -> boolean maps to Success or Failure. Never Running. |
BehaviorTree.Action(name?, run) |
Work leaf: run(blackboard, dt) -> Status?. A nil return counts as Success, so an action that finishes at once needs no explicit return. |
Decorators
Section titled “Decorators”| Constructor | Semantics |
|---|---|
BehaviorTree.Invert(child) |
Swaps Success and Failure. Running passes through. |
BehaviorTree.Succeed(child) |
Any result other than Running becomes Success. |
BehaviorTree.Cooldown(seconds, child) |
Runs the subtree at most 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 passed to everyAction. The module does not measure time between ticks.clockis an optional() -> numberpassed down toCooldownnodes. 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 specs. Without it,Cooldownreads a module-level clock that defaults toos.clock.BehaviorTree._setClock(fn)swaps that default for every tree, which is a spec convenience; the per-tick parameter needs no global swap.
BehaviorTree.reset(node) drops all resume memory below a node. Call it whenever the plan’s premise is gone (the target changed, the tactic was aborted): half a plan built against the old target is wrong against the new one. reset clears _resume everywhere. It does not clear Cooldown timers.
A complete brain: patrol, chase, attack
Section titled “A complete brain: patrol, chase, attack”One tree per wolf, a blackboard that wraps the NPC handle, and a tick that resets the tree when the target changes:
--!strict-- WolfService: wolves that patrol a route, chase what they notice, and bite in reach.
local ServerScriptService = game:GetService("ServerScriptService")local ServerStorage = game:GetService("ServerStorage")
local BehaviorTree = require(ServerScriptService.ChloeKernelServer.Kits.BehaviorTree)local NPCKit = require(ServerScriptService.ChloeKernelServer.Kits.NPCKit)
local Sequence = BehaviorTree.Sequencelocal Condition = BehaviorTree.Conditionlocal Action = BehaviorTree.Action
local TickSeconds = 0.15
local function buildBrain() -- ReactiveSelector root: a target appearing preempts the patrol mid-leg return BehaviorTree.ReactiveSelector("Brain", { Sequence("Attack", { Condition("TargetInReach", function(board: any): boolean local Npc = board.Npc local Position = Npc:targetPosition(Npc.Target) return Position ~= nil and (Position - Npc:position()).Magnitude <= 8 end), Action("Bite", function(board: any) 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: any): boolean return board.Npc.Target ~= nil end), Action("Close", function(board: any) 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: any) 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; the next tick starts the next leg end Npc:moveTowards(Goal) return BehaviorTree.Running end), })end
local WolfService = { Name = "WolfService",}
function WolfService.init(self: any, kernel: any) self.Kernel = kernel self.Boards = {} self.Npcs = NPCKit.attach(kernel, { Seed = 7 }) self.Npcs:define("Wolf", { Model = ServerStorage.NPC.Wolf, -- a Humanoid rig; movement uses Humanoid:MoveTo Difficulty = "Novice", Moveset = { Bite = { Kind = "Melee", Range = 6, Damage = 15, Cooldown = 1 }, }, })end
function WolfService.start(self: any) local Npcs = self.Npcs for _, At in { Vector3.new(0, 5, 0), Vector3.new(20, 5, 0) } do local Npc = Npcs:spawn("Wolf", At) self.Boards[Npc.Id] = { Npc = Npc, Tree = buildBrain(), -- one tree per NPC: resume memory lives in the nodes Route = { Vector3.new(0, 0, 0), Vector3.new(40, 0, 0), Vector3.new(40, 0, 40) }, RouteIndex = 1, LastTarget = nil, } end self.DiedConnection = self.Kernel.Bus:subscribe("Npc.Died", function(_topic: string, npc: any) self.Boards[npc.Id] = nil end) self.Think = self.Kernel.Scheduler:every(TickSeconds, function() Npcs:update() for _, Npc in Npcs:all() do local Board = self.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 wrong against the new one BehaviorTree.reset(Board.Tree) Board.LastTarget = Npc.Target end BehaviorTree.tick(Board.Tree, Board, TickSeconds, Npcs.Clock) end end, self.Kernel.Priority.Low)end
function WolfService.stop(self: any) if self.Think then self.Think:cancel() end if self.DiedConnection then self.DiedConnection:disconnect() end self.Npcs:destroy()end
return WolfServiceWith no target, Attack and Chase fail their conditions and Patrol runs, Running leg by leg. When updateTarget acquires, the ReactiveSelector evaluates from the top on the next tick: Chase preempts the running patrol and its 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 and the patrol runs again from its top. The reset on target change handles stale state inside a branch.
How NPCKit runs trees
Section titled “How NPCKit runs trees”It does not. NPCKit has no tree slot, no tree tick and no opinion about your decision layer. The integration is three conventions:
- Build one tree per NPC at spawn and 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). - Call
resetwhen the target changes.
Everything the tree does uses the NPCKit helpers (updateTarget, canSee, act, defend, moveTowards, kit:findCover, squad:role), so the tree stays decision structure and the kit stays 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 a plainSelectoronly where committing to a started plan is correct. - Conditions gate and actions work. A
Conditionruns every time its composite reaches it, so 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 callsmoveTowardsand returnsSuccessat once tells itsSequenceto advance, and the NPC starts the next step while still walking. - Put
Cooldownabove the whole attempt, not only the effect. Since onlySuccessarms the timer, aCooldownaround aSequenceof check-then-act retries until the attempt works, then holds off. The specs verify this pattern. - Name the nodes you will debug.
nameis optional on every constructor, costs nothing, and makes a tree dump readable.
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 |
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 or Failure. |
BehaviorTree.Action(name?, run) -> Node |
run(blackboard, dt) -> Status?. nil counts as Success. |
BehaviorTree.Invert(child) -> Node |
Swaps Success and Failure. Running passes. |
BehaviorTree.Succeed(child) -> Node |
Everything but Running becomes Success. |
BehaviorTree.Cooldown(seconds, child) -> Node |
Failure while cooling. Only a child Success starts the timer. |
BehaviorTree.tick(node, blackboard, dt, clock?) -> Status |
One synchronous walk. clock feeds Cooldown nodes. |
BehaviorTree.reset(node) |
Drops resume memory below the node. Cooldown timers stay. |
BehaviorTree.Success, BehaviorTree.Failure, BehaviorTree.Running |
Status constants. |
BehaviorTree._setClock(fn) |
Swaps the module-level default clock, for specs. Prefer the tick parameter. |
BehaviorTree publishes no Bus topics and defines no hook points. It has no kernel dependency.