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.
Load, update, draw, and frame-by-frame state.
A pulsing signal and a player ship.
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.
- State is data that persists between frames: player position, score, timers.
- Update advances that state: movement, countdowns, collisions.
- Draw reads state and sends drawing commands.
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:
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.