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, state machines are straightforward to implement 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
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
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
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"))
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.
UI screens. A game menu system with “title”, “playing”, “paused”, and “game_over” screens. Transitions happen on user input rather than on a timer.
Networking protocols. TCP has states like “listen”, “syn_sent”, “established”, “closing”. A Lua state machine can model this cleanly for protocol implementations.
Animations and cutscenes. A cutscene state machine cycles through predefined animation sequences triggered by story events.
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
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/control-flow/ — control flow primitives that state machines rely on