State and Logic
StateMachine
Finite state machines with guards, timed transitions, tags, history and nested parallel regions.
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.
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?): FSMValidates 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
| Field | Description |
|---|---|
initial: string | The 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
| Field | Description |
|---|---|
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(): BuilderBuilds 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): booleanFires 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): ScheduledFireFires the event later. The returned object has :Cancel().
FireSequence
fsm:FireSequence(events: { string | { any } }): booleanFires 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): booleanWhether :Fire() would transition right now, without doing it.
Wait
fsm:Wait(stateName: string, timeout: number?): booleanYields 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(): booleanReset 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
| Member | Description |
|---|---|
:GetState(): string | The current state. |
:Is(state): boolean | Whether the machine is in state. |
:IsInAny(...states): boolean | Whether the machine, or one of its active regions, is in any of the states. |
:GetFullState(): string | The state including regions, like "Playing[combat:Idle, music:Calm]". |
PreviousState: string? | The state before the last transition. |
TimeInState: number | Seconds 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
| Member | Description |
|---|---|
:GetStates(): { string } | Every state name. |
:GetTransitions(state?) | The transition table of a state (default: current). |
:CanReach(state, maxDepth?): boolean | Whether state can be reached through transitions from the current state. |
:GetReachableStates(maxDepth?): { string } | Every state reachable from the current one. |
:Describe(): string | The 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