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.
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
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.
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.
-- 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.
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.
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.
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 nottranslate 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.
The shake timer is the same accumulator you have written four times. It is also the first one that cannot live with the others.
function love.update(dt) shake = shake - dt
if gameState ~= "playing" then return end
-- the rest of the timers live down hereendThe 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.
The three .wav files go beside main.lua. enemy.lua and collision.lua are untouched.
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.-- main.lua
local Enemy = require("enemy")local collision = require("collision")
local shiplocal fontlocal titleFontlocal soundslocal spawnEvery = 0.8local gameState = "menu"
local playerlocal bulletslocal fireCooldownlocal enemieslocal spawnTimerlocal scorelocal liveslocal invuln
local function resetGame() player = { x = 400, y = 500, size = 40, speed = 300 } bullets = {} fireCooldown = 0 enemies = {} spawnTimer = 0 score = 0 lives = 3 invuln = 0end
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" endend
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 endend
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) endend
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") endendsounds.shoot:clone():play(). One character of thought, and the stutter is gone.-- main.lua
local Enemy = require("enemy")local collision = require("collision")
local shiplocal fontlocal titleFontlocal soundslocal spawnEvery = 0.8local gameState = "menu"
local playerlocal bulletslocal fireCooldownlocal enemieslocal spawnTimerlocal scorelocal liveslocal invuln
local function resetGame() player = { x = 400, y = 500, size = 40, speed = 300 } bullets = {} fireCooldown = 0 enemies = {} spawnTimer = 0 score = 0 lives = 3 invuln = 0end
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" endend
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 endend
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) endend
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") endendshake to the per-run group and zero it in resetGame. Set it to 0.4 when a life is lost.push and pop and translate by a small random offset while the timer runs. Leave the HUD and the overlay outside.-- main.lua
local Enemy = require("enemy")local collision = require("collision")
local shiplocal fontlocal titleFontlocal soundslocal spawnEvery = 0.8local gameState = "menu"
local playerlocal bulletslocal fireCooldownlocal enemieslocal spawnTimerlocal scorelocal liveslocal invulnlocal 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 = 0end
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" endend
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 endend
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) endend
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") endendresetGame, 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 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."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.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.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.