Learning

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.

Learn

Finite states and one-time key events.

Build

Title and game-over screens with restart.

Prove it

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:

  1. Launch: no meteors should move behind the title screen.
  2. Press Enter: score is 0, lives are 3, and the player starts in the same place.
  3. Collect a signal, then lose all lives.
  4. On game over, the final score remains visible and simulation freezes.
  5. Press Enter: old meteors and signals are gone; score and lives reset.
  6. 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.