Right now touching an enemy wipes the entire board and silently zeroes your score. It is a placeholder, and it has the worst property a punishment can have: you cannot tell it happened. The screen simply empties.
This activity gives you three lives, a moment of safety after being hit, and a heads-up display that shows both. No new LÖVE concepts are involved — every piece is a number, a countdown, or a loop you have already written.
You want the player to be untouchable for two seconds after a hit. The tempting design is a countdown and a boolean saying whether it is running. Resist it.
-- Storing the answer: now two things can disagreeif invuln > 0 then isInvulnerable = trueelse isInvulnerable = falseend
-- Asking the question: there is nothing to disagree withif invuln > 0 then -- still flashing, still safeendThe first version has two facts that must agree, and they will not: forget one assignment on one branch and the player is permanently immortal. The second stores one number and derives the answer on the spot, which cannot drift because there is nothing to drift from.
Note that nothing stops the countdown. Subtract dt every frame and after a minute it is sitting at -60, which answers "am I invulnerable?" perfectly well. A countdown you never have to remember to stop is simpler than one you do, and it is why the test is invuln > 0 rather than invuln ~= 0. The second version would go wrong on the first frame that overshoots zero, which is every frame.
Task 1 adds lives without it, and the result is worth feeling before you fix it. Enemies arrive about eight tenths of a second apart, and each one that reaches you takes a life. Three lives are gone in under two seconds, in a stretch where nothing you press changes the outcome.
With a two-second window the second and third arrivals pass straight through you, and the next life you lose is the one you had time to see coming. The rule generalizes: a penalty that can repeat before the player can react is not a penalty, it is a deletion.
Invulnerability the player cannot see is indistinguishable from a bug. The fix is one line:
local blink = invuln > 0 and math.floor(invuln * 10) % 2 == 1Read it right to left. invuln * 10 counts tenths of a second, math.floor turns that into a whole number, and % 2 asks whether it is odd. The answer flips every tenth of a second, so the ship blinks five times a second for as long as the timer runs. The modulo operator from Activity 1.4 is doing all of the work.
Skip the drawing, not the updating. The instinct is to guard the movement code too, and it is wrong: an invulnerable ship still flies, still shoots, still clamps to the screen. It is invisible for a tenth of a second at a time, not absent.
Everything in love.draw paints over what came before it, so drawing order is layer order. The HUD has to be last or an enemy at the top of the screen will slide over your score.
function love.draw() -- The world, in world coordinates drawPlayer() drawBullets() drawEnemies()
-- The HUD, in screen coordinates, last so nothing can cover it drawHud()endThe HUD is also the one thing on screen that does not live in the game world. An enemy at x = 400 is somewhere in the level; the score at x = 10 is glued to the window. That difference matters the moment you add a camera, and it is worth keeping the two kinds of drawing in separate functions from the start.
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)endThree small ships in the corner read faster than the text "Lives: 3", and the loop has a quiet virtue: the number of icons is the value of lives, not a copy of it. There is no way for the display to disagree with the game, which is the same principle as the timer, applied to pixels.
The x is measured backwards from love.graphics.getWidth() rather than hard-coded, so the icons stay in the corner if you ever change the window size in conf.lua. The rest of the call is the centering idiom from Activity 2.4, at scale 1 instead of 2.
The default font is small. A bigger one is a file LÖVE has to build glyphs for, which puts it in the same category as newImage from Activity 2.4: make it once, in love.load.
-- Wrong: builds a brand new font sixty times a secondfunction love.draw() love.graphics.setFont(love.graphics.newFont(20))end
-- Right: build it once, keep it foreverfunction love.load() font = love.graphics.newFont(20) love.graphics.setFont(font)endsetFont is sticky in exactly the way setColor is — the state machine from Activity 2.2 again. Set it once in love.load and it is still set on frame forty thousand.
Your enemy.lua and collision.lua do not change in this activity. Everything below happens in main.lua.
local lives = 3 to your state block.-- main.lua
local Enemy = require("enemy")local collision = require("collision")
local shiplocal player = { x = 400, y = 500, size = 40, speed = 300 }local bullets = {}local fireCooldown = 0local enemies = {}local spawnTimer = 0local spawnEvery = 0.8local score = 0local lives = 3
function love.load() love.graphics.setBackgroundColor(0.05, 0.05, 0.1) math.randomseed(os.time()) ship = love.graphics.newImage("ship.png")end
function love.update(dt) 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 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 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
for i = #enemies, 1, -1 do if collision.overlaps(enemies[i], player) then table.remove(enemies, i) lives = lives - 1 if lives <= 0 then lives = 3 score = 0 enemies = {} bullets = {} end break 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) 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
function love.draw() love.graphics.setColor(1, 1, 1) love.graphics.draw(ship, player.x, player.y, 0, 2, 2, ship:getWidth() / 2, ship:getHeight() / 2)
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.setColor(1, 1, 1) love.graphics.print("Score: " .. score, 10, 10) love.graphics.print("Lives: " .. lives, 10, 30)endlocal invuln = 0 and count it down beside fireCooldown. They are the same pattern, so put them together.if invuln <= 0 then. While the timer is running, enemies pass straight through you and are not removed.invuln = 2 at the moment you take the hit.-- main.lua
local Enemy = require("enemy")local collision = require("collision")
local shiplocal player = { x = 400, y = 500, size = 40, speed = 300 }local bullets = {}local fireCooldown = 0local enemies = {}local spawnTimer = 0local spawnEvery = 0.8local score = 0local lives = 3local invuln = 0
function love.load() love.graphics.setBackgroundColor(0.05, 0.05, 0.1) math.randomseed(os.time()) ship = love.graphics.newImage("ship.png")end
function love.update(dt) 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 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 if lives <= 0 then lives = 3 score = 0 enemies = {} bullets = {} 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) 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
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
love.graphics.setColor(1, 1, 1) love.graphics.print("Score: " .. score, 10, 10) love.graphics.print("Lives: " .. lives, 10, 30)endfont local, build it at size 20 in love.load, and set it there. Never in draw.drawHud function, defined above love.draw so the locals it reads already exist.getWidth().drawHud() as the last line of love.draw, and start it with a white setColor — the enemies left the color red.-- main.lua
local Enemy = require("enemy")local collision = require("collision")
local shiplocal fontlocal player = { x = 400, y = 500, size = 40, speed = 300 }local bullets = {}local fireCooldown = 0local enemies = {}local spawnTimer = 0local spawnEvery = 0.8local score = 0local lives = 3local invuln = 0
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) love.graphics.setFont(font)end
function love.update(dt) 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 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 if lives <= 0 then lives = 3 score = 0 enemies = {} bullets = {} 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) 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
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()endscore % 10 == 0, is a trap — it is true on every frame you spend at ten points, which is most of them. Keep a nextExtraLife number and raise it when you pass it. The general lesson is that "when it changes" and "what it is now" are different questions, and only one of them survives a game loop.hp. It belongs in Enemy:draw, not the HUD — it moves with the enemy, so it is world, not screen.setColor computed from invuln. Remember to set the alpha back to 1 afterwards.
Score on the left, lives on the right. Two icons have gone because the loop draws one per life and there is one life left — nothing erased them.
If the ship never comes back, your invuln = invuln - dt is somewhere that does not run every frame — check it is not inside an if. If you lose all three lives at once, the collision loop is not actually wrapped in the invulnerability check. If the score turns red, the HUD is missing its white setColor; if it disappears entirely, it is being drawn before the enemies rather than after.