Skip to content
Engine

State and Logic

StateMachine

Finite state machines with guards, timed transitions, tags, history and nested parallel regions.

PackageShared

Getting it

local StateMachine = Engine:GetPackage("StateMachine")

Overview

A state machine is always in exactly one state. It moves to another state when you fire an event that the current state has a transition for. This keeps logic such as a round loop, a door, an NPC or a weapon easy to follow: each state lists what it reacts to, and nothing else can happen.

A round loop
local StateMachine = Engine:GetPackage("StateMachine")

local round = StateMachine.new({
	initial = "Lobby",
	states = {
		Lobby = {
			transitions = {
				Start = {
					Target = "Intermission",
					Guard = function(fsm)
						return #Players:GetPlayers() >= 2
					end,
				},
			},
		},
		Intermission = {
			after = { seconds = 15, target = "Playing" },
			transitions = { ["<Timeout>"] = "Playing" },
			onEnter = function(fsm)
				announce("Round starts in 15 seconds")
			end,
		},
		Playing = {
			onEnter = function(fsm) spawnEveryone() end,
			onExit = function(fsm) despawnEveryone() end,
			after = { seconds = 180, target = "Results" },
			transitions = { LastPlayerStanding = "Results", ["<Timeout>"] = "Results" },
		},
		Results = {
			after = { seconds = 10, target = "Lobby" },
			transitions = { ["<Timeout>"] = "Lobby" },
		},
	},
})

round.StateChanged:Connect(function(from, to)
	print("round:", from, "->", to)
end)

round:Fire("Start")

Defining a machine

new

StateMachine.new(def: StateMachineDef, clock: (() -> number)?, historyCapacity: number?): FSM

Validates the definition, enters def.initial and runs its onEnter. clock drives timers and TimeInState (default os.clock). historyCapacity sets how many transitions GetHistory keeps.

StateMachineDef

FieldDescription
initial: stringThe starting state.
states: { [string]: StateDef }Every state by name. A state named "*" is a wildcard: its transitions apply in every state that has no transition for that event itself.
onTransition: (fsm, from, to, event) -> ()?Called after every transition.
onUpdate: (fsm, dt) -> ()?Called on every :Update(dt).

StateDef

FieldDescription
transitions: { [event]: TransitionList }?What each event does in this state.
onEnter: ActionList?Called as (fsm, fromState, event, ...args) when the state is entered.
onExit: ActionList?Called as (fsm, toState, event) when the state is left.
update: (fsm, dt) -> ()?Called on every :Update(dt) while in this state.
after: { seconds, target, event? }?Fires an event automatically after seconds in this state: event, or "<Timeout>" when you leave it out. See Timed transitions.
minDuration: number?Seconds the machine must stay in this state before any transition out is allowed.
tags: { string }?Labels for grouping states, checked with HasTag.
regions: { [name]: StateMachineDef }?Nested machines that run in parallel while this state is active.
resetRegionsOnEnter: boolean?Rebuild the regions every time the state is entered instead of resuming them.

An action list is one function or a list of functions.

Transitions

A transition is either the target state name, or a table with a guard:

transitions = {
	Open = "Opened",                                  -- always
	Unlock = { Target = "Closed", Guard = hasKey },   -- only when hasKey returns true
	Use = {                                           -- first matching guard wins
		{ Target = "Opened", Guard = isUnlocked },
		{ Target = "Rattling" },
	},
}

Guards are called as guard(fsm, ...args) with the arguments passed to :Fire(). Combine guards with StateMachine.All(...), StateMachine.Any(...) and StateMachine.Not(guard).

Timed transitions

When an after timer runs out, the machine fires its event exactly as if you had called :Fire(). The move itself comes from the state's transitions, so give the state a transition for that event. target must name an existing state and is what :Describe() draws for the timer.

Cooldown = {
	after = { seconds = 2, target = "Ready" },
	transitions = { ["<Timeout>"] = "Ready" },
},

Leaving the state any other way cancels the timer, and entering the state again restarts it.

Builder

StateMachine.Builder(): Builder

Builds the same definition step by step.

local door = StateMachine.Builder()
	:SetInitial("Closed")
	:AddState("Closed")
	:AddState("Opened", { after = { seconds = 5, target = "Closed" } })
	:AddTransition("Closed", "Open", "Opened")
	:AddTransition("Opened", "Close", "Closed")
	:AddTransition("Opened", "<Timeout>", "Closed")
	:AddTag("Opened", "Passable")
	:Build()

Builder methods: SetInitial(name), AddState(name, def?), AddTransition(from, event, target, guard?), OnTransition(fn), OnUpdate(fn), AddTag(state, tag), Build(clock?, historyCapacity?).

Firing events

Fire

fsm:Fire(event: string, ...: any): boolean

Fires an event. Active regions get it first, then the current state. Returns true if a transition happened (or a region handled it). Returns false when no transition matches, every guard says no, or minDuration has not passed yet. Firing from inside one of this machine's own callbacks raises an error; use FireAfter or task.defer instead.

FireAfter

fsm:FireAfter(seconds: number, event: string, ...: any): ScheduledFire

Fires the event later. The returned object has :Cancel().

FireSequence

fsm:FireSequence(events: { string | { any } }): boolean

Fires several events in order and stops at the first one that does not transition. An entry can be a table { event, ...args }.

Broadcast

fsm:Broadcast(event: string, ...: any)

Fires the event into every region the machine has built, including nested ones.

CanFire

fsm:CanFire(event: string, ...: any): boolean

Whether :Fire() would transition right now, without doing it.

Wait

fsm:Wait(stateName: string, timeout: number?): boolean

Yields until the machine enters stateName. Returns false if the timeout passes first.

Update

fsm:Update(dt: number)

Runs the current state's update, the machine's onUpdate and every region's update. Call it yourself, for example from RunService.Heartbeat. after timers run on their own and do not need it.

Reset / Rollback

fsm:Reset()
fsm:Rollback(): boolean

Reset goes back to the initial state (event "<Reset>") and drops all regions. Rollback returns to the previous state (event "<Rollback>"); it returns false when there is no previous state.

Reading state

MemberDescription
:GetState(): stringThe current state.
:Is(state): booleanWhether the machine is in state.
:IsInAny(...states): booleanWhether the machine, or one of its active regions, is in any of the states.
:GetFullState(): stringThe state including regions, like "Playing[combat:Idle, music:Calm]".
PreviousState: string?The state before the last transition.
TimeInState: numberSeconds since the current state was entered.
StateChanged: Signal<(from, to)>Fires after every transition.

HasTag

fsm:HasTag(tag: string): boolean
fsm:GetTags(stateName: string?): { string }
fsm:GetStatesByTag(tag: string): { string }

Tags let you ask "is the door passable?" instead of listing every state that is.

GetHistory

fsm:GetHistory(): { HistoryEntry }

The most recent transitions as { From, To, Event, Time }, oldest first.

Regions

fsm:GetRegion(regionName: string, stateName: string?): FSM?
fsm:GetRegions(stateName: string?): { [string]: FSM }?

The nested machines of the current state, or of stateName if it has been entered before.

Introspection

MemberDescription
:GetStates(): { string }Every state name.
:GetTransitions(state?)The transition table of a state (default: current).
:CanReach(state, maxDepth?): booleanWhether state can be reached through transitions from the current state.
:GetReachableStates(maxDepth?): { string }Every state reachable from the current one.
:Describe(): stringThe machine as a Graphviz DOT graph, handy for drawing it while debugging.

Types

export type StateMachineDef
export type StateDef
export type TransitionDef = { Target: string, Guard: ((fsm: FSM, ...any) -> boolean)? }
export type Transition = string | TransitionDef
export type TransitionList = Transition | { Transition }
export type AfterDef = { seconds: number, target: string, event: string? }
export type Action = (fsm: FSM, ...any) -> ()
export type HistoryEntry = { From: string, To: string, Event: string, Time: number }
export type FSM
export type ScheduledFire
export type Builder