How to Build a Game for the Playdate Console Using the Playdate SDK and Lua | by Michael J. Fordham | Nov, 2022

Build a goalkeeping game

Playdate console with the Keeper game on it.

A little while ago, Panic and teenage engineering revealed their new indie console: Playdate.

It’s a cute, fun little device with a 1-bit screen, a classic design and…a crank. Yep, a crank.

Needless to say, this random but loveable device really piqued my interest.

So, when I finally received my console in the post, I decided I would try my hand at creating a game for it. I love football (soccer for those of you across the pond) — so the game I had in mind was that of a goalkeeper who has to save shots from hitting the back of their net.

After about three weekends of tinkering and a few gotcha moments, I finally released my game on Itch. You can download it here for free if you want to give it a whirl.

As I was developing the game, there were a few times I thought “I really wish someone had made tutorials on how to do this”. So that’s what I’m going to try to do in this series of posts. I’m going to explain exactly how I built my game, with the hope that it helps others understand how to build their games for the Playdate too.

Massive shoutout: SquidGodDev’s videos were a huge help to me when trying to understand different parts of the development process for PlayDate, I probably couldn’t have made this game quite so quickly if it wasn’t for them. I wanted to link to their YouTube and Patreon as a little thank you!

Before we start, I want to set some expectations about what this tutorial will and won’t be about. It will:

  • walk you through how to build a game for the Playdate,
  • link to some helpful resources to assist you during your design and development, and,
  • give practical examples of the Playdate SDK in action.

However, this article is not going to:

  • teach you how to code, a basic level of programming knowledge is assumed (however I will link to helpful tutorials from others), or
  • teach you how to design (but I will link to helpful design resources needed to create this game, plus a Figma template of my existing graphics and tutorials for creating your own graphics)

We will go over different areas of the game’s development. Here’s a breakdown of what to expect:

  • Best resources to help you hit the ground running with Lua and the Playdate SDK
  • Planning what to build
  • File structure
  • Designing the game
  • Why we’ll use Object-Oriented Programming (OOP)
  • Creating game scenes
  • Progressing through scenes
  • Developing the Player which can move around the screen
  • Developing the Ball which you have to defend against
  • Developing the Goal which you have to protect as a goalkeeper
  • Collision detection
  • Responding to collisions
  • Keeping track of the player’s score
  • Saving, storing and loading a high score from the Playdate’s memory
  • Displaying the score and high score on-screen
  • Developing power-ups for the game
  • Playing audio when different events happen
  • Changing the rate (speed) of the audio based on gameplay
  • Getting your game ready for release
  • Sharing your game with the world

To develop games for the Playdate, you need to learn Lua or C. Lua is the simpler of the two, and that’s what we’ll be using in this tutorial. Here are some resources I’ve hand-picked for learning the basics of Lua, setting up the Playdate SDK, and basic programming for the Playdate:

You’ll also need a code editor to write the code for your game. I recommend Visual Studio Code (VS Code), which you can download here.

A common problem with building any piece of software is not knowing where to stop and letting more features and requirements slowly build up until it becomes overwhelming. In the industry, it’s commonly referred to as “scope creep”, as the scope of your project gradually creeps up.

My recommendation (and how we’ll be building this game), is to define the exact functionality you want from your game before you build it. Not only will this help you think of how to do it and what to design, but it will also mean you know what your end goal is.

I encourage you to follow along with the tutorial but make your own changes to the game so that it feels unique to you. The core functionality will be:

  1. Making a player that can move around and save shots from hitting the goal
  2. Keeping score of how many saves they’ve made
  3. Introducing some power-ups to the gameplay
  4. Creating fun interactions with audio

Before you start, you should install the Playdate SDK and make sure it’s working on your machine. Here is a really clear tutorial on how to do that:

Next, you should create a new folder with your game’s name, and then inside that folder, you should have these two folders:

  • .vscode — contains information about what you’re building and what programming language it’s in
  • source — where all your source code will be

I also recommend setting up a shortcut for building your game and running it in the Playdate SDK. If you hit CMD+Shift+P you should see a menu open with the available actions. One of them should be ‘Run app in Playdate simulator’. There should be a cog icon next to that command, where you can then select a shortcut to run the command. Personally, I like CMD+Shift+0 as I had nothing bound to it already.

Inside your source folder, you’ll need to add a pdxinfo file, which contains all the information about your game. Without this file, your game might not be able to build or work when you sideload it onto a Playdate. In the pdxinfo file, you should add something like this:

name=Your game's name
author=Your name
description=Description of your game
bundleID=com.yourname.yourgame
version=0.1
buildNumber=0001
imagePath=/images
launchSoundPath=/sounds

You’ll notice that we are pointing to folders for images and sounds. You’ll need to create those folders inside your source folder. They will be used for the visual and audio assets you create for your game.

Now you’ve got the basics done, you should create a new file named main.lua, which will be the file which runs every time the game is opened.

In main.lua you should have some code to import all of these files so they can be used in the game. You should also add some boilerplate code as shown below to make shorthands for the Playdate SDK, and allow timers to function properly.

import "CoreLibs/object"
import "CoreLibs/graphics"
import "CoreLibs/sprites"
import "CoreLibs/timer"
local gfx = playdate.graphics

function helloPlaydate()
print("beep boop beep")
end

helloPlaydate()

function playdate.update()
-- Update the sprites and the timer
gfx.sprite.update()
playdate.timer.updateTimers()
end

Before we can build our game for the first time, we need to add one final thing. In the .vscode folder, you should add a settings.json file, which tells VS Code where the SDK is:


"Lua.runtime.version": "Lua 5.4",
"Lua.diagnostics.disable": ["undefined-global", "lowercase-global"],
"Lua.diagnostics.globals": ["playdate", "import"],
"Lua.runtime.nonstandardSymbol": ["+=", "-=", "*=", "/="],
"Lua.workspace.library": [
"/Users/YOURNAME/Developer/PlaydateSDK/CoreLibs"
],
"Lua.workspace.preloadFileSize": 1000,
"playdate.sdkPath": "/Users/YOURNAME/Developer/PlaydateSDK"

Note: your SDK path may be different on your machine, especially if you’re on Windows as the path shown above is for Mac.

Once all that is done, you should be able to hit CMD+Shift+0 (or whatever the shortcut you created was) and the Playdate simulator should open, then in the console, you should see your message ‘beep boop beep’.

If something isn’t working, I have created an open repo on GitHub for this tutorial, and part one is available to download there. Feel free to clone that to your computer and continue following on from there if needed.

Now for some of the fun stuff: designing your game.

We need to design a:

  • welcome screen,
  • background for our game,
  • game-over screen,
  • player,
  • ball,
  • goal, and,
  • a set of powerups.

For this, we will use Figma. Figma is a really intuitive digital design tool that is available for free. You can sign up for an account here.

Here are some helpful tutorials on learning the basics of Figma too:

Once you understand how Figma generally works, you can begin creating your own visuals. To help, I created this Figma file which has the correctly sized frames for your Playdate Card (which is shown in the list of games installed on your Playdate), a player/sprite and the background for your game. When you click the link it will duplicate the file into your Figma account, so you can begin editing.

Gotcha moment: The Playdate has a 1-bit screen! Which means it only supports black and white. Playdate does not render colour images.

My advice: in your designs only use absolute black (#000000) and absolute white (#FFFFFF). If you need to create gradient-like effects, you could use a dithering tool. Just make sure in the dithering settings that you select a black and white palette.

When you’re designing your graphics in Figma, I recommend selecting View > Pixel Grid to turn on the pixel grid for the editor. This will show you a faint grid outline and allow your shapes to snap into place. You can design your artwork pixel by pixel, or you can draw shapes that overlap pixels and Playdate will render them as slightly more boxy versions.

Example of pixel grid in Figma

On the second page of the Figma file (‘Example Graphics’) you’ll see the graphics which I used for my version of the game. Feel free to use/edit these to match your style, or export them as they are if you want to build the game exactly the same.

When you’re happy with your designs, you can select all of the frames and in the bottom right of the Figma editor, you will see an option to export them.

You should note that the card image is special, and should be named ‘card’, as the Playdate SDK will look for this image and automatically make the card image the cover of your game when on the home screen. In the Figma file I created for you, I have already set the name as ‘card’, so all you need to do is export it as a PNG to your /images folder in your project.

A lot of open source games I’ve seen for the Playdate put all the game logic into a single main.lua file. This is fine, but over time it can become really difficult to read and maintain, let alone add new features.

Personally, I find structuring the game in an object-oriented way is clearer.

You may be wondering, “what the heck does object-oriented” mean?

Essentially, it allows you to build a blueprint for something, and then use that blueprint to create many different versions of something without having to duplicate code.

For example, a Ball could be an object. It could have a certain style, speed, direction etc. Without OOP you would have to hard-code each different type of Ball, but with OOP you can build a Ball class once and then re-use it however many times you like. This will all become clearer as we progress through the tutorials, but if you’re interested in diving deeper then this tutorial from SquidGodDev tells you everything you need to know and more.

In this tutorial, we will be using the OOP style.

In our game, we will have three game scenes:

  • A starting scene where we display things like a welcome message for the game, or instructions.
  • A game scene where the actual gameplay takes place.
  • A game-over scene where we take the player too after they concede so they can reflect on how well they did.

Of course, in your game, you might want to add more scenes based on the complexity of your game. For example, you might want to create additional game scenes for different areas or rooms in your game.

For now, though, we will stick to those three listed above.

In your source folder, add two new files:

  • globals.lua
  • sceneController.lua

globals.lua will be where we store global variables that we want every file to be able to access, and the sceneController.lua file will be where we handle changing scenes. We don’t want to create too many global variables, as that can result in complex bugs later, but a few global variables will support our gameplay.

In the globals.lua file, add this:

- Global gamestate variable which indicates what stage the user is at (start, game, game over)
gameState = 'start'

This gameState variable will be used to keep track of which state the user is in (e.g. are they on the starting scene, playing the game, or in the game over scene?)

In the sceneController.lua file, we need to add this code:

local pd  = playdate
local gfx = pd.graphics

-- Removes all sprites from the screen
function clearSprites()
local allSprites = gfx.sprite.getAllSprites()
for index, sprite in ipairs(allSprites) do
sprite:remove()
end
end

-- Sets the background image
function setBackground(imageName)
-- Display a background image
local backgroundImage = gfx.image.new( "images/" .. imageName )
assert( backgroundImage )

gfx.sprite.setBackgroundDrawingCallback(
function( x, y, width, height )
backgroundImage:draw( 0, 0 )
end
)
end

-- Sets up the starting scene
function setStartingScene()
gameState = 'start'
clearSprites()

setBackground('startingBackground')
end

-- Sets up the game scene
function setGameScene()
gameState = 'game'
clearSprites()

setBackground('background')
end

-- Sets up the game over scene
function setGameOverScene()
gameState = 'game over'
clearSprites()
setBackground('endingBackground')
end

There are a few things to digest here, so let’s break them down one-by-one.

The clearSprites() function allows us to get rid of everything on screen. It loops through every sprite that exists and removes it. This is useful when we want to go from one scene to another as it means we have a clean slate.

The setBackground(name) function allows us to change the background for the different scenes. We pass in the name parameter (which is just the name of the image) and then draw the image to the background, starting from 0, 0 (the top left of the screen).

The setStartingScene(), setGameScene() and setGameOverScene() all do similar things:

  1. Change the gameState so that it’s updated to the latest scene.
  2. Clear all the sprites.
  3. Changes the background by calling the setBackground function and passing in the name of the background image.

To hook this all up, we need to go back to the main.lua file and import our newly created files:

import "globals"
import "sceneController"

If you don’t import the files into main.lua, then you will not be able to access their functions in the file or other imported files.

After importing, we need to call a function to set the scene:

setStartingScene()

If you run the game again, you will now see your starting scene appear.

If you want to see the game scene or the game over scene, you would just have to change that function call to setGameScene() or setGameOverScene().

Your main.lua should be looking like this:

import "CoreLibs/object"
import "CoreLibs/graphics"
import "CoreLibs/sprites"
import "CoreLibs/timer"
import "globals"
import "sceneController"

local gfx = playdate.graphics

setStartingScene()

function playdate.update()
-- Update the sprites and the timer
gfx.sprite.update()
playdate.timer.updateTimers()
end

We start in the starting scene, but we want to be able to progress to the main game scene from there.

To do this, we will create a component that allows us to press the A button which then calls the setGameScene() function for us.

Create a new file named playGameButton.lua in your source folder. Inside the file, you should use this code:

local pd  = playdate
local gfx = pd.graphics

class('PlayGameButton').extends(gfx.sprite)

function PlayGameButton:init(x, y)
local playGameButtonImage = gfx.image.new("images/playGameMessage")
assert( playGameButtonImage )
self:setImage(playGameButtonImage)
self:moveTo( x, y )
self:add()
end

-- When the A button is pressed and released, the game state changes and the user can play the game
function playdate.AButtonUp()
if gameState == 'start' or gameState == 'game over' then
setGameScene()
end
end

Let’s digest what’s going on here.

First, we’re creating a PlayGameButton class which extends the Sprite class. This means we can make a PlayGameButton object in the future.

We then have an init function for the PlayGameButton, which takes an X and Y coordinate, indicating where we want to place it on screen.

In that init function we also set the image that we want to use for the button, and a function call to add it to the screen.

Note: You’ll notice that inside the init function we’re using the self keyword. This basically means we are referring to that particular instance of the button. If you’ve ever programmed in a language like TypeScript, it’s similar to using the this keyword.

This is most useful when there are multiple instances of the object across the game at one time. We’ll only be creating one button, but when we get to creating multiple footballs you’ll see that the impact of having multiple instances of an object is very handy.

The only other function in the file is the AButtonUp() callback, which is run any time the A button on the console is pressed and released.

Gotcha moment: One issue that was tricky to diagnose during development is that when I was pressing the A button, the console was treating it like I was spamming the A button repeatedly.

The problem? I was using the AButtonDown() function, which keeps being fired as long as the button is pressed (so if you prolong the press of the button the event keeps firing). AButtonUp() resolved this as it only calls the functionality once, when the A button is pressed and released.

Once the button is pressed, there is some logic to check whether the player is at the beginning or end scene of the game, and if they are, they are sent to the main game scene.

This is so that the user can go to the main game from the starting scene or the game-over scene.

The next thing we need to do is make sure the PlayGameButton is added to the correct scenes. In the sceneController.lua file, amend the functions for the starting scene and game-over scenes so they add the PlayGameButton to the screen:

-- Sets up the starting scene
function setStartingScene()
gameState = 'start'
clearSprites()

PlayGameButton(200, 200)
setBackground('startingBackground')
end

-- Sets up the game over scene
function setGameOverScene()
gameState = 'game over'
clearSprites()

PlayGameButton(200, 200)
setBackground('endingBackground')
end

We pass in ‘200, 200’ to the PlayGameButton to set the coordinates we want it to render at on screen.

Finally, we need to import the playGameButton.lua file in our main.lua file:

import “playGameButton”

Once that’s done and saved, you should be able to run the command to play your game in the Playdate simulator and see that you now have a starting scene and a play game button.

If you tap the A button, you will progress to the main game scene. Of course, nothing is happening yet as we haven’t built any of the playable game yet, but we will soon.

And eventually, when the game finishes, we will also see the play game button in the game over scene.

A Playdate emulator showing the work in progress game on the starting screen.
The starting screen for your game

Creating a moveable player

We want to create a goalkeeper that we can move around on screen to save shots.

Firstly, we need to add a new file named player.lua to our source folder. In the player file, add this code:

local pd  = playdate
local gfx = pd.graphics

class('Player').extends(gfx.sprite)

-- Instantiates the Player
function Player:init(x, y)
local playerImage = gfx.image.new("images/playerImage")
assert( playerImage )
self:setImage(playerImage)
self:moveTo( x, y )
self:setCollideRect(0, 4, 32, 24)
self:add()

self.speed = 4
end

-- Runs every time the playdate refreshes, constantly checking if a button is being presssed (multiple can be pressed at once)
function Player:update()
-- Allow player movement with the D-pad
if playdate.buttonIsPressed( playdate.kButtonUp ) then
if (self.y > 8) then
self:moveBy( 0, -self.speed * keeperSpeedMultiplier )
end
end
if playdate.buttonIsPressed( playdate.kButtonRight ) then
if (self.x < 384) then
self:moveBy( self.speed * keeperSpeedMultiplier, 0 )
end
end
if playdate.buttonIsPressed( playdate.kButtonDown ) then
if (self.y < 210) then
self:moveBy( 0, self.speed * keeperSpeedMultiplier )
end
end
if playdate.buttonIsPressed( playdate.kButtonLeft ) then
if (self.x > 16) then
self:moveBy( -self.speed * keeperSpeedMultiplier, 0 )
end
end
end

Firstly, we import some core Playdate packages. Next, we create a new Player class, which is essentially extending the functionality of a default sprite.

Then, in Player:init we initialise the Player. We pass in X and Y values which will be the coordinates of where the Player is initially placed on-screen.

We set the Player image to be something from our images folder, that we prepared earlier.

You’ll notice we also use setCollideRect here for the first time. This allows us to customise the collision box on the sprite, in case our image isn’t pressed up against all borders of the sprite. We pass in 0, 4, 32, and 24 which means:

  • The X coordinate of the start of the collision rectangle is 0
  • The Y coordinate of the start of the collision rectangle is down 4 pixels
  • The width of the collision rectangle is 32 pixels
  • The height of the collision rectangle is 24 pixels

We customise the collision rectangle to make gameplay feel more logical — we wouldn’t want to “save” a shot before the ball even appears to hit our goalkeeper.

Finally, in the initialization function, you’ll see we set self:speed to 4. This is the default speed of our goalkeeper. You should play around with the value and see how it impacts gameplay.

Our next function is Player:update, which is triggered every frame change (so, very regularly). In here we have four separate if statements which use the buttonIsPressed function, which then passes in a button type (for example, kButtonUp, for the up button).

If a directional button is pressed, we check that the Player is within the bounds of the screen. For example, if we press the right directional button, we check if the Player’s self.x (their X coordinate) is less than 384 (remember the Playdate screen is 400 pixels wide). We check a lower figure than 400 so the Player remains on screen rather than disappearing and then stopping.

If the Player is within the bounds of the screen, then we allow them to move.

We call the moveBy function, passing in the X and Y values. If we are moving left or right, we keep the Y value 0. If we are moving up or down, we keep the X value 0.

You’ll notice we move the Player by self.speed multiplied by the keeperSpeedMultiplier. Currently, this multiplier variable does not exist, so we need to add it to the globals.lua file.

keeperSpeedMultiplier = 1

What this means is that, in the future, if we want the gameplay to impact the way the Player moves we can change the global keeperSpeedMultiplier. For example, if we want the Player to speed up when they save a shot, we can add 0.01 to the multiplier.

This is great, the Player is ready to go. But if you run the game now, you’ll see nothing has changed. Why?

We need to add the Player to the game scene. In the sceneController file, update the setGameScene function to have the Player added to the screen:

-- Sets up the game scene
function setGameScene()
gameState = 'game'
clearSprites()

Player(200, 180)
setBackground('background')
end

Finally, we need to import the player.lua file into our main.lua file:

import “player”

Once you’ve made these changes and saved the files, you’ll be able to run the game, press the A button to start a new game, and then you’ll see your goalkeeper on screen who you can move with the directional arrows.

Additionally, under the Playdate simulator settings, you can enable ‘Show Sprite Collision Rects’, which will show a pink outline indicating the collision box on your sprites. This is very handy when you are developing the game to make sure collision rectangles are set properly and match your imagery. You can leave this enabled too, as the collision indicator will not be shown when you build the game and sideload it onto a device.

Creating a moving ball that you can defend against

Now we have a player moving around the screen, we should add footballs that they can interact with.

In your source folder, add a new file named ball.lua. In that file, you should have this code:

local pd  = playdate
local gfx = pd.graphics

class('Ball').extends(gfx.sprite)

function Ball:init(x, y, speed, direction)
local ballImage = gfx.image.new("images/ball")
assert( ballImage )

self:setImage(ballImage)
self:moveTo( x, y )
self:setCollideRect(8, 8, 16, 16)
self.speed = speed
self.direction = direction
self:add()
end

function Ball:update()
local actualX, actualY, collisions, length = self:moveWithCollisions(self.x + self.direction, self.y + (self.speed * ballSpeedMultiplier))

-- If there is a collision
if length > 0 then
for index, collision in ipairs(collisions) do
local collidedObject = collision['other']

if collidedObject:isa(Player) then
self:remove()
end
end
end

-- If the ball has flown off screen, it is removed from the list of sprites for performance reasons
if (self.y > 240) then
self:remove()
end
end

-- Ensures the Balls overlap each other rather than bumping into each other and sliding slowly
function Ball:collisionResponse()
return 'overlap'
end

Like the player.lua file, we create the Ball class which extends the Sprite class.

When we initialise the ball, we give it:

  • An X coordinate
  • A Y coordinate
  • A speed value
  • A direction value

These values can be randomised later to create a more challenging and fun game.

The next interesting thing we do is check for collisions in the update function. If there is a collision, we can check what type of thing the ball collided with. For now, we only have a Player — but in the future, we will add a goal and a wall, so we will update this logic again.

You’ll notice in the logic we’re using a new global variable named ‘ballSpeedMultiplier’. We need to add this to the globals.lua file:

gameState = ‘start’
keeperSpeedMultiplier = 1
ballSpeedMultiplier = 1

We will use this variable later to create fun effects when the player uses power-ups and advances in the game.

If there is a collision with the player, we remove the ball from the screen.

We also have an additional check for if the ball has flown off the screen, and if it has we remove it. This is for performance reasons. If we didn’t remove the ball, technically it would still exist off-screen and there would still be computing power dedicated to tracking it. Once we have lots of footballs being generated, this could become problematic for the system to handle.

There is then another function that handles the collision response of the Ball. This function returns the string ‘overlap’ so that the Ball sprites can overlap each other if they collide on screen. The Playdate SDK explains in further detail which other types of collision responses you can utilize.

We’re all done with the ball now, but now we should actually implement something to trigger the spawning of the footballs.

Create another new file in your source folder named ballSpawner.lua and add this code to it:

import "ball"

local pd = playdate
local gfx = pd.graphics

local spawnTimer

function startBallSpawner()
-- Makes the game randomly spawn balls
math.randomseed(pd.getSecondsSinceEpoch())
createBallTimer()
end

function createBallTimer()
-- Generates a random period between spawning the next ball
local spawnTime = math.random(700, 1200)

-- Waits for the random amount of time, then calls the callback functions to spawn the ball
spawnTimer = pd.timer.performAfterDelay(spawnTime, function()
createBallTimer()
spawnBall()
end)
end

-- Spawns a ball at a random location
function spawnBall()
local spawnPosition = math.random(0, 240)
local spawnDirection = math.random(-3, 3)
local spawnSpeed = math.random(2, 5)

-- Spawns the ball at a random position and changes speed as the player progresses
Ball(spawnPosition, -20, spawnSpeed, spawnDirection)
end

-- Stops the ball spawner
function stopBallSpawner()
if spawnTimer then
spawnTimer:remove()
end
end

-- Clears all the Balls from the display
function clearBalls()
local allSprites = gfx.sprite.getAllSprites()
for index, sprite in ipairs(allSprites) do
if sprite:isa(Ball) then
sprite:remove()
end
end
end

This file imports the Ball class and then operates it.

We have functions to start the ball spawner, which waits a random amount of time (between 700ms and 1200ms) to spawn a new ball. The function is recursive, which means it calls itself over and over again until we stop it. This means that footballs will continue to spawn while we play.

In the spawnBall function, we randomise the spawning position for the X coordinate so that it comes from somewhere unexpected. This is between 0 and 240 as the screen’s width is 240 pixels.

We then randomize the direction of the ball, anywhere from -3 to 3. This means that the ball can travel left (negatively on the X axis) or right (positively on the X axis).

Next, we randomize the speed of the ball between 2 and 5.

You should play with all these minimum and maximum values to make the game feel unique and fun to you.

Finally, we use the Ball class and feed in the randomised variables we just created with the notable exception of the Y coordinate which we set as -20 every time so that the ball spawns just off-screen. If you spawned it on-screen it would look a bit strange, whereas off-screen it feels like someone has taken a long shot.

Other functions we have are stopBallSpawner to…you guessed it…stop the balls from spawning, and clearBalls which…clears all the balls from the screen!

These are handy for us to use when ending a game and clearing all sprites.

Next, we will update the sceneController file to start and stop the ball spawner:

-- Sets up the game scene
function setGameScene()
gameState = 'game'
clearSprites()

Player(200, 180)
startBallSpawner()
setBackground('background')
end

-- Sets up the game over scene
function setGameOverScene()
gameState = 'game over'
clearSprites()

stopBallSpawner()
PlayGameButton(200, 200)
setBackground('endingBackground')
end

And finally, we will import the ballSpawner into the main.lua file:

import “ballSpawner”

Note that you haven’t had to import the Ball too, as we already imported it into the ballSpawner file.

If you run your game now, you should have balls spawning into the screen and when you collide with them they should disappear.

Creating a goal that you have to defend

The fundamental parts of our game are coming together now, but we’re missing a key element: the goal.

As the goalkeeper, the aim of the game is for you to defend your goal against the oncoming shots, ensuring none of them hit the back of the net.

So, let’s create the goal!

In your source folder, create a new file named goal.lua, which contains this code:

local pd  = playdate
local gfx = pd.graphics

class('Goal').extends(gfx.sprite)

function Goal:init(x, y)
local goalImage = gfx.image.new("images/goal")
assert( goalImage )

self:setImage(goalImage)
self:moveTo( x, y )
self:setCollideRect(16, 8, 222, 16)
self:add()
end

function Goal:collisionResponse()
return 'overlap'
end

This is a pretty simple file, and very similar to other ones we’ve created before. The most notable thing in the init function is that we’re defining quite an odd shape for the collision rectangle. We are offsetting it and shrinking it down slightly compared to the image (which you can find in the Figma document I created for these tutorials).

The reason for the adjustments to the collision shape is so that footballs don’t appear to hit the post and then count as goals, which could be confusing for players.

Another notable mention in this file is that the collision response for the goal is set to ‘overlap’, so that footballs can overlap the goal.

Now we’ve got that sorted, head into your ball.lua file, and add this conditional statement to check whether the ball has hit the goal:

-- If we concede a goal, the game is over
if collidedObject:isa(Goal) then
setGameOverScene()
self:remove()
end

This means that if the ball collides with the goal, the game-over function is called and the ball is removed from the screen.

We also need to create the goal in our sceneController.lua file. In the setGameScene function, add this line of code to place the goal on screen:

Goal(200, 235)

Finally, we need to import the goal file into our main.lua file:

import “goal”

After you’ve saved your work and launched the game again, you should have a goal to defend! The core functionality of our game is now complete, as you can stop footballs flying into the back of your net.

If you had any trouble following along so far or something isn’t working quite right, feel free to clone the GitHub repository I have made available here.

Keeping track and displaying the player’s score

To make the game fun, we should be working towards achievement. For this game, we will be trying to achieve the highest score possible per session.

In our globals.lua file, we should add a score variable:

score = 0

This is the variable we’ll use to track the score across the entire game. We’re putting it as a global variable so we can use it to impact other areas of the game later on.

We should now create a new file named scoreDisplay.lua:

local pd  = playdate
local gfx = pd.graphics
local scoreSprite

function createScoreDisplay()
-- Current score sprite
scoreSprite = gfx.sprite.new()
score = 0
scoreSprite:setCenter(0, 0)
scoreSprite:moveTo(320, 4)
scoreSprite:add()
end

function updateDisplay()
-- Current score
local scoreText = 'Score: ' .. score
local textWidth, textHeight = gfx.getTextSize(scoreText)
local scoreImage = gfx.image.new(textWidth, textHeight)
gfx.pushContext(scoreImage)
gfx.drawText(scoreText, 0, 0)
gfx.popContext()
scoreSprite:setImage(scoreImage)
end

function incrementScore()
-- Updates the score
score += 1
updateDisplay()
end

function resetScore()
score = 0
updateDisplay()
end

function getScore()
return score
end

Let’s analyse this file from top to bottom.

First, we create a local scoreSprite variable, which will be used to display the score in the createScoreDisplay function. In this function, we create the sprite and place it in the top right of the screen.

In the next function updateDisplay, we generate the text we want to appear in the score sprite, and draw it to the screen. At the start of the game, it will simply read Score: 0 as we haven’t scored any points yet.

We then have an incrementScore function, which increases the score by one and calls the updateDisplay function to update the score displayed on the screen.

Finally, we have two simple functions to reset the score to zero and get the current score.

The next thing we’ll want to do is update the setGameScene function in our sceneController.lua so that we trigger the displaying of the score:

-- Sets up the game scene
function setGameScene()
gameState = 'game'
clearSprites()
createScoreDisplay()
Player(200, 180)
Goal(200, 235)
setBackground('background')
end

In the main.lua file, we should add a new function called resetGame to call some of our other helper functions whenever a new game needs to be started:

function resetGame()
resetScore()
clearBalls()
stopBallSpawner()
startBallSpawner()
end

We need to call the resetGame function from the playGameButton.lua file, in the AButtonUp callback function:

function playdate.AButtonUp()
if gameState == 'start' or gameState == 'game over' then
setGameScene()
resetGame()
end
end

In our ball.lua file, we should update what happens when there is a collision between the ball and the player so that the score is increased:

if collidedObject:isa(Player) then
-- If we save a shot, the score is incremented by 1
incrementScore()
self:remove()
end

We should then import our scoreDisplay function to our main.lua file:

import "scoreDisplay"

If you save and run your game now, you will be able to see the score when you start a new game, the score will rise as you make saves and the score will reset when you have ended a game and want to play again.

Saving, storing, and loading a high score from the Playdate’s memory

We want to be able to store the player’s high score on the Playdate, so we know what the all-time high score is and so that we can pick up where we left off if we quit the game.

To do this, we need to communicate with the Playdate’s internal memory system to store our high score.

At the moment, we don’t display the high score yet. Let’s change that.

We’ll be adding some new functionality to our scoreDisplay.lua file:

  • Showing the high score on the screen
  • Storing the high score in the Playdate’s memory
local pd  = playdate
local gfx = pd.graphics
local scoreSprite
local highscoreSprite
local highscore
local scoreTable

-- If there is a highscore stored, it loads it to the game, otherwise it initialises it to zero
function loadHighscore()
scoreTable = playdate.datastore.read('scoreInfo')
if scoreTable ~= nil then
highscore = scoreTable[1]
else
scoreTable =
scoreTable[1] = 0
highscore = 0
end
end

-- Saves the score to the device if it's higher than the highscore
function saveScore(newScore)
if scoreTable ~= nil then
if newScore > scoreTable[1] then
scoreTable[1] = newScore
playdate.datastore.write(scoreTable, 'scoreInfo')
end
end
end

function createScoreDisplay()
-- Current score sprite
scoreSprite = gfx.sprite.new()
score = 0
scoreSprite:setCenter(0, 0)
scoreSprite:moveTo(320, 4)
scoreSprite:add()
-- Highscore sprite
highscoreSprite = gfx.sprite.new()
highscoreSprite:setCenter(0, 0)
highscoreSprite:moveTo(8, 4)
highscoreSprite:add()
end

function updateDisplay()
-- Current score
local scoreText = 'Score: ' .. score
local textWidth, textHeight = gfx.getTextSize(scoreText)
local scoreImage = gfx.image.new(textWidth, textHeight)
gfx.pushContext(scoreImage)
gfx.drawText(scoreText, 0, 0)
gfx.popContext()
scoreSprite:setImage(scoreImage)
-- Highscore
local highscoreText = 'Highscore: ' .. highscore
local highscoreTextWidth, highscoreTextHeight = gfx.getTextSize(highscoreText)
local highscoreImage = gfx.image.new(highscoreTextWidth, highscoreTextHeight)
gfx.pushContext(highscoreImage)
gfx.drawText(highscoreText, 0, 0)
gfx.popContext()
highscoreSprite:setImage(highscoreImage)
end

function incrementScore()
-- Updates the score
score += 1
updateDisplay()
end

function resetScore()
score = 0
updateDisplay()
end

function getScore()
return score
end

Going from top to bottom, let’s discuss the changes here.

First, we create some new local variables for our high score sprite, high score value, and the score table which we are going to be storing in the Playdate’s memory.

Tables are essentially like an array if you’ve ever used another programming language before, but there are a few differences:

  • The index starts at 1, not 0
  • You can name your indices

We can let Lua know that a variable is a table by assigning it empty curly brackets:

local myFirstTable = 

We can assign values either during the instantiation of the table or later on. Here’s an example of both being done:

-- During instantiation
local myFirstTable = 'hello', 'world'

-- After instantiation
myFirstTable[1] = 'hello'
myFirstTable[2] = 'world'

You can also assign a value to a string index, which is handy if we want to be clear about what the value is that we’re storing. For example:

local myFirstTable = 
myFirstTable['greeting'] = 'hello world'

We can then read the values very easily, for example, if you wanted to print out the values to screen you could do this:

local myFirstTable = 
myFirstTable['greeting'] = 'hello there'
myFirstTable['name'] = 'Michael'
print(myFirstTable['greeting'] + " " + myFirstTable['name'])

You’ll notice in our scoreDisplay.lua file we now have this function:

-- If there is a highscore stored, it loads it to the game, otherwise it initialises it to zero
function loadHighscore()
scoreTable = playdate.datastore.read('scoreInfo')
if scoreTable ~= nil then
highscore = scoreTable[1]
else
scoreTable =
scoreTable[1] = 0
highscore = 0
end
end

This tries to retrieve a score from memory. However, if the user is playing for the first time, there will be no score in memory already. So, we have a conditional statement that checks whether the scoreTable’s value is nil or not (think undefined if you’ve programmed in other languages like JavaScript).

If the scoreTable is not nil (~=), then we set the high score to be equal to the value we just retrieved from memory.

If the scoreTable is nil, then we tell Lua the scoreTable is a table, we set the first value inside it (representing our high score) to zero, and we set the local high score to zero.

Elsewhere in our updated code you’ll see we now have implemented some code to show the high score on the display, in the top left corner of the screen. I won’t dissect that as it’s very similar to other code we’ve discussed before.

Before we try out our new high score functionality, we’ll need to call our loadHighscore function from the resetGame function in the main.lua file:

function resetGame()
loadHighscore()
resetScore()
clearBalls()
stopBallSpawner()
startBallSpawner()
end

If you save and run your game now, you should see a high score in the top left. That score will also persist even if you close the game and re-open it.

We now have a high score in the top left! P.S. 104 is my highest ever score…

Developing power-ups for the game

What’s a game without some power-ups, right?

This is a really fun addition to the game which makes it that little bit crazier and more random.

We’re going to be adding four different power-ups to the game:

  • Super-fast goalkeeper movement
  • Slow down all footballs
  • Double your points per save
  • Enable all power-ups at once

First, we need to create a new file named powerUp.lua:

local pd  = playdate
local gfx = pd.graphics
class('PowerUp').extends(gfx.sprite)

function PowerUp:init(x, y, type, imageName)
local powerUpImage = gfx.image.new("images/" .. imageName)
assert( powerUpImage )
self:setImage(powerUpImage)
self:moveTo( x, y )
self:setCollideRect(4, 4, 24, 24)
self.type = type
self:add()
end

function PowerUp:update()
local actualX, actualY, collisions, length = self:moveWithCollisions(self.x, self.y)
-- If there is a collision
if length > 0 then
for index, collision in ipairs(collisions) do
local collidedObject = collision['other']

if collidedObject:isa(Player) then
-- If the player hits the powerup, change the game somehow
activatePowerUp(self.type)
self:remove()
end
if collidedObject:isa(Ball) then
-- If the ball hits the powerup, remove the powerup
self:remove()
end
end
end
end

-- Changes a global powerup to impact the gameplay
function activatePowerUp(type)
if (type == 'all') then
-- Activate all powerups
keeperSpeedMultiplier = 2
ballSpeedMultiplier = 0.5
pointsMultiplier = 2
elseif (type == 'slow ball') then
-- Makes the balls slower
ballSpeedMultiplier = 0.5
elseif (type == 'fast keeper') then
-- Makes the keeper faster
keeperSpeedMultiplier = 2
elseif (type == 'double points') then
pointsMultiplier = 2
end
end

-- Resets all the global powerups back to their defaults
function resetPowerUps()
keeperSpeedMultiplier = 1
ballSpeedMultiplier = 1
pointsMultiplier = 1
end

-- Ensures the sprites overlap each other rather
function PowerUp:collisionResponse()
return 'overlap'
end

Here, we create a new PowerUp class that extends the Sprite class.

We initialize a PowerUp with:

  • an x coordinate,
  • a y coordinate,
  • a type (e.g. ‘slow ball’), and,
  • an image name.

We set up the sprite to be displayed as we do with other sprites we’ve created before, and also give it an appropriate collision rectangle based on the image we’re using.

Note: the images I have used for the power-ups are available in the Figma document I created to accompany this tutorial.

In the PowerUp:update() function, we check if there is a collision between a Player or a Ball.

If the collision is with the Player, we call the function to activate a PowerUp, passing in the type of the power-up we collided with.

The activatePowerUp function then determines which changes to apply to the game based on which type of power-up was collided with.

We do need to add one more global variable to our globals.lua file:

pointsMultiplier = 1

This allows us to control how many points should be earned as the user plays the game.

Finally, in the powerUp.lua file, we have some functions to reset the power-ups and ensure colliding sprites overlap each other.

This is great, but we haven’t actually created any specific power-ups yet. For that, we’ll need a PowerUp spawner (kind of like how we have a Ball and a BallSpawner).

Create a new file named powerUpSpawner.lua:

import "powerUp"
local pd = playdate
local gfx = pd.graphics
local spawnTimer

function startPowerUpSpawner()
-- Makes the game randomly spawn powerups
math.randomseed(pd.getSecondsSinceEpoch())
createPowerUpTimer()
end

function createPowerUpTimer()
-- Generates a random period between spawning the next powerup
local spawnTime = math.random(8000, 20000)
-- Waits for the random amount of time, then calls the callback functions to spawn the powerup
spawnTimer = pd.timer.performAfterDelay(spawnTime, function()
createPowerUpTimer()
spawnPowerUp()
end)
end

-- Spawns a powerup at a random location
function spawnPowerUp()
local spawnX = math.random(20, 380)
local spawnY = math.random(30, 140)
local spawnType = math.random(1, 4)
-- Resets and removes any powerups currently impacting gameplay to incentivise the player getting the new one
clearPowerUps()
resetPowerUps()
-- Spawns a random powerup at a random position on screen
if (spawnType == 1) then
PowerUp(spawnX, spawnY, 'all', 'allPowerUp')
elseif (spawnType == 2) then
PowerUp(spawnX, spawnY, 'slow ball', 'slowBallPowerUp')
elseif (spawnType == 3) then
PowerUp(spawnX, spawnY, 'fast keeper', 'fastKeeperPowerUp')
elseif (spawnType == 4) then
PowerUp(spawnX, spawnY, 'double points', 'doublePointPowerUp')
end
end

-- Stops the powerup spawner
function stopPowerUpSpawner()
if spawnTimer then
spawnTimer:remove()
end
end

-- Clears all the powerups from the display
function clearPowerUps()
local allSprites = gfx.sprite.getAllSprites()
for index, sprite in ipairs(allSprites) do
if sprite:isa(PowerUp) then
sprite:remove()
end
end
end

Let’s analyze this file top-to-bottom.

First, we import the powerUp file we created, so that we can use the PowerUp class.

Next, we create a local spawnTimer variable, and start the PowerUp spawner. This calls the createPowerUpTimer function, which randomly spawns a new PowerUp between 8 and 20 seconds since the timer started.

The spawnPowerUp function spawns the PowerUp at a random X and Y coordinate (however, we set the minimum and maximum limits to ensure the PowerUp doesn’t spawn too close to the Player).

We then generate a random number between 1 and 4 (inclusive) to determine which PowerUp we should randomly spawn.

There is some logic to remove old PowerUps from the display and reset the game to default settings.

We have a conditional statement that checks which number was generated and spawns a PowerUp based on that value. For example, if spawnType was 2, the slow ball power-up would be created.

-- Spawns a random powerup at a random position on screen
if (spawnType == 1) then
PowerUp(spawnX, spawnY, 'all', 'allPowerUp')
elseif (spawnType == 2) then
PowerUp(spawnX, spawnY, 'slow ball', 'slowBallPowerUp')
elseif (spawnType == 3) then
PowerUp(spawnX, spawnY, 'fast keeper', 'fastKeeperPowerUp')
elseif (spawnType == 4) then
PowerUp(spawnX, spawnY, 'double points', 'doublePointPowerUp')
end

Finally, we have functions to stop the spawner and clear all PowerUps from the display.

Before we can start playing with the PowerUps, we need to adapt some of the code to support PowerUps. In the scoreDisplay file, update the incrementScore function to increment the score by one, multiplied by the pointsMultiplier global variable we created:

function incrementScore()
-- Updates the score
score += 1 * pointsMultiplier
updateDisplay()
end

We also need to import the powerUpSpawner in our main.lua file, and call the functions to operate the PowerUp spawner in the resetGame function:

import "CoreLibs/object"
import "CoreLibs/graphics"
import "CoreLibs/sprites"
import "CoreLibs/timer"
import "globals"
import "sceneController"
import "playGameButton"
import "player"
import "ballSpawner"
import "goal"
import "scoreDisplay"
import "powerUpSpawner"
local gfx = playdate.graphics

function resetGame()
loadHighscore()
resetScore()
clearBalls()
clearPowerUps()
stopBallSpawner()
stopPowerUpSpawner()
startBallSpawner()
startPowerUpSpawner()
end

setStartingScene()

function playdate.update()
-- Update the sprites and the timer
gfx.sprite.update()
playdate.timer.updateTimers()
end

If you save and run your game now, you’ll be able to see random power-ups spawn onto the screen as you play, and if you run into them you will feel the impact they have on the game.

What you’ll also notice is that a Ball can collide with a PowerUp and if it does, it takes out the power-up. I added this so that there is a bit more difficulty in attaining a power-up.

Power-ups now appear as you play!

Displaying your final score on the game-over scene

If you’ve been following along with the tutorials so far, you’ll have probably noticed that when you finish the game, there is no score displayed on the game-over scene.

We’ll change that now.

In your scoreDisplay.lua file, you should add a local final score sprite variable:

local finalScoreSprite

Then, we’ll add a function to the same file to display the final score:

function showFinalScore()
-- Final score sprite
finalScoreSprite = gfx.sprite.new()
finalScoreSprite:setCenter(0, 0)
finalScoreSprite:moveTo(190, 110)
finalScoreSprite:add()
local finalScoreText = score
local finalScoreTextWidth, finalScoreTextHeight = gfx.getTextSize(finalScoreText)
local finalScoreImage = gfx.image.new(finalScoreTextWidth, finalScoreTextHeight)
gfx.pushContext(finalScoreImage)
-- Wrapping the text with asterisks makes the text bold
gfx.drawText('*' .. finalScoreText .. '*', 0, 0)
gfx.popContext()
finalScoreSprite:setImage(finalScoreImage)
end

Here, we’re putting the final score in the middle of the display. We’re also wrapping the text with asterisks to make it bold.

Finally, we need to call the showFinalScore function from the setGameOverScene function in the sceneController.lua file:

-- Sets up the game over scene
function setGameOverScene()
gameState = 'game over'
clearSprites()
stopBallSpawner()
PlayGameButton(200, 200)
showFinalScore()
setBackground('endingBackground')
end

If you save and run the game now, you should see your final score on the game-over scene!

Leave a Reply