Lua + LÖVE: Make a Game · Lesson 08 · 45 min
Give the game a beginning and an end
Model title, play, and game-over states with event input, reset logic, and readable UI.
Finite states and one-time key events.
Title and game-over screens with restart.
Reset every run-specific value correctly.
Orbit Runner can now be played, but it begins without asking and never formally ends. A small state machine makes the flow explicit:
Enter lives reach 0
title ─────────► playing ───────────────────► gameover
▲ │
└────────── Enter ─────────────┘
Only one state is active at a time.
Name the state
Add this near the top of main.lua:
local gameState = "title"
Strings make this first state machine easy to read. A misspelling such as "gameOver" would be a new value, so keep names lowercase and centralized.
Make simulation conditional. Near the beginning of love.update, after any purely visual background work, return unless the game is playing:
function love.update(dt)
if gameState ~= "playing" then
return
end
-- movement, timers, entities, and collisions continue here
end
An early return keeps the main path from being indented inside a large if.
When damage removes the final life, change state:
if lives <= 0 then
gameState = "gameover"
end
Reset one run in one place
Starting and restarting must initialize the same values. Put all run-specific reset logic in a function:
local function resetGame()
gameState = "playing"
score = 0
lives = 3
signals = {}
meteors = {}
signalTimer = 0
meteorTimer = 0.7
player.x = love.graphics.getWidth() / 2
player.y = love.graphics.getHeight() * 0.72
player.invulnerable = 0
end
The backdrop does not reset because it belongs to the whole application. Score, hazards, and player position belong to one run.
Ask who owns the lifetime
If a value should survive a restart, initialize it in love.load. If it belongs to one attempt, initialize it in resetGame. Clear ownership prevents “ghost” objects and stale timers after restart.
React to the press edge
Define another callback at top level:
function love.keypressed(key)
if key == "return" and gameState ~= "playing" then
resetGame()
elseif key == "escape" then
love.event.quit()
end
end
LÖVE calls this once when a key goes down. That is exactly the behavior a start/restart action needs.
The key name for Enter is "return". Escape asks LÖVE to quit cleanly.
You can also toggle hitbox debugging here:
if key == "f1" then
debugHitboxes = not debugHitboxes
end
Draw centered overlay screens
Create fonts once in love.load, not once per frame:
local font
local bigFont
function love.load()
-- existing setup...
font = love.graphics.newFont(18)
bigFont = love.graphics.newFont(52)
end
Add a helper above love.draw:
local function centered(text, y, chosenFont)
love.graphics.setFont(chosenFont)
love.graphics.printf(
text,
0,
y,
love.graphics.getWidth(),
"center"
)
end
At the end of love.draw, after the world and HUD, draw the active overlay:
if gameState == "title" then
love.graphics.setColor(0, 0, 0, 0.55)
love.graphics.rectangle("fill", 0, 0, love.graphics.getDimensions())
love.graphics.setColor(0.33, 0.91, 0.84)
centered("ORBIT RUNNER", 178, bigFont)
love.graphics.setColor(1, 1, 1)
centered("Collect gold. Dodge red.", 250, font)
centered("Press Enter to launch", 286, font)
centered("Move with WASD or arrow keys", 326, font)
elseif gameState == "gameover" then
love.graphics.setColor(0, 0, 0, 0.68)
love.graphics.rectangle("fill", 0, 0, love.graphics.getDimensions())
love.graphics.setColor(1, 0.49, 0.62)
centered("RUN OVER", 178, bigFont)
love.graphics.setColor(1, 1, 1)
centered("Score: " .. score, 252, font)
centered("Press Enter to try again", 292, font)
end
The translucent black rectangle dims the world without hiding its context. The world still draws first; the overlay draws over it.
Make every state answer three questions
For each state, decide:
| State | What updates? | What draws? | What input matters? |
|---|---|---|---|
title |
nothing yet | backdrop + title overlay | Enter, Escape |
playing |
full simulation | world + HUD | movement, Escape |
gameover |
nothing yet | final world + score overlay | Enter, Escape |
Larger games often give each state separate enter, update, draw, and keypressed functions. This string-and-branch approach is appropriate while there are only three.
Test transitions, not only steady states
Most state bugs happen at boundaries. Test this exact sequence:
- Launch: no meteors should move behind the title screen.
- Press Enter: score is 0, lives are 3, and the player starts in the same place.
- Collect a signal, then lose all lives.
- On game over, the final score remains visible and simulation freezes.
- Press Enter: old meteors and signals are gone; score and lives reset.
- Restart several times to catch values you forgot to reset.
Your turn: pause
Add a "paused" state toggled by P only while playing or paused. Freeze simulation and draw a centered “PAUSED” overlay. Enter should not restart from pause.
Transition hint
Put this in love.keypressed before the Enter branch:
if key == "p" then
if gameState == "playing" then
gameState = "paused"
elseif gameState == "paused" then
gameState = "playing"
end
end
Then add a paused drawing branch. The existing early return already freezes it.
Checkpoint
The application must launch on a title, start on one Enter press, freeze at zero lives, and restart with a clean world. Verify the transition sequence above before continuing.