Created
July 6, 2025 12:59
-
-
Save DonBattery/02a66ffc12844d3448ec6851d0e8726f to your computer and use it in GitHub Desktop.
PICO-8 Finite State Machine
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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