Forest Logo
search
package_2

arbor

By @axp3cter

Roblox

Mirrored

Arbor

Composable, typed behavior trees for Roblox AI.

Releases · API Reference

Install

Wally (Luau)

[dependencies]
arbor = "axp3cter/arbor@2.1.0"

npm (roblox-ts)

npm install @axpecter/arbor

Direct download

Grab the latest .rbxm from Releases.

Quick Start

local bt = require(path.to.bt)

local board = { target = nil :: Player?, health = 100 }
local npc = script.Parent

local root = bt.select {
    bt.sequence {
        bt.check(function(b) return b.health < 30 end),
        bt.action(function(_b, agent)
            agent:runAway()
            return "running"
        end),
    },
    bt.sequence {
        bt.check(function(b) return b.target ~= nil end),
        bt.action(function(_b, agent)
            agent:attack()
            return "success"
        end),
    },
    bt.action(function(_b, agent)
        agent:patrol()
        return "running"
    end),
}

local ctx = bt.run(root, board, npc, 10)

npc.Destroying:Once(function()
    ctx:destroy()
end)

Concepts

Status

Every node returns one of three statuses each tick:

StatusMeaning
"success"Done, it worked.
"failure"Done, it did not work.
"running"Still in progress. Tick again next frame.

These are the only three values in the system. Composites and decorators make decisions based on which status their children return.

Board

The board is a shared data table that every node callback receives as its first argument. Put anything the AI needs to reason about on here: targets, health, flags, positions. You define the shape.

local board = {
    target    = nil :: Player?,
    health    = 100,
    canSee    = false,
    allies    = 0,
    lastHeard = nil :: Vector3?,
}

Conditions only receive (board). Actions receive (board, agent, dt). If you need agent state in a condition, write it to the board first (a poll is the natural place for that).

Conditions

Conditions read the board and return "success" or "failure". They never return "running". Use them as gates in sequences and selectors.

local hasTarget = bt.check(function(b) return b.target ~= nil end)
local isHurt    = bt.check(function(b) return b.health < 30 end)

Actions

Actions are where the NPC does work. Two forms exist depending on whether the work is instant or spans multiple frames.

Function form runs every tick the action is active. No state is tracked internally. The handler receives (board, agent, dt) and must return a status.

local attack = bt.action(function(_b, agent)
    agent:swingWeapon()
    return "success"
end)

Table form is for work that spans multiple frames. Three optional hooks:

  • enter(board, agent, dt) runs once on the first tick after activation. If it returns "running", the action enters the tick phase. If it returns "success" or "failure", the action completes immediately without ever calling tick.
  • tick(board, agent, dt) runs every subsequent tick while the action is "running". Return "success" or "failure" to complete.
  • halt(board, agent) runs if the action is interrupted while "running" (a parent halts it). Not called on normal completion. Does not receive dt.

At least one of enter or tick must be provided. If enter is omitted, the action skips straight to tick on its first frame. If tick is omitted and enter returns "running", the action stays "running" indefinitely until halted externally. This is a valid fire-and-forget pattern (start an animation, keep running until the branch switches).

local chase = bt.action({
    enter = function(b, agent)
        agent:pathTo(b.target)
        return "running"
    end,
    tick = function(_b, agent)
        return if agent:reachedTarget() then "success" else "running"
    end,
    halt = function(_b, agent)
        agent:stopMoving()
    end,
})

Composites

Composites combine multiple nodes into control flow.

bt.select runs children left to right and succeeds on the first child that succeeds. Re-evaluates from child 1 every tick. This means higher-priority branches automatically take over when their conditions become true. If a lower-priority child was "running", it gets halted. Returns "failure" only if every child fails.

bt.select {
    bt.sequence { isHurt, flee },       -- priority 1
    bt.sequence { hasTarget, attack },  -- priority 2
    patrol,                             -- priority 3
}

bt.sequence runs children left to right and fails on the first child that fails. Unlike select, sequence uses sticky resume. It remembers which child was "running" and picks up there next tick. Earlier children that already succeeded are not re-evaluated. Returns "success" only if every child succeeds.

bt.sequence {
    hasTarget,
    canSee,
    chase,
}

bt.parallel(succeed, fail?) ticks all children every frame. Resolves when enough children have reached a terminal status. succeed is the number of children that must succeed for the parallel to return "success". fail is the number that must fail for "failure", defaulting to the total child count. Both thresholds must be > 0 and ≤ the child count.

When a threshold is met mid-tick, all remaining active children are halted. If all children resolve without either threshold being met (possible when succeed + fail > count), the parallel returns "failure".

bt.parallel(1) {        -- succeed when 1 child succeeds
    chase,
    attackLoop,
}

bt.parallel(2, 1) {     -- succeed when 2 succeed, fail when 1 fails
    taskA,
    taskB,
    taskC,
}

Note the curried API. bt.parallel(succeed) returns a function that takes the children table. This reads naturally in Luau thanks to call sugar: bt.parallel(1) { ... }.

bt.random picks one child at random and sticks with it until it resolves ("success" or "failure"). While the chosen child returns "running", it stays selected. On resolution, the selection is cleared and the next activation picks fresh. Optional weights make some children more likely.

bt.random({
    patrol,
    idleAnimation,
}, { 3, 1 })  -- patrol is 3x more likely

Decorators

Decorators are chained methods on any node. Each returns a new node wrapping the original. Read left to right:

chase:timeout(6):retry(3)
-- "chase, with a 6-second timeout, retried up to 3 times"
DecoratorBehavior
node:invert()Flips "success""failure". "running" passes through unchanged.
node:always(status)Forces "success" or "failure" when the child completes. "running" passes through. The child still runs until it finishes, then the forced status is returned.
node:loop(count?)Counted (with count): repeats the child up to N times. If the child returns "running", the loop yields and resumes the count next tick. Stops immediately on "failure". Returns "success" after N completions. Infinite (no count): ticks the child once per frame. On "success", yields "running" to prevent spin. The child runs again next frame. Stops on "failure".
node:cooldown(seconds)After the child succeeds, blocks re-entry for N seconds (wall-clock via os.clock). Returns "failure" during the cooldown window. The cooldown timestamp survives branch-level halts. If a selector switches away and comes back, the cooldown still applies. ctx:stop() and ctx:destroy() clear all state including cooldown timestamps.
node:timeout(seconds)Starts a wall-clock timer on entry. If the child is still "running" after N seconds, halts it and returns "failure". Timer resets on normal completion.
node:retry(times)If the child fails, halts it (resetting internal state) and tries again, up to N total attempts. Returns "failure" after exhausting all attempts. Returns the child's "running" and "success" directly.
node:guard(check)Re-evaluates check(board) every tick before ticking the child. If the check returns false and the child was "running", halts it. Returns "failure". If the check returns false and the child was not running, returns "failure" without halting.
node:throttle(seconds)Limits how often the child is evaluated. If a cached terminal result ("success" or "failure") exists and the interval has not elapsed, returns the cached result without ticking the child. Running children pass through every tick. Cache survives branch-level halts but is cleared by stop()/destroy().
node:tag(name)Attaches a debug name string to the node. Currently inert. Intended for future debug tooling.
node:serve(polls...)Attaches poll nodes that tick before the child every frame. When the child is halted, the polls are halted too (timers reset). On re-entry, polls fire immediately.

Poll Services

Polls run a function on a wall-clock interval (os.clock) and always return "success". The interval is independent of the context's tick rate. A 0.3s poll fires based on real elapsed time, not simulation ticks.

Attach polls to nodes via :serve(). The polls are scoped to the served node's lifecycle: they tick every frame while the served branch is active, and their timers reset when the branch is halted.

local scan = bt.poll(0.3, function(b, agent)
    b.target = agent:findNearestEnemy()
    b.canSee = b.target ~= nil and agent:hasLineOfSight(b.target)
end)

local root = bt.select {
    -- decision tree...
} :serve(scan)

Wait

bt.wait(seconds) returns "running" until the specified duration has elapsed, then returns "success". Duration is tracked by accumulating ctx.dt, so it measures simulation time, not wall-clock time. With a fixed-timestep runner at 10Hz, each tick advances by 0.1s of simulation time.

bt.sequence {
    attack:cooldown(0.8),
    bt.wait(0.2),        -- brief pause after attack
} :loop()

Context

A tree is just a structure, a frozen graph of nodes. To run it, bind it to a board and an agent by creating a context. The context holds all runtime state (which child is running, timers, cooldown timestamps). One tree can be shared across many contexts with independent state.

-- Manual ticking:
local ctx = bt.bind(root, board, npc)
RunService.Heartbeat:Connect(function(dt)
    ctx:tick(dt)
end)

-- Automatic runner at N Hz (fixed timestep):
local ctx = bt.run(root, board, npc, 10)

-- Automatic runner at frame rate (variable dt):
local ctx = bt.run(root, board, npc)

When ticking manually, always pass dt. Omitting it defaults to 0, which means time-based nodes like bt.wait and node:timeout will never make progress.

ctx:stop() disconnects the runner, halts all running nodes (triggering their cleanup), and clears all internal state. After stop, calling tick() or start() begins a completely fresh run. No prior state survives.

ctx:destroy() calls stop() and marks the context as dead. All subsequent tick() calls return "failure". Idempotent.

Always call ctx:destroy() when the NPC is removed. Without it, the Heartbeat connection leaks.

npc.Destroying:Once(function()
    ctx:destroy()
end)

Full Example

local bt = require(path.to.bt)

type Board = {
    target: Player?,
    health: number,
    canSee: boolean,
    allies: number,
    lastHeard: Vector3?,
}

local board: Board = {
    target    = nil,
    health    = 100,
    canSee    = false,
    allies    = 0,
    lastHeard = nil,
}

local npc = script.Parent

-- Conditions

local hasTarget  = bt.check(function(b: Board) return b.target ~= nil end)
local isHurt     = bt.check(function(b: Board) return b.health < 30 end)
local canSee     = bt.check(function(b: Board) return b.canSee end)
local hasAllies  = bt.check(function(b: Board) return b.allies > 0 end)
local heardNoise = bt.check(function(b: Board) return b.lastHeard ~= nil end)

-- Actions

local attack = bt.action(function(_b: Board, agent)
    agent:swingWeapon()
    return "success"
end)

local callForHelp = bt.action(function(_b: Board, agent)
    agent:shout()
    return "success"
end)

local heal = bt.action(function(b: Board, agent)
    agent:playAnimation("Heal")
    b.health = math.min(100, b.health + 30)
    return "success"
end)

local patrol = bt.action(function(_b: Board, agent)
    agent:walkToNextWaypoint()
    return "running"
end)

local chase = bt.action({
    enter = function(b: Board, agent)
        agent:pathTo(b.target)
        return "running"
    end,
    tick = function(_b: Board, agent)
        return if agent:reachedTarget() then "success" else "running"
    end,
    halt = function(_b: Board, agent)
        agent:stopMoving()
    end,
})

local flee = bt.action({
    enter = function(_b: Board, agent)
        agent:runAway()
        return "running"
    end,
    tick = function(_b: Board, agent)
        return if agent:isSafe() then "success" else "running"
    end,
    halt = function(_b: Board, agent)
        agent:stopMoving()
    end,
})

local investigate = bt.action({
    enter = function(b: Board, agent)
        agent:pathTo(b.lastHeard)
        return "running"
    end,
    tick = function(b: Board, agent)
        if agent:reachedTarget() then
            b.lastHeard = nil
            return "success"
        end
        return "running"
    end,
    halt = function(_b: Board, agent)
        agent:stopMoving()
    end,
})

-- Tree

local root = bt.select {
    bt.sequence {
        isHurt,
        bt.select {
            bt.sequence { hasAllies:invert(), flee },
            bt.sequence { callForHelp, heal:cooldown(8) },
        },
    },

    bt.sequence {
        hasTarget,
        canSee,
        bt.parallel(1) {
            chase:timeout(6):retry(3),
            bt.sequence { attack:cooldown(0.8), bt.wait(0.2) } :loop(),
        },
    },

    bt.sequence { heardNoise, investigate:timeout(10) },

    bt.random({
        bt.sequence { patrol, bt.wait(3) } :loop(),
        bt.wait(5),
    }, { 3, 1 }),

} :serve(
    bt.poll(0.3, function(b: Board, agent)
        b.target = agent:findNearestEnemy()
        b.canSee = b.target ~= nil and agent:hasLineOfSight(b.target)
        b.allies = agent:countNearbyAllies()
    end),
    bt.poll(1.0, function(b: Board, agent)
        b.lastHeard = agent:getLastHeardPosition()
    end)
)

-- Run

local ctx = bt.run(root, board, npc, 10)

npc.Destroying:Once(function()
    ctx:destroy()
end)

API Reference

Leaves

FunctionDescription
bt.check(predicate)Boolean gate. Returns "success" or "failure". Predicate receives (board).
bt.action(handler)Function form. Handler receives (board, agent, dt), runs every tick.
bt.action({ enter, tick, halt })Table form. enter on first tick, tick on subsequent, halt on interrupt. At least one of enter or tick required. enter and tick receive (board, agent, dt). halt receives (board, agent).
bt.wait(seconds)Returns "running" for N seconds via dt accumulation (simulation time), then "success".
bt.event(signal)Returns "running" until the signal fires once, then "success". Connects on entry, disconnects on halt or completion. One-shot per activation.
bt.poll(interval, updater)Fires updater(board, agent) on a wall-clock interval (os.clock). Always "success".

Composites

FunctionDescription
bt.select(children)Left to right. Succeeds on first "success". Re-evaluates from child 1 every tick.
bt.sequence(children)Left to right. Fails on first "failure". Resumes from running child.
bt.parallel(succeed, fail?)(children)Curried. Ticks all children. Resolves by threshold. fail defaults to child count. Both thresholds must be > 0 and ≤ child count.
bt.random(children, weights?)Picks one at random. Sticks while "running". Optional weights.

Decorators

Chained methods on Node. Each returns a new Node.

MethodDescription
node:invert()Flips "success""failure".
node:always(status)Forces "success" or "failure" on completion.
node:loop(count?)Counted: repeats up to N times, yields on "running". Infinite: once per tick, yields on "success". Stops on "failure".
node:cooldown(seconds)Blocks for N seconds after success. Survives branch-level halts. Cleared by stop()/destroy().
node:timeout(seconds)Fails if child runs longer than N seconds (wall-clock).
node:retry(times)Retries on failure up to N times. Halts child between attempts.
node:guard(check)Re-checks check(board) every tick. Halts child if false.
node:throttle(seconds)Caches terminal results for N seconds. Running passes through.
node:tag(name)Attaches a debug name.
node:serve(polls...)Attaches polls scoped to this node's lifecycle.

Context

FunctionDescription
bt.bind(root, board, agent)Creates a context for manual ticking.
bt.run(root, board, agent, tickRate?)Creates a context and starts the runner. Rate > 0 uses fixed timestep. Rate 0 or omitted uses frame dt.
ctx:tick(dt?)Ticks the tree once. dt defaults to 0. Always pass it when ticking manually.
ctx:start(tickRate?)Starts via RunService.Heartbeat. Ignored if already running.
ctx:stop()Stops runner, halts all nodes, clears all state. Fresh start on next tick/start.
ctx:destroy()Full teardown. Idempotent. tick() returns "failure" after this.
ctx:isRunning()Whether the runner is active.
bt.snapshot(ctx)Returns a flat list of every node in the tree with its kind, tag, depth, active state, and kind-specific details (timing, progress, thresholds, phase). Plain data.

License

MIT

Package Details

Install command (Click to copy)


Version

2.1.0

License

MIT

check_circle

Safe for commercial use

infoThe package archive does not include its license text; the license is declared in its manifest metadata.

Automated license review — not legal advice.