Choosing Between a State Machine and a Monolithic Loop in LÖVE (Love2D)
Choosing between a monolithic update loop and a state‑machine pattern in LÖVE (Love2D) depends on project size, maintenance, and overlay needs. This guide compares the two approaches, shows a concrete implementation, and explains trade‑offs.
13 Mar 2026, 09:02 UTC

Decision Context
When building a multi‑scene game in LÖVE, you quickly realize that the framework’s flat callback structure (love.update, love.draw, love.keypressed, etc.) forces you to write a lot of conditional logic in a single place. For small prototypes this is fine, but as the number of scenes grows, the main loop can become cluttered and hard to maintain. The core decision is whether to keep a single monolithic loop that dispatches based on flags, or to adopt a state‑machine pattern that encapsulates each scene’s logic into its own table.
Option 1: Monolithic Update Loop
In a monolithic design you keep a global variable, e.g. currentScene = "menu", and then use if / elseif blocks inside love.update and love.draw to call scene‑specific functions.
-- main.lua
local currentScene = "menu"
function love.update(dt)
if currentScene == "menu" then
updateMenu(dt)
elseif currentScene == "game" then
updateGame(dt)
end
end
function love.draw()
if currentScene == "menu" then
drawMenu()
elseif currentScene == "game" then
drawGame()
end
end
function switchTo(scene)
currentScene = scene
end
Pros:
- Very little boilerplate – just a few
ifstatements. - All logic lives in
main.lua, which can be convenient for quick prototypes.
Cons:
- Logic for each scene is scattered across many callback functions, making it hard to locate a specific piece of code.
- Adding new scenes increases the number of
ifbranches linearly. - State‑specific initialization is hard to guarantee; you must remember to reset variables when switching.
Option 2: State‑Machine Pattern
A state machine keeps each scene in its own table and delegates the LÖVE callbacks to the currently active state. The pattern can be simple (current state only) or stack‑based (allowing overlays such as pause menus).
-- state_machine.lua
local StateMachine = {}
StateMachine.__index = StateMachine
function StateMachine:new()
local sm = setmetatable({stack = {}}, self)
return sm
end
function StateMachine:push(state)
table.insert(self.stack, state)
if state.enter then state:enter() end
end
function StateMachine:pop()
local state = table.remove(self.stack)
if state and state.exit then state:exit() end
end
function StateMachine:current()
return self.stack[#self.stack]
end
function StateMachine:update(dt)
local state = self:current()
if state and state.update then state:update(dt) end
end
function StateMachine:draw()
local state = self:current()
if state and state.draw then state:draw() end
end
return StateMachine
Each scene is a Lua table with optional enter, update, draw, exit methods. Example:
-- menu_state.lua
local Menu = {}
function Menu:enter()
print("Entering Menu")
end
function Menu:update(dt)
if love.keyboard.isDown("return") then
stateMachine:push(Game)
end
end
function Menu:draw()
love.graphics.print("Press Enter to Start", 100, 100)
end
return Menu
Integration in main.lua:
local StateMachine = require "state_machine"
local Menu = require "menu_state"
local Game = require "game_state"
local stateMachine = StateMachine:new()
stateMachine:push(Menu)
function love.update(dt)
stateMachine:update(dt)
end
function love.draw()
stateMachine:draw()
end
Trade‑offs Table
| Aspect | Monolithic Loop | State Machine |
|---|---|---|
| Boilerplate | Low – just if branches | Higher – state objects, manager, enter/exit hooks |
| Scalability | Linear growth in if statements | Linear growth in state files, no change to loop |
| Encapsulation | ||
| Initialization | enter hook||
| Overlay support | Pause state onto stack||
| Memory leaks | Possible if popped states keep references | |
| Learning curve |
Concrete Validation Steps
- Set up the state machine. Run
stateMachine:push(Menu)at startup. - Switch scenes. In
Menu:update, callstateMachine:push(Game)when the player pressesEnter. Verify thatGame:enterruns and thatMenu:updatestops executing. - Test pause overlay. In
Game:update, push aPausestate whenEscis pressed. Confirm thatGame:updateno longer runs whilePause:updateis active. - Check memory. After popping
Pause, ensureGame:updateresumes and that no stray references toPauseremain (e.g.,collectgarbage("collect")anddebug.getregistry()checks). - Performance. Profile
love.updatewith a large number of states. The overhead of a singleifchain vs. a state machine lookup is negligible for most games.
Limitations & Practical Check
The state‑machine approach is overkill for a single‑screen game or a tiny prototype. In those cases, keep the monolithic loop. For anything that grows beyond two scenes, the state machine reduces code duplication and makes adding new scenes trivial.
To verify that a state transition worked, add a simple log in each enter and exit method:
function Menu:enter() print("Menu entered") end
function Menu:exit() print("Menu exited") end
Run the game and watch the console output when switching scenes or pausing. If the messages appear in the expected order, the state machine is functioning correctly.
Bottom Line
Use a state machine when you need clean separation of scenes, easy overlay support, or plan to grow the game. Stick with a monolithic loop for quick prototypes or single‑screen projects. The decision hinges on code maintainability versus minimal initial boilerplate.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.