Back

Activity 2.11: Sound and Polish

divider

The Idea

Your game works and it feels like nothing. Shots leave silently, enemies vanish without complaint, and losing a life looks the same as not losing one until you count the icons.

Feedback is not decoration. It is how the game confirms that what you did registered, and it is the difference between a program that responds and a program that merely changes. This activity adds three sounds and one shaking screen, and it is the shortest distance between what you have and something that feels like a game.

Loading a sound

Download all three and put them in your skynest folder, next to main.lua and ship.png.

shoot.wav — 0.18s hit.wav — 0.12s explode.wav — 0.55s

main.lua
sounds = {
shoot = love.audio.newSource("shoot.wav", "static"),
hit = love.audio.newSource("hit.wav", "static"),
explode = love.audio.newSource("explode.wav", "static"),
}

The second argument is required, and it is a real decision. "static" decodes the whole file into memory at load, which is right for short effects you will play hundreds of times. "stream" decodes as it plays, which is right for a three-minute music track you would rather not unpack into RAM.

A table keyed by name beats three separate locals, and it is the record table from Activity 1.10 doing ordinary work. Loading goes in love.load for the third time on this course, for the third identical reason.

The thing about play() that will confuse you

Here is the behavior nobody warns you about, taken from LÖVE's own source rather than from folklore: calling play() on a Source that is already playing does nothing at all. It does not restart the sound and it does not layer a second copy over it. The call is simply lost, and nothing tells you.

That matters immediately, because your fire cooldown is 0.15 seconds and shoot.wav is 0.18 seconds long. Hold the fire button for five seconds and you fire thirty shots and hear fifteen of them. I ran exactly that under a harness: 15 heard, 15 silently discarded. The game is not broken, the gun just sounds wrong and you cannot see why.

main.lua
-- Fires thirty times. Fifteen of them make no sound at all.
sounds.shoot:play()
-- Fires thirty times. Thirty of them are heard.
sounds.shoot:clone():play()

clone() hands back a new Source pointing at the same decoded audio — LÖVE's copy constructor shares the buffer and copies the pitch, volume and loop settings — so cloning a "static" sound is cheap. Same five seconds, same thirty shots, thirty heard. Note that cloning a "stream" source is not cheap, because it has to clone the decoder too.

There is a second reason to clone, and it is the more interesting one. Pitch and volume belong to the Source, not to the sound. If you want every hit slightly detuned so a burst of them does not sound mechanical, each hit needs a Source of its own.

main.lua
local thud = sounds.hit:clone()
thud:setPitch(0.9 + math.random() * 0.3)
thud:play()

There is a ceiling. LÖVE keeps a pool of 64 simultaneous voices; past that, play() returns false and the sound is dropped. Thirty shots over five seconds is nowhere near it, and a clone every frame would be. Since play() returns a boolean, you can find out rather than wonder.

Not everything should clone

The explode sound stays a single shared Source on purpose. You can only lose one life at a time, and if two losses ever did coincide, one explosion is what you would want to hear. Deciding which sounds clone is a design question, not a mechanical one, and "clone everything" is the wrong answer as reliably as "clone nothing".

Volume is the same kind of judgment. Shooting happens constantly and belongs underneath everything else, so it is set to 0.35 once at load and every clone inherits it.

Screen shake, and the transform stack

main.lua
love.graphics.push()
love.graphics.translate(math.random(-6, 6), math.random(-6, 6))
-- everything drawn here is offset
love.graphics.pop()
-- and everything drawn here is not

translate moves the coordinate system rather than any object, which is why one call shakes the entire world and none of your drawing code has to know about it. push saves the current transform and pop restores it, so the offset applies to exactly what you put between them.

The HUD goes after the pop. A score that jitters is a bug, not an effect, and the world-versus-screen split from Activity 2.9 is finally the difference between two lines of code rather than a distinction on paper.

One place the countdown cannot go

The shake timer is the same accumulator you have written four times. It is also the first one that cannot live with the others.

main.lua
function love.update(dt)
shake = shake - dt
if gameState ~= "playing" then
return
end
-- the rest of the timers live down here
end

The reason is the guard from Activity 2.10. The last shake of a run starts on the hit that ends the run, and one frame later update returns early forever — so a countdown below the guard never runs again, and the screen shakes behind the game-over text until you quit.

I measured both versions over the same ten-second run. Above the guard: 75 shaking frames, three neat bursts. Below it: 184, still going when the run ended. The guard is a decision about which work belongs to a run, not a gate on all work, and this is the first time the difference bites.

divider

Build

The three .wav files go beside main.lua. enemy.lua and collision.lua are untouched.


Task 1: Three sounds

  • Add a sounds local to the top group of the state block — it belongs to the program, not to a run — and fill it in love.load.
  • Turn the shooting down to 0.35 and the hit to 0.6. The loudest sound in a game is rarely the one that happens most.
  • Play shoot when a bullet is created, hit when a bullet lands, and explode when a life is lost.
  • Hold the fire button and listen. The gun stutters, because half the shots are landing on a Source that is still busy. Nothing is broken; you are hearing exactly what the code asked for.
main.lua
-- main.lua
local Enemy = require("enemy")
local collision = require("collision")
local ship
local font
local titleFont
local sounds
local spawnEvery = 0.8
local gameState = "menu"
local player
local bullets
local fireCooldown
local enemies
local spawnTimer
local score
local lives
local invuln
local function resetGame()
player = { x = 400, y = 500, size = 40, speed = 300 }
bullets = {}
fireCooldown = 0
enemies = {}
spawnTimer = 0
score = 0
lives = 3
invuln = 0
end
function love.load()
love.graphics.setBackgroundColor(0.05, 0.05, 0.1)
math.randomseed(os.time())
ship = love.graphics.newImage("ship.png")
font = love.graphics.newFont(20)
titleFont = love.graphics.newFont(48)
love.graphics.setFont(font)
sounds = {
shoot = love.audio.newSource("shoot.wav", "static"),
hit = love.audio.newSource("hit.wav", "static"),
explode = love.audio.newSource("explode.wav", "static"),
}
sounds.shoot:setVolume(0.35)
sounds.hit:setVolume(0.6)
resetGame()
end
function love.keypressed(key)
if key == "escape" then
love.event.quit()
elseif key == "space" and gameState ~= "playing" then
resetGame()
gameState = "playing"
end
end
function love.update(dt)
if gameState ~= "playing" then
return
end
if love.keyboard.isDown("left", "a") then
player.x = player.x - player.speed * dt
end
if love.keyboard.isDown("right", "d") then
player.x = player.x + player.speed * dt
end
local half = player.size / 2
player.x = math.max(half, math.min(love.graphics.getWidth() - half, player.x))
fireCooldown = fireCooldown - dt
invuln = invuln - dt
if love.keyboard.isDown("space") and fireCooldown <= 0 then
table.insert(bullets, { x = player.x, y = player.y - 30, size = 6, speed = 500 })
fireCooldown = 0.15
sounds.shoot:play()
end
for i = #bullets, 1, -1 do
local b = bullets[i]
b.y = b.y - b.speed * dt
if b.y < -20 then
table.remove(bullets, i)
end
end
spawnTimer = spawnTimer - dt
if spawnTimer <= 0 then
table.insert(enemies, Enemy.new(math.random(40, love.graphics.getWidth() - 40)))
spawnTimer = spawnEvery
end
for i = #enemies, 1, -1 do
local e = enemies[i]
e:update(dt)
if e.y > love.graphics.getHeight() + 20 then
table.remove(enemies, i)
end
end
if invuln <= 0 then
for i = #enemies, 1, -1 do
if collision.overlaps(enemies[i], player) then
table.remove(enemies, i)
lives = lives - 1
invuln = 2
sounds.explode:play()
if lives <= 0 then
gameState = "gameover"
end
break
end
end
end
for i = #enemies, 1, -1 do
for j = #bullets, 1, -1 do
if collision.overlaps(enemies[i], bullets[j]) then
table.remove(bullets, j)
sounds.hit:play()
enemies[i].hp = enemies[i].hp - 1
if enemies[i].hp <= 0 then
table.remove(enemies, i)
score = score + 1
end
break
end
end
end
end
local function drawHud()
love.graphics.setColor(1, 1, 1)
love.graphics.print("Score: " .. score, 10, 12)
for i = 1, lives do
local x = love.graphics.getWidth() - 24 - (i - 1) * 30
love.graphics.draw(ship, x, 24, 0, 1, 1, ship:getWidth() / 2, ship:getHeight() / 2)
end
end
local function drawOverlay(title, subtitle)
love.graphics.setColor(0, 0, 0, 0.7)
love.graphics.rectangle("fill", 0, 0, love.graphics.getWidth(), love.graphics.getHeight())
love.graphics.setColor(1, 1, 1)
love.graphics.setFont(titleFont)
love.graphics.printf(title, 0, 220, love.graphics.getWidth(), "center")
love.graphics.setFont(font)
love.graphics.printf(subtitle, 0, 300, love.graphics.getWidth(), "center")
end
function love.draw()
local blink = invuln > 0 and math.floor(invuln * 10) % 2 == 1
if not blink then
love.graphics.setColor(1, 1, 1)
love.graphics.draw(ship, player.x, player.y, 0, 2, 2, ship:getWidth() / 2, ship:getHeight() / 2)
end
love.graphics.setColor(1, 0.9, 0.3)
for _, b in ipairs(bullets) do
love.graphics.rectangle("fill", b.x - 2, b.y - 8, 4, 16)
end
for _, e in ipairs(enemies) do
e:draw()
end
drawHud()
if gameState == "menu" then
drawOverlay("SKYNEST", "Press space to play")
elseif gameState == "gameover" then
drawOverlay("GAME OVER", "Score " .. score .. " - press space to play again")
end
end

Task 2: Clone the ones that overlap

  • Shooting becomes sounds.shoot:clone():play(). One character of thought, and the stutter is gone.
  • Give the hit sound its own clone and a random pitch between 0.9 and 1.2, so a run of hits does not sound like a machine.
  • Leave explode alone. One shared Source is correct there, and being able to say why is the point of the task.
  • Hold fire again. Every shot now speaks, and the two sounds sit on top of each other the way they should.
main.lua
-- main.lua
local Enemy = require("enemy")
local collision = require("collision")
local ship
local font
local titleFont
local sounds
local spawnEvery = 0.8
local gameState = "menu"
local player
local bullets
local fireCooldown
local enemies
local spawnTimer
local score
local lives
local invuln
local function resetGame()
player = { x = 400, y = 500, size = 40, speed = 300 }
bullets = {}
fireCooldown = 0
enemies = {}
spawnTimer = 0
score = 0
lives = 3
invuln = 0
end
function love.load()
love.graphics.setBackgroundColor(0.05, 0.05, 0.1)
math.randomseed(os.time())
ship = love.graphics.newImage("ship.png")
font = love.graphics.newFont(20)
titleFont = love.graphics.newFont(48)
love.graphics.setFont(font)
sounds = {
shoot = love.audio.newSource("shoot.wav", "static"),
hit = love.audio.newSource("hit.wav", "static"),
explode = love.audio.newSource("explode.wav", "static"),
}
sounds.shoot:setVolume(0.35)
sounds.hit:setVolume(0.6)
resetGame()
end
function love.keypressed(key)
if key == "escape" then
love.event.quit()
elseif key == "space" and gameState ~= "playing" then
resetGame()
gameState = "playing"
end
end
function love.update(dt)
if gameState ~= "playing" then
return
end
if love.keyboard.isDown("left", "a") then
player.x = player.x - player.speed * dt
end
if love.keyboard.isDown("right", "d") then
player.x = player.x + player.speed * dt
end
local half = player.size / 2
player.x = math.max(half, math.min(love.graphics.getWidth() - half, player.x))
fireCooldown = fireCooldown - dt
invuln = invuln - dt
if love.keyboard.isDown("space") and fireCooldown <= 0 then
table.insert(bullets, { x = player.x, y = player.y - 30, size = 6, speed = 500 })
fireCooldown = 0.15
sounds.shoot:clone():play()
end
for i = #bullets, 1, -1 do
local b = bullets[i]
b.y = b.y - b.speed * dt
if b.y < -20 then
table.remove(bullets, i)
end
end
spawnTimer = spawnTimer - dt
if spawnTimer <= 0 then
table.insert(enemies, Enemy.new(math.random(40, love.graphics.getWidth() - 40)))
spawnTimer = spawnEvery
end
for i = #enemies, 1, -1 do
local e = enemies[i]
e:update(dt)
if e.y > love.graphics.getHeight() + 20 then
table.remove(enemies, i)
end
end
if invuln <= 0 then
for i = #enemies, 1, -1 do
if collision.overlaps(enemies[i], player) then
table.remove(enemies, i)
lives = lives - 1
invuln = 2
sounds.explode:play()
if lives <= 0 then
gameState = "gameover"
end
break
end
end
end
for i = #enemies, 1, -1 do
for j = #bullets, 1, -1 do
if collision.overlaps(enemies[i], bullets[j]) then
table.remove(bullets, j)
local thud = sounds.hit:clone()
thud:setPitch(0.9 + math.random() * 0.3)
thud:play()
enemies[i].hp = enemies[i].hp - 1
if enemies[i].hp <= 0 then
table.remove(enemies, i)
score = score + 1
end
break
end
end
end
end
local function drawHud()
love.graphics.setColor(1, 1, 1)
love.graphics.print("Score: " .. score, 10, 12)
for i = 1, lives do
local x = love.graphics.getWidth() - 24 - (i - 1) * 30
love.graphics.draw(ship, x, 24, 0, 1, 1, ship:getWidth() / 2, ship:getHeight() / 2)
end
end
local function drawOverlay(title, subtitle)
love.graphics.setColor(0, 0, 0, 0.7)
love.graphics.rectangle("fill", 0, 0, love.graphics.getWidth(), love.graphics.getHeight())
love.graphics.setColor(1, 1, 1)
love.graphics.setFont(titleFont)
love.graphics.printf(title, 0, 220, love.graphics.getWidth(), "center")
love.graphics.setFont(font)
love.graphics.printf(subtitle, 0, 300, love.graphics.getWidth(), "center")
end
function love.draw()
local blink = invuln > 0 and math.floor(invuln * 10) % 2 == 1
if not blink then
love.graphics.setColor(1, 1, 1)
love.graphics.draw(ship, player.x, player.y, 0, 2, 2, ship:getWidth() / 2, ship:getHeight() / 2)
end
love.graphics.setColor(1, 0.9, 0.3)
for _, b in ipairs(bullets) do
love.graphics.rectangle("fill", b.x - 2, b.y - 8, 4, 16)
end
for _, e in ipairs(enemies) do
e:draw()
end
drawHud()
if gameState == "menu" then
drawOverlay("SKYNEST", "Press space to play")
elseif gameState == "gameover" then
drawOverlay("GAME OVER", "Score " .. score .. " - press space to play again")
end
end

Task 3: Make the screen flinch

  • Add shake to the per-run group and zero it in resetGame. Set it to 0.4 when a life is lost.
  • Count it down above the early return, on its own, for the reason above.
  • Wrap the world in push and pop and translate by a small random offset while the timer runs. Leave the HUD and the overlay outside.
  • Die on purpose. The world lurches for four tenths of a second, the score sits perfectly still, and then it stops.
main.lua
-- main.lua
local Enemy = require("enemy")
local collision = require("collision")
local ship
local font
local titleFont
local sounds
local spawnEvery = 0.8
local gameState = "menu"
local player
local bullets
local fireCooldown
local enemies
local spawnTimer
local score
local lives
local invuln
local shake
local function resetGame()
player = { x = 400, y = 500, size = 40, speed = 300 }
bullets = {}
fireCooldown = 0
enemies = {}
spawnTimer = 0
score = 0
lives = 3
invuln = 0
shake = 0
end
function love.load()
love.graphics.setBackgroundColor(0.05, 0.05, 0.1)
math.randomseed(os.time())
ship = love.graphics.newImage("ship.png")
font = love.graphics.newFont(20)
titleFont = love.graphics.newFont(48)
love.graphics.setFont(font)
sounds = {
shoot = love.audio.newSource("shoot.wav", "static"),
hit = love.audio.newSource("hit.wav", "static"),
explode = love.audio.newSource("explode.wav", "static"),
}
sounds.shoot:setVolume(0.35)
sounds.hit:setVolume(0.6)
resetGame()
end
function love.keypressed(key)
if key == "escape" then
love.event.quit()
elseif key == "space" and gameState ~= "playing" then
resetGame()
gameState = "playing"
end
end
function love.update(dt)
shake = shake - dt
if gameState ~= "playing" then
return
end
if love.keyboard.isDown("left", "a") then
player.x = player.x - player.speed * dt
end
if love.keyboard.isDown("right", "d") then
player.x = player.x + player.speed * dt
end
local half = player.size / 2
player.x = math.max(half, math.min(love.graphics.getWidth() - half, player.x))
fireCooldown = fireCooldown - dt
invuln = invuln - dt
if love.keyboard.isDown("space") and fireCooldown <= 0 then
table.insert(bullets, { x = player.x, y = player.y - 30, size = 6, speed = 500 })
fireCooldown = 0.15
sounds.shoot:clone():play()
end
for i = #bullets, 1, -1 do
local b = bullets[i]
b.y = b.y - b.speed * dt
if b.y < -20 then
table.remove(bullets, i)
end
end
spawnTimer = spawnTimer - dt
if spawnTimer <= 0 then
table.insert(enemies, Enemy.new(math.random(40, love.graphics.getWidth() - 40)))
spawnTimer = spawnEvery
end
for i = #enemies, 1, -1 do
local e = enemies[i]
e:update(dt)
if e.y > love.graphics.getHeight() + 20 then
table.remove(enemies, i)
end
end
if invuln <= 0 then
for i = #enemies, 1, -1 do
if collision.overlaps(enemies[i], player) then
table.remove(enemies, i)
lives = lives - 1
invuln = 2
shake = 0.4
sounds.explode:play()
if lives <= 0 then
gameState = "gameover"
end
break
end
end
end
for i = #enemies, 1, -1 do
for j = #bullets, 1, -1 do
if collision.overlaps(enemies[i], bullets[j]) then
table.remove(bullets, j)
local thud = sounds.hit:clone()
thud:setPitch(0.9 + math.random() * 0.3)
thud:play()
enemies[i].hp = enemies[i].hp - 1
if enemies[i].hp <= 0 then
table.remove(enemies, i)
score = score + 1
end
break
end
end
end
end
local function drawHud()
love.graphics.setColor(1, 1, 1)
love.graphics.print("Score: " .. score, 10, 12)
for i = 1, lives do
local x = love.graphics.getWidth() - 24 - (i - 1) * 30
love.graphics.draw(ship, x, 24, 0, 1, 1, ship:getWidth() / 2, ship:getHeight() / 2)
end
end
local function drawOverlay(title, subtitle)
love.graphics.setColor(0, 0, 0, 0.7)
love.graphics.rectangle("fill", 0, 0, love.graphics.getWidth(), love.graphics.getHeight())
love.graphics.setColor(1, 1, 1)
love.graphics.setFont(titleFont)
love.graphics.printf(title, 0, 220, love.graphics.getWidth(), "center")
love.graphics.setFont(font)
love.graphics.printf(subtitle, 0, 300, love.graphics.getWidth(), "center")
end
function love.draw()
love.graphics.push()
if shake > 0 then
love.graphics.translate(math.random(-6, 6), math.random(-6, 6))
end
local blink = invuln > 0 and math.floor(invuln * 10) % 2 == 1
if not blink then
love.graphics.setColor(1, 1, 1)
love.graphics.draw(ship, player.x, player.y, 0, 2, 2, ship:getWidth() / 2, ship:getHeight() / 2)
end
love.graphics.setColor(1, 0.9, 0.3)
for _, b in ipairs(bullets) do
love.graphics.rectangle("fill", b.x - 2, b.y - 8, 4, 16)
end
for _, e in ipairs(enemies) do
e:draw()
end
love.graphics.pop()
drawHud()
if gameState == "menu" then
drawOverlay("SKYNEST", "Press space to play")
elseif gameState == "gameover" then
drawOverlay("GAME OVER", "Score " .. score .. " - press space to play again")
end
end

Challenge (Optional)

  • A scrolling starfield. Build a table of sixty stars with an x, a y and a speed in resetGame, move them down in update, and wrap them to the top when they pass the bottom. Draw them first, before anything else. It is entirely Unit 1 — a list of tables and a loop — and it transforms how the game looks. Give the faster ones a brighter color and you have parallax for free.
  • Flash an enemy white when it is hit. Give it a flash field, set it to 0.06 on a hit, count it down in Enemy:update and pick the color in Enemy:draw. All of it lands in enemy.lua, which is Activity 2.8 paying you back.
  • Add music. Find a loop you like, load it with "stream" rather than "static", call setLooping(true), and start it in love.load. It must not be cloned and it must not be restarted on every new game.
  • Mute on the m key. love.audio.setVolume(0) silences everything at once, including clones that do not exist yet. Keep a boolean and flip it in love.keypressed — and note this is one of the few times a boolean really is the right shape, because there is no timer underneath it to derive the answer from.
divider

Check Your Work

  • Held fire is a steady stream of shots, not a stutter. Every trigger pull makes a noise.
  • Hits are audibly different from each other, because each one is a clone with its own pitch.
  • Losing a life sounds heavy and the world lurches for four tenths of a second. The score and the life icons do not move.
  • The shaking stops, including on the hit that ends the run.

If half your shots are silent, you skipped clone(). If the game will not start at all, check that all three .wav files are beside main.lua and that both arguments are present in newSource — the type is not optional. If the HUD shakes with the world, it is inside the push and pop. And if the screen never stops shaking after the last life, the countdown is below the early return.