Endless Space Shooter with LÖVE on Raspberry Pi 4

Build a game starring Nyan cat, Grumpy cats and a whole lot of cake!

Written By: Cherie Tan

Dash icon
Difficulty
Moderate
Steps icon
Steps
18

Introduction

The Raspberry Pi isn't just a popular choice for learning about physical computing, but also game development. If you haven't already, check out the basics on how to install, set up and use LÖVE on the Raspberry Pi 4

In this guide, we'll show you how to build an endless runner/space shooter game on the Raspberry Pi 4. Instead of aliens and bullets, we'll create a talking nyan cat, grumpy cats, shooting hearts and lots of tasty treats. 

Complete this guide to create your own space shooters game. While doing so, learn about the three main callback functions in the LOVE framework, custom functions, conditional statements, for loops, and animations.

Step 1   Overview

  • The Raspberry Pi is a popular choice for learning about physical computing, gaming, robotics, and a whole heap of other wondrous topics. It's  a fantastic time to tinker and play around with the Raspberry Pi 4 if you're a gaming enthusiast! After all, it's now possible to run the Dreamcast emulator, REDREAM on the Raspberry Pi 4. It's also possible to build your own games. If you haven't already, check out the basics on how to install, set up and use LÖVE on the Raspberry Pi 4

    In this guide, we'll show you how to build an endless runner/space shooter game on the Raspberry Pi 4. Instead of aliens and bullets, we'll create a talking nyan cat, grumpy cats, shooting hearts and lots of tasty treats. While doing so, learn about the three main callback functions in the LOVE framework, custom functions, conditional statements, for loops, and animations.

Step 2   Create sprites

  • Open up your pixel art tool of choice and create some sprites. Using Aseprite, we created a start/menu screen, sprites for nyan cat and a rainbow trail (player), grumpy cat (obstacles), cake (items) and a tiny heart (projectile). 
  • In this guide, we've created a start/menu screen (800x600 pixels), a space background image (800x600 pixels), a spritesheet for nyan cat (320x64 pixels), static images (64x64 pixels) for the cake, grumpy cat, and heart (16x16 pixels).

Step 3   Load images

function love.load()
	startscreen = love.graphics.newImage("startscreen.png")
	startbtn = love.graphics.newImage("startbutton.png")
	startblurb = love.graphics.newImage("startblurb.png")
	background = love.graphics.newImage("mainbg.png")
	cakeSprite = love.graphics.newImage("item.png")
	enemySprite = love.graphics.newImage("grumpycat.png")
end
  • Now that the sprites have been created, it's time to load them up into the game. The love.load function is called exactly once at the beginning of the game. This is where we load our image assets for the game.
  • For each sprite, load it with love.graphics.newImage and store it in a separately named variable. These variables will be used to refer to the sprite in the program.
  • A variable can be thought of as a word in which you can store a value. For example, using the variable enemySprite, the grumpycat.png sprite is stored to it by using the love.graphics.newImage function.

Step 4   Set custom font

function love.load()
	startscreen = love.graphics.newImage("startscreen.png")
	startbtn = love.graphics.newImage("startbutton.png")
	startblurb = love.graphics.newImage("startblurb.png")
	background = love.graphics.newImage("mainbg.png")
	cakeSprite = love.graphics.newImage("item.png")
	enemySprite = love.graphics.newImage("grumpycat.png")
	font = love.graphics.newFont("Retro Gaming.ttf", 24)
	love.graphics.setFont(font)
end
  • The love.load function is also where we can load any custom fonts by using love.graphics.newFont 
  • Remember to set the font in the game by using love.graphics.setFont

Step 5   Create variables and tables in love.load

function love.load()
	startscreen = love.graphics.newImage("startscreen.png")
	startbtn = love.graphics.newImage("startbutton.png")
	startblurb = love.graphics.newImage("startblurb.png")
	background = love.graphics.newImage("mainbg.png")
	cakeSprite = love.graphics.newImage("item.png")
	enemySprite = love.graphics.newImage("grumpycat.png")
	font = love.graphics.newFont("Retro Gaming.ttf", 24)
	love.graphics.setFont(font)
	currentScreen = 'menu'
	Sx, Sy = love.graphics.getDimensions()
	items = {}
	enemies = {}
end
  • You can do more than store sprites in variables. Create a new variable named currentScreen and set its value to the string menu

    This string will be used to check on the game state later on in our code.
  • To easily get the width and height in pixels of the game window within our code, this can be done by using the love.graphics.getDimensions function, and place them in variables Sx and Sy
  • Next, create a table and assign it to variable items to store the cakes.
  • Then create another empty table and assign it to the variable enemies which will be used to store the grumpy cats.
  • To create a table use the table constructor, which are defined by using curly brackets, i.e. { }.

    Tables are the main data structuring mechanism in Lua. We can use tables to represent lists, arrays, sets, and many other data structures.

    In fact, they are the only "container" type in Lua programming. These are associative which means they store key/value pairs. In a key/value pair, you can store a value under a key and then later retrieve the value by using that particular key. 

Step 6   Anim8 library

local anim8 = require 'anim8'
local playerSpritesheet, animation 
  • How can we implement animations into the game? One quick and easy way is to incorporate a library such as anim8 which helps you create animations for LÖVE.

    The latest version of anim8 can always be found on its github page, so navigate over to: https://github.com/kikito/anim8

  • Download the folder then copy the anim8.lua file into your project folder where main.lua is stored.
  • Then place this code at the very top of the program, before love.load

Step 7   function createPlayer()

function createPlayer()
	player = {
		x = 0,
		y = 250, 
		health = 3,
		alive = true,
		score = 0,
		bullets = {},
		bulletSprite = love.graphics.newImage("heartbullet.png"),
		rainbow = love.graphics.newImage('rainbow-trails.png'),
		cooldown = 20,
		speed = 5,
		dialogue = {},
		sentences = {"FOOD!","NYAAAAN!","Cake!","Yay.","OM NOM NOM"},
		fire = function()
		if player.cooldown <= 0 then 
			player.cooldown = 50
			bullet = {}
			bullet.x = player.x + 35
			bullet.y = player.y + 25
			table.insert(player.bullets, bullet)
		end
	end
	}
	playerSpritesheet = love.graphics.newImage('player-spritesheet.png')
	g = anim8.newGrid(64,64,playerSpritesheet:getWidth(),playerSpritesheet:getHeight())
	g2 = anim8.newGrid(64,64,player.rainbow:getWidth(),player.rainbow:getHeight())
	-- animation for player sprite
	animation = anim8.newAnimation(g('1-4',1),0.1)
	-- animation for rainbow trail
	animation2 = anim8.newAnimation(g2('1-1',1),0.1)
end
  • Next, create a new function and name it createPlayer. Here, create a new table and name it player, then add various attributes of the player such as its x coordinate, y coordinate, health, score, and so on. 
  • So that nyan cat has multiple lines for dialogue, two tables were created. The first table was named sentences which holds five strings while the second table was empty and named dialogue. Every time a collision between nyan cat and cake sprite is detected, a random string from sentences is inserted into the dialogue table.

Step 8   Collisions

function collision(x1, y1, width1, height1, x2, y2, width2, height2)
	if x2 + width2 > x1 and x2 < x1 + width1 then
		if y2 + height2 > y1 and y2 < y1 + height1 then
			return true
		end
	end
	return false
end
  • To create a collision check function, we will be checking between "rectangles" or "collision boxes". Add the following function that will detect when collisions occur. 

Step 9   function newItem()

function newItem(x,y)
	item = {}
	item.x = x
	item.y = y
	function item.update(dt)
		for i, item in pairs(items) do
			if item.x < -10 then 
				table.remove(items,i)
			end
			if collision(player.x, player.y, 64, 64, item.x, item.y, 16, 16) then 
				table.remove(items, i)
				player.score = player.score + 10
				table.insert(player.dialogue,player.sentences[math.random(#player.sentences)])
				player.speaking = true
			end
		end
	end
	table.insert(items,item)
end
  • What's a game without some obstacles and items? Create a new function and name it newItem() 

Step 10   function newEnemy()

function newEnemy(x,y)
	enemy = {}
	enemy.x = x
	enemy.y = y 
	function enemy.update(dt)
		for i, enemy in pairs(enemies) do
			if enemy.x < -10 then
				table.remove(enemies,i)
			end
			for i, b in pairs(player.bullets) do 
				if collision(bullet.x,bullet.y,16,16,enemy.x,enemy.y,64,64) then
					table.remove(enemies,i)
					table.remove(player.bullets,i)
					player.score = player.score + 100
				end
			end
			if collision(player.x,player.y,64,64,enemy.x,enemy.y,32,32) then
				table.remove(enemies,i)
				if player.health > 0 then 
					player.hit = true 
					player.health = player.health - 1
					animation = anim8.newAnimation(g('5-5',1),0.1)
				end
			end
		end
	end
	table.insert(enemies,enemy)
end
  • Create a new function and name it newEnemy()

Step 11   function love.load()

function love.load()
	startscreen = love.graphics.newImage("startscreen.png")
	startbtn = love.graphics.newImage("startbutton.png")
	startblurb = love.graphics.newImage("startblurb.png")
	background = love.graphics.newImage("mainbg.png")
	enemySprite = love.graphics.newImage("grumpycat.png")
	cakeSprite = love.graphics.newImage("item.png")
	backgroundPosition = 0
	currentScreen = 'menu'
	font = love.graphics.newFont("Retro Gaming.ttf", 24)
	love.graphics.setFont(font)
	Sx, Sy = love.graphics.getDimensions()
	items = {}
	enemies = {}
	time = 0
	timeLimit = 3
	createPlayer()
	-- spawn items at start
	for i = 1, math.random(0,10) do
		newItem(math.random(0,700),math.random(0,500))
	end
end
  • Update love.load with the following code. Now, the createPlayer function will be called immediately when the game starts. Using a for loop and math.random function, a randomised number of cakes will be generated at random x and y coordinates.
  • We have created two very important variables : time and timeLimit 

    These variables will be crucial for the generation of cakes and grumpy cats throughout the game. 

Step 12   function love.update(dt)

function love.update(dt)
	if currentScreen == 'menu' then
		menuUpdate(dt)
	elseif currentScreen == 'game' then
	    gameUpdate(dt)
	    animation:update(dt)
	end
end
  • For our menu screen, we simply want to display the start/menu screen created earlier on, until the 'x' key has been pressed. Once the 'x' key is pressed, then the game starts. So go ahead and add a conditional statement if currentScreen == 'menu' then 
  • Although we have not yet created the menuUpdate(dt) function, it is within love.update(dt) where it will be called. So go ahead and call menuUpdate(dt). 
  • Then add another condition elseif currentScreen == 'game' then 
  • Again, these functions have not yet been created but it is here where they will be called: gameUpdate(dt) and animation:update(dt)

Step 13   function love.draw()

function love.draw()
	if currentScreen == 'menu' then
		-- draw start menu
		love.graphics.draw(startscreen, Sx/2,Sy/2, 0, 1, 1, startscreen:getWidth()/2, startscreen:getHeight()/2)
		love.graphics.draw(startblurb, Sx/2+250,Sy/2+150, 0, 1, 1, startblurb:getWidth()/2, startblurb:getHeight()/2)
		love.graphics.draw(startbtn, Sx/2+225,Sy/2+225, 0, 1, 1, startbtn:getWidth()/2, startbtn:getHeight()/2)
	end
	if currentScreen == 'game' then
		-- draw the background
		love.graphics.draw(background, backgroundPosition, 0, 0, 1, 1)
		love.graphics.draw(background, backgroundPosition + 800, 0, 0, 1, 1)
		-- draw the player
		animation2:draw(player.rainbow,player.x-50,player.y)
		animation2:draw(player.rainbow,player.x-100,player.y)
		animation:draw(playerSpritesheet,player.x,player.y)
		-- draw bullets
		for _, b in pairs(player.bullets) do 
			love.graphics.draw(player.bulletSprite,b.x,b.y,0,1,1)
		end
		-- draw items
		for _, item in pairs(items) do
			love.graphics.draw(cakeSprite,item.x,item.y)
		end
		-- draw enemies
		for _,enemy in pairs(enemies) do
			love.graphics.draw(enemySprite,enemy.x,enemy.y)
		end
		-- draw score
		love.graphics.print("Score: " .. player.score,50,30,0,1,1)
		-- draw player health
		love.graphics.print("Health: " .. player.health,50,60,0,1,1)
		-- draw player dialogue
		if player.speaking == true then
			playerDialogue()
		end
		-- if player goes off bottom of screen then game over condition reached 
		if player.y > love.graphics.getHeight() or player.health == 0 then
			animation = anim8.newAnimation(g('5-5',1),0.1)
			love.graphics.print("GAME OVER. PRESS X TO RESTART.", 150,250)
			player.alive = false
			player.health = 0
			if love.keyboard.isDown("x") then
				love.load()
			end
		end
	end
end
  • Add the following code in love.draw function. As it was the case for love.load, the love.draw function is a callback function and it is used to draw on the screen every frame.

Step 14   function menuUpdate(dt)

function menuUpdate(dt)
	if love.keyboard.isDown("x") then
		currentScreen = 'game'
	end
end
  • All the menuUpdate function does is check to see if the 'x' key has been pressed. Once it has been pressed, the game starts and the value of currentScreen is changed to 'game'

Step 15   function playerUpdate(dt)

function playerUpdate(dt)
-- all this code is required in gameUpdate(dt) function
	player.cooldown = player.cooldown - 1
	-- player's y position always moving by 2 every frame
	player.y = player.y+2
	-- player x coordinate shifts by 1 if it is less than 100 every frame
	if player.x < 100 then
		player.x = player.x+1
	end
	-- player controls: can move up or fire hearts
	if player.alive == true then
		if love.keyboard.isDown("up") then
			player.y = player.y-player.speed
		end
		if love.keyboard.isDown("z") then 
			player.fire()
		end
	end
	-- update bullets table
	for i, b in pairs(player.bullets) do
		if b.x < 10 then 
			table.remove(player.bullets, i)
		end
		b.x = b.x + 10
	end
	function playerDialogue()
		if player.dialogue[1] then
			if player.alive == true then 
				love.graphics.print(player.dialogue[1],player.x+25,player.y-50)
			end
		end
	end
end
  • Now that we've got a function that creates the player, we still need one that contains all the code that will update the player throughout the game. So go ahead and create a new function and name it playerUpdate(dt)

Step 16   function gameUpdate(dt)

function gameUpdate(dt)
	time = time + dt 

	if time >= timeLimit then
		-- generate items and enemies at random coordinates
		for i = 1, math.random(0,10) do
			newItem(800 + math.random(0,700),math.random(0,500))
		end
		for i =1, math.random(0,3) do
			newEnemy(800 + math.random(0,700),math.random(0,500))
		end
		-- at the end of the time timit, remove string from dialogue table
		table.remove(player.dialogue,1)
		-- remove time = 0 for no repeats
		time = 0
	end
	if time >= 1 then 
		if player.hit == true then 
			animation = anim8.newAnimation(g('1-4',1),0.1)
			player.hit = false
		end
	end

	-- items and enemies' x position always moving by 2 every frame
	for i, item in ipairs(items) do
		item.x = item.x - 2
	end
	for i, enemy in ipairs(enemies) do
		enemy.x = enemy.x - 2
	end

	-- Update the player
	playerUpdate()

	-- parallax background
	if backgroundPosition > -800 then
		backgroundPosition = backgroundPosition - dt * 100
	else
	    backgroundPosition = 0
	end

	-- update items table
	for i, item in ipairs(items) do
		item.update(dt)
	end

	-- update enemies table
	for i, enemy in ipairs(enemies) do
		enemy.update(dt)
	end
end
  • While we could have added this chunk of code within love.update(dt), to organise it we have created a new function and named it gameUpdate(dt)

    Previously, a new variable time was created and set to 0 and a timeLimit was created and set to 5 in love.load

    These two variables will be used in a conditional statement within gameUpdate(dt) to create a timer for our game. As the comments in the code indicates, this timer is used to generate cakes and grumpy cats at random coordinates. It is also used to remove the latest added string from the dialogue table. Within tfunction is also where the playerUpdate function will be called, parallax scrolling created, and the items and enemies tables updated. 
  • dt stands for delta-time. It is the most common shorthand for delta-time, which is usually passed through love.update to represent the amount of time which has passed since it was last called. Although, the LOVE wiki does state: It is in seconds, but because of the speed of modern processors is usually smaller than 1, values like 0.01 are common
  • A frame refers to the image we see on screen, which gets updated a certain number of times a second. The frequency in which the frame is updated is known as frame rate, usually measured in frames per second (fps).

Step 17   Complete code

local anim8 = require 'anim8'
local playerSpritesheet, animation 

function love.load()
	startscreen = love.graphics.newImage("startscreen.png")
	startbtn = love.graphics.newImage("startbutton.png")
	startblurb = love.graphics.newImage("startblurb.png")
	background = love.graphics.newImage("mainbg.png")
	backgroundPosition = 0
	currentScreen = 'menu'
	font = love.graphics.newFont("Retro Gaming.ttf", 24)
	love.graphics.setFont(font)
	Sx, Sy = love.graphics.getDimensions()
	items = {}
	cakeSprite = love.graphics.newImage("item.png")
	time = 0
	timeLimit = 3
	createPlayer()
	enemies = {}
	enemySprite = love.graphics.newImage("grumpycat.png")
	-- spawn items at start
	for i = 1, math.random(0,10) do
		newItem(math.random(0,700),math.random(0,500))
	end
end

function createPlayer()
	player = {
		x = 0,
		y = 250, 
		health = 3,
		alive = true,
		score = 0,
		bullets = {},
		bulletSprite = love.graphics.newImage("heartbullet.png"),
		rainbow = love.graphics.newImage('rainbow-trails.png'),
		cooldown = 20,
		speed = 5,
		dialogue = {},
		sentences = {"FOOD!","NYAAAAN!","Cake!","Yay.","OM NOM NOM"},
		fire = function()
		if player.cooldown <= 0 then 
			player.cooldown = 50
			bullet = {}
			bullet.x = player.x + 35
			bullet.y = player.y + 25
			table.insert(player.bullets, bullet)
		end
	end
	}
	playerSpritesheet = love.graphics.newImage('player-spritesheet.png')
	g = anim8.newGrid(64,64,playerSpritesheet:getWidth(),playerSpritesheet:getHeight())
	g2 = anim8.newGrid(64,64,player.rainbow:getWidth(),player.rainbow:getHeight())
	animation = anim8.newAnimation(g('1-4',1),0.1)
	animation2 = anim8.newAnimation(g2('1-1',1),0.1)
end

function playerUpdate(dt)
-- all this code is required in gameUpdate(dt) function
	player.cooldown = player.cooldown - 1
	-- player's y position always moving by 2 every frame
	player.y = player.y+2
	-- player x coordinate shifts by 1 if it is less than 100 every frame
	if player.x < 100 then
		player.x = player.x+1
	end
	-- player controls: can move up or fire hearts
	if player.alive == true then
		if love.keyboard.isDown("up") then
			player.y = player.y-player.speed
		end
		if love.keyboard.isDown("z") then 
			player.fire()
		end
	end
	-- update bullets table
	for i, b in pairs(player.bullets) do
		if b.x < 10 then 
			table.remove(player.bullets, i)
		end
		b.x = b.x + 10
	end
	function playerDialogue()
		if player.dialogue[1] then
			if player.alive == true then 
				love.graphics.print(player.dialogue[1],player.x+25,player.y-50)
			end
		end
	end
end


function collision(x1, y1, width1, height1, x2, y2, width2, height2)
	if x2 + width2 > x1 and x2 < x1 + width1 then
		if y2 + height2 > y1 and y2 < y1 + height1 then
			return true
		end
	end
	return false
end


function newItem(x,y)
	item = {}
	item.x = x
	item.y = y
	function item.update(dt)
		for i, item in pairs(items) do
			if item.x < -10 then 
				table.remove(items,i)
			end
			if collision(player.x, player.y, 64, 64, item.x, item.y, 16, 16) then 
				table.remove(items, i)
				player.score = player.score + 10
				table.insert(player.dialogue,player.sentences[math.random(#player.sentences)])
				player.speaking = true
			end
		end
	end
	table.insert(items,item)
end


function newEnemy(x,y)
	enemy = {}
	enemy.x = x
	enemy.y = y 
	function enemy.update(dt)
		for i, enemy in pairs(enemies) do
			if enemy.x < -10 then
				table.remove(enemies,i)
			end
			for i, b in pairs(player.bullets) do 
				if collision(bullet.x,bullet.y,16,16,enemy.x,enemy.y,64,64) then
					table.remove(enemies,i)
					table.remove(player.bullets,i)
					player.score = player.score + 100
				end
			end
			if collision(player.x,player.y,64,64,enemy.x,enemy.y,32,32) then
				table.remove(enemies,i)
				if player.health > 0 then 
					player.hit = true 
					player.health = player.health - 1
					animation = anim8.newAnimation(g('5-5',1),0.1)
				end
			end
		end
	end
	table.insert(enemies,enemy)
end


function menuUpdate(dt)
	if love.keyboard.isDown("x") then
		currentScreen = 'game'
	end
end

function gameUpdate(dt)
	time = time + dt 

	if time >= timeLimit then
		-- generate items and enemies at random coordinates
		for i = 1, math.random(0,10) do
			newItem(800 + math.random(0,700),math.random(0,500))
		end
		for i =1, math.random(0,3) do
			newEnemy(800 + math.random(0,700),math.random(0,500))
		end
		table.remove(player.dialogue,1)
		time = 0
	end
	if time >= 1 then 
		if player.hit == true then 
			animation = anim8.newAnimation(g('1-4',1),0.1)
			player.hit = false
		end
	end
	-- items and enemies' x position always moving by 2 every frame
	for i, item in ipairs(items) do
		item.x = item.x - 2
	end
	for i, enemy in ipairs(enemies) do
		enemy.x = enemy.x - 2
	end
	-- Update the player
	playerUpdate()
	-- parallax background
	if backgroundPosition > -800 then
		backgroundPosition = backgroundPosition - dt * 100
	else
	    backgroundPosition = 0
	end
	-- update items table
	for i, item in ipairs(items) do
		item.update(dt)
	end
	-- update enemies table
	for i, enemy in ipairs(enemies) do
		enemy.update(dt)
	end
end

function love.update(dt)
	if currentScreen == 'menu' then
		menuUpdate(dt)
	elseif currentScreen == 'game' then
	    gameUpdate(dt)
	    animation:update(dt)
	end
end

function love.draw()
	if currentScreen == 'menu' then
		-- draw start menu
		love.graphics.draw(startscreen, Sx/2,Sy/2, 0, 1, 1, startscreen:getWidth()/2, startscreen:getHeight()/2)
		love.graphics.draw(startblurb, Sx/2+250,Sy/2+150, 0, 1, 1, startblurb:getWidth()/2, startblurb:getHeight()/2)
		love.graphics.draw(startbtn, Sx/2+225,Sy/2+225, 0, 1, 1, startbtn:getWidth()/2, startbtn:getHeight()/2)
	end
	if currentScreen == 'game' then
		-- draw the background
		love.graphics.draw(background, backgroundPosition, 0, 0, 1, 1)
		love.graphics.draw(background, backgroundPosition + 800, 0, 0, 1, 1)
		-- draw the player
		animation2:draw(player.rainbow,player.x-50,player.y)
		animation2:draw(player.rainbow,player.x-100,player.y)
		animation:draw(playerSpritesheet,player.x,player.y)
		-- draw bullets
		for _, b in pairs(player.bullets) do 
			love.graphics.draw(player.bulletSprite,b.x,b.y,0,1,1)
		end
		-- draw items
		for _, item in pairs(items) do
			love.graphics.draw(cakeSprite,item.x,item.y)
		end
		-- draw enemies
		for _,enemy in pairs(enemies) do
			love.graphics.draw(enemySprite,enemy.x,enemy.y)
		end
		-- draw score
		love.graphics.print("Score: " .. player.score,50,30,0,1,1)
		-- draw player health
		love.graphics.print("Health: " .. player.health,50,60,0,1,1)
		-- draw player dialogue
		if player.speaking == true then
			playerDialogue()
		end
		-- if player goes off bottom of screen then game over condition reached 
		if player.y > love.graphics.getHeight() or player.health == 0 then
			animation = anim8.newAnimation(g('5-5',1),0.1)
			love.graphics.print("GAME OVER. PRESS X TO RESTART.", 150,250)
			player.alive = false
			player.health = 0
			if love.keyboard.isDown("x") then
				love.load()
			end
		end
	end
end
  • The complete code can be seen on the left.

Step 18   Conclusion

© 2021 Little Bird Electronics Pty Ltd.
Made with ❤️ in SYD. All prices inc GST. ABN 15 634 521 449. We're 🐥 @lbhq on Twitter.