luaguides

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