Lua + LÖVE: Make a Game · Lesson 09 · 55 min
Make actions feel consequential
Layer particles, screen shake, blinking, and procedural sound onto events without changing the rules.
Event-driven feedback and transform isolation.
Bursts, impact shake, and generated tones.
Remove each effect without breaking rules.
The rules already work. Game feel makes the player perceive those rules. A collected signal should answer immediately with motion, light, and sound. A hit should feel heavier than a pickup.
The key architecture rule is simple: effects listen to events; they do not decide the rules.
Make a particle burst
Add effect state:
local particles = {}
A burst inserts short-lived particle tables:
local function burst(x, y, color, amount)
for _ = 1, amount do
local angle = love.math.random() * math.pi * 2
local speed = love.math.random(60, 190)
table.insert(particles, {
x = x,
y = y,
vx = math.cos(angle) * speed,
vy = math.sin(angle) * speed,
life = love.math.random() * 0.35 + 0.35,
maxLife = 0.7,
color = color,
})
end
end
The angle spans one full circle in radians. Cosine supplies its horizontal direction and sine its vertical direction.
Update particles even on title or game-over screens so an ending impact can finish. Put this before the early return in love.update:
for index = #particles, 1, -1 do
local particle = particles[index]
particle.x = particle.x + particle.vx * dt
particle.y = particle.y + particle.vy * dt
local drag = 1 - math.min(1, 3 * dt)
particle.vx = particle.vx * drag
particle.vy = particle.vy * drag
particle.life = particle.life - dt
if particle.life <= 0 then
table.remove(particles, index)
end
end
Draw them with fading alpha before the player:
for _, particle in ipairs(particles) do
local alpha = math.max(0, particle.life / particle.maxLife)
love.graphics.setColor(
particle.color[1],
particle.color[2],
particle.color[3],
alpha
)
love.graphics.circle("fill", particle.x, particle.y, 3)
end
Trigger different bursts in the existing collision responses:
-- Signal collected:
burst(item.x, item.y, {1, 0.83, 0.47}, 14)
-- Meteor hit:
burst(meteor.x, meteor.y, {1, 0.35, 0.48}, 22)
The color is itself a small array table. The particle keeps a reference to it, which is safe because we never mutate these color tables.
Shake the world, not the interface
Add a timer:
local shake = 0
When a meteor hits:
shake = 0.3
Before the game’s early return, count it down:
shake = math.max(0, shake - dt)
In love.draw, isolate world drawing with the transform stack:
-- Draw backdrop first if you want it to remain stable.
love.graphics.push()
if shake > 0 then
love.graphics.translate(
love.math.random(-5, 5),
love.math.random(-5, 5)
)
end
-- Draw signals, meteors, particles, and player here.
love.graphics.pop()
-- Draw HUD and overlays here so text stays readable.
push saves the current graphics transform. translate offsets later drawing. pop restores the previous transform. Forgetting pop would shake or displace everything drawn afterward.
Keep it restrained
Shake should communicate impact, not obscure play. Keep the amplitude small, duration short, and UI stable. Consider a setting to disable it for players sensitive to motion.
Generate two tiny sounds
Ordinary games load .wav or .ogg files. Orbit Runner can stay asset-free by generating a short sine wave once at startup.
Add this helper:
local function makeTone(frequency, duration, volume)
local sampleRate = 44100
local sampleCount = math.floor(sampleRate * duration)
local data = love.sound.newSoundData(
sampleCount,
sampleRate,
16,
1
)
for index = 0, sampleCount - 1 do
local time = index / sampleRate
local attack = math.min(1, time / 0.01)
local release = 1 - index / sampleCount
local wave = math.sin(math.pi * 2 * frequency * time)
data:setSample(index, wave * volume * attack * release)
end
return love.audio.newSource(data)
end
Audio sample indexes begin at 0 in this API, unlike ordinary Lua arrays. Each sample must be between -1 and 1. The short attack and release envelope prevents a harsh click at the boundaries.
Create sources once in love.load:
local collectSound
local hitSound
function love.load()
-- existing setup...
collectSound = makeTone(660, 0.11, 0.22)
hitSound = makeTone(110, 0.22, 0.28)
end
Make replay deterministic:
local function play(source)
source:stop()
source:play()
end
Call play(collectSound) when collecting and play(hitSound) on damage.
For real assets, short sound effects normally use static sources while long music streams:
local pickup = love.audio.newSource("pickup.wav", "static")
local music = love.audio.newSource("music.ogg", "stream")
music:setLooping(true)
music:play()
Load sources once. Creating a source inside love.update or love.draw wastes work and may cause stutter.
Design a feedback hierarchy
Events should not all shout at the same volume.
| Event | Visual | Motion | Sound |
|---|---|---|---|
| collect | 14 gold particles | none | short high tone |
| damage | 22 red particles + blink | brief shake | longer low tone |
| game over | frozen final frame + overlay | no extra shake | damage tone is enough |
Stronger consequence receives heavier feedback. Consistency teaches the player what events mean before they read the UI.
Test effects independently
Temporarily add debug triggers:
function love.keypressed(key)
-- existing state logic...
if key == "1" then
burst(player.x, player.y, {1, 0.83, 0.47}, 14)
play(collectSound)
elseif key == "2" then
burst(player.x, player.y, {1, 0.35, 0.48}, 22)
shake = 0.3
play(hitSound)
end
end
Remove these shortcuts after tuning. Direct triggers let you iterate without waiting for a random collision.
Your turn: tune, do not stack
Change only one parameter at a time—particle count, particle speed, shake duration, shake distance, tone frequency, or tone duration. Write down which value makes collection crisp and which makes damage heavy.
Checkpoint
Collection and damage should be distinguishable with the score and lives text hidden. Then comment out all effect triggers: the underlying scoring, damage, and game-over rules must still work.