-
Notifications
You must be signed in to change notification settings - Fork 0
API BehaviorTree
BehaviorTree is a tiny functional behavior-tree helper used by RBXStateMachine for decision composition. Nodes are plain functions that take a context object and return one of three status strings.
Important
This page documents FSM/Orchestrator/Core/Factory/BehaviorTree.luau and example usage patterns from RetryBehavior.luau and ServiceBrain.luau.
A behavior tree node in this framework is simply:
(context: any) -> "Success" | "Failure" | "Running"The module intentionally stays small:
SelectorSequenceInverterSucceederConditionSetStateBehaviorTreeStatus
BehaviorTree.BehaviorTreeStatus = {
Success = "Success",
Failure = "Failure",
Running = "Running",
}| Status | Meaning |
|---|---|
Success |
node finished successfully |
Failure |
node failed and parent should continue / stop according to its own semantics |
Running |
node is still in progress; composites pass it upward without converting it |
Selector
ββββββββββββ
β Selector β
ββββββ¬ββββββ
βΌ
βββββββββββββββ
β Run child 1 β
ββββββ¬βββββββββ
βΌ
ββββββββββββ
β Failure? β
βββ¬βββββ¬ββββ
βyes βno
βΌ βΌ
ββββββββββββββββ βββββββββββββββββββββββ
β Run next child β β Return child status β
ββββββββ¬βββββββββ βββββββββββββββββββββββ
βΌ
ββββββββββββββββββββββββββββββββββββββββββββ
β Repeat until non-Failure or no children β
ββββββββββββββββββββββββββββββββββββββββββββ
Sequence
ββββββββββββ
β Sequence β
ββββββ¬ββββββ
βΌ
βββββββββββββββ
β Run child 1 β
ββββββ¬βββββββββ
βΌ
ββββββββββββ
β Success? β
βββ¬βββββ¬ββββ
βyes βno
βΌ βΌ
ββββββββββββββββ βββββββββββββββββββββββ
β Run next child β β Return child status β
ββββββββ¬βββββββββ βββββββββββββββββββββββ
βΌ
ββββββββββββββββββββββββββββββββββββββββββ
β Repeat until non-Success or all pass β
ββββββββββββββββββββββββββββββββββββββββββ
Every composite / decorator uses an internal normalization step. If a child returns anything other than the three official status strings, it is coerced to Failure.
That includes:
nil- unexpected strings
- accidental booleans
- any other unsupported return type
This makes the tree fail safe instead of crashing the whole caller.
BehaviorTree.Initialize(Util: table) -> ()One-time setup called by Factory.
-
@reads:Initialized -
@writes: module logger, initialized flag
If you try to construct nodes before initialization, the module asserts.
BehaviorTree.Selector(children: { (context: any) -> string }) -> (context: any) -> stringRuns children in order and returns the first child that is not Failure.
-
Successif a child succeeds -
Runningif a child reports running before any success -
Failureonly if every child fails or normalizes to failure
-
@reads: child return values -
@writes: none directly
local BT = shared.fsm.BehaviorTree
local chooseAction = BT.Selector({
BT.Sequence({
BT.Condition(function(ctx) return ctx.Health < 20 end),
BT.SetState("Flee"),
}),
BT.Sequence({
BT.Condition(function(ctx) return ctx.Target ~= nil end),
BT.SetState("Attack"),
}),
BT.SetState("Patrol"),
})BehaviorTree.Sequence(children: { (context: any) -> string }) -> (context: any) -> stringRuns children in order and returns the first child that is not Success.
-
Failureif any child fails -
Runningif any child is still running before a failure occurs -
Successonly if every child succeeds
local attackSequence = BT.Sequence({
BT.Condition(function(ctx)
return ctx.Target ~= nil and ctx.TargetDistance < 10
end),
function(ctx)
ctx.FSM:ChangeState({ Name = "Attack" })
return BT.BehaviorTreeStatus.Success
end,
})BehaviorTree.Inverter(child: (context: any) -> string) -> (context: any) -> stringFlips Success and Failure, while passing Running through unchanged.
local noTarget = BT.Inverter(BT.Condition(function(ctx)
return ctx.Target ~= nil
end))BehaviorTree.Succeeder(child: (context: any) -> string) -> (context: any) -> stringConverts any final status to Success, but preserves Running.
This is useful when a side-effecting node is optional and should not break the parent sequence.
local safeTelemetry = BT.Succeeder(function(ctx)
local ok, err = pcall(ctx.PushMetrics)
return ok and BT.BehaviorTreeStatus.Success or BT.BehaviorTreeStatus.Failure
end)BehaviorTree.Condition(predicate: (context: any) -> boolean) -> (context: any) -> stringWraps a boolean predicate.
-
Successwhenpredicate(context)is truthy -
Failureotherwise
local hasTarget = BT.Condition(function(ctx)
return ctx.Target ~= nil
end)BehaviorTree.SetState(stateName: string) -> (fsm: any) -> stringReturns a leaf node that directly assigns fsm.State = stateName if the state differs, then returns Success.
-
@reads:fsm.State -
@writes:fsm.State
local goIdle = BT.SetState("Idle")Caution
SetState writes the State property directly. In RBXStateMachine, that means the real validated transition happens on the next FSM _Update() pass. If you need immediate validation, OnLeave, history, and signal ordering right now, use a custom leaf that calls fsm:ChangeState(...) instead.
local brain = BT.Selector({
BT.Sequence({
BT.Condition(function(fsm) return fsm.Context.Health < 15 end),
BT.SetState("Flee"),
}),
BT.Sequence({
BT.Condition(function(fsm) return fsm.Context.Target ~= nil end),
BT.SetState("Attack"),
}),
BT.SetState("Patrol"),
})
self:AddState("Think", {
OnHeartbeat = function(_, fsm)
brain(fsm)
end,
}, { "Flee", "Attack", "Patrol" })Use this when you need the transition to happen immediately inside the node.
local BT = shared.fsm.BehaviorTree
local function ChangeStateNode(targetState)
return function(fsm)
fsm:ChangeState({ Name = targetState })
return BT.BehaviorTreeStatus.Success
end
end
local doorDecisionTree = BT.Selector({
BT.Sequence({
BT.Condition(function(fsm)
return fsm.Context.IsLocked == true
end),
ChangeStateNode("Locked"),
}),
BT.Sequence({
BT.Condition(function(fsm)
return fsm.Context.ShouldOpen == true
end),
ChangeStateNode("Opening"),
}),
ChangeStateNode("Idle"),
})Running short-circuits the parent without converting to success/failure. That lets you model multi-tick work.
local waitForAnimation = function(ctx)
if not ctx.AnimationTrack then
return BT.BehaviorTreeStatus.Failure
end
if ctx.AnimationTrack.IsPlaying then
return BT.BehaviorTreeStatus.Running
end
return BT.BehaviorTreeStatus.Success
endFSM/Orchestrator/Core/DataStore/RetryBehavior.luau uses a selector with ordered fallback:
local Tree = BT.Selector({
BT.Sequence({
AttemptCall,
CompleteRequest,
}),
BT.Sequence({
CanRetry,
ScheduleRetry,
}),
FailRequest,
})This means:
- try the request
- if it succeeds, mark the FSM successful
- if it fails, check whether retry budget remains
- if retries remain, increment
RetryCount, setFSM.WaitSpan, and move toRetrying - otherwise fail permanently
The ServiceManager root behavior (ServiceBrain.Build) uses a large Sequence / Selector composition to gate initialization, refresh data only when needed, and always tick animations.
local tree = BehaviorTree.Sequence({
BehaviorTree.Condition(function(ctx)
return ctx.Nexus and ctx.Nexus._initialized
end),
SyncContextData,
BehaviorTree.Selector({
BehaviorTree.Sequence({
NeedsDataRefresh,
FetchSubsystemData,
BehaviorTree.Succeeder(RunInsightAnalysis),
RenderActiveSubsystem,
}),
BehaviorTree.Sequence({
HasDirtyUI,
RenderActiveSubsystem,
}),
function()
return BehaviorTree.BehaviorTreeStatus.Success
end,
}),
BehaviorTree.Succeeder(UpdateAnimations),
})That pattern is useful whenever you want:
- a hard precondition gate at the top
- a best-effort analysis step that should not fail the whole tree
- a fallback branch that still reports success when no expensive work is necessary
| Place | When to use it |
|---|---|
OnHeartbeat |
decision tree reevaluated every FSM update |
OnEnter |
one-shot branch selection when entering a state |
| dedicated function state | tree becomes the entire behavior of the state |
function EnemyFSM:RegisterStates()
local BT = shared.fsm.BehaviorTree
local decide = BT.Selector({
BT.Sequence({
BT.Condition(function(fsm)
return fsm.Context.Health and fsm.Context.Health < 20
end),
BT.SetState("Retreat"),
}),
BT.Sequence({
BT.Condition(function(fsm)
return fsm.Context.Target ~= nil
end),
BT.SetState("Attack"),
}),
BT.SetState("Patrol"),
})
self:AddState("Think", {
OnHeartbeat = function(_, fsm)
decide(fsm)
end,
}, { "Retreat", "Attack", "Patrol" })
endlocal tree = BT.Sequence({
BT.Condition(function(ctx) return ctx.Entity ~= nil end),
function(ctx)
ctx.Entity.Health = math.max(0, (ctx.Entity.Health or 0) - 5)
return BT.BehaviorTreeStatus.Success
end,
})
tree({ Entity = entity })-
Invalid child returns become
Failure. If you accidentally forgetreturn Status.Success, the composite treats that child as failure. -
SetStateis deferred in practice. It only writesfsm.State; BaseStateMachine later notices the change and performs the real transition. -
Runningis preserved by decorators.InverterandSucceederdo not convert it. - There is no built-in memory / blackboard system. Your context object is the blackboard.
-
Selectors stop on
Running. BecauseRunning ~= Failure, a selector will not evaluate later children once a child reports running. -
Sequences stop on
Running. BecauseRunning ~= Success, a sequence also short-circuits until the next tick. - Nodes are plain functions. Any mutation, yielding, logging, or FSM calls come entirely from your leaf logic.
-
Initialization is mandatory. Factory handles this for you; standalone manual use must still call
BehaviorTree.Initialize(...)first.
Quick Links: Home Β· Quick Start Β· API Reference Β· Architecture Β· Examples Β· Glossary
Copyright: Β© 2026 RBXStateMachine contributors Β· Repository Β· License information