Forest Logo
search
package_2

luau-hfsm

By @herilfiu

Roblox

Mirrored from Wally

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.
  • 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.
  • 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.
  • 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.
  • Mistakes fail at compile time. Dangling edges, missing initial children, misspelled hooks and cyclic hierarchies are [luau-hfsm] errors that name the state.
  • Strictly typed: --!strict from line 1, with generic context types throughout.
  • Zero engine dependencies: no game, no script, no task. 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

MemberPurpose
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.contextYour 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

eventtoKindWhat happens
namednamedExternalWaits for the event, then exits, acts and enters.
namedomittedInternalWaits for the event, then acts. Nothing is exited or entered.
omittednamedSystem-drivenTaken 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 by tests/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.

Package Details

Install command (Click to copy)


Version

0.1.0

License

MIT

check_circle

Safe for commercial use

Automated license review — not legal advice.