Learning

Lua + LÖVE: Make a Game · Lesson 04 · 35 min

Own the game loop

Separate game state, simulation, and drawing while building the first Orbit Runner scene.

Learn

Load, update, draw, and frame-by-frame state.

Build

A pulsing signal and a player ship.

Prove it

Change the scene without mixing its jobs.

You now know enough Lua to reason about the heart of a LÖVE game. This lesson turns the callback sketch from Lesson 1 into a tiny simulation.

A frame is a snapshot

LÖVE’s default loop conceptually does this:

start → love.load()


      read events


      love.update(dt) ── change state


      clear window


      love.draw() ────── show state

          └───────────── repeat

You do not need to write that loop. You supply callbacks that participate in it.

Drawing is not permanent paint on a canvas. The window is cleared and reconstructed every frame.

Replace main.lua

Return to your orbit-runner folder and replace main.lua with this:

local player = {
  x = 480,
  y = 360,
  radius = 14,
}

local signal = {
  x = 480,
  y = 180,
  radius = 9,
  pulse = 0,
}

function love.load()
  love.window.setMode(960, 540)
  love.window.setTitle("Orbit Runner")
  love.graphics.setBackgroundColor(0.035, 0.03, 0.07)
end

function love.update(dt)
  signal.pulse = signal.pulse + dt * 4
end

function love.draw()
  -- Signal glow, then signal core.
  local glowRadius = signal.radius + 8 + math.sin(signal.pulse) * 2
  love.graphics.setColor(1, 0.83, 0.47, 0.18)
  love.graphics.circle("fill", signal.x, signal.y, glowRadius)
  love.graphics.setColor(1, 0.83, 0.47)
  love.graphics.circle("fill", signal.x, signal.y, signal.radius)

  -- Player body, dark window, then outer ring.
  love.graphics.setColor(0.33, 0.91, 0.84)
  love.graphics.circle("fill", player.x, player.y, player.radius)
  love.graphics.setColor(0.06, 0.12, 0.18)
  love.graphics.circle("fill", player.x + 4, player.y - 3, 3)
  love.graphics.setColor(1, 1, 1, 0.65)
  love.graphics.circle("line", player.x, player.y, player.radius + 4)

  love.graphics.setColor(1, 1, 1)
  love.graphics.print("ORBIT RUNNER", 20, 18)
end

Run it. The signal should breathe while the player waits below it.

dt measures real time

LÖVE passes love.update the time since the previous update, in seconds. A typical value might be 0.016 near 60 frames per second.

function love.update(dt)
  signal.pulse = signal.pulse + dt * 4
end

The pulse value therefore changes at about four units per second, not four units per frame. math.sin converts that ever-growing phase into a smooth value cycling from -1 to 1.

The state changes in update. Draw only interprets the current phase as a radius:

local glowRadius = signal.radius + 8 + math.sin(signal.pulse) * 2

Drawing order is layering

LÖVE processes drawing calls in order. Later pixels appear over earlier pixels. That is why the translucent glow is drawn before the solid core and the player’s dark window is drawn after its body.

Try swapping two drawing calls. No depth property is required for a simple 2D scene; order is the depth system.

Color is also state inside the graphics API. After this call:

love.graphics.setColor(1, 0.83, 0.47)

later shapes and text remain gold until another setColor changes it. Resetting to white before text prevents accidental tinting.

Version trap

LÖVE 11.x colors use values from 0 to 1. Older tutorials may use 255. For a familiar byte color such as (84, 231, 215), divide each channel by 255 or call love.math.colorFromBytes(84, 231, 215).

Put window configuration in conf.lua

love.window.setMode works, but LÖVE can configure the window before startup through an optional conf.lua next to main.lua:

orbit-runner/ ├── conf.lua └── main.lua

Create conf.lua:

function love.conf(t)
  t.identity = "orbit-runner"
  t.version = "11.5"
  t.window.title = "Orbit Runner"
  t.window.width = 960
  t.window.height = 540
  t.window.vsync = 1
end

Then remove love.window.setMode and love.window.setTitle from love.load. t.identity will later give the game a stable save directory.

Draw a fixed backdrop

It is tempting to generate random stars inside love.draw:

-- Do not do this: every point jumps to a new position each frame.
love.graphics.circle("fill", love.math.random(960), love.math.random(540), 1)

Generate persistent random data once in love.load, then draw it repeatedly:

local backdrop = {}

function love.load()
  love.graphics.setBackgroundColor(0.035, 0.03, 0.07)

  for _ = 1, 80 do
    table.insert(backdrop, {
      x = love.math.random() * love.graphics.getWidth(),
      y = love.math.random() * love.graphics.getHeight(),
      size = love.math.random() * 1.5 + 0.5,
    })
  end
end

At the top of love.draw, before the signal, add:

love.graphics.setColor(0.72, 0.69, 1, 0.55)
for _, point in ipairs(backdrop) do
  love.graphics.circle("fill", point.x, point.y, point.size)
end

Calling love.graphics.getWidth() and getHeight() avoids repeating the window dimensions throughout the code.

Experiment

Add a second signal with a different phase. Make its pulse alternate with the first rather than match it. Hint: initialize its pulse to math.pi.

Checkpoint

You are done when the backdrop stays fixed, the signal pulses smoothly, and you can point to exactly where scene data changes versus where it is drawn.

The LÖVE callback reference summarizes the callbacks and graphics calls introduced here.