herilfiu/luau_hfsm
A reusable HFSM util for Roblox games.
luau-hfsm
A hierarchical finite state machine for Roblox game frameworks: compile a statechart once, run it on hundreds of interactive objects.
Features
- Definition and instance are separate.
HFSM.compilereturns an immutable graph;HFSM.createmakes a session that shares it and owns only its own active configuration, queues and teardown scopes. A chart is a class; a session is an object whose behaviour depends on the state it is in. - True hierarchy, SCXML semantics. Events resolve innermost-first and bubble outward. Transitions exit and enter around the transition domain, precomputed at compile time so a hop costs no traversal.
- Orthogonal regions. Mark a state
paralleland its children all run at once, each with its own current state, its own scopes and its own answer to an event. A character can be running, mid-cast and chilled without a state namedRunningWhileCastingChilled. - Internal transitions. Leave
tooff an edge and it claims its event, runs its action, and never leaves the state — noonExit, no teardown, noonEnter. A weapon counts rounds and a channel absorbs damage without restarting their own animations. - Time, built in and injected.
{ after = 5, to = "Idle" }is taken five seconds into a visit, armed on entry and disarmed on exit. The clock is a scheduler you pass tocreate, so the package still touches no engine global and tests run on a fake clock. - Interrupt and resume.
{ to = "Casting", history = "deep" }hands a stunned cast back exactly where it was; an ordinaryto = "Casting"starts it afresh. - Edges that must win.
preempt = truelets "die from anywhere" on a parent beat every child's own edges, instead of repeatinghealth > 0in each of them. - Every callback is handed its session. Hooks, guards and actions get the session
last, the way a method gets
self— nocontext.session = sessioncycle to set up.onEnterandonExitare also told which event caused them. - Failures never corrupt a session. A transition that has started always finishes,
whatever throws on the way, so the configuration stays legal; the error names the hook
or edge (
[luau-hfsm] action failed on Idle --Go--> Chasing: …). Guards cannot change the session they guard. - Teardown that respects the hierarchy. Every state gets its own cleanup scope. A
sibling hop disposes the sibling; a shared parent's resources are never touched, and
neither is a neighbouring region's.
cleanup:add(handle, "unsubscribe")binds anything that spells its teardown its own way. - Macrosteps that settle. One
sendtakes every enabled conditionless edge and drains internal events before returning, so a waypoint state never leaks into the game loop.settle()re-checks those edges after the context changes, and says whether the machine moved. - Four queues, in a fixed order. Events a hook raises are served first; external
events wait in a
high,normalorlowband. A death overtakes a queued attack without making the chart non-deterministic — order inside a band never changes. - Intents can be checked before they are taken.
Session:can(event)answers whether an event would be claimed by the state the session is actually nested in, without running a single hook — the question a server has to ask of a client's request. - Anything outside can follow along.
Session:subscribe(listener)runs a listener once per macrostep that moved the machine — never on a configuration the chart was only passing through — so a HUD redraws from state without the chart knowing a HUD exists. - Machines compose. A session is an ordinary value, and
Cleanupdisposes of anything with adestroymethod, socleanup:add(childSession)makes a match own its players and a spawner own its minions, cascading on teardown with no bookkeeping. - Mistakes fail early and loudly. Dangling edges, ambiguous targets, missing
initialchildren, misspelled hooks and cyclic hierarchies are compile errors that name the state; strict mode makes a misspelled event or state id at runtime an error too. - Strictly typed:
--!strictfrom line 1, generic context types throughout, a publicSessioninterface with nothing private on it, and transition helpers that give inline guards and actions their types under either Luau solver. - Zero engine dependencies: no
game, noscript, notask. Runs identically under standalone Lune and Roblox. - Specified and tested. Every promise is a numbered requirement in the specification, and every requirement names the test that pins it.
- Dual package ecosystem: native support for Wally and pesde.
Installation
Wally
Add luau-hfsm to your wally.toml:
[dependencies]
luau-hfsm = "herilfiu/luau-hfsm@^0.2.0" # x-release-please-version
pesde
Install via pesde:
pesde add herilfiu/luau_hfsm
Quick Start
local HFSM = require(path.to.luau_hfsm)
-- Compile once, at module scope.
local machine = HFSM.compile({
initial = "Closed",
states = {
Closed = {
transitions = { { event = "Open", to = "Opening" } },
},
Opening = {
onEnter = function(context, cleanup)
-- Bind the thing, not the call: `:Play()` returns nothing, and a state
-- binds what has to be undone when it is left.
local swing = context.model.Hinge.Swing
swing:Play()
cleanup:add(function()
swing:Stop()
end)
end,
-- No event: taken as soon as the guard holds, inside the same macrostep.
transitions = { { to = "Open", guard = function(context)
return context.isFullyOpen
end } },
},
Open = {
initial = "Unlocked",
transitions = { { event = "Close", to = "Closed" } }, -- works from either child
states = {
Unlocked = { transitions = { { event = "Lock", to = "Locked" } } },
Locked = { transitions = { { event = "Unlock", to = "Unlocked" } } },
},
},
},
})
-- Create one lightweight session per object.
local door = HFSM.create(machine, { model = model, isFullyOpen = true }):start()
door:send("Open") --> passes through Opening and settles in Open.Unlocked
door:send("Lock") --> claimed by Open.Unlocked; Open keeps its cleanup scope
print(door:getState()) --> "Open.Locked"
print(door:matches("Open")) --> true
door:destroy() --> exits innermost-first, disposing every bound resource
Coming from a flat state machine
If you have written a flat machine before, the schema above looks familiar right up until
Open contains states of its own. That one addition is the whole idea, and it buys
three things a flat machine cannot express.
A state can be inside another state. Open is not a leaf; it is a compound state
containing Unlocked and Locked. The door is in Open and in Open.Unlocked at
once. getState() gives you the innermost one, matches("Open") asks about the whole
subtree, and getPath() gives you the chain.
A parent handles what its children do not. In a flat machine, Close is written on
Unlocked, again on Locked, and again on every child added later. Here it is written
once on Open. An event is offered to the deepest active state first and bubbles outward
until something claims it:
-- Flat: every state repeats the edges it shares with its neighbours.
Unlocked = { transitions = { { event = "Lock", to = "Locked" }, { event = "Close", to = "Closed" } } },
Locked = { transitions = { { event = "Unlock", to = "Unlocked" }, { event = "Close", to = "Closed" } } },
-- Nested: the shared edge is written once, on the region that owns it.
Open = {
initial = "Unlocked",
transitions = { { event = "Close", to = "Closed" } },
states = {
Unlocked = { transitions = { { event = "Lock", to = "Locked" } } },
Locked = { transitions = { { event = "Unlock", to = "Unlocked" } } },
},
},
A parent's resources survive its children changing. This is the part with no flat
equivalent. Every state gets its own cleanup scope, and moving between two children
disposes only the child being left — the parent is the domain of the hop, the innermost
state containing both ends, and a domain is never exited. An aggro highlight bound to
Combat lives across every Melee ⇄ Ranged switch and dies exactly once, when Combat
itself is left. In a flat machine you would re-create it on every switch, or leak it.
Nothing else changes: send, getState and destroy are the calls you already know.
Recipe 5 works all three points
through one NPC.
Running several things at once
Nesting says which state you are in. Regions say how many things are going on at the
same time. Mark a state parallel = true, leave out initial, and its children all run
together — each with its own current state, its own teardown scopes, and its own answer
to an event:
Alive = {
parallel = true,
transitions = { { event = "Died", to = "Dead" } }, -- reached from every region
states = {
Ability = {
initial = "Ready",
states = {
Ready = { transitions = { { event = "Cast", to = "Channeling" } } },
Channeling = { transitions = { { event = "Release", to = "Ready" } } },
},
},
Locomotion = {
initial = "Idle",
states = {
Idle = { transitions = { { event = "Move", to = "Running" } } },
Running = { transitions = { { event = "Stop", to = "Idle" } } },
},
},
},
},
character:send("Cast")
character:send("Move") -- claimed by Locomotion; the cast keeps channeling
character:getLeaves() --> { "Alive.Ability.Channeling", "Alive.Locomotion.Running" }
character:send("Died") -- both regions bubble it to Alive; taken once
Without regions this needs a state per combination — RunningWhileCasting and every
other pair. With them the chart grows by addition instead of multiplication, and a
resource bound in one region is never disposed by something happening in another.
getState() has no single answer while regions are running and says so;
Recipe 12 works
a three-region character through in full.
API summary
| Member | Purpose |
|---|---|
HFSM.compile(schema) | Validate a schema and return an immutable Machine. |
HFSM.create(machine, context, options?) | Create a Session, in the "idle" status. Options: schedule, strict, maxMicrosteps, maxListenerRounds. |
HFSM.on(event, to?, opts?) · HFSM.always(to, opts?) · HFSM.after(seconds, to?, opts?) | Build typed transitions; bind once with local on: HFSM.On<Ctx> = HFSM.on. |
HFSM.Cleanup.new() | A standalone teardown scope. |
HFSM.VERSION · HFSM.getVersion() | The package version. |
Session:start() | Enter the initial configuration; returns the session. |
Session:send(event, data?, priority?) | Queue an external event and run a macrostep to quiescence. |
Session:raise(event, data?) | Queue an internal event, served before every external band. |
Session:settle() | Re-check the system-driven edges after the context changed; returns whether the machine moved. |
Session:can(event, data?) | Whether the event would be claimed, without taking it. |
Session:subscribe(listener) | Watch the session; returns a function that unsubscribes. |
Session:getEvents() | Every event name the chart mentions, sorted. |
Session:matches(id) | Whether a state id is in the active configuration. |
Session:getState() | The innermost active state; throws when regions leave more than one. |
Session:getLeaves() · Session:getPath() | One leaf per region, or the whole configuration. |
Session:getStatus() | "idle", "running" or "destroyed". |
Session:destroy() | Unwind the whole path and dispose every scope. |
Session.context | Your data, handed to every hook, guard and action, along with the session itself. |
cleanup:add(binding, disposer?) | Bind a function, thread, Instance, connection — or anything, given a method name or teardown function. |
Exported types: Machine<C>, Session<C>, Context, Event<D>, Priority, History,
Delay<C>, Schema<C>, StateSchema<C>, TransitionSchema<C>, EdgeOptions<C>, On<C>,
Always<C>, After<C>, Guard<C>, Action<C>, EnterHook<C>, ExitHook<C>,
Observer<C>, Cleanup, CleanupTask, Disposer<T>, CreateOptions, Scheduler,
Status, and the opaque CompiledState<C> and CompiledTransition<C>.
The shapes a transition takes
| Trigger | to | Kind | What happens |
|---|---|---|---|
event | named | External | Waits for the event, then exits, acts and enters. |
event | omitted | Internal | Waits for the event, then acts. Nothing is exited or entered. |
after | named | Delayed | Taken that many seconds into a visit, unless the state was left first. |
after | omitted | Delayed, internal | Runs its action once, that many seconds into the visit. |
| neither | named | System-driven | Taken as soon as its guard holds, inside the same macrostep. |
Any of them can also carry guard, action, preempt = true (offered before the
states beneath its source) and history = "shallow" or "deep" (resume the target where
it was left).
local on: HFSM.On<WeaponContext> = HFSM.on -- bound once, so every edge is typed
Firing = {
onEnter = function(context, cleanup)
context.muzzle.Enabled = true
cleanup:add(function()
context.muzzle.Enabled = false
end)
end,
transitions = {
-- Internal: the muzzle stays lit across every round.
on("Shot", nil, { action = function(context)
context.ammo -= 1
end }),
on("Empty", "Reloading"),
},
},
Writing to as the state's own name still means an external self transition, which
genuinely exits and re-enters — sometimes exactly what you want. The two are one field
apart on purpose.
Documentation
Full documentation is available in the docs/ directory:
- 01 Concepts & Mental Model
- 02 Architecture & Invariants
- 03 Lifecycle & Resource Management
- 04 Usage Guide
- 05 API Reference
- 06 Recipes & Common Patterns — nineteen worked charts: interactive
objects and chests, doors and their locks, items, weapons, abilities and micro-abilities,
skills sharing one chart, cash registers and shop interfaces, NPC behaviour and AI
decision-making, match logic, nested interfaces, characters running three regions at
once, priority bands, server-authoritative intents, and headless tests. Every chart there
is executed by
tests/specs/recipes.luau. - 07 A Complete Worked Example — one mounted turret, built
end to end: the context, the whole chart, the Roblox wiring, an annotated trace of what
happens inside each
send, every query, the teardown order, and a headless test. Every member of the API summary above appears in it at least once, with a table saying where. Executed bytests/specs/walkthrough.luau. - 08 Composing Machines — building a whole game out of several
charts: a match that owns its players, NPCs that fight each other, entities that sense
each other's state, spawners and queues, object pools, driving a UI, and swapping a
machine at runtime. Executed by
composition.luau,sensing.luauandswapping.luau. - 09 Data & State — where a chart's data lives, state-local data with
the lifetime of its state, passing a payload into a state, and the one trap that follows
from sharing a compiled machine. Executed by
tests/specs/data.luau. - 10 Structuring a Roblox Project — a complete Rojo game in
examples/roblox-game: file layout, a module loader, sessions created on join and destroyed on leave, whatdestroyreleases, NPCs pooled with luau-object-pool, timers, threading and Parallel Luau, and keeping package types under Wally. Executed bytests/specs/example.luau. - 11 A Zombie, End to End — one entity built from parts: stats, armor and
modifiers, status effects, a melee ability, pathfinding, and the chart that decides when each
runs; cleanup scopes and callbacks step by step, the parts and the injected body and how a
zombie is run from outside its chart, classes versus composition, and how an archer
chooses between bow and dagger. Executed by
tests/specs/zombie.luau.
Working through the package for the first time? Read Concepts, then the complete example. Building a whole game rather than one object? Carry on to Composing Machines, then Structuring a Roblox Project.
License
This project is licensed under the MIT License.