Implementing State Machines in Lua
Overview
A state machine is a model for controlling program flow based on discrete states and defined transitions between them. Instead of scattering if/else checks everywhere to figure out what the program should do next, you encode the logic around explicit states — “idle”, “walking”, “jumping”, “attacking” — and events that trigger transitions.
In Lua, implementing state machines is straightforward because tables and functions are first-class values. You can store state, transition rules, and callbacks all in a single table structure.
Why state machines matter
Without explicit states, game characters and UI flows accumulate branching logic that becomes hard to follow. A player character might check if self.jumping and self.on_ground and self.health > 0 scattered across dozens of places.
State machines fix this by guaranteeing that only one state is active at a time, and transitions only happen through well-defined events. This makes the code easier to test, debug, and extend.
A minimal state machine
At its core, a state machine has three things: a current state, a set of states, and transition rules:
local fsm = {
current = "idle",
states = {
idle = { next_state = "walk" },
walk = { next_state = "run" },
run = { next_state = "idle" },
attack = { next_state = "idle" },
},
events = {
walk = { from = "idle", to = "walk" },
run = { from = "walk", to = "run" },
attack = { from = "idle", to = "attack" },
stop = { from = "walk", to = "idle" },
stop = { from = "run", to = "idle" },
},
}
function fsm:transition(event)
local t = self.events[event]
if not t then return false, "unknown event" end
if t.from ~= self.current then
return false, ("cannot %s while in %s"):format(event, self.current)
end
self.current = t.to
return true, self.current
end
print(fsm:transition("walk")) --> true, walk
print(fsm:transition("attack")) --> false, cannot attack while in walk
This shows the idea: transitions are validated against the current state. But for real use, each state needs behavior: entry actions, exit actions, and update logic.
State pattern with metatables
Real state machines attach behavior to each state. A clean approach uses a state object table per state, with optional on_enter, on_exit, and update callbacks:
local StateMachine = {}
StateMachine.__index = StateMachine
function StateMachine.new()
local fsm = setmetatable({
current = nil,
states = {}
}, StateMachine)
return fsm
end
function StateMachine:add(name, callbacks)
self.states[name] = callbacks or {}
end
function StateMachine:transition(to)
local from_state = self.states[self.current]
local to_state = self.states[to]
if not to_state then
error(("state %q does not exist"):format(to))
end
if from_state and from_state.on_exit then
from_state:on_exit(to)
end
self.current = to
if to_state.on_enter then
to_state:on_enter(from_state and from_state.name or nil)
end
end
function StateMachine:update(dt)
local state = self.states[self.current]
if state and state.update then
state:update(dt)
end
end
The StateMachine class gives you a reusable foundation with add, transition, and update methods. Each state is a plain table with optional callbacks — no inheritance, no magic. The metatable-based approach keeps things idiomatic: you call StateMachine.new() to create an instance, then populate it with named states that carry their own behavior. This pattern scales from simple character controllers to complex UI flow managers without growing unwieldy.
Usage:
local player = StateMachine.new()
player:add("idle", {
name = "idle",
on_enter = function() print("entering idle") end,
on_exit = function() print("exiting idle") end,
update = function(self, dt)
-- wait for player input
end,
})
player:add("walk", {
name = "walk",
on_enter = function() print("entering walk") end,
update = function(self, dt)
self.x = self.x + self.speed * dt
end,
})
player:add("jump", {
name = "jump",
on_enter = function() self.vy = 10 end,
update = function(self, dt)
self.y = self.y + self.vy * dt
self.vy = self.vy - 20 * dt
if self.y <= 0 then
self.y = 0
self:transition("idle")
end
end,
})
player.x, player.y = 0, 0
player.speed = 100
player:transition("walk")
-- entering walk
for i = 1, 10 do
player:update(0.016)
end
print(("player at x=%.1f"):format(player.x)) --> player at x=0.2
player:transition("jump")
-- exiting walk
-- entering jump
The example shows the full lifecycle: registering three named states, transitioning into “walk”, simulating half a second of movement at 60 FPS, then transitioning to “jump”. Notice how each state’s on_enter and on_exit callbacks fire automatically; the transition method handles cleanup of the old state and setup of the new one. The update loop never needs to know which state is active; it just calls player:update(dt) and the current state’s behavior runs.
Guard Conditions
Not every event should trigger a transition. Guard conditions check extra criteria before allowing the transition:
player:add("walk", {
update = function(self, dt)
self.x = self.x + self.speed * dt
if self.stamina <= 0 then
self:transition("tired")
end
end,
})
player:add("jump", {
can_enter = function(from)
return from == "idle" or from == "walk"
end,
on_enter = function()
print("jumping!")
end,
})
-- Calling jump from tired state is blocked
player:transition("tired")
player:transition("jump") -- silently rejected if can_enter returns false
Guard conditions act as gatekeepers; they run before on_enter and can reject a transition entirely. In the example above, the can_enter function blocks jumping from any state other than “idle” or “walk”, preventing nonsensical transitions like jumping while already airborne. This pattern is essential for character controllers where certain actions are only valid from specific states, and it keeps the transition logic centralized rather than scattered across every state’s update function.
Coroutine-based state machines
Coroutines offer an elegant alternative for states that represent long-running processes: animations, cutscenes, or sequences of steps. Each state runs as a coroutine that yields when waiting:
local function state_sequence(...)
local steps = {...}
for i = 1, #steps do
print("step: " .. steps[i])
coroutine.yield() -- wait one frame
end
end
local player = {
current_co = nil,
}
function player:transition_to(coro)
if self.current_co and coroutine.status(self.current_co) == "suspended" then
coroutine.close(self.current_co)
end
self.current_co = coroutine.create(coro)
end
function player:update()
if self.current_co then
local ok, err = coroutine.resume(self.current_co)
if not ok then
print("state error: " .. err)
self.current_co = nil
elseif coroutine.status(self.current_co) == "dead" then
self.current_co = nil
end
end
end
player:transition_to(state_sequence("approach", "attack", "retreat"))
Coroutines give each state its own execution context; the state function can yield to pause and resume to continue, without manually tracking step counters or phase variables. This is particularly useful for cutscenes, AI behavior trees, and multi-step animations where the flow is sequential but needs to span multiple frames. The downside is that coroutine-based states are harder to interrupt mid-sequence, since the coroutine owns the control flow until the next yield.
Hierarchical state machines
For complex entities, states can have substates. An “airborne” superstate might contain “jump”, “fall”, and “double_jump” substates; they all share airborne behavior but each has distinct details:
player:add("airborne.jump", {
on_enter = function() self.vy = 10 end,
update = function(self, dt)
self.vy = self.vy - 20 * dt
self.y = self.y + self.vy * dt
if self.vy < 0 then
self:transition("airborne.fall")
end
end,
})
player:add("airborne.fall", {
update = function(self, dt)
self.y = self.y + self.vy * dt
self.vy = self.vy - 20 * dt
if self.y <= 0 then
self.y = 0
self:transition("grounded.idle")
end
end,
})
player:add("airborne.double_jump", {
on_enter = function()
self.vy = 12
self.can_double_jump = false
end,
})
The dot-notation isn’t enforced by the code; it’s a naming convention that makes the hierarchy clear.
State machine libraries
Rather than building from scratch, consider:
- lState, a minimal Lua state machine library
- lua-state-machine, based on Jake Gordon’s JavaScript state machine, supports callbacks and transition guards
With LuaRocks:
luarocks install lstate
Common use cases
Game character AI. Enemies with states like “patrol”, “chase”, “attack”, “retreat” are the classic example. Each state defines what the character does when it’s in that mode. The FSM approach makes AI behavior predictable: you can test each state in isolation, compose behaviors by adding new states, and debug transitions by logging state changes without tracing through a tangle of conditional checks.
UI screens. A game menu system with “title”, “playing”, “paused”, and “game_over” screens. Transitions happen on user input rather than on a timer. State machines prevent common UI bugs like pausing during a loading screen or opening the inventory during a cutscene — each screen state explicitly declares which other screens it can transition to, so invalid flows are impossible by construction.
Networking protocols. TCP has states like “listen”, “syn_sent”, “established”, “closing”. A Lua state machine can model this cleanly for protocol implementations, ensuring that messages are only processed when the connection is in the right state and that unexpected packets trigger appropriate error handling rather than undefined behavior.
Animations and cutscenes. A cutscene state machine cycles through predefined animation sequences triggered by story events. Unlike a linear script, a state machine can branch at decision points, handle interrupts like the player pressing “skip”, and resume from checkpoints after a save-load cycle.
Gotchas
Transitions during update. Calling transition() from within an update() callback is fine, but avoid calling it twice in the same frame; the second call sees the new current state, not the old one. If you need to queue a transition, store it and process it at the end of the frame:
player.pending_transition = "jump"
-- process at end of update loop
if player.pending_transition then
player:transition(player.pending_transition)
player.pending_transition = nil
end
Pending transitions solve a real problem: state machines often need to change state based on conditions detected during an update tick, but changing mid-frame can cascade into unexpected behavior. The queue-deferred pattern above ensures all update logic finishes before any transition fires, keeping the state machine in a consistent state throughout the frame.
Coroutines and pcall. When wrapping coroutine resume in pcall, pass arguments after the coroutine; they become the values returned by the first yield or received by the coroutine function:
local ok, err = pcall(coroutine.resume, co, arg1, arg2)
State explosion. Adding too many states makes the machine hard to follow. If you have more than 10-15 states, consider grouping related ones under a hierarchical parent state.
Mutable state data. Keep state data (position, health, stamina) in the entity table, not in the state callbacks. States should be stateless behavior objects that operate on the entity.
See Also
- /guides/lua-closures/, closures capture state, useful for per-state callback functions
- /guides/lua-metatables/, metatables power the state object pattern and inheritance for substates
- /tutorials/lua-fundamentals/control-flow/, control flow primitives that state machines rely on