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, 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