Skip to content

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.

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.

This is the whole module surface. There are no other node kinds, and an unknown Kind errors at tick.

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.

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.
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.
local Status = BehaviorTree.tick(Tree, Blackboard, Dt, Clock)
  • dt is passed to every Action. The module does not measure time between ticks.
  • clock is an optional () -> number passed down to 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 specs. Without it, Cooldown reads a module-level clock that defaults to os.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.

One tree per wolf, a blackboard that wraps the NPC handle, and a tick that resets the tree when the target changes:

src/Server/Game/Services/WolfService.luau
--!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.Sequence
local Condition = BehaviorTree.Condition
local 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 WolfService

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

It does not. NPCKit has no tree slot, no tree tick and no opinion about your decision layer. The integration is three conventions:

  1. Build one tree per NPC at spawn and 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. Call reset when 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.

  • Root combat brains on ReactiveSelector. Higher-priority branches (“under fire”, “enemy visible”) must preempt lower Running plans (“investigate”, “patrol”). Use a plain Selector only where committing to a started plan is correct.
  • Conditions gate and actions work. A Condition runs every time its composite reaches it, so 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 calls moveTowards and returns Success at once tells its Sequence to advance, and the NPC starts the next step while still walking.
  • Put Cooldown above the whole attempt, not only the effect. Since only Success arms the timer, a Cooldown around a Sequence of check-then-act retries until the attempt works, then holds off. The specs verify this pattern.
  • Name the nodes you will debug. name is optional on every constructor, costs nothing, and makes a tree dump readable.
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.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.