-
Notifications
You must be signed in to change notification settings - Fork 0
API BaseStateMachine
BaseStateMachine is the runtime core for RBXStateMachine finite state machines. It owns state registration, validation, transition ordering, timing, signals, history, and cleanup.
Important
This page documents FSM/Orchestrator/Core/Factory/BaseStateMachine.luau.
BaseStateMachine gives you three distinct ways to move between states:
-
Immediate transitions with
:ChangeState() -
Per-state delayed transitions with
:ScheduleTransition()orTimeout/OnTimeout -
Per-frame conditional transitions with object-state
Transitions
It is also responsible for:
- validating
ValidStatesand legal outgoing transitions - guarding against re-entrant
:ChangeState()calls - tracking
StateDuration,TotalDuration, andTransitionHistory - auto-finishing terminal states
- driving hierarchical child machines created by
:AddSubMachine() - cleaning up managed resources and lifecycle signals
[new / Extend] βββ [RegisterStates] βββ [Start] βββ [Active update loop]
β
ββββ [ChangeState] βββ [Active update loop]
ββββ [Finish] ββββββββ
ββββ [Fail] ββββββββββΌβββ [Teardown] βββ [Destroy optional final disposal]
ββββ [Cancel] ββββββββ
[ChangeState request]
ββββ {Already transitioning?}
βββ yes β [Queue request and return]
βββ no β {WaitSpan / delayed?}
βββ yes β [task.delay then retry]
βββ no β [Validate target state]
β [Validate legal outgoing transition]
β [Run old OnLeave + legacy cleanup]
β [Commit State / CurrentState]
β [Reset timers and timed transition state]
β [Append TransitionHistory]
β [Fire StateChanged newState oldState]
β [Run OnEnter or function state]
β {Terminal state?}
βββ yes β [Finish / Fail / Cancel]
βββ no β [Resume update loop]
BaseStateMachine.new(params: {
Id: string,
Name: string?,
ValidStates: {string}?,
TerminalStates: {string}?,
InitialState: string?,
Context: {[any]: any}?,
Priority: number?,
TransitionHistoryMax: number?,
}) -> BaseStateMachineCreates an inactive machine instance.
| Field | Type | Required | Default | Details |
|---|---|---|---|---|
Id |
string |
yes | β | Unique runtime ID. Logged as OperationId. |
Name |
string? |
no | "BaseStateMachine" |
Logger / display name. |
ValidStates |
{string}? |
no | {} |
Optional whitelist. If empty, any registered state name is allowed. |
TerminalStates |
{string}? |
no | {} |
States that automatically end the machine after entry. Invalid names log a warning and are skipped. |
InitialState |
string? |
no | first ValidStates entry if present |
Used by :Start() when no override is passed. |
Context |
table? |
no | {} |
Arbitrary instance data. Non-tables warn and become {}. __index falls back into this table. |
Priority |
number? |
no | 1 |
Positive integer update cadence. Invalid values are clamped to 1, then floored. |
TransitionHistoryMax |
number? |
no | 100 |
Ring-buffer size for TransitionHistory. Clamped to 10..500. |
- An inactive FSM instance with signals, timers, history, and state maps allocated.
-
@reads: nothing external -
@writes: instance fields such asContext,_states,_transitions,TransitionHistory, signals, timing counters, and performance data
local Factory = require(game:GetService("ReplicatedStorage").RBXStateMachine).Orchestrator.Factory
local BaseStateMachine = Factory.BaseModules.BaseStateMachine
local raw = BaseStateMachine.new({
Id = "fsm-001",
Name = "LooseMachine",
ValidStates = { "Idle", "Done" },
TerminalStates = { "Done" },
InitialState = "Idle",
Context = { Counter = 0 },
Priority = BaseStateMachine.Priorities.Render,
TransitionHistoryMax = 25,
})Priority controls how often the global FSM loop updates the machine.
| Constant | Value | Meaning |
|---|---|---|
BaseStateMachine.Priorities.Render |
1 |
every eligible frame, inline update |
BaseStateMachine.Priorities.High |
2 |
roughly every 2 frames |
BaseStateMachine.Priorities.Medium |
5 |
roughly every 5 frames |
BaseStateMachine.Priorities.Low |
10 |
roughly every 10 frames |
BaseStateMachine.Priorities.Background |
30 |
background cadence |
Note
Priority = 1 runs inline inside the global step. Those states should not yield.
BaseStateMachine.Extend(params: {
className: string,
validStates: {string}?,
terminalStates: {string}?,
InitialState: string?,
context: {[any]: any}?,
Priority: number?,
}) -> anyBuilds a subclass table with a .new() constructor. The generated constructor:
- calls
BaseStateMachine.new(...) - installs the subclass metatable
- calls
self:RegisterStates()
| Field | Type | Details |
|---|---|---|
className |
string |
Class / logger name. |
validStates |
{string}? |
Allowed state names for the subclass. |
terminalStates |
{string}? |
Terminal state whitelist for the subclass. |
InitialState |
string? |
Default start state. |
context |
table? |
Definition-time default context metadata. The runtime constructor still receives the actual context table. |
Priority |
number? |
Default update cadence for instances. |
- A subclass table whose instances inherit
BaseStateMachinemethods.
-
@reads: extension definition -
@writes: none outside the returned class table
local FSM = require(game:GetService("ReplicatedStorage").RBXStateMachine)
local BaseStateMachine = FSM.Orchestrator.Factory.BaseModules.BaseStateMachine
local DoorFSM = BaseStateMachine.Extend({
className = "DoorFSM",
validStates = { "Closed", "Opening", "Open", "Failed" },
terminalStates = { "Failed" },
InitialState = "Closed",
Priority = BaseStateMachine.Priorities.Render,
})
function DoorFSM:RegisterStates()
self:AddState("Closed", function(fsm)
fsm.Context.IsOpen = false
end, { "Opening" })
self:AddState("Opening", {
OnEnter = function(_, fsm)
fsm:ScheduleTransition(0.75, "Open")
end,
}, { "Open", "Failed" })
self:AddState("Open", function(fsm)
fsm.Context.IsOpen = true
end)
end
local instance = DoorFSM.new({
StateMachineId = "door:front",
DoorId = "front",
})
instance:Start()AddState accepts either a function state or an object state.
(name: string, state: (fsm: any, ...any) -> (() -> ())?, validOutcomes: {string}?) -> ()The function receives the FSM plus any Args. If it returns another function, that return value is treated as a legacy cleanup callback and is run when the state is left or when the machine tears down.
self:AddState("Playing", function(fsm, track)
track:Play()
return function()
track:Stop()
end
end, { "Stopped" })(name: string, state: {
OnEnter: ((self: any, fsm: any, ...any) -> ())?,
OnLeave: ((self: any, fsm: any) -> ())?,
OnHeartbeat: ((self: any, fsm: any, dt: number) -> ())?,
Transitions: {{ Condition: (fsm: any, dt: number) -> boolean, TargetState: string, Args: {any}? }}?,
Timeout: number?,
OnTimeout: string | ((self: any, fsm: any) -> ())?,
}, validOutcomes: {string}?) -> ()| Field | Type | When it runs | Notes |
|---|---|---|---|
OnEnter |
function? |
after commit | Deferred for non-terminal states; synchronous for terminal states. |
OnLeave |
function? |
before commit to new state / teardown | Wrapped in xpcall; errors log but do not wedge the FSM. |
OnHeartbeat |
function? |
every _Update while active |
Receives (stateObject, fsm, dt). |
Transitions |
{ transition }? |
before OnHeartbeat each update |
First Condition(self, dt) returning truthy wins and triggers ChangeState. |
Timeout |
number? |
state-local timer | Compared against StateDuration; must be > 0. |
OnTimeout |
`string | function` | once when StateDuration >= Timeout
|
self:AddState("Chasing", {
OnEnter = function(_, fsm, target)
fsm.Context.Target = target
end,
Transitions = {
{
Condition = function(fsm)
return fsm.Context.Target == nil
end,
TargetState = "Searching",
},
{
Condition = function(fsm)
return fsm.Context.TargetDistance and fsm.Context.TargetDistance < 6
end,
TargetState = "Attacking",
},
},
OnHeartbeat = function(_, fsm, dt)
local mover = fsm.Context.Mover
if mover and fsm.Context.TargetPosition then
mover:MoveTo(fsm.Context.TargetPosition)
end
end,
Timeout = 8,
OnTimeout = function(_, fsm)
fsm:ChangeState({ Name = "Searching", Args = { "timed-out" } })
end,
}, { "Searching", "Attacking" })BaseStateMachine:AddState(
name: string,
state: ((fsm: any, ...any) -> (() -> ())?) | {[string]: any},
validOutcomes: {string}?
) -> ()Registers a state definition and the list of legal outgoing state names.
| Parameter | Type | Default | Details |
|---|---|---|---|
name |
string |
β | State key stored in _states. If ValidStates was provided, this name must be present there. |
state |
`function | table` | β |
validOutcomes |
{string}? |
{} |
Allowed targets when leaving this state. If empty or omitted, ChangeState only checks ValidStates / registration. |
- Nothing. Validation failures are logged, not thrown.
-
@reads:self.validStates,self._started -
@writes:self._states[name],self._transitions[name]
function GuardFSM:RegisterStates()
self:AddState("Idle", function(fsm)
fsm.Context.Alert = false
end, { "Alerted" })
self:AddState("Alerted", {
OnEnter = function(_, fsm, source)
fsm.Context.Alert = true
fsm.Context.LastSource = source
end,
}, { "Idle", "Searching" })
end- Calling
AddStateafter:Start()only logs a warning. It still writes into the tables, but late registration can miss transitions already in flight. - If
ValidStatesis populated andnameis not in it, the state is rejected.
BaseStateMachine:AddSubMachine(name: string, class: any, config: {
Transitions: {
OnCompleted: string?,
OnFailed: string?,
OnCancelled: string?,
},
InitialState: string,
StoreReference: string?,
}) -> ()Registers a parent state whose OnEnter spins up a child FSM and whose OnLeave disconnects and cancels it.
| Parameter | Type | Details |
|---|---|---|
name |
string |
Parent state name that hosts the child FSM. |
class |
any |
Child FSM class table with .new(...). |
config.Transitions |
table |
Maps child completion signals to parent state names. Missing transitions fall back to Finish(), Fail(reason), or Cancel(). |
config.InitialState |
string |
Child state passed to child:Start({ State = ... }). |
config.StoreReference |
string? |
Optional key written into the parent Context containing the child instance. |
- Nothing. Missing
TransitionsorInitialStatelogs an error and aborts registration.
-
@reads:self.Context -
@writes:self._states,self._transitions, optionalself.Context[StoreReference]
local ChargeFSM = require(script.Parent.ChargeFSM)
function BossFSM:RegisterStates()
self:AddSubMachine("Charging", ChargeFSM, {
InitialState = "Windup",
StoreReference = "ActiveChargeFSM",
Transitions = {
OnCompleted = "Attack",
OnFailed = "Recover",
OnCancelled = "Idle",
},
})
end- The child FSM ID is generated as
parent.Id .. "." .. name. - Child completion hooks call back into the parent with
ChangeState,Fail, orCancel. - Leaving the parent submachine state cancels the child if it is still active.
-
validOutcomesfor the parent submachine state is synthesized fromTransitions.
AddSubMachine calls subMachineClass.new({ StateMachineId = ..., Context = fsm.Context }). Because subclass constructors pass the whole constructor table into BaseStateMachine.new, the child context is not a flat clone of the parent context. In practice the child sees a context table that contains a nested Context key pointing at the parent context.
BaseStateMachine:Start(params: {
State: string?,
Args: {any}?,
}? ) -> ()Activates the machine, registers it with the global FSM loop, and enters the initial state.
| Parameter | Type | Default | Details |
|---|---|---|---|
State |
string? |
self.initialState / self.InitialState
|
Optional runtime override. |
Args |
{any}? |
{} |
Forwarded to the first state's OnEnter / function body. |
- Nothing. If no usable start state exists, the machine logs an error and stays inactive.
-
@reads:self.initialState,BaseStateMachine._ExternalLoop -
@writes:self.IsActive,self._started, globalActiveMachines, global heartbeat connection, current state viaChangeState
fsm:Start({
State = "Warmup",
Args = { workspace.SpawnLocation },
})-
:Start()is idempotent while active; calling it again does nothing. - If the initial transition fails and
_activeStateNamestaysnil, the machine unregisters itself to avoid leaking an active machine.
BaseStateMachine:ChangeState(params: {
Name: string,
Args: {any}?,
WaitSpan: number?,
}) -> ()The main transition API. It validates the request, optionally defers it, runs OnLeave, commits the new state, appends history, fires StateChanged, and enters the new state.
| Parameter | Type | Default | Details |
|---|---|---|---|
Name |
string |
β | Target state name. May be an implicit terminal state even if no state object exists. |
Args |
{any}? |
{} |
Forwarded to the new state's OnEnter / function body. History stores a sanitized snapshot of up to 8 args. |
WaitSpan |
number? |
0 |
Optional delayed transition in seconds. If self.WaitSpan is already non-zero, that field wins over params.WaitSpan. |
- Nothing. Illegal transitions are logged and ignored.
-
@reads:self.validStates,self._transitions,self._states,self.TerminalStates,self.WaitSpan,self.CurrentState -
@writes:self._inTransition,self._pendingTransitions,self.State,self.CurrentState,self._activeStateName,self.StateDuration,self.TransitionCount,self._transitionId,self._pendingTimedTransition,self.TransitionHistory
fsm:ChangeState({
Name = "Patrol",
Args = { nextWaypoint },
})
fsm:ChangeState({
Name = "Retreat",
WaitSpan = 0.5,
Args = { "low-health" },
})If ChangeState is called while another transition is still executing, the request is queued in _pendingTransitions and replayed with task.defer after the current commit releases _inTransition.
That means this is safe:
self.StateChanged:Connect(function(newState)
if newState == "Warmup" then
self:ChangeState({ Name = "Running" })
end
end)-
ValidStatesmembership, if defined - legal outgoing transition from the current state (
validOutcomes) - state registration, unless the target is an implicit terminal state
StateChanged is fired after commit (State, CurrentState, _activeStateName already updated) but before deferred non-terminal OnEnter / function-state work runs.
If Name matches the current state, the FSM increments TransitionCount but does not reset StateDuration. This is important for WaitSpan self-loops and repeated scheduled self-reentry patterns.
BaseStateMachine:ScheduleTransition(
seconds: number,
targetState: string,
args: {any}?
) -> ()Schedules a one-shot transition tied to the current state's StateDuration instead of task.delay.
| Parameter | Type | Default | Details |
|---|---|---|---|
seconds |
number |
β | Delay relative to the current state's StateDuration. Must be >= 0. |
targetState |
string |
β | Non-empty target state name. |
args |
{any}? |
nil |
Forwarded to ChangeState({ Args = ... }) when the timer fires. |
- Nothing. Invalid inputs warn and are ignored.
-
@reads:self.IsActive,self.StateDuration, existing_pendingTimedTransition -
@writes:self._pendingTimedTransition
self:AddState("Red", {
OnEnter = function(_, fsm)
fsm:ScheduleTransition(4, "Green")
end,
}, { "Green" })- The timer is auto-cancelled when the machine leaves the state.
- Re-scheduling the exact same target and fire time is ignored. This lets you call
ScheduleTransitionfromOnHeartbeatwithout re-allocating the same timer every frame. - The most recent non-identical call wins and replaces the pending timer.
BaseStateMachine:CancelScheduledTransition() -> booleanCancels the current pending scheduled transition, if any.
-
trueif a timer existed and was cleared -
falseif nothing was pending
-
@reads:self._pendingTimedTransition -
@writes:self._pendingTimedTransition
if playerLeftZone then
fsm:CancelScheduledTransition()
fsm:ChangeState({ Name = "Idle" })
endBaseStateMachine:Finish() -> ()Teardown path for successful completion.
-
@reads:self.IsActive -
@writes: teardown state via:_Teardown(), then firesCompleted
self:AddState("Done", function(fsm)
fsm:Finish()
end)BaseStateMachine:Fail(reason: string?) -> ()Teardown path for failure.
- Nothing.
Failedlisteners receive the provided reason plus concatenatedERRORlog messages captured on the machine logger.
-
@reads:self.IsActive,self.Logger.History -
@writes: teardown state via:_Teardown(), then firesFailed(combinedReason)
fsm:Fail("Target disappeared during attack")BaseStateMachine:Cancel() -> ()Idempotent cancellation path.
- Nothing.
Cancelledis only fired once because_cancelledFiredguards repeated calls.
-
@reads:self.IsActive,_cancelledFired -
@writes:_cancelledFired, teardown state,Cancelledsignal
if not questStillValid then
fsm:Cancel()
endBaseStateMachine:Destroy() -> ()Permanent disposal. Safe to call after Finish, Fail, or Cancel.
- active state teardown via
:_Teardown() - every object registered by
:Manage() - pending transition queue and history buffers
- lifecycle signals (
Completed,Failed,Cancelled,StateChanged)
-
@reads:self._cleanupTasks -
@writes:_destroyed,_cleanupTasks, transition queues, history, lifecycle signals
fsm:Destroy()BaseStateMachine:Manage(
object: Instance | RBXScriptConnection | (() -> ()) | { Destroy: (self: any) -> () }
) -> anyRegisters a disposable resource that should be torn down when Destroy() runs.
- The same object, so you can inline it.
-
@reads: none -
@writes: appends toself._cleanupTasks
function MyFSM:RegisterStates()
self:AddState("Watching", {
OnEnter = function(_, fsm)
fsm:Manage(workspace.ChildAdded:Connect(function(child)
print("Observed", child.Name)
end))
end,
})
endObject states can declare timeout behavior without writing scheduler code:
self:AddState("Spinning", {
OnEnter = function(_, fsm)
fsm.Context.StartedAt = os.clock()
end,
Timeout = 3,
OnTimeout = "Idle",
}, { "Idle" })OnTimeout may also be a function:
OnTimeout = function(_, fsm)
if fsm.Context.CanRecover then
fsm:ChangeState({ Name = "Recovering" })
else
fsm:Fail("Spin timeout")
end
end| Tool | Best for | Clock source | Auto-cancels on state exit | Where declared | Notes |
|---|---|---|---|---|---|
WaitSpan |
delayed ChangeState semantics or legacy self-reentry |
wall-clock task.delay for explicit ChangeState, StateDuration polling for same-state reentry in _Update
|
partly; same-state reentry is auto-cleared, delayed task.delay transition is guarded by _transitionId
|
per call or by setting fsm.WaitSpan
|
existing self.WaitSpan takes precedence over params.WaitSpan
|
ScheduleTransition |
fixed one-shot delay in the current state | StateDuration |
yes | imperative call inside state logic | preferred for most timed transitions |
Transitions[].Condition |
frame-by-frame decision logic | every _Update
|
n/a | object-state definition | first matching transition wins |
- use
ScheduleTransitionfor βafter N seconds in this state, go somewhere elseβ - use
Timeout/OnTimeoutwhen timeout is part of the state contract - use
Transitionsfor continuously evaluated conditions - keep
WaitSpanfor legacy or explicit delayed-ChangeStateflows
| Property | Type | Meaning |
|---|---|---|
State |
string? |
Current committed state name. Also used as a writable βrequested stateβ field; _Update detects external assignments like fsm.State = "Next". |
StateDuration |
number |
Seconds spent in the currently committed state. Resets on real state changes. |
TotalDuration |
number |
Total runtime seconds since Start(). |
WaitSpan |
number |
Delayed transition helper. Consumed by _Update / ChangeState. Nil-safe reads are built into _Update. |
Context |
table |
Arbitrary runtime data. __index falls back here after class lookup. |
TransitionHistory |
{table} |
Bounded history ring buffer used by ServiceManager. Entries include From, To, Time, sanitized Args, WaitSpan, ContextSnapshot, and TransitionId. |
Priority |
number |
Update cadence tier. 1 is every eligible frame; larger values skip more frames. |
IsActive |
boolean |
true between successful Start() and teardown. |
TransitionCount |
number |
Number of consecutive entries into the current state. Same-state reentry increments this instead of resetting to 1. |
Completed / Failed / Cancelled / StateChanged
|
Signal |
Lifecycle signals exposed as properties. |
| Signal | Payload | Fires when |
|---|---|---|
Completed |
none |
Finish() completes teardown |
Failed |
(reason: string) |
Fail() completes teardown |
Cancelled |
none |
Cancel() completes teardown |
StateChanged |
(newState: string, oldState: string?) |
commit succeeded and state fields were updated |
fsm.StateChanged:Connect(function(newState, oldState)
print("Transition", oldState, "->", newState)
end)
fsm.Failed:Connect(function(reason)
warn("FSM failed:", reason)
end)BaseStateMachine.DisableInternalLoop() -> ()Stops the module's own RunService.Heartbeat driver and marks the FSM system as externally driven. The Orchestrator uses this so the Scheduler can own the global FSM heartbeat.
BaseStateMachine.Step(dt: number) -> ()Ticks all active machines once, respecting priority skipping and accumulated delta time.
Note
Machines with Priority == 1 are updated inline. Higher priorities are wrapped in task.spawn for isolation.
-
StateChangeddoes not meanOnEnterfinished. For non-terminal states,OnEnteris deferred withtask.defer. - Function states may return cleanup callbacks. If you forget that and return a function unintentionally, it will be stored as legacy cleanup.
-
WaitSpanprecedence is subtle.self.WaitSpanwins overparams.WaitSpanif it is non-zero. -
Terminal states can be implicit. If a name is listed in
TerminalStates,ChangeStateallows it even when no state body is registered. -
ScheduleTransitionis state-relative, not wall-clock. It measures againstStateDuration, so pausing / skipped updates do not fire early. -
Same-state reentry does not reset
StateDuration. This is intentional and makes self-loop timing patterns possible. -
Queued re-entrant transitions are replayed later. If multiple listeners call
ChangeStateduring one transition, only the in-flight commit happens immediately. -
Destroy is the only API that disposes managed resources.
Finish,Fail, andCancelperform teardown but do not iterateManage()cleanup tasks. -
Submachine context is nested. Child constructors receive a table containing
Context = parent.Context; the parent context is not flattened into the child. - Invalid transitions log instead of throwing. Your code must observe logs or signals if you need failure visibility.
-- Avoid
self:AddState("Warmup", function(fsm)
task.delay(2, function()
if fsm.State == "Warmup" then
fsm:ChangeState({ Name = "Attack" })
end
end)
end)Prefer:
self:AddState("Warmup", function(fsm)
fsm:ScheduleTransition(2, "Attack")
end, { "Attack" })Register all states from RegisterStates() before calling :Start().
fsm.State = "Next" is picked up later by _Update. If you need immediate, validated commit ordering, call fsm:ChangeState({ Name = "Next" }).
If a state uses WaitSpan, ScheduleTransition, and Timeout all at once, the last transition that successfully commits wins and may invalidate the others.
Render-priority machines update inline; long yields or expensive work there can stall the global loop.
Quick Links: Home Β· Quick Start Β· API Reference Β· Architecture Β· Examples Β· Glossary
Copyright: Β© 2026 RBXStateMachine contributors Β· Repository Β· License information