LÖVE Roguelike State Machines: hump.gamestate vs a Custom State Stack
A side-by-side look at hump.gamestate and a hand-rolled LIFO state stack for LÖVE roguelikes, with code patterns for layered UIs, pause overlays, and modal scenes.
LÖVE Roguelike State Machines: hump.gamestate vs a Custom State Stack
Roguelikes punish sloppy scene management. One run touches a title screen, a dungeon scene, an inventory overlay, a targeting cursor mode, a help modal, a game-over screen, and a meta-progression hub between runs. Every one of those scenes wants its own input handling, its own draw order, and its own update tick. Wire them together with a flat if state == "menu" then ... elseif state == "play" then ... block and the project drowns by the third scene.
LÖVE ships with no opinion about scene management, which is part of why it suits roguelikes so well. Two patterns dominate the ecosystem: the hump.gamestate module from vrld/hump, and a hand-rolled state stack you write yourself. They look similar from the outside and diverge sharply once overlays enter the picture.
What hump.gamestate actually gives you
hump.gamestate is a thin dispatcher. You define a state as a Lua table with optional callbacks (enter, leave, update, draw, keypressed, etc.) and tell the module which one is active.
local Gamestate = require 'hump.gamestate'
local menu = {}
local play = {}
function menu:draw()
love.graphics.print("Press space to start", 100, 100)
end
function menu:keypressed(key)
if key == "space" then
Gamestate.switch(play)
end
end
function play:enter()
self.player = { x = 50, y = 50, hp = 10 }
end
function play:update(dt) end
function play:draw()
love.graphics.print("HP: " .. self.player.hp, 10, 10)
end
function love.load()
Gamestate.registerEvents()
Gamestate.switch(menu)
end
registerEvents() patches LÖVE's global callbacks so love.update, love.draw, and the input callbacks forward to the active state. switch calls leave on the outgoing state and enter on the incoming one. That is roughly 90% of what most prototypes need.
The module also exposes push and pop, which manage a stack internally. push(newState) calls enter on the new state without calling leave on the previous one, and pop returns to the previous state. So a stack exists. The interesting question is what happens on the layers underneath the top.
Where hump.gamestate falls short for roguelike overlays
When you push an inventory state on top of a dungeon state in hump.gamestate, only the top state receives update and draw. The dungeon disappears. For a hard scene transition (dungeon to game-over) that is correct. For an inventory overlay where you want to see the dungeon dimmed beneath the panel, it is wrong.
You can work around this by drawing the dungeon manually from the inventory's draw, or by storing a screenshot in a canvas before pushing. Both options leak coupling between states. The dungeon's render code now lives in two places, or you allocate a 1080p canvas per overlay open and hope the GPU memory budget holds.
Roguelikes routinely stack three or four scenes:
- Dungeon (background, paused while a menu is up)
- Inventory panel (visible, accepting input)
- Item-detail tooltip (visible, not accepting input)
- Confirmation modal (visible, accepting input, blocks everything else)
hump.gamestate can do this, but every nontrivial overlay forces the same pattern: the overlay state has to know how to render whatever was beneath it. That coupling grows quadratically.
A custom state stack: 60 lines of explicit control
A custom stack inverts the model. The stack itself walks every layer for draw, but only delivers update and input events to the layers that opt in. Each state declares whether layers below should keep updating and whether layers below should keep rendering.
local StateStack = {}
StateStack.__index = StateStack
function StateStack.new()
return setmetatable({ stack = {} }, StateStack)
end
function StateStack:push(state)
table.insert(self.stack, state)
if state.enter then state:enter(self) end
end
function StateStack:pop()
local top = table.remove(self.stack)
if top and top.leave then top:leave() end
return top
end
function StateStack:update(dt)
for i = #self.stack, 1, -1 do
local s = self.stack[i]
if s.update then s:update(dt) end
if not s.transparent_update then break end
end
end
function StateStack:draw()
local first_drawn = #self.stack
for i = #self.stack, 1, -1 do
if not self.stack[i].transparent_draw then
first_drawn = i
break
end
end
for i = first_drawn, #self.stack do
if self.stack[i].draw then self.stack[i]:draw() end
end
end
function StateStack:keypressed(key)
local top = self.stack[#self.stack]
if top and top.keypressed then top:keypressed(key) end
end
return StateStack
A state declares its behavior with two flags: transparent_update (let the layer below keep ticking) and transparent_draw (let the layer below keep rendering). An inventory overlay sets transparent_draw = true and transparent_update = false. A targeting cursor sets both true so animations underneath keep running. A confirmation modal sets transparent_draw = true and intercepts all input.
local inventory = {
transparent_draw = true,
transparent_update = false,
}
function inventory:draw()
love.graphics.setColor(0, 0, 0, 0.6)
love.graphics.rectangle("fill", 0, 0, love.graphics.getDimensions())
love.graphics.setColor(1, 1, 1, 1)
love.graphics.print("Inventory (Esc to close)", 100, 80)
end
function inventory:keypressed(key)
if key == "escape" then app.stack:pop() end
end
The dungeon scene below stays exactly as it was. No screenshot, no shared draw helper, no coupling.
Trade-offs side by side
hump.gamestate wins on time-to-prototype. Five minutes after git clone https://github.com/vrld/hump, you have a functioning state machine with input forwarding wired through. For a 7-day game jam with a single dungeon scene and a title screen, it is the right call.
A custom stack wins as soon as overlays multiply. The 60-line implementation above costs roughly 30 minutes to write and test, but it pays back the first time three scenes share the screen. The transparency flags also make pause behavior trivial: a pause menu sets transparent_update = false, and the world freezes without a single line in the dungeon scene.
A rough rule of thumb from shipping a roguelike in LÖVE: if the design has ≤2 simultaneously visible scenes ever, use hump.gamestate. If ≥3 layers can stack, switch to a custom stack before the third overlay ships, because retrofitting is harder than starting flat.
There is also a hybrid worth knowing about. You can keep hump.gamestate for top-level scene transitions (title to dungeon to game-over) and run a smaller custom stack for UI overlays inside the dungeon scene. The dungeon owns its own Hud stack of panels and modals. This keeps the global namespace simple while solving the layered-UI problem locally.
Practical notes that bite roguelike projects
A few details that the docs do not stress enough:
- Input bubbling: when an overlay declines to handle a key, do not bubble it to the layer below. Roguelikes use single-key inputs for every action, and an
escapethat closes a menu should not also be interpreted as "quit to title" by the dungeon. Either consume every key on the top layer or maintain an explicit allowlist. - Save points: pushing a state during
updateis fine; pushing duringdrawcorrupts the stack on the same frame. Defer pushes through a queue if you discover them mid-render. - Coroutines for sequenced scenes: a death animation that fades, shows stats, then returns to the title is a sequence, not a state.
hump.timerhandles this cleanly without growing the stack. See thevrld/humpREADME forTimer.script.
When the answer is neither
If the project grows past 10 distinct scenes with rich transitions (slide, crossfade, animated reveals), neither approach feels great. At that point, a dedicated FSM library like kikito/stateful.lua or an entity-component-system that treats scenes as entities scales further. For a typical solo roguelike in the 5000-15000 line range, the hump-or-stack decision covers it.
The deciding question is layering, not complexity. Count how many things will be on screen at once during a worst-case turn. If the answer is one, hump is fine. If the answer is three or more, write the stack on day one.
References: