Managing Game States and Scenes
As your games grow beyond a single screen, you’ll need a way to organize different parts of your application. A menu screen behaves differently from gameplay, which is different from a pause overlay. Game states give you a clean pattern for managing these distinct modes.
What Are Game States?
A game state is a self-contained module that handles its own logic, rendering, and input. Each state owns a slice of your game’s behavior. The menu state draws the title screen and waits for the player to start. The play state runs the main game loop. The pause state overlays the current game without replacing it.
Managing states manually with flags and conditionals gets messy fast. You end up with code like if gameMode == "menu" then ... elseif gameMode == "playing" then ... scattered everywhere. A state machine solves this by letting each state manage itself, and the framework handles switching between them.
Installing hump.gamestate
The hump library is the standard solution for LÖVE game states. It provides a clean API for creating states, switching between them, and managing a stack of active states.
Download hump and place hump/gamestate.lua in your project directory, then require it:
Gamestate = require "hump.gamestate"
That’s all you need to get started.
Creating Your First States
States in hump are plain Lua tables. You don’t call any constructor function. Each state is simply a table with callback methods that the gamestate system invokes at appropriate times.
local menu = {}
local play = {}
local gameover = {}
Define callbacks on these tables to control what happens when the state initializes, becomes active, and gets left behind.
Understanding the State Lifecycle
Every state has several lifecycle callbacks that hump invokes automatically:
init()runs once, before the state ever becomes active. Use this for one-time setup that doesn’t depend on external data.enter(previous, ...)runs every time the state becomes active. Thepreviousargument is the state you came from, and...contains any arguments passed fromswitch()orpush().leave()runs when the state is exited viaswitch()orpop().resume(...)runs when a state returns to the top of the stack after a pushed state above it is popped.update(dt)anddraw()run every frame while the state is active.- Input callbacks (
keypressed,keyreleased, etc.) run when the state is active.
Switching Between States
The Gamestate.switch(to, ...) function replaces the current state entirely. It calls leave() on the old state, then init() (if this is the first time) or enter() on the new one. The ... arguments flow into enter() as parameters.
function menu:keyreleased(key)
if key == "return" then
Gamestate.switch(play)
elseif key == "escape" then
love.event.quit()
end
end
The return before Gamestate.switch() matters. When you switch states, callback processing in the new state waits until update() is called on it. Any code after switch() in the same function will still execute unless you return to stop it.
Registering Callbacks
To connect hump to LÖVE’s callback system, call Gamestate.registerEvents() in your love.load() function. This tells hump to listen for LÖVE’s callbacks and forward them to whichever state is currently active.
function love.load()
Gamestate.registerEvents()
Gamestate.switch(menu)
end
Once registered, you don’t need to define love.update, love.draw, or input callbacks in your main file. Each active state handles them through its own callback methods.
If you prefer manual control, you can omit registerEvents() and forward callbacks yourself. The upside is control. The downside is that you must forward every callback you use, or those events won’t reach your states.
A Complete Example: Menu, Play, and Game Over
This example ties everything together. The player moves a circle around the screen, accumulates a score, and presses space to trigger the game over state. The game over screen shows the final score and lets the player return to the menu.
-- main.lua
Gamestate = require "hump.gamestate"
local menu = {}
local play = {}
local gameover = {}
-- MENU STATE
function menu:init()
print("Menu: init() called once")
end
function menu:enter(previous)
print("Menu: enter() called")
end
function menu:draw()
love.graphics.setColor(1, 1, 1)
love.graphics.print("=== MAIN MENU ===", 100, 100)
love.graphics.print("Press ENTER to start", 100, 150)
love.graphics.print("Press ESC to quit", 100, 180)
end
function menu:keyreleased(key)
if key == "return" then
Gamestate.switch(play)
elseif key == "escape" then
love.event.quit()
end
end
-- PLAY STATE
function play:enter()
self.score = 0
self.player_x = 400
self.player_y = 300
print("Play: Game started!")
end
function play:update(dt)
local speed = 200
if love.keyboard.isDown("left") then
self.player_x = self.player_x - speed * dt
elseif love.keyboard.isDown("right") then
self.player_x = self.player_x + speed * dt
end
if love.keyboard.isDown("up") then
self.player_y = self.player_y - speed * dt
elseif love.keyboard.isDown("down") then
self.player_y = self.player_y + speed * dt
end
self.score = self.score + dt * 10
if love.keyboard.isDown("space") then
Gamestate.switch(gameover, self.score)
end
end
function play:draw()
love.graphics.setColor(0.2, 0.6, 1)
love.graphics.circle("fill", self.player_x, self.player_y, 20)
love.graphics.setColor(1, 1, 1)
love.graphics.print("Score: " .. math.floor(self.score), 10, 10)
love.graphics.print("Press SPACE for game over", 10, 30)
end
-- GAME OVER STATE
function gameover:enter(previous, final_score)
self.final_score = final_score or 0
print("GameOver: Final score was " .. self.final_score)
end
function gameover:draw()
love.graphics.setColor(1, 0.2, 0.2)
love.graphics.print("=== GAME OVER ===", 100, 100)
love.graphics.setColor(1, 1, 1)
love.graphics.print("Final Score: " .. math.floor(self.final_score), 100, 150)
love.graphics.print("Press ENTER to return to menu", 100, 200)
love.graphics.print("Press ESC to quit", 100, 230)
end
function gameover:keyreleased(key)
if key == "return" then
Gamestate.switch(menu)
elseif key == "escape" then
love.event.quit()
end
end
function love.load()
Gamestate.registerEvents()
Gamestate.switch(menu)
end
Notice how play:update(dt) passes the score to the game over state via Gamestate.switch(gameover, self.score). The game over state receives it in gameover:enter(previous, final_score).
Pause Screens with Push and Pop
switch() replaces the current state completely. Sometimes you want to overlay a new state on top of the existing one without destroying it. A pause screen is the classic example. You push the pause state onto a stack, draw the paused game underneath it, and pop the pause state when the player resumes.
Gamestate.push(to, ...) puts a new state on top of the stack without calling leave() on the current state. When you pop the pause state, resume() is called on the state beneath it.
function play:keyreleased(key)
if key == "p" then
Gamestate.push(pause)
end
end
function pause:enter(from)
self.from = from
end
function pause:draw()
self.from:draw()
local W, H = love.graphics.getDimensions()
love.graphics.setColor(0, 0, 0, 0.7)
love.graphics.rectangle("fill", 0, 0, W, H)
love.graphics.setColor(1, 1, 1)
love.graphics.printf("PAUSED", 0, H/2 - 20, W, "center")
love.graphics.printf("Press P to resume", 0, H/2 + 20, W, "center")
end
function pause:keyreleased(key)
if key == "p" then
Gamestate.pop()
end
end
The pause state stores a reference to the state below it (self.from = from). When drawing, it calls self.from:draw() first to render the game underneath the pause overlay. Without this step, the paused game would not be visible beneath the overlay.
Saving and Loading Game State
Game state data needs to survive across sessions. LÖVE provides love.filesystem.write() and love.filesystem.read() for persisting data to the save directory. The location varies by operating system, but these functions handle the platform differences automatically.
Only primitive data can be serialized. Numbers, strings, booleans, and tables of primitives are safe. LÖVE objects like images, sounds, and fonts cannot be written to disk. You must recreate them when loading.
A serialization library like lume converts Lua tables into strings and back. Note that lume is a separate library by the same author as hump — it is not bundled with hump.gamestate and must be installed on its own (typically by placing lume.lua alongside gamestate.lua in your project).
local lume = require "lume"
local function saveGame(filename, data)
local serialized = lume.serialize(data)
return love.filesystem.write(filename, serialized)
end
local function loadGame(filename)
if not love.filesystem.getInfo(filename) then
return nil
end
local contents = love.filesystem.read(filename)
return lume.deserialize(contents)
end
When loading a saved game, pass the data to your play state through Gamestate.switch():
local data = loadGame("savegame.txt")
if data then
Gamestate.switch(play, data)
end
Your play state receives it in enter():
function play:enter(previous, data)
if data then
self.player_x = data.player.x
self.player_y = data.player.y
self.score = data.score
else
self.player_x = 400
self.player_y = 300
self.score = 0
end
end
Common Pitfalls
State bleeding occurs when the wrong state’s callbacks run. If you omit Gamestate.registerEvents() and forget to call Gamestate.update(dt) or Gamestate.draw(), your states won’t update or render. Use registerEvents() in love.load() to avoid this.
init() vs enter() trips up many developers. init() runs only once, before the state is ever active. Use it for one-time setup. Any reset logic that must run every time the state becomes active belongs in enter(previous, ...).
resume() is not called after switch(). When you call Gamestate.switch(), the new state runs init() (if first time) then enter(). resume() only fires when pop() brings a state back to the top of the stack. If you need post-pause logic after a modal closes, use push() and pop() instead of switch().
Passing arguments after switch() without return. If you do computation after a switch in the same function and don’t return, that code still executes. Prefer return Gamestate.switch(play) to halt the current function cleanly.
Summary
Game states let you partition your game into distinct, self-contained modules. hump.gamestate handles the plumbing: registering LÖVE callbacks, dispatching them to the active state, and managing transitions via switch() and push()/pop().
The lifecycle callbacks (init, enter, leave, resume) give you hooks for setup, teardown, and state-specific logic. Pass data between states through the ... arguments that flow through switch() and push().
For pause screens, push the pause state and draw the underlying state manually. For full transitions, switch. For saves, serialize only primitive data and reload resources when loading back in.
With these patterns in place, you can organize games of any complexity while keeping each piece of logic focused and maintainable.
See Also
- Getting Started with LÖVE — Set up LÖVE, run your first program, and understand the basic LÖVE callback lifecycle before managing multiple states.
- Collision Detection in LÖVE — Once states are in place, hook up a collision system to detect interactions between game objects.