{"id":"herilfiu/luau-hfsm","name":"luau-hfsm","scope":"herilfiu","platform":"roblox","description":"A reusable HFSM util for Roblox games.","version":"0.1.0","latest":"0.1.0","versions":["0.1.0"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{},"integrity":"00c46807d4782b69c7f3d2a6d634c90585ef4d6016c893fad3f179a6b6114499","likes":0,"downloads":0,"install":"forest install herilfiu/luau-hfsm","url":"https://forest.dev/p/roblox/herilfiu/luau-hfsm","files":"https://api.forest.dev/ai/package/roblox/herilfiu/luau-hfsm/files","readme":"# luau-hfsm\n\n[![CI](https://github.com/HeriLFIU/luau-hfsm/actions/workflows/ci.yml/badge.svg)](https://github.com/HeriLFIU/luau-hfsm/actions/workflows/ci.yml)\n[![Wally Package](https://img.shields.io/endpoint?url=https%3A%2F%2Ftwirly.dev%2Fwally%2Fv1%2Fherilfiu%2Fluau-hfsm)](https://wally.run/package/herilfiu/luau-hfsm)\n[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)\n\nA hierarchical finite state machine for Roblox game frameworks: compile a statechart\nonce, run it on hundreds of interactive objects.\n\n---\n\n## Features\n\n- **Definition and instance are separate.** `HFSM.compile` returns an immutable graph;\n  `HFSM.create` makes a session that shares it and owns only its own active\n  configuration, queues and teardown scopes.\n- **True hierarchy, SCXML semantics.** Events resolve innermost-first and bubble outward.\n  Transitions exit and enter around the transition domain, precomputed at compile time so\n  a hop costs no traversal.\n- **Orthogonal regions.** Mark a state `parallel` and its children all run at once, each\n  with its own current state, its own scopes and its own answer to an event. A character\n  can be running, mid-cast and chilled without a state named `RunningWhileCastingChilled`.\n- **Internal transitions.** Leave `to` off an edge and it claims its event, runs its\n  action, and never leaves the state — no `onExit`, no teardown, no `onEnter`. A weapon\n  counts rounds and a channel absorbs damage without restarting their own animations.\n- **Teardown that respects the hierarchy.** Every state gets its own cleanup scope. A\n  sibling hop disposes the sibling; a shared parent's resources are never touched, and\n  neither is a neighbouring region's. `cleanup:add(handle, \"unsubscribe\")` binds anything\n  that spells its teardown its own way.\n- **Macrosteps that settle.** One `send` takes every enabled conditionless edge and\n  drains internal events before returning, so a waypoint state never leaks into the game\n  loop.\n- **Four queues, in a fixed order.** Events a hook raises are served first; external\n  events wait in a `high`, `normal` or `low` band. A death overtakes a queued attack\n  without making the chart non-deterministic — order inside a band never changes.\n- **Intents can be checked before they are taken.** `Session:can(event)` answers whether\n  an event would be claimed by the state the session is actually nested in, without\n  running a single hook — the question a server has to ask of a client's request.\n- **Mistakes fail at compile time.** Dangling edges, missing `initial` children,\n  misspelled hooks and cyclic hierarchies are `[luau-hfsm]` errors that name the state.\n- **Strictly typed**: `--!strict` from line 1, with generic context types throughout.\n- **Zero engine dependencies**: no `game`, no `script`, no `task`. Runs identically under\n  standalone Lune and Roblox.\n- **Dual package ecosystem**: native support for Wally and pesde.\n\n---\n\n## Installation\n\n### Wally\n\nAdd `luau-hfsm` to your `wally.toml`:\n\n```toml\n[dependencies]\nluau-hfsm = \"herilfiu/luau-hfsm@^0.1.0\"\n```\n\n### pesde\n\nInstall via `pesde`:\n\n```bash\npesde add herilfiu/luau_hfsm\n```\n\n---\n\n## Quick Start\n\n```luau\nlocal HFSM = require(path.to.luau_hfsm)\n\n-- Compile once, at module scope.\nlocal machine = HFSM.compile({\n    initial = \"Closed\",\n    states = {\n        Closed = {\n            transitions = { { event = \"Open\", to = \"Opening\" } },\n        },\n        Opening = {\n            onEnter = function(context, cleanup)\n                -- Bind the thing, not the call: `:Play()` returns nothing, and a state\n                -- binds what has to be undone when it is left.\n                local swing = context.model.Hinge.Swing\n                swing:Play()\n                cleanup:add(function()\n                    swing:Stop()\n                end)\n            end,\n            -- No event: taken as soon as the guard holds, inside the same macrostep.\n            transitions = { { to = \"Open\", guard = function(context)\n                return context.isFullyOpen\n            end } },\n        },\n        Open = {\n            initial = \"Unlocked\",\n            transitions = { { event = \"Close\", to = \"Closed\" } }, -- works from either child\n            states = {\n                Unlocked = { transitions = { { event = \"Lock\", to = \"Locked\" } } },\n                Locked = { transitions = { { event = \"Unlock\", to = \"Unlocked\" } } },\n            },\n        },\n    },\n})\n\n-- Create one lightweight session per object.\nlocal door = HFSM.create(machine, { model = model, isFullyOpen = true }):start()\n\ndoor:send(\"Open\")            --> passes through Opening and settles in Open.Unlocked\ndoor:send(\"Lock\")            --> claimed by Open.Unlocked; Open keeps its cleanup scope\nprint(door:getState())       --> \"Open.Locked\"\nprint(door:matches(\"Open\"))  --> true\ndoor:destroy()               --> exits innermost-first, disposing every bound resource\n```\n\n---\n\n## Coming from a flat state machine\n\nIf you have written a flat machine before, the schema above looks familiar right up until\n`Open` contains `states` of its own. That one addition is the whole idea, and it buys\nthree things a flat machine cannot express.\n\n**A state can be inside another state.** `Open` is not a leaf; it is a *compound* state\ncontaining `Unlocked` and `Locked`. The door is in `Open` *and* in `Open.Unlocked` at\nonce. `getState()` gives you the innermost one, `matches(\"Open\")` asks about the whole\nsubtree, and `getPath()` gives you the chain.\n\n**A parent handles what its children do not.** In a flat machine, `Close` is written on\n`Unlocked`, again on `Locked`, and again on every child added later. Here it is written\nonce on `Open`. An event is offered to the deepest active state first and bubbles outward\nuntil something claims it:\n\n```luau\n-- Flat: every state repeats the edges it shares with its neighbours.\nUnlocked = { transitions = { { event = \"Lock\", to = \"Locked\" }, { event = \"Close\", to = \"Closed\" } } },\nLocked   = { transitions = { { event = \"Unlock\", to = \"Unlocked\" }, { event = \"Close\", to = \"Closed\" } } },\n\n-- Nested: the shared edge is written once, on the region that owns it.\nOpen = {\n    initial = \"Unlocked\",\n    transitions = { { event = \"Close\", to = \"Closed\" } },\n    states = {\n        Unlocked = { transitions = { { event = \"Lock\", to = \"Locked\" } } },\n        Locked = { transitions = { { event = \"Unlock\", to = \"Unlocked\" } } },\n    },\n},\n```\n\n**A parent's resources survive its children changing.** This is the part with no flat\nequivalent. Every state gets its own cleanup scope, and moving between two children\ndisposes only the child being left — the parent is the *domain* of the hop, the innermost\nstate containing both ends, and a domain is never exited. An aggro highlight bound to\n`Combat` lives across every `Melee ⇄ Ranged` switch and dies exactly once, when `Combat`\nitself is left. In a flat machine you would re-create it on every switch, or leak it.\n\nNothing else changes: `send`, `getState` and `destroy` are the calls you already know.\n[Recipe 5](docs/06-recipes.md#recipe-5-an-npc-three-levels-deep) works all three points\nthrough one NPC.\n\n---\n\n## Running several things at once\n\nNesting says *which* state you are in. Regions say how many things are going on at the\nsame time. Mark a state `parallel = true`, leave out `initial`, and its children all run\ntogether — each with its own current state, its own teardown scopes, and its own answer\nto an event:\n\n```luau\nAlive = {\n    parallel = true,\n    transitions = { { event = \"Died\", to = \"Dead\" } },  -- reached from every region\n    states = {\n        Ability = {\n            initial = \"Ready\",\n            states = {\n                Ready = { transitions = { { event = \"Cast\", to = \"Channeling\" } } },\n                Channeling = { transitions = { { event = \"Release\", to = \"Ready\" } } },\n            },\n        },\n        Locomotion = {\n            initial = \"Idle\",\n            states = {\n                Idle = { transitions = { { event = \"Move\", to = \"Running\" } } },\n                Running = { transitions = { { event = \"Stop\", to = \"Idle\" } } },\n            },\n        },\n    },\n},\n```\n\n```luau\ncharacter:send(\"Cast\")\ncharacter:send(\"Move\")           -- claimed by Locomotion; the cast keeps channeling\ncharacter:getLeaves()            --> { \"Alive.Ability.Channeling\", \"Alive.Locomotion.Running\" }\ncharacter:send(\"Died\")           -- both regions bubble it to Alive; taken once\n```\n\nWithout regions this needs a state per combination — `RunningWhileCasting` and every\nother pair. With them the chart grows by addition instead of multiplication, and a\nresource bound in one region is never disposed by something happening in another.\n`getState()` has no single answer while regions are running and says so;\n[Recipe 12](docs/06-recipes.md#recipe-12-a-character-that-moves-and-casts-at-once) works\na three-region character through in full.\n\n---\n\n## API summary\n\n| Member | Purpose |\n| :--- | :--- |\n| `HFSM.compile(schema)` | Validate a schema and return an immutable `Machine`. |\n| `HFSM.create(machine, context, options?)` | Create a `Session`, in the `\"idle\"` status. |\n| `HFSM.Cleanup.new()` | A standalone teardown scope. |\n| `HFSM.VERSION` · `HFSM.getVersion()` | The package version. |\n| `Session:start()` | Enter the initial configuration; returns the session. |\n| `Session:send(event, data?, priority?)` | Queue an external event and run a macrostep to quiescence. |\n| `Session:raise(event, data?)` | Queue an internal event, served before every external band. |\n| `Session:can(event, data?)` | Whether the event would be claimed, without taking it. |\n| `Session:getEvents()` | Every event name the chart mentions, sorted. |\n| `Session:matches(id)` | Whether a state id is in the active configuration. |\n| `Session:getState()` | The innermost active state; throws when regions leave more than one. |\n| `Session:getLeaves()` · `Session:getPath()` | One leaf per region, or the whole configuration. |\n| `Session:getStatus()` | `\"idle\"`, `\"running\"` or `\"destroyed\"`. |\n| `Session:destroy()` | Unwind the whole path and dispose every scope. |\n| `Session.context` | Your data, handed to every hook, guard and action. |\n| `cleanup:add(binding, disposer?)` | Bind a function, thread, Instance, connection — or anything, given a method name or teardown function. |\n\nExported types: `Machine<C>`, `Session<C>`, `Context`, `Event`, `Priority`, `Schema<C>`,\n`StateSchema<C>`, `TransitionSchema<C>`, `Guard<C>`, `Action<C>`, `EnterHook<C>`,\n`ExitHook<C>`, `Cleanup`, `CleanupTask`, `Disposer<T>`, `CreateOptions`, `Status`,\n`CompiledState<C>`, `CompiledTransition<C>`.\n\n### The three shapes a transition takes\n\n| `event` | `to` | Kind | What happens |\n| :--- | :--- | :--- | :--- |\n| named | named | **External** | Waits for the event, then exits, acts and enters. |\n| named | omitted | **Internal** | Waits for the event, then acts. Nothing is exited or entered. |\n| omitted | named | **System-driven** | Taken as soon as its guard holds, inside the same macrostep. |\n\n```luau\nFiring = {\n    onEnter = function(context, cleanup)\n        context.muzzle.Enabled = true\n        cleanup:add(function()\n            context.muzzle.Enabled = false\n        end)\n    end,\n    transitions = {\n        -- Internal: the muzzle stays lit across every round.\n        { event = \"Shot\", action = function(context)\n            context.ammo -= 1\n        end },\n        { event = \"Empty\", to = \"Reloading\" },\n    } :: { HFSM.TransitionSchema<WeaponContext> },\n},\n```\n\nWriting `to` as the state's own name still means an *external* self transition, which\ngenuinely exits and re-enters — sometimes exactly what you want. The two are one field\napart on purpose.\n\n---\n\n## Documentation\n\nFull documentation is available in the [`docs/`](docs/README.md) directory:\n\n- [01 Concepts & Mental Model](docs/01-concepts.md)\n- [02 Architecture & Invariants](docs/02-architecture.md)\n- [03 Lifecycle & Resource Management](docs/03-lifecycle.md)\n- [04 Usage Guide](docs/04-guide.md)\n- [05 API Reference](docs/05-api-reference.md)\n- [06 Recipes & Common Patterns](docs/06-recipes.md) — nineteen worked charts: interactive\n  objects and chests, doors and their locks, items, weapons, abilities and micro-abilities,\n  skills sharing one chart, cash registers and shop interfaces, NPC behaviour and AI\n  decision-making, match logic, nested interfaces, characters running three regions at\n  once, priority bands, server-authoritative intents, and headless tests. Every chart there\n  is executed by [`tests/specs/recipes.luau`](tests/specs/recipes.luau).\n- [07 A Complete Worked Example](docs/07-complete-example.md) — one mounted turret, built\n  end to end: the context, the whole chart, the Roblox wiring, an annotated trace of what\n  happens inside each `send`, every query, the teardown order, and a headless test. Every\n  member of the API summary above appears in it at least once, with a table saying where.\n  Executed by [`tests/specs/walkthrough.luau`](tests/specs/walkthrough.luau).\n\nWorking through the package for the first time? Read\n[Concepts](docs/01-concepts.md), then\n[the complete example](docs/07-complete-example.md).\n\n---\n\n## License\n\nThis project is licensed under the [MIT License](LICENSE).\n","readmeTruncated":false}