Skip to content

API BaseStateMachine

iKryptonic edited this page May 1, 2026 · 14 revisions

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.


Overview

BaseStateMachine gives you three distinct ways to move between states:

  1. Immediate transitions with :ChangeState()
  2. Per-state delayed transitions with :ScheduleTransition() or Timeout / OnTimeout
  3. Per-frame conditional transitions with object-state Transitions

It is also responsible for:

  • validating ValidStates and legal outgoing transitions
  • guarding against re-entrant :ChangeState() calls
  • tracking StateDuration, TotalDuration, and TransitionHistory
  • auto-finishing terminal states
  • driving hierarchical child machines created by :AddSubMachine()
  • cleaning up managed resources and lifecycle signals

Lifecycle

[new / Extend] ──→ [RegisterStates] ──→ [Start] ──→ [Active update loop]
                                                     β”‚
                                                     β”œβ”€β”€β†’ [ChangeState] ──→ [Active update loop]
                                                     β”œβ”€β”€β†’ [Finish] ───────┐
                                                     β”œβ”€β”€β†’ [Fail] ─────────┼──→ [Teardown] ──→ [Destroy optional final disposal]
                                                     └──→ [Cancel] β”€β”€β”€β”€β”€β”€β”€β”˜

Transition commit sequence

[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]

Constructor and Class Extension

BaseStateMachine.new(params)

BaseStateMachine.new(params: {
    Id: string,
    Name: string?,
    ValidStates: {string}?,
    TerminalStates: {string}?,
    InitialState: string?,
    Context: {[any]: any}?,
    Priority: number?,
    TransitionHistoryMax: number?,
}) -> BaseStateMachine

Creates an inactive machine instance.

Parameters

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.

Returns

  • An inactive FSM instance with signals, timers, history, and state maps allocated.

Side effects

  • @reads: nothing external
  • @writes: instance fields such as Context, _states, _transitions, TransitionHistory, signals, timing counters, and performance data

Example

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 levels

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)

BaseStateMachine.Extend(params: {
    className: string,
    validStates: {string}?,
    terminalStates: {string}?,
    InitialState: string?,
    context: {[any]: any}?,
    Priority: number?,
}) -> any

Builds a subclass table with a .new() constructor. The generated constructor:

  1. calls BaseStateMachine.new(...)
  2. installs the subclass metatable
  3. calls self:RegisterStates()

Parameters

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.

Returns

  • A subclass table whose instances inherit BaseStateMachine methods.

Side effects

  • @reads: extension definition
  • @writes: none outside the returned class table

Example

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()

Authoring States

AddState accepts either a function state or an object state.

Function state form

(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" })

Object state form

(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}?) -> ()

Supported object-state fields

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

Example: object state with transitions, heartbeat, and 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" })

Core API

:AddState(name, state, validOutcomes)

BaseStateMachine:AddState(
    name: string,
    state: ((fsm: any, ...any) -> (() -> ())?) | {[string]: any},
    validOutcomes: {string}?
) -> ()

Registers a state definition and the list of legal outgoing state names.

Parameters

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.

Returns

  • Nothing. Validation failures are logged, not thrown.

Side effects

  • @reads: self.validStates, self._started
  • @writes: self._states[name], self._transitions[name]

Example

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

Gotchas

  • Calling AddState after :Start() only logs a warning. It still writes into the tables, but late registration can miss transitions already in flight.
  • If ValidStates is populated and name is not in it, the state is rejected.

:AddSubMachine(name, class, config)

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.

Parameters

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.

Returns

  • Nothing. Missing Transitions or InitialState logs an error and aborts registration.

Side effects

  • @reads: self.Context
  • @writes: self._states, self._transitions, optional self.Context[StoreReference]

Example

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

Important behavior

  • The child FSM ID is generated as parent.Id .. "." .. name.
  • Child completion hooks call back into the parent with ChangeState, Fail, or Cancel.
  • Leaving the parent submachine state cancels the child if it is still active.
  • validOutcomes for the parent submachine state is synthesized from Transitions.

Gotcha: context shape

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.

:Start(params?)

BaseStateMachine:Start(params: {
    State: string?,
    Args: {any}?,
}? ) -> ()

Activates the machine, registers it with the global FSM loop, and enters the initial state.

Parameters

Parameter Type Default Details
State string? self.initialState / self.InitialState Optional runtime override.
Args {any}? {} Forwarded to the first state's OnEnter / function body.

Returns

  • Nothing. If no usable start state exists, the machine logs an error and stays inactive.

Side effects

  • @reads: self.initialState, BaseStateMachine._ExternalLoop
  • @writes: self.IsActive, self._started, global ActiveMachines, global heartbeat connection, current state via ChangeState

Example

fsm:Start({
    State = "Warmup",
    Args = { workspace.SpawnLocation },
})

Gotchas

  • :Start() is idempotent while active; calling it again does nothing.
  • If the initial transition fails and _activeStateName stays nil, the machine unregisters itself to avoid leaking an active machine.

:ChangeState(params)

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.

Parameters

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.

Returns

  • Nothing. Illegal transitions are logged and ignored.

Side effects

  • @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

Example

fsm:ChangeState({
    Name = "Patrol",
    Args = { nextWaypoint },
})

fsm:ChangeState({
    Name = "Retreat",
    WaitSpan = 0.5,
    Args = { "low-health" },
})

Re-entrance guard

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)

What gets validated

  1. ValidStates membership, if defined
  2. legal outgoing transition from the current state (validOutcomes)
  3. state registration, unless the target is an implicit terminal state

When StateChanged fires

StateChanged is fired after commit (State, CurrentState, _activeStateName already updated) but before deferred non-terminal OnEnter / function-state work runs.

Same-state re-entry

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.

:ScheduleTransition(seconds, targetState, args?)

BaseStateMachine:ScheduleTransition(
    seconds: number,
    targetState: string,
    args: {any}?
) -> ()

Schedules a one-shot transition tied to the current state's StateDuration instead of task.delay.

Parameters

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.

Returns

  • Nothing. Invalid inputs warn and are ignored.

Side effects

  • @reads: self.IsActive, self.StateDuration, existing _pendingTimedTransition
  • @writes: self._pendingTimedTransition

Example

self:AddState("Red", {
    OnEnter = function(_, fsm)
        fsm:ScheduleTransition(4, "Green")
    end,
}, { "Green" })

Important behavior

  • 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 ScheduleTransition from OnHeartbeat without re-allocating the same timer every frame.
  • The most recent non-identical call wins and replaces the pending timer.

:CancelScheduledTransition()

BaseStateMachine:CancelScheduledTransition() -> boolean

Cancels the current pending scheduled transition, if any.

Returns

  • true if a timer existed and was cleared
  • false if nothing was pending

Side effects

  • @reads: self._pendingTimedTransition
  • @writes: self._pendingTimedTransition

Example

if playerLeftZone then
    fsm:CancelScheduledTransition()
    fsm:ChangeState({ Name = "Idle" })
end

:Finish()

BaseStateMachine:Finish() -> ()

Teardown path for successful completion.

  • @reads: self.IsActive
  • @writes: teardown state via :_Teardown(), then fires Completed
self:AddState("Done", function(fsm)
    fsm:Finish()
end)

:Fail(reason?)

BaseStateMachine:Fail(reason: string?) -> ()

Teardown path for failure.

Returns

  • Nothing. Failed listeners receive the provided reason plus concatenated ERROR log messages captured on the machine logger.

Side effects

  • @reads: self.IsActive, self.Logger.History
  • @writes: teardown state via :_Teardown(), then fires Failed(combinedReason)
fsm:Fail("Target disappeared during attack")

:Cancel()

BaseStateMachine:Cancel() -> ()

Idempotent cancellation path.

Returns

  • Nothing. Cancelled is only fired once because _cancelledFired guards repeated calls.

Side effects

  • @reads: self.IsActive, _cancelledFired
  • @writes: _cancelledFired, teardown state, Cancelled signal
if not questStillValid then
    fsm:Cancel()
end

:Destroy()

BaseStateMachine:Destroy() -> ()

Permanent disposal. Safe to call after Finish, Fail, or Cancel.

What it cleans up

  • active state teardown via :_Teardown()
  • every object registered by :Manage()
  • pending transition queue and history buffers
  • lifecycle signals (Completed, Failed, Cancelled, StateChanged)

Side effects

  • @reads: self._cleanupTasks
  • @writes: _destroyed, _cleanupTasks, transition queues, history, lifecycle signals
fsm:Destroy()

:Manage(object)

BaseStateMachine:Manage(
    object: Instance | RBXScriptConnection | (() -> ()) | { Destroy: (self: any) -> () }
) -> any

Registers a disposable resource that should be torn down when Destroy() runs.

Returns

  • The same object, so you can inline it.

Side effects

  • @reads: none
  • @writes: appends to self._cleanupTasks

Example

function MyFSM:RegisterStates()
    self:AddState("Watching", {
        OnEnter = function(_, fsm)
            fsm:Manage(workspace.ChildAdded:Connect(function(child)
                print("Observed", child.Name)
            end))
        end,
    })
end

Timing Options Compared

State-owned timeout fields

Object 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

WaitSpan vs ScheduleTransition vs Transitions[].Condition

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

Rule of thumb

  • use ScheduleTransition for β€œafter N seconds in this state, go somewhere else”
  • use Timeout / OnTimeout when timeout is part of the state contract
  • use Transitions for continuously evaluated conditions
  • keep WaitSpan for legacy or explicit delayed-ChangeState flows

Runtime Properties

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.

Signals

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

Example

fsm.StateChanged:Connect(function(newState, oldState)
    print("Transition", oldState, "->", newState)
end)

fsm.Failed:Connect(function(reason)
    warn("FSM failed:", reason)
end)

Advanced Loop Control

BaseStateMachine.DisableInternalLoop()

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)

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.


Edge Cases & Gotchas

  1. StateChanged does not mean OnEnter finished. For non-terminal states, OnEnter is deferred with task.defer.
  2. Function states may return cleanup callbacks. If you forget that and return a function unintentionally, it will be stored as legacy cleanup.
  3. WaitSpan precedence is subtle. self.WaitSpan wins over params.WaitSpan if it is non-zero.
  4. Terminal states can be implicit. If a name is listed in TerminalStates, ChangeState allows it even when no state body is registered.
  5. ScheduleTransition is state-relative, not wall-clock. It measures against StateDuration, so pausing / skipped updates do not fire early.
  6. Same-state reentry does not reset StateDuration. This is intentional and makes self-loop timing patterns possible.
  7. Queued re-entrant transitions are replayed later. If multiple listeners call ChangeState during one transition, only the in-flight commit happens immediately.
  8. Destroy is the only API that disposes managed resources. Finish, Fail, and Cancel perform teardown but do not iterate Manage() cleanup tasks.
  9. Submachine context is nested. Child constructors receive a table containing Context = parent.Context; the parent context is not flattened into the child.
  10. Invalid transitions log instead of throwing. Your code must observe logs or signals if you need failure visibility.

Anti-patterns

1. Using task.delay inside states for simple timers

-- 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" })

2. Registering states after startup

Register all states from RegisterStates() before calling :Start().

3. Mutating fsm.State when you actually need immediate validation

fsm.State = "Next" is picked up later by _Update. If you need immediate, validated commit ordering, call fsm:ChangeState({ Name = "Next" }).

4. Combining multiple timing systems without a clear owner

If a state uses WaitSpan, ScheduleTransition, and Timeout all at once, the last transition that successfully commits wins and may invalidate the others.

5. Yielding in Priority = 1 state logic

Render-priority machines update inline; long yields or expensive work there can stall the global loop.


Related Pages

🏠 Home


πŸš€ Getting Started


πŸ—οΈ Architecture


πŸ”§ API Reference


πŸ“š Guides


βš™οΈ Advanced


πŸ› οΈ Development


πŸ“– Reference

Clone this wiki locally