Back

Activity 2.9: Score, Lives, and a HUD

divider

The Idea

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.

A timer is a number; a state is a question

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.

two designs
-- Storing the answer: now two things can disagree
if invuln > 0 then
isInvulnerable = true
else
isInvulnerable = false
end
-- Asking the question: there is nothing to disagree with
if invuln > 0 then
-- still flashing, still safe
end

The 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.

Why the grace period is not a nicety

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.

The flash is how the player knows

Invulnerability the player cannot see is indistinguishable from a bug. The fix is one line:

main.lua
local blink = invuln > 0 and math.floor(invuln * 10) % 2 == 1

Read 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.

The HUD is drawn last

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.

main.lua
function love.draw()
-- The world, in world coordinates
drawPlayer()
drawBullets()
drawEnemies()
-- The HUD, in screen coordinates, last so nothing can cover it
drawHud()
end

The 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.

Draw the lives, do not count them

main.lua
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

Three 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.

Fonts are loaded, not typed

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.

two versions
-- Wrong: builds a brand new font sixty times a second
function love.draw()
love.graphics.setFont(love.graphics.newFont(20))
end
-- Right: build it once, keep it forever
function love.load()
font = love.graphics.newFont(20)
love.graphics.setFont(font)
end

setFont 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.

divider

Build

Your enemy.lua and collision.lua do not change in this activity. Everything below happens in main.lua.


Task 1: Lives instead of a wipe

  • Add local lives = 3 to your state block.
  • Change the player collision so it removes the enemy that hit you and takes one life, instead of emptying the board. The enemy is destroyed in the crash too, so it should not survive it.
  • When lives reach zero, reset lives, score, and both tables. This is a stand-in: Activity 2.10 replaces it with a real game-over screen.
  • Print the lives under the score for now, so you can watch the number move.
  • Play it and lose on purpose. Stand still in a column of enemies and count how long three lives last. That is the problem Task 2 solves.
main.lua
-- main.lua
local Enemy = require("enemy")
local collision = require("collision")
local ship
local player = { x = 400, y = 500, size = 40, speed = 300 }
local bullets = {}
local fireCooldown = 0
local enemies = {}
local spawnTimer = 0
local spawnEvery = 0.8
local score = 0
local 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
end
end
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)
end

Task 2: Two seconds of grace

  • Add local invuln = 0 and count it down beside fireCooldown. They are the same pattern, so put them together.
  • Wrap the whole player collision loop in if invuln <= 0 then. While the timer is running, enemies pass straight through you and are not removed.
  • Set invuln = 2 at the moment you take the hit.
  • Skip the ship's draw on alternating tenths of a second. Do not touch the update.
  • Lose a life and watch the clock. The next one you can lose is two seconds later, and you will see every frame of the wait.
main.lua
-- main.lua
local Enemy = require("enemy")
local collision = require("collision")
local ship
local player = { x = 400, y = 500, size = 40, speed = 300 }
local bullets = {}
local fireCooldown = 0
local enemies = {}
local spawnTimer = 0
local spawnEvery = 0.8
local score = 0
local lives = 3
local 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
end
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
love.graphics.setColor(1, 1, 1)
love.graphics.print("Score: " .. score, 10, 10)
love.graphics.print("Lives: " .. lives, 10, 30)
end

Task 3: A real HUD

  • Add a font local, build it at size 20 in love.load, and set it there. Never in draw.
  • Move the score and lives into their own drawHud function, defined above love.draw so the locals it reads already exist.
  • Replace the lives text with one ship icon per life, in the top right corner, positioned from getWidth().
  • Call drawHud() as the last line of love.draw, and start it with a white setColor — the enemies left the color red.
main.lua
-- main.lua
local Enemy = require("enemy")
local collision = require("collision")
local ship
local font
local player = { x = 400, y = 500, size = 40, speed = 300 }
local bullets = {}
local fireCooldown = 0
local enemies = {}
local spawnTimer = 0
local spawnEvery = 0.8
local score = 0
local lives = 3
local 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
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
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()
end

Challenge (Optional)

  • Give an extra life every ten points. The obvious test, score % 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.
  • Cap lives at five and make the sixth pickup worth points instead. Your icon loop needs no change at all, which is the payoff for deriving it from the value.
  • Draw a thin damage bar under each enemy from its hp. It belongs in Enemy:draw, not the HUD — it moves with the enemy, so it is world, not screen.
  • Make the blink fade instead of flicker by passing a fourth alpha argument to setColor computed from invuln. Remember to set the alpha back to 1 afterwards.
divider

Check Your Work

Gameplay with Score 4 top-left and a single remaining life icon top-right

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.

  • Three small ships sit in the top right corner and the score is in the top left, in a noticeably larger font than before.
  • Touching an enemy destroys that enemy, removes one icon, and starts the ship flashing. The rest of the board keeps falling.
  • For the next two seconds enemies pass through you harmlessly, and you can still move and shoot the whole time.
  • After the third life everything resets with no announcement. That is expected — the announcement is Activity 2.10.

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.