Back

Activity 2.6: Enemies and Spawn Timers

divider

The Idea

Bullets were plain tables, and that was fine because a bullet does almost nothing. Enemies will grow behavior — they move, they take damage, later they die in interesting ways — so they are worth making properly.

This is Activity 1.11, unchanged.

main.lua
local Enemy = {}
Enemy.__index = Enemy
function Enemy.new(x)
return setmetatable({ x = x, y = -20, size = 30, speed = 120 }, Enemy)
end
function Enemy:update(dt)
self.y = self.y + self.speed * dt
end
function Enemy:draw()
love.graphics.rectangle("fill", self.x - self.size / 2, self.y - self.size / 2, self.size, self.size)
end

Four lines you already know: an empty table for the class, Enemy.__index = Enemy to make lookups fall back to it, a constructor that attaches it with setmetatable, and methods defined with a colon so they get self.

What it buys you is the shared-method property. Fifty enemies hold fifty positions but there is still exactly one update function. And your main loop gets shorter: it says e:update(dt) and stops caring how an enemy moves.

Notice where the knowledge now lives. The main file no longer knows an enemy's speed or shape. Change Enemy:draw to render a sprite instead of a square and not one line of love.draw changes. That is the whole argument for the pattern, and it is what makes Activity 2.8 possible.

Spawning on a timer

The same accumulator you used for the weapon, with a different number.

main.lua
spawnTimer = spawnTimer - dt
if spawnTimer <= 0 then
table.insert(enemies, Enemy.new(math.random(40, love.graphics.getWidth() - 40)))
spawnTimer = spawnEvery
end

Starting spawnTimer at 0 means the first enemy arrives immediately rather than after a wait. Start it at spawnEvery instead and the player gets a moment of calm first. Both are reasonable; pick deliberately.

The spawn x is random but inset from the edgesmath.random(40, width - 40) keeps an enemy from being born half off the screen. And enemies start at a negative y so they slide into view rather than popping into existence at the top edge.

Seed the generator in love.load. This is the warning from Activity 1.4 coming due: LÖVE's Lua does not seed itself, so without math.randomseed(os.time()) every playthrough gets the identical wave pattern. It will look deliberate and it is not.

Two lists, the same treatment

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

Identical in shape to the bullet loop, and backwards for the same reason. Enemies that reach the bottom are removed, and in a finished game that would also cost the player something — Activity 2.9 adds lives.

An enemy leaving the bottom and a bullet leaving the top are the same problem. If you find yourself writing this loop a third time, that is the signal to write a helper that takes a list and a test. Resist it until then; two is not yet a pattern.

divider

Build

Same file. You are giving the player something to shoot at.


Task 1: An Enemy class

  • Write the Enemy class with new, update and draw methods.
  • Create two by hand in love.load so you have something on screen before the timer exists.
  • Update and draw them by calling their methods, not by reaching into their fields.
main.lua
-- main.lua
local ship
local player = { x = 400, y = 500, size = 40, speed = 300 }
local bullets = {}
local fireCooldown = 0
local enemies = {}
local Enemy = {}
Enemy.__index = Enemy
function Enemy.new(x)
return setmetatable({ x = x, y = -20, size = 30, speed = 120 }, Enemy)
end
function Enemy:update(dt)
self.y = self.y + self.speed * dt
end
function Enemy:draw()
love.graphics.rectangle("fill", self.x - self.size / 2, self.y - self.size / 2, self.size, self.size)
end
function love.load()
love.graphics.setBackgroundColor(0.05, 0.05, 0.1)
math.randomseed(os.time())
ship = love.graphics.newImage("ship.png")
table.insert(enemies, Enemy.new(200))
table.insert(enemies, Enemy.new(600))
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
for _, e in ipairs(enemies) do
e:update(dt)
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
love.graphics.setColor(1, 0.3, 0.4)
for _, e in ipairs(enemies) do
e:draw()
end
love.graphics.setColor(1, 1, 1)
love.graphics.print("Enemies: " .. #enemies, 10, 10)
end

Task 2: Spawn them on a timer

  • Remove the two hand-made enemies.
  • Add spawnTimer and spawnEvery, and seed the random generator in love.load.
  • Spawn one enemy at a random x every 0.8 seconds.
  • Run it twice and confirm the pattern differs. Then comment out the seed line, run twice more, and watch the exact same wave arrive both times.
main.lua
-- main.lua
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 Enemy = {}
Enemy.__index = Enemy
function Enemy.new(x)
return setmetatable({ x = x, y = -20, size = 30, speed = 120 }, Enemy)
end
function Enemy:update(dt)
self.y = self.y + self.speed * dt
end
function Enemy:draw()
love.graphics.rectangle("fill", self.x - self.size / 2, self.y - self.size / 2, self.size, self.size)
end
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 _, e in ipairs(enemies) do
e:update(dt)
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
love.graphics.setColor(1, 0.3, 0.4)
for _, e in ipairs(enemies) do
e:draw()
end
love.graphics.setColor(1, 1, 1)
love.graphics.print("Enemies: " .. #enemies, 10, 10)
end

Task 3: Let them leave

  • Turn the enemy loop into a backwards one and remove any enemy that has gone off the bottom.
  • Watch the count. It should settle at a steady number — spawning and leaving in balance.
  • Change spawnEvery to 0.2 and watch that steady number roughly quadruple. One variable controls the difficulty of the whole game.
main.lua
-- main.lua
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 Enemy = {}
Enemy.__index = Enemy
function Enemy.new(x)
return setmetatable({ x = x, y = -20, size = 30, speed = 120 }, Enemy)
end
function Enemy:update(dt)
self.y = self.y + self.speed * dt
end
function Enemy:draw()
love.graphics.rectangle("fill", self.x - self.size / 2, self.y - self.size / 2, self.size, self.size)
end
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
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
love.graphics.setColor(1, 0.3, 0.4)
for _, e in ipairs(enemies) do
e:draw()
end
love.graphics.setColor(1, 1, 1)
love.graphics.print("Enemies: " .. #enemies, 10, 10)
end

Challenge (Optional)

  • Give each enemy a random speed as well as a random position, so waves stop arriving in a flat line.
  • Make the game get harder: reduce spawnEvery slightly on every spawn, with a math.max floor so it never reaches zero.
  • Add a second enemy type that inherits from Enemy and overrides only its speed and color, using the bonus inheritance page. Then spawn one at random and note that your update loop needs no change at all.
  • Give enemies a gentle side-to-side drift by storing a start x and using math.sin of an accumulated time.
divider

Check Your Work

Six red enemy squares falling at different heights while bullets climb

Enemies arriving on a timer at random x, falling while the bullets climb past them. Nothing collides yet.

  • Red squares slide down from the top at a steady rate.
  • They appear at different horizontal positions each run.
  • The enemy count rises then holds steady instead of climbing forever.
  • You can still fly and fire; bullets pass straight through.

Bullets ignoring enemies is correct for now — nothing has been told to check. That is Activity 2.7, and it is about twelve lines.