herilfiu/luau_hfsm

A reusable HFSM util for Roblox games.

luau-hfsm

CI Wally Package License: MIT

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.compile returns an immutable graph; HFSM.create makes 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 parallel and 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 named RunningWhileCastingChilled.
  • Internal transitions. Leave to off an edge and it claims its event, runs its action, and never leaves the state — no onExit, no teardown, no onEnter. 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 to create, 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 ordinary to = "Casting" starts it afresh.
  • Edges that must win. preempt = true lets "die from anywhere" on a parent beat every child's own edges, instead of repeating health > 0 in each of them.
  • Every callback is handed its session. Hooks, guards and actions get the session last, the way a method gets self — no context.session = session cycle to set up. onEnter and onExit are 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 send takes 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, normal or low band. 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 own machines. HFSM.group() holds a changing set of child sessions -- the players in a round, the abilities on a character, the missiles in the air. Bind it to a state's cleanup scope and every member dies with that state, cascading through groups of groups. Members can be broadcast to, heard from with one listen, followed by a UI with observe, and handed to a new owner with release and adopt.
  • Charts talk back. session:emit("Damaged", data) sends an output to whoever called session:listen("Damaged", fn) -- a HUD, a sound system, a parent machine -- delivered once the machine is at rest, never in the middle of a transition.
  • Write a piece once, mount it anywhere. A state table, a chain of states or a whole chart can be plugged in as a state, and a function that returns one is a template. namespace = true makes a fragment's edges mean its own states wherever it is mounted.
  • Mistakes fail early and loudly. Dangling edges, ambiguous targets, missing initial children, 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: --!strict from line 1, generic context types throughout, a public Session interface 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, no script, no task. 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.4.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

MemberPurpose
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.group(options?)An empty group of sessions that it owns, creating each with options.
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:emit(name, data?)Emit an output, delivered to listeners once the machine is at rest.
Session:listen(name, listener)Hear one output name; returns a function that unsubscribes.
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.contextYour 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.
Group:spawn(machine, context, key?) · Group:adopt(session, key?)Create, start and own a member; or take ownership of a running session.
Group:release(member) · Group:remove(member)Hand a member back alive; or destroy it.
Group:get(key) · Group:count() · Group:members()Look-ups, and iteration in join order.
Group:send(event, data?, priority?)Broadcast to every member whose chart mentions the event; returns how many claimed it.
Group:listen(name, listener)Hear one output name from every member, present and future.
Group:observe(observer)Run per member with a cleanup scope that lasts while it stays — the UI hook.
Group:destroy() · Group:isDestroyed()Destroy every member, last joined first.

State fields beyond the basics: namespace = true makes a reusable fragment's edges find its own states first, and emits = { ... } declares the outputs a state emits, which strict mode checks.

Exported types: Machine<C>, Session<C>, Group<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>, Listener<C>, MemberListener<C>, MemberObserver<C>, Cleanup, CleanupTask, Disposer<T>, CreateOptions, Scheduler, Status, and the opaque CompiledState<C> and CompiledTransition<C>.

The shapes a transition takes

TriggertoKindWhat happens
eventnamedExternalWaits for the event, then exits, acts and enters.
eventomittedInternalWaits for the event, then acts. Nothing is exited or entered.
afternamedDelayedTaken that many seconds into a visit, unless the state was left first.
afteromittedDelayed, internalRuns its action once, that many seconds into the visit.
neithernamedSystem-drivenTaken 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 by tests/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.luau and swapping.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, what destroy releases, NPCs pooled with luau-object-pool, timers, threading and Parallel Luau, and keeping package types under Wally. Executed by tests/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.
  • 12 Reuse and Components — writing a state, a chain or a whole chart once and mounting it anywhere, namespace, why there is no inheritance, outputs for UIs and other machines, and groups of machines that own machines. Worked through as a round of parallel arenas with swappable abilities, an ability bar, and missiles that die with their robot or outlive it. Executed by reuse.luau, outputs.luau, groups.luau and arena.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.