Skip to content

Instantly share code, notes, and snippets.

@DonBattery
Created July 6, 2025 12:59
Show Gist options
  • Select an option

  • Save DonBattery/02a66ffc12844d3448ec6851d0e8726f to your computer and use it in GitHub Desktop.

Select an option

Save DonBattery/02a66ffc12844d3448ec6851d0e8726f to your computer and use it in GitHub Desktop.
PICO-8 Finite State Machine
pico-8 cartridge // http://www.pico-8.com
version 41
__lua__
-- finite state machine for about 240 tokens
function state_machine(params)
local machine = {
state = params.initial or "none",
events = {},
-- helper to safely call a handler function if it exists
call = function(self, handler, ...)
if handler then
return handler(...)
end
end,
-- check if a transition is valid from current state
-- returns: can_transition (boolean), target_state (string or nil)
can = function(self, event_name)
local transition = self.events[event_name]
-- check for direct state mapping or wildcard mapping
local target = transition and transition.map[self.state] or transition and transition.map['*']
return target ~= nil, target
end,
-- creates a transition function for a specific event
transition = function(self, name)
return function(self, ...)
-- check if this transition is valid from current state
local valid_transition, to_state = self:can(name)
local from_state = self.state
-- early exit if transition not allowed
if not valid_transition then
return false
end
-- package all transition info for callbacks
local params = { self, name, from_state, to_state, ... }
-- pre-transition callbacks can block the transition by returning false
if self:call(self["onbefore" .. name], unpack(params)) == false then
return false
end
if self:call(self["onleave" .. from_state], unpack(params)) == false then
return false
end
-- actually change the state
self.state = to_state
-- post-transition callbacks for side effects
self:call(self["onafter" .. name], unpack(params))
self:call(self["onenter" .. to_state], unpack(params))
self:call(self.onstatechange, unpack(params))
return true
end
end
}
-- setup transition methods and event mappings
for _, event in pairs(params.events) do
-- create the transition method (e.g. fsm:start())
machine[event.name] = machine:transition(event.name)
-- initialize event mapping table
machine.events[event.name] = machine.events[event.name] or { map = {} }
-- handle both single source state and array of source states
local sources = type(event.from) == 'string' and { event.from } or event.from
for _, source in pairs(sources) do
machine.events[event.name].map[source] = event.to
end
end
return machine
end
-- test the machine
fsm = state_machine {
initial = "none",
events = {
{ name = "start", from = "none", to = "running" },
{ name = "pause", from = "running", to = "paused" },
{ name = "stop", from = { "running", "paused" }, to = "none" },
{ name = "reset", from = "*", to = "none" }
}
}
-- test initial state
assert(fsm.state == "none", "initial state is none")
-- test can method
local ok, target = fsm:can("start")
assert(ok and target == "running", "can start from none -> running")
ok, target = fsm:can("pause")
assert(not ok and target == nil, "cannot pause from none")
-- test valid transition
assert(fsm:start(), "start returns true")
assert(fsm.state == "running", "state changed to running")
-- test pause
assert(fsm:pause(), "pause returns true")
assert(fsm.state == "paused", "state changed to paused")
-- test stop from paused
assert(fsm:stop(), "stop returns true")
assert(fsm.state == "none", "state changed to none after stop")
-- test wildcard reset from none
assert(fsm:reset(), "reset returns true")
assert(fsm.state == "none", "state remains none after reset")
-- test wildcard reset from running
fsm.state = "running"
assert(fsm:reset(), "reset from running returns true")
assert(fsm.state == "none", "reset maps any state to none")
-- test invalid transition
assert(not fsm:pause(), "pause from none returns false")
assert(fsm.state == "none", "state unchanged after invalid pause")
-- test callbacks ordering and side-effects
fsm2 = state_machine {
initial = "none",
events = { { name = "go", from = "none", to = "done" } }
}
calls = {}
function fsm2:onbeforego(self, name, from, to)
add(calls, "before")
end
function fsm2:onleavenone(self, name, from, to)
add(calls, "leave")
end
function fsm2:onaftergo(self, name, from, to)
add(calls, "after")
end
function fsm2:onenterdone(self, name, from, to)
add(calls, "enter")
end
function fsm2:onstatechange(self, name, from, to)
add(calls, "change")
end
assert(fsm2:go(), "go returns true")
assert(#calls == 5, "five callbacks called")
assert(calls[1] == "before", "onbeforego first")
assert(calls[2] == "leave", "onleavenone second")
assert(calls[3] == "after", "onaftergo third")
assert(calls[4] == "enter", "onenterdone fourth")
assert(calls[5] == "change", "onstatechange fifth")
-- test blocking in onbefore
fsm3 = state_machine {
initial = "none",
events = { { name = "go", from = "none", to = "done" } }
}
function fsm3:onbeforego(self) return false end
assert(not fsm3:go(), "go blocked by onbefore")
assert(fsm3.state == "none", "state unchanged when blocked by onbefore")
-- test blocking in onleave
fsm4 = state_machine {
initial = "none",
events = { { name = "go", from = "none", to = "done" } }
}
function fsm4:onleavenone(self) return false end
assert(not fsm4:go(), "go blocked by onleave")
assert(fsm4.state == "none", "state unchanged when blocked by onleave")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment