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. - 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. - 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. - 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. - Mistakes fail at compile time. Dangling edges, missing
initialchildren, misspelled hooks and cyclic hierarchies are[luau-hfsm]errors that name the state. - Strictly typed:
--!strictfrom line 1, with generic context types throughout. - Zero engine dependencies: no
game, noscript, notask. Runs identically under standalone Lune and Roblox. - 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.1.0"
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. |
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:can(event, data?) | Whether the event would be claimed, without taking it. |
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. |
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, Priority, Schema<C>,
StateSchema<C>, TransitionSchema<C>, Guard<C>, Action<C>, EnterHook<C>,
ExitHook<C>, Cleanup, CleanupTask, Disposer<T>, CreateOptions, Status,
CompiledState<C>, CompiledTransition<C>.
The three shapes a transition takes
event | to | Kind | What happens |
|---|---|---|---|
| named | named | External | Waits for the event, then exits, acts and enters. |
| named | omitted | Internal | Waits for the event, then acts. Nothing is exited or entered. |
| omitted | named | System-driven | Taken as soon as its guard holds, inside the same macrostep. |
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.
{ event = "Shot", action = function(context)
context.ammo -= 1
end },
{ event = "Empty", to = "Reloading" },
} :: { HFSM.TransitionSchema<WeaponContext> },
},
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.
Working through the package for the first time? Read Concepts, then the complete example.
License
This project is licensed under the MIT License.