chi0sk
github.com/chi0sk

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()
Concurrency model: Single-actor cooperative. Transitions are serialized through a _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 States table and an Initial child.
  • 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
Note: Context and payloads must be acyclic serialisable data. Cyclic tables error on deepClone instead of silently stack overflowing.

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.

Iteration order: Lua's 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()
Config hooks vs listener hooks: Config 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

MethodDescription
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

MethodReturnsDescription
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

MethodDescription
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

FieldTypeDefaultDescription
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

FieldTypeDescription
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

FieldTypeDescription
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 pairs is 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.