Skip to content

State Machines

Tua includes a typed finite state machine through the built-in stateMachine({...}) helper. It follows the interface of lua-state-machine, requires no package installation or explicit require, and preserves ordinary Lua runtime behavior.

When the configuration is a static table, Tua derives exact state and event names for checking and editor tooling. A build lowers the helper to require("tuastatemachine").create and writes the bundled runtime only when it is required.

Create A Machine

local light = stateMachine({
  initial = "green",
  events = {
    { name = "warn", from = "green", to = "yellow" },
    { name = "panic", from = "yellow", to = "red" },
    { name = "calm", from = "red", to = "yellow" },
    { name = "clear", from = "yellow", to = "green" },
  },
  callbacks = {
    onenterred = function(self, event, from, to)
      print(event, from, to, self.current)
    end,
  },
})

light:warn()
light:panic()
local __tua_state_machine = require("tuastatemachine").create
local light = __tua_state_machine({
  initial = "green",
  events = {
    { name = "warn", from = "green", to = "yellow" },
    { name = "panic", from = "yellow", to = "red" },
    { name = "calm", from = "red", to = "yellow" },
    { name = "clear", from = "yellow", to = "green" },
  },
  callbacks = {
    onenterred = function(self, event, from, to)
      print(event, from, to, self.current)
    end,
  },
})

light:warn()
light:panic()

The static declaration gives light.current the type "green" | "red" | "yellow". It also creates typed warn, panic, calm, and clear methods. Completion after light: offers those event methods, while completion inside light:is("...") and light:can("...") offers only configured state or event names.

Let completion write callback signatures

Inside callbacks, start typing on and choose a generated callback such as onenterred or onbeforewarn. The inserted function annotates self, event, from, and to with the machine's exact unions.

Hover inside nested options, events, and callbacks shows the specific field or parameter type. Definition on fixed fields such as initial and name opens a generated built-in schema. Definition on an event method such as light:warn() or a callback key such as onenterred returns to the source event or state string from which Tua derived it.

Preview The Graph

Enable tua.preview.enabled, then run Tua: Preview State Machine from a Tua file, or follow Preview <name> State Machine directly from the machine's hover. The compiler-backed preview shows the initial state, all statically derived states, event routes, multi-source transitions, wildcard transitions, cycles, and self-transitions. Selecting a state or route opens a source-linked inspector; double-clicking it jumps to the declaration.

The layout keeps states in ordered columns and reserves separate corridors for long forward routes, return routes, same-column transitions, and self-loops. This keeps transition paths outside state cards and separates repeated loops as the graph becomes denser. Each real state receives a distinct, theme-aware pastel color for quick visual tracking; the wildcard source stays neutral.

The preview follows edits and can switch between multiple machines in one file. It reads the same static model used for checking and completion, so it does not execute project code or simulate the machine's current runtime state. Computed configurations remain valid Tua, but cannot produce a graph until their states and events are visible in a direct table literal.

Configure Events

Each event entry has three fields:

Field Meaning
name Event method added to the machine.
from One source state, an array of source states, or "*".
to Destination state entered after the event.

Use an array when several states share one transition:

local menu = stateMachine({
  initial = "closed",
  events = {
    { name = "open", from = "closed", to = "main" },
    { name = "back", from = { "settings", "credits" }, to = "main" },
    { name = "close", from = "*", to = "closed" },
  },
})

initial defaults to "none" when omitted. Event methods accept optional Lua payload arguments; every callback receives them after the standard callback parameters.

Use Callbacks

Callbacks receive (self, event, from, to, ...). Tua derives these names from the configuration:

Callback Timing
onbefore<event> Before the event starts. Returning false cancels it.
onleave<state> Before leaving a state. Returning false cancels; returning self.ASYNC defers.
onenter<state> After changing current. Returning self.ASYNC defers completion.
onafter<event> After the event completes.
on<event> Shorthand for onafter<event>.
on<state> Shorthand for onenter<state>.
onstatechange After every completed state change.
local door = stateMachine({
  initial = "closed",
  events = {
    { name = "open", from = "closed", to = "open" },
  },
  callbacks = {
    onbeforeopen = function(self, event, from, to, actor)
      return actor ~= nil
    end,
    onenteropen = function(self)
      print("door opened", self.current)
    end,
  },
})

local player = { name = "Mina" }
door:open(player)

Event and state callbacks can share a shorthand name

If an event and a state have the same name, prefer the explicit onafter<event> or onenter<state> form so the callback's purpose remains clear.

Machine API

Member Behavior
current Current state, typed as the configured state union.
currentTransitioningEvent Event currently waiting on an async callback, or nil.
<event>(...) Fire a configured event and forward payload values to callbacks.
is(state) Test the current state.
can(event) Return whether an event can fire and its destination state.
cannot(event) Inverse of can.
transition(event) Resume a transition deferred with self.ASYNC.
cancelTransition(event) Cancel a deferred transition.
todot(filename) Write the transition graph as Graphviz DOT.

To defer work, return self.ASYNC from onleave<state> or onenter<state>, then call transition(event) when the asynchronous work is complete:

callbacks = {
  onleavepaused = function(self)
    startFade(function()
      self:transition("resume")
    end)
    return self.ASYNC
  end,
}

Static And Dynamic Configurations

Exact names come from a table literal passed directly to stateMachine. If the configuration is computed or typed as any, the call remains valid but returns the generic StateMachine surface with string-based state and event methods:

local options: any = loadStateMachineOptions()
local machine = stateMachine(options)

A gradual boundary, not a runtime restriction

Dynamic event construction still works at runtime. Tua does not guess names produced by loops, mutation, file loading, or arbitrary function calls, so exact suggestions stop at that boundary.

Runtime And Attribution

tua build writes tuastatemachine.lua beside generated Lua when emitted code requires it. A project-provided file with that name is never overwritten, so a compatible customized runtime can replace the bundled one.

The bundled implementation is adapted from Kyle Conroy's lua-state-machine and keeps its MIT license header.