thefnox/btree
A behavior tree library
BTree
A behavior tree library for Roblox, written in Luau.
Features
- Fully tested
- Flat array-based runtime — no nested table allocations during ticks
- Full Luau strict-mode types
- Built-in parameter validation for task modules
- Subtree inlining — subtrees are merged into the parent flat array at build time
- Pause/resume support
- Debug snapshots with per-node status, compatible with the BTree Studio plugin
Installation
Install via pesde:
pesde add thefnox/btree
Then require it in your code:
local BT = require("@Packages/btree")
Usage
Defining a tree
Trees are built from a nested definition using builder functions. The definition is a plain Luau table — it can be stored in a ModuleScript and required like any other module.
-- ServerStorage/AI/PatrolTree.lua
local BT = require(game.ReplicatedStorage.BehaviorTree)
local MoveToPoint = require(script.Parent.Tasks.MoveToPoint)
local IsAlerted = require(script.Parent.Tasks.IsAlerted)
local ChaseTarget = require(script.Parent.Tasks.ChaseTarget)
return BT.selector({
BT.sequence({
BT.condition(function(bb) return bb.alerted end),
BT.task(ChaseTarget),
}),
BT.task(MoveToPoint, { speed = 8 }),
})
Creating and running a tree
local BT = require(game.ReplicatedStorage.BehaviorTree)
local definition = require(game.ServerStorage.AI.PatrolTree)
local blackboard = { alerted = false, target = nil }
local tree = BT.new(definition, blackboard)
-- In a RunService loop or heartbeat:
RunService.Heartbeat:Connect(function()
tree:update()
end)
update() ticks the tree once and returns the root status:
local status = tree:update()
if status == BT.SUCCESS then ... end
if status == BT.FAILURE then ... end
if status == BT.RUNNING then ... end
Writing a task module
A task is a ModuleScript that returns a BTreeTask table. All callbacks are optional.
-- Tasks/MoveToPoint.lua
local BT = require(game.ReplicatedStorage.BehaviorTree)
return {
params = {
speed = "number",
},
onStart = function(bb, params)
bb.agent:MoveTo(bb.targetPosition)
end,
run = function(bb, params): BT.Status?
if bb.agent.MoveToFinished:Wait() then
return BT.SUCCESS
end
return BT.FAILURE
end,
onExit = function(bb, params)
bb.agent:MoveTo(bb.agent.HumanoidRootPart.Position) -- cancel
end,
} :: BT.Task
| Callback | When it fires |
|---|---|
onEnter | First tick this node is reached after not being reached last tick |
onExit | When a previously reached task is no longer active, including tree:stop() |
onStart | When a fresh execution of the task begins |
onEnd | When the task exits with SUCCESS or FAILURE |
run | Every tick while active — return RUNNING to stay active, FAILURE to fail, or nothing / SUCCESS to succeed |
Subtrees
Use BT.subtree to compose a definition from another ModuleScript. Subtree nodes are inlined into the parent's flat array at build time — there is no runtime indirection.
local PatrolLoop = require(script.Parent.PatrolLoop)
return BT.sequence({
BT.subtree(PatrolLoop),
BT.task(ReturnToBase),
})
Node reference
Composites
| Function | Behaviour |
|---|---|
BT.sequence(children, meta?) | Ticks children left to right. Fails on first FAILURE. Succeeds when all succeed. |
BT.selector(children, meta?) | Ticks children left to right. Succeeds on first SUCCESS. Fails when all fail. |
BT.parallel(children, successPolicy?, failurePolicy?, meta?) | Ticks all children every frame. Policies are "requireAll" (default) or "requireOne". |
BT.randomSelector(children, weights?, meta?) | Picks one child at random each activation. weights must be the same length as children if provided. |
Decorators
| Function | Behaviour |
|---|---|
BT.invert(child, meta?) | Flips SUCCESS ↔ FAILURE. RUNNING passes through. |
BT.alwaysSucceed(child?, meta?) | Returns SUCCESS regardless of child result. |
BT.alwaysFail(child?, meta?) | Returns FAILURE regardless of child result. |
BT.repeatNode(child, times, meta?) | Repeats child on SUCCESS. Pass -1 for infinite. |
BT.retryNode(child, times, meta?) | Retries child on FAILURE. Pass -1 for infinite. |
Leaves
| Function | Behaviour |
|---|---|
BT.task(module, params?, meta?) | Runs a task module. |
BT.condition(check, meta?) | Calls check(blackboard) — returns SUCCESS if true, FAILURE if false. |
BT.subtree(module, meta?) | Inlines another definition tree. |
Tree API
tree:update() -- tick once, returns (Status, DebugSnapshot?)
tree:reset() -- rewind runtime state to the root without firing interruption callbacks
tree:stop() -- fire onExit for active tasks, rewind, and resume so the next update starts at the root
tree:pause() -- suspend ticking
tree:resume() -- resume ticking
tree:isPaused() -- returns boolean
Debug mode
Pass a third argument to BT.new to enable debug mode. This makes update() return a DebugSnapshot as a second value and fires a BTDebugSnapshot BindableEvent that the BTree Studio plugin listens to.
-- Pass true to enable (definition path defaults to "")
local tree = BT.new(definition, blackboard, true)
-- Pass the script path so the plugin can locate the definition source
local tree = BT.new(definition, blackboard, script:GetFullName())
The DebugSnapshot contains:
type DebugSnapshot = {
tick: number, -- monotonically increasing tick counter
paused: boolean,
nodeStates: { [number]: Status }, -- keyed by 1-based DFS index
}
Debug mode adds no overhead when disabled (false / nil).
Parameter validation
When a task module declares a params schema, BT.task() validates the values passed at definition time. Supported type strings:
Omitted or nil task params are normalized to an empty table before being stored on the task definition and passed to task hooks.
- Lua primitives:
"number","string","boolean" - Roblox types:
"Vector2","Vector3", any Roblox Instance class name
BT.task(MyTask, { speed = 14, target = workspace.Boss })
NodeMeta
Every builder function accepts an optional meta table as its last argument. This is used by the visual editor and has no effect at runtime.
type NodeMeta = {
label: string?,
size: Vector2?,
position: Vector2?,
}
Remote debugging
In addition to the in-process BTDebugSnapshot BindableEvent, the server exposes four buffer-based RemoteEvents so clients can observe any tree created with debugging enabled. The RemoteEvents are lazily parented under the library script the first time a debug-enabled tree is registered, so non-debug builds carry no remote-event overhead.
| RemoteEvent | Direction | Payload |
|---|---|---|
DebugTreeList | Client ↔ Server | Client fires an empty buffer; server replies with u16 count followed by {u32 id, u32 executionCount, len-prefixed debugName, len-prefixed definitionPath} per tree. |
DebugTreeDefinition | Client ↔ Server | Client fires u32 treeId; server replies with u32 treeId (0 if unknown, and no further payload) followed by the encoded tree-definition packet (see below). Tree definitions don't replicate through normal Roblox replication, so this is the only way for a client to learn the tree's structure. |
DebugSubscribe | Client → Server | Buffer: u32 treeId, u8 subscribe (1 to start, 0 to stop). |
DebugSnapshot | Server → Client | Buffer: u8 kind (0=full, 1=delta), u32 treeId, u32 tick, u8 paused, node-state entries, then blackboard entries. |
The first snapshot a subscriber receives is a full packet containing every node state and the complete serialized blackboard. Each subsequent packet is a delta containing only the node states that changed and the blackboard keys that were set or removed.
Tree-definition packet format. To avoid hardcoding a shared type enum on both server and client, every tree-definition packet starts with a self-describing type enum. The layout is:
u32 treeId
u8 typeEnumCount
repeat typeEnumCount times: len-prefixed string -- e.g. "task", "sequence", ...
u32 nodeCount
repeat nodeCount times:
u8 typeEnumIndex -- 0-based into the header above
u16 childCount -- composite children (DFS indices)
repeat childCount times: u32 childIndex
u32 singleChild -- decorator/subtree child, 0 if none
u8 hasLabel; if 1: len-prefixed string
u8 hasSize; if 1: f32 x, f32 y
u8 hasPosition; if 1: f32 x, f32 y
-- type-specific payload:
-- task : hasName (u8), optional len-prefixed name, u32 paramCount,
-- repeat: len-prefixed key + value (tagged blackboard value)
-- parallel : len-prefixed successPolicy, len-prefixed failurePolicy
-- repeat / retry : i32 times (-1 = infinite)
-- randomSelector : hasWeights (u8), optional u32 count + f32 weights
-- other types : no extra payload
DFS node indices match the native library's buildFlatTree ordering, so they line up 1:1 with the nodeIndex keys used in DebugSnapshot packets.
Decoder helpers are available on the debugNetwork submodule:
local debugNetwork = require(path.to.BehaviorTree.debugNetwork)
-- Client-side
local remotes = debugNetwork.waitForRemotes()
-- List all active debug trees.
remotes.treeList.OnClientEvent:Connect(function(buf)
for _, entry in debugNetwork.decodeTreeList(buf) do
print(entry.id, entry.debugName, entry.executionCount)
end
end)
remotes.treeList:FireServer()
-- Fetch the static structure of a tree so the client can render node graphs.
remotes.treeDefinition.OnClientEvent:Connect(function(buf)
local packet = debugNetwork.decodeTreeDefinition(buf)
for i, node in packet.nodes do
-- node.type, node.children, node.singleChild, node.label,
-- node.taskName, node.taskParams, node.successPolicy, etc.
end
end)
remotes.treeDefinition:FireServer(debugNetwork.encodeTreeDefinitionRequest(treeId))
-- Stream snapshot updates.
remotes.snapshot.OnClientEvent:Connect(function(buf)
local packet = debugNetwork.decodeSnapshot(buf)
-- packet.kind == "full" | "delta"
-- packet.nodeStates, packet.blackboardSet, packet.blackboardRemoved
end)
remotes.subscribe:FireServer(debugNetwork.encodeSubscribe(treeId, true))
License
MIT