Managing Game States and Scenes

· 8 min read · Updated March 20, 2026 · intermediate
lua gamedev love2d state-machine hump

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. The previous argument is the state you came from, and ... contains any arguments passed from switch() or push().
  • leave() runs when the state is exited via switch() or pop().
  • resume(...) runs when a state returns to the top of the stack after a pushed state above it is popped.
  • update(dt) and draw() 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.