Learning

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.

Learn

Event-driven feedback and transform isolation.

Build

Bursts, impact shake, and generated tones.

Prove it

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.