StateMachine v1.4
HSM for Roblox. Nested states, guards, timeout transitions, snapshot/restore, and a cooperative event queue.
Hierarchical States
Nested compound states with correct LCA-based exit/enter chains
Guards
Conditional transitions, first passing guard wins on branch arrays
Timeout Transitions
Per-delay independent handles, cancelled automatically on exit
Event Queue
Send inside actions enqueues safely, no assert, no drop
Snapshot / Restore
Plain serialisable tables, works directly with ChronicleStore
Config Validation
All transition targets checked at construction, not at runtime
Why StateMachine?
Ad-hoc boolean flags and if/elseif chains don't scale past a handful of states. StateMachine gives you a proper primitive:
- Correctness: LCA-based exit/enter chains, hooks never double-fire
- Safety: Config validated at construction, bad targets error before anything runs
- Reentrancy: Send from inside an action is queued, not dropped or asserted
- Persistence: Snapshot/Restore with stale-state detection across deploys
- Observability: Full transition history, listener hooks, GetHistory()
_processing lock. Timeouts defer if a Send is in flight. Deterministic under Roblox's cooperative scheduler.
Quick Start
Drop StateMachine.lua in ReplicatedStorage and require it from a server script.
local StateMachine = require(game.ReplicatedStorage.StateMachine)
local machine = StateMachine.new({
Initial = "idle",
Context = { health = 100 },
States = {
idle = {
On = {
ENGAGE = "combat",
},
},
combat = {
Initial = "alive",
OnEnter = function(ctx) print("entered combat") end,
OnExit = function(ctx) print("left combat") end,
States = {
alive = {
On = {
TAKE_DAMAGE = {
Target = "dead",
Guard = function(ctx) return ctx.health <= 0 end,
},
FLEE = "idle",
},
},
dead = {
After = { [5] = "idle" }, -- auto-respawn after 5s
},
},
},
},
})
machine:Send("ENGAGE")
print(machine:GetState()) -- "combat.alive"
machine:SetContext("health", 0)
machine:Send("TAKE_DAMAGE")
print(machine:GetState()) -- "combat.dead"
-- 5 seconds later -> "idle"
Core Concepts
States
A state is a node in the machine. States can be simple (leaf) or compound (have children). The machine is always in exactly one leaf state at a time.
- Leaf state: No children. Machine rests here.
- Compound state: Has a
Statestable and anInitialchild. - Current state: Always the full dotted path e.g.
"combat.alive"
Hierarchical States
When transitioning between states, StateMachine computes the Lowest Common Ancestor (LCA) of the source and target. Only the states below the LCA are exited and entered. Parent state hooks don't re-fire if the parent isn't part of the transition.
States = {
combat = {
Initial = "alive",
OnEnter = function(ctx) print("entered combat") end, -- fires once on entering combat
States = {
alive = {
On = { HIT = "stunned" } -- stays inside combat, OnEnter NOT re-fired
},
stunned = {
After = { [2] = "alive" }
},
},
},
}
Transitioning from combat.alive to combat.stunned only fires alive.OnExit and stunned.OnEnter. combat.OnEnter stays quiet because combat is the LCA.
Context
Context is a free-form table passed into all guards and actions. It's the machine's mutable data.
local machine = StateMachine.new({
Initial = "idle",
Context = { health = 100, stamina = 50 },
States = { ... },
})
-- set a single key
machine:SetContext("health", 80)
-- shallow merge
machine:PatchContext({ health = 80, stamina = 40 })
-- GetContext returns a deep clone, mutations won't affect internal state
local ctx = machine:GetContext()
print(ctx.health) -- 80
Events and Guards
Events are strings sent via Send(). The machine searches for a matching handler starting from the current leaf and bubbling up to root. First match wins.
Simple string target
On = {
JUMP = "airborne", -- always transitions
}
Guarded transition
On = {
HIT = {
Target = "dead",
Guard = function(ctx, event, payload)
return ctx.health - payload.damage <= 0
end,
},
}
Branch arrays, first passing guard wins
On = {
HIT = {
{ Target = "critical", Guard = function(ctx) return ctx.health < 20 end },
{ Target = "hurt", Guard = function(ctx) return ctx.health < 60 end },
{ Target = "alive" }, -- no guard = always passes
},
}
Wildcard events
On = {
["*"] = "idle", -- catch any unhandled event at this level
}
Events bubble from leaf to root. A wildcard on a parent state catches anything not handled by a child.
Timeout Transitions
After maps a delay in seconds to a transition. Multiple delays on the same state are independent and each gets its own cancellation handle.
States = {
stunned = {
After = {
[2] = "recovering",
[5] = {
Target = "idle",
Guard = function(ctx) return ctx.stamina > 50 end,
},
},
},
}
All timeout handles are cancelled when the state exits. If a Send is in flight when a timeout fires, the timeout defers once via task.defer to prevent interleaving.
pairs order is undefined. Multiple After entries shouldn't depend on each other's execution order.
Event Queue
Calling Send from inside a transition action, OnEnter, or OnExit is safe. The event goes into a FIFO queue and drains after the active transition completes.
States = {
a = {
OnEnter = function(ctx, event)
machine:Send("NEXT") -- queued, fires after this transition settles
end,
On = { NEXT = "b" },
},
b = {
OnEnter = function(ctx)
print("reached b") -- will print
end,
},
}
Drain order
Events drain FIFO. If a queued event itself causes a transition whose action queues another event, the chain resolves iteratively with no recursion, bounded by queue depth.
| Call site | Behaviour |
|---|---|
| Normal (not in transition) | Fires immediately, returns bool |
| Inside OnEnter / OnExit / action | Enqueued, returns false, fires after transition |
| Inside task.defer from timeout | Fires normally, transition is done by then |
Snapshot and Restore
Snapshots are plain tables. Pass them directly to ChronicleStore or any DataStore.
-- save
local snapshot = machine:Snapshot()
-- { State = "combat.alive", Context = {...}, History = {...}, Timestamp = 1234 }
ChronicleStore:Save(userId, snapshot)
-- restore
local saved = ChronicleStore:Load(userId)
if saved then
machine:Restore(saved)
end
What gets saved
- Current state (full dotted path)
- Full context table (deep cloned)
- Transition history up to
MaxHistory - Timestamp
Entry hooks on restore
OnEnter hooks are not replayed. The machine resumes as if it was always in that state. If you need entry side-effects on restore, call them manually after Restore().
Stale snapshot protection
Restore validates the saved state against the current config before touching any internal state. If a deploy removed the state the snapshot points to, you get a clear error immediately.
-- if "combat.alive" was removed in a config update:
machine:Restore(oldSnapshot)
-- errors: "Restore: state 'combat.alive' not found in current config - snapshot may be stale"
Listeners
All listener methods return a disconnect function. Listeners are snapshotted before notification so disconnecting during iteration is safe.
-- all transitions
local disconnect = machine:OnTransition(function(from, to, event, payload)
print(from, "->", to, "on", event)
end)
-- entering a specific state or any of its children
machine:OnEnter("combat", function(ctx, event, payload)
print("entered combat subtree")
end)
-- exiting a specific state or any of its children
machine:OnExit("combat", function(ctx, event, payload)
print("left combat subtree")
end)
disconnect()
OnEnter/OnExit fire inside the transition, before history is recorded. machine:OnEnter/machine:OnExit are wrappers around OnTransition and fire after the transition is fully committed.
API Reference
StateMachine.new
StateMachine.new(config: MachineConfig) -> StateMachine
Creates and validates the machine. All transition targets in On and After are checked against the full state tree. Any bad target errors immediately with the exact location.
Send
machine:Send(event: string, payload?: any) -> boolean
Sends an event. Returns true if a transition fired. If called during a transition, the event is enqueued and returns false. It fires after the current transition settles.
Context Methods
| Method | Description |
|---|---|
| GetContext() | Returns a deep clone of context. Mutations don't affect internal state. |
| SetContext(key, value) | Sets a single key on the context table. |
| PatchContext(patch) | Shallow merges a table into context. |
Query Methods
| Method | Returns | Description |
|---|---|---|
| GetState() | string | Full dotted path of current leaf state e.g. "combat.alive" |
| Matches(state) | boolean | True if current state equals or is a descendant of state |
| Can(event, payload?) | boolean | True if the event would fire a transition from the current state (guards evaluated) |
| AvailableEvents() | {string} | All events that can currently fire |
| GetHistory() | {HistoryEntry} | Deep clone of transition history, capped at MaxHistory |
Snapshot / Restore
machine:Snapshot() -> Snapshot
Returns { State, Context, History, Timestamp }. Safe to pass directly to DataStore.
machine:Restore(snapshot: Snapshot)
Restores from a snapshot. Validates the saved state exists in the current config. Cancels all pending timeouts before overwriting state. Does not replay entry hooks.
Listener Methods
| Method | Description |
|---|---|
| OnTransition(fn) | Subscribe to all transitions. Returns disconnect(). |
| OnEnter(state, fn) | Fires when entering state or any descendant. Returns disconnect(). |
| OnExit(state, fn) | Fires when exiting state or any descendant. Returns disconnect(). |
Destroy
machine:Destroy()
Cancels all timeout handles, clears listeners, clears the event queue. Call when the machine's owner (character, NPC, etc.) is cleaned up.
Config Reference
MachineConfig
| Field | Type | Default | Description |
|---|---|---|---|
| Initial | string | required | Starting state. Must exist in States. |
| States | table | required | State tree. |
| Context | table? | {} | Initial context. Deep cloned on construction. |
| MaxHistory | number? | 100 | Max transition history entries. |
| OnTransition | function? | nil | Called via task.spawn after every transition. (from, to, event, ctx) |
| OnError | function? | warn | Called when a hook or action throws. (err, ctx) |
StateConfig
| Field | Type | Description |
|---|---|---|
| On | table? | Event map: string, TransitionConfig, or {TransitionConfig} |
| OnEnter | function? | (ctx, event, payload) |
| OnExit | function? | (ctx, event, payload) |
| After | table? | { [seconds] = target } |
| Initial | string? | Required for compound states. Default child on entry. |
| States | table? | Child states. Makes this state compound. |
TransitionConfig
| Field | Type | Description |
|---|---|---|
| Target | string | Target state. Validated at construction. |
| Guard | function? | (ctx, event, payload) -> boolean |
| Actions | {function}? | Run between exit and enter. (ctx, event, payload) |
Examples
Combat System
local StateMachine = require(game.ReplicatedStorage.StateMachine)
local function createCombatMachine(character)
local machine = StateMachine.new({
Initial = "alive",
Context = { health = 100 },
OnError = function(err) warn("[CombatMachine]", err) end,
States = {
alive = {
Initial = "idle",
States = {
idle = {
On = { ATTACK = "attacking", DODGE = "dodging" },
},
attacking = {
OnEnter = function(ctx, _, payload)
-- deal damage to target
end,
After = { [0.5] = "idle" },
},
dodging = {
After = { [0.4] = "idle" },
},
},
On = {
TAKE_DAMAGE = {
Target = "dead",
Guard = function(ctx, _, payload)
ctx.health = ctx.health - payload.damage
return ctx.health <= 0
end,
},
},
},
dead = {
OnEnter = function(ctx)
character:SetAttribute("Dead", true)
end,
After = { [5] = "alive" },
OnExit = function(ctx)
ctx.health = 100
character:SetAttribute("Dead", false)
end,
},
},
})
return machine
end
Door / Interactable
local door = StateMachine.new({
Initial = "closed",
States = {
closed = {
On = { INTERACT = "opening" },
},
opening = {
OnEnter = function(ctx)
-- play open tween
end,
After = { [0.6] = "open" },
},
open = {
After = { [3] = "closing" }, -- auto-close after 3s
On = { INTERACT = "closing" },
},
closing = {
OnEnter = function(ctx)
-- play close tween
end,
After = { [0.6] = "closed" },
},
},
})
NPC AI
local npc = StateMachine.new({
Initial = "patrol",
Context = { target = nil, alertLevel = 0 },
States = {
patrol = {
On = {
DETECT = {
Target = "alert",
Guard = function(ctx, _, payload)
ctx.target = payload.player
return true
end,
},
},
},
alert = {
OnEnter = function(ctx)
print("NPC spotted", ctx.target)
end,
After = {
[2] = {
Target = "chase",
Guard = function(ctx) return ctx.alertLevel >= 3 end,
},
[8] = "patrol", -- give up if not escalated
},
On = {
LOSE_SIGHT = "patrol",
ESCALATE = {
Target = "alert",
Actions = {
function(ctx) ctx.alertLevel += 1 end,
},
},
},
},
chase = {
OnEnter = function(ctx)
print("NPC chasing", ctx.target)
end,
On = {
LOSE_SIGHT = {
Target = "patrol",
Actions = {
function(ctx)
ctx.target = nil
ctx.alertLevel = 0
end,
},
},
IN_RANGE = "attack",
},
},
attack = {
OnEnter = function(ctx)
-- start attacking ctx.target
end,
After = { [0.5] = "chase" },
},
},
})
Integration
With ChronicleStore
Snapshots serialize directly into ChronicleStore's session data.
-- on session load
chronicle:OnLoad(userId, function(data)
if data.machineSnapshot then
machine:Restore(data.machineSnapshot)
end
end)
-- on session save
chronicle:OnSave(userId, function(data)
data.machineSnapshot = machine:Snapshot()
return data
end)
With TaskScheduler
State transitions can submit deferred tasks. Calling Send from an action is safe since it queues.
alive = {
On = {
TRANSACTION = {
Target = "processing",
Actions = {
function(ctx, _, payload)
scheduler:SubmitTask({
Name = "process_transaction",
ExactlyOnce = true,
Payload = payload,
})
end,
},
},
},
},
processing = {
On = {
DONE = "alive",
FAILED = "alive",
},
}
Limitations
- Acyclic payloads only. Cyclic tables error on deepClone instead of stack overflowing.
- No reference equality. Shared non-cyclic references in context become independent clones across GetContext() and Snapshot().
- After iteration order. Lua's
pairsis undefined. Multiple After entries shouldn't depend on each other's order. - Single-actor. Not thread-safe across concurrent Roblox tasks. One logical owner per machine.
- No reachability analysis. Config validation checks target existence, not reachability.