Implementing a Stack-Based Game State Manager in LÖVE
Learn how to implement a stack-based Game State Manager in LÖVE to handle transitions between menus and gameplay without relying on messy global if/else blocks.
09 Aug 2026, 21:35 UTC

The Problem: Callback Fragmentation
LÖVE (Love2D) uses a global callback architecture. By default, love.update(dt) and love.draw() run every frame regardless of whether the player is in a main menu, a pause screen, or active gameplay. Hard-coding if/else blocks inside these callbacks to check the current game mode quickly becomes unmanageable as the project grows.
The solution is a Game State Manager (GSM). A stack-based GSM allows you to delegate LÖVE's callbacks to a specific state object and "layer" states—such as pushing a pause menu on top of the game world without destroying the game world's current progress.
Requirements for a Minimal GSM
To avoid global state pollution and ensure predictable transitions, the manager must satisfy these requirements:
- Delegation: The manager must intercept
love.update,love.draw, andlove.keypressedand pass them to the active state. - State Persistence: The system must support
push(adding a state to the top) andpop(removing the top state to return to the previous one). - Context Injection: Shared data (like player stats or asset caches) must be passed into states via a context object rather than stored in
_G(the global table).
The Smallest Suitable Design
The most efficient design uses a simple Lua table as a stack and a manager object to handle the logic. In this architecture, each "State" is a table containing its own update and draw functions.
-- StateManager.lua
local StateManager = {}
StateManager.stack = {}
function StateManager.push(state)
table.insert(StateManager.stack, state)
end
function StateManager.pop()
table.remove(StateManager.stack)
end
function StateManager.update(dt)
local active = StateManager.stack[#StateManager.stack]
if active and active.update then
active.update(dt)
end
end
function StateManager.draw()
-- Option: iterate through stack to draw underlying states
for i, state in ipairs(StateManager.stack) do
if state.draw then state.draw() end
end
end
return StateManager
Example: Implementing a Pause Menu
To implement a pause menu that overlays the game, you create two separate state tables. The GameState handles movement, while the PauseState handles menu navigation.
-- GameState.lua
local GameState = {}
function GameState.update(dt)
-- Move player, check collisions
if love.keyboard.isDown("escape") then
StateManager.push(PauseState)
end
end
function GameState.draw()
love.graphics.print("Game Running...", 10, 10)
end
-- PauseState.lua
local PauseState = {}
function PauseState.update(dt)
if love.keyboard.isDown("escape") then
StateManager.pop()
end
end
function PauseState.draw()
-- Draw a semi-transparent overlay
love.graphics.setColor(0, 0, 0, 0.5)
love.graphics.rectangle("fill", 0, 0, 800, 600)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.print("PAUSED", 350, 250)
end
Trust and Data Boundaries
A common failure point in LÖVE projects is the use of global variables for player health or scores. This makes it impossible to reset a state without manually clearing every global variable.
Instead, use a GameContext object. Create a table containing all shared resources and pass it to the state during initialization:
local context = { player = { hp = 100 }, assets = {} }
StateManager.push(GameState(context))
By injecting the context, the state manager remains a pure dispatcher, and the states remain decoupled from the global environment.
Operational Checks
To verify the GSM is functioning correctly, perform these checks during development:
- Delta Time (dt) Propagation: Ensure
love.update(dt)passes thedtvariable all the way to the active state. If movement speed changes when you push/pop states,dtis likely being lost or recalculated. - Input Bleed: Verify that when a
PauseStateis active, theGameStateis not still processinglove.keypressedevents. The manager should only call the top-most state's input handler. - Stack Depth: Print
#StateManager.stackto the console during transitions to ensure you aren't accidentally pushing the same state multiple times in a loop.
Failure Modes and Design Shifts
This stack-based design is ideal for small to medium projects, but certain conditions require a change in architecture:
- Memory Leaks: If states load large images or sounds in a
loadfunction and are pushed/popped frequently, memory usage will climb. If you notice performance degradation, transition to a State Factory that explicitly calls adestroy()orcleanup()method on popped states. - Complex Transitions: If your game requires "fade-out" transitions between states, a simple stack is insufficient because you need two states to be active (and drawing) simultaneously during the animation. In this case, implement a TransitionState that manages the alpha blending between the outgoing and incoming states.
Rollback Procedure
Since this implementation replaces the default love.update and love.draw logic, to revert to the standard LÖVE flow, remove the StateManager calls from main.lua and move the logic from your state tables back into the primary LÖVE callbacks.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.