Introduction: Cactus Runner (Pygame Zero Intermediate Tutorial)

About: Hello, my name is Michael. I am an electrical and computer engineer turned educator. I have a lot of experience in embedded software and hardware design. I am taking my technical experiences and leveraging the…

Follow along with this Pygame Zero tutorial to implement the game: "Cactus Runner". In this game, the player will run across a landscape filled with cacti and must jump at the correct time to avoid them. Dodge them all or you will lose! Your score increases the longer you survive.

I use this tutorial in my high school Game Design course at NCSSM. After learning the basics of Pygame Zero, we begin our unit on 2D games. This tutorial is the third in a series they complete as they are learning the fundamentals of developing 2D games. At the end of the unit, they create a 2D game of their own. Below are links to all the tutorials:

Note: This tutorial gives an exhaustive overview of how to implement this game, with complete explanations for each step. Feel free to skip over certain parts you feel more comfortable with. I wrote a thorough explanation of each concept, so that when you encountered something you weren't as familiar with, you could read in more detail.

Supplies

All you need is a Windows/Mac/Linux machine with Python installed. If you are on a Chromebook or can't install Python on your machine, you can use the Pygame template on replit for free. To set up your local environment, follow the steps below.


Install Python

https://www.python.org/downloads/

Go to the page listed above. Download the latest version available for your operating system. Run the installer and follow the instructions. Make sure you install pip and add the Python executable to the path (should be check boxes for each).

 

Install VSCode

There are many, many different options for a text editor for developing code. Far be it from me to mandate a specific code editor only for you to blame me later on when you find an editor more suitable for your purposes. Each editor has its pros and cons; it is really based on personal preference and how you will use it. Feel free to search around and find one that works for you. I recommend using VScode.

VScode - https://code.visualstudio.com/ - A powerful code editor that is quickly becoming the standard for many forms of software development. Includes a lot of features and has many useful packages/extensions that can be installed. 

 

Install VSCode Extensions

Below is a list of helpful extensions. Open the extensions tab, search for the extension, and install it.

 

Install Pygame Zero

Instructions: https://pygame-zero.readthedocs.io/en/stable/installation.html

Once you have Python installed, we will need to install Pygame Zero. To do this, you will need to "Command Prompt" on Windows or "Terminal" on Mac. Once you have done this, enter the following command:

pip install pgzero

If pip is not installed, try running the following command:

python -m ensurepip --upgrade

You may experience some weirdness on Macs. Try running the following commands.

python3 -m ensurepip --upgrade
pip3 install pgzero

 

Setup VSCode Environment

Create a folder where you want to save your project. Make sure it is an easily accessible location on your computer. In VScode, click the "File" menu and select "Open Folder". Select the folder containing the examples and assets.

 

Running Your Games

Inside VScode, open one of the examples or create a new file and copy your code from repl.it. Once the file is open, click the "play" button in the top right-hand corner to run the code.

 

Creating New Files

On the left side of VScode is the file navigation pane. You can create a new file by clicking on the icon. All files should end with .py, indicating that they are a Python source file.

Step 1: Assets

Before you start developing your game, you will need to download all the assets. Assets are the general term we use to describe any sort of image or sound used in the game. Instructables will not let you upload a zip file, so you will need to download each image above individually and each sound file below individually. Assets for your game must be stored in their corresponding directory:

  • images - Directory for storing images. Must be .png files.
  • sounds - Directory for storing sound effects. Must be .wav or .ogg files.
  • music - Directory for storing backing music tracks. Must be .mp3 files.

Asset names follow the same rules as Python variables. Must be lowercase, must not contain spaces, can only contain letters, numbers, or underscores, can only begin with a letter.

Step 2: Pygame Zero Set Up

In order to be able to utilize the Pygame Zero library, we need to import the library and run the game loop. The following code should be in all of your games:

import pgzrun # first line - imports the pygame zero module


pgzrun.go() # last line - executes the game loop

The game loop consists of endless calls to your draw() and update() functions that we will implement later. It also has some other features like event listening to check for input from the keyboard and mouse or check to see if clock events have occurred.

Step 3: Pygame Constant Set Up

Pygame Zero uses three special constants to set up the game. They look like variables, but they are all uppercase to indicate that they are constant values and cannot be changed after they are set the first time.

The first two constants are WIDTH and HEIGHT. These constants set the width and height of the game window. You should set these constants at the top of your program just after the pgzrun import statement.

For this game, we will set the width to 480 and the height to 720.

WIDTH = 480
HEIGHT = 720

The other Pygame constant is TITLE, which sets the name of Pygame window.

TITLE = "Cactus Runner"

Step 4: Draw Scene

Instead of using an image as the backdrop for this game, we are going to use rectangles. Rect is the name for the rectangle object in Pygame. It operates similarly to an Actor. First, we create the Rect then we can draw them. We create a rectangle object like shown below. (x, y) represents the position where the topleft corner of the rectangle begins, and (width, height) defines the size.

# rectangle = Rect((x, y), (width, height))
rectangle = Rect((0, 0), (10, 10))
# Creates a rectangle object whose topleft corner begins at (x, y) and has the defined width and height.

For this game, we want two rectangles to represent the sky and the ground. Define these towards the top of your program, where you would define Actors.

# background rectangles
sky = Rect(0, 0, 800, 400)
ground = Rect(0, 400, 800, 200)

To draw a rectangle, you can use screen.draw.rect() or screen.draw.filled_rect(). We will use filled_rect because we want our rectangles to be the solid color as specified by the string. Add the following code to your draw() function.

def draw():
screen.clear()
screen.draw.filled_rect(sky, "skyblue")
screen.draw.filled_rect(ground, "mediumspringgreen")

Step 5: Actor Setup

In this game, there are two key Actors: the runner and the cactus. An Actor is just a representation of some visual object within our game. It can be a player, enemy, background, obstacle, etc. It is a way of showing and controlling an image.

Actor reference: https://pygame-zero.readthedocs.io/en/stable/builtins.html#actors

Create the runner Actor and set it to the "p1_walk01" image. In a future step, we will deal with animating the Actor. Set the actor's initial position to (100, 400). The graphic below shows all the ways we can get or set the position of an Actor.

# actor definition
runner = Actor("p1_walk01")
runner.pos = (100, 400)

Finally, create a cactus Actor and set it to the "cactus_medium" image. There will be many instances of this cactus, but for now we will start with one. For now, we will just start it at a static position.

cactus = Actor("cactus_medium")
cactus.bottomleft = (600, 420)

Now it is time to draw these Actors into the scene/game. We will do this using the variable representing each Actor with the .draw() method. This code will go inside the draw() function because it is displaying the new frame. Remember to always start your draw() functions with screen.clear() or it will display artifacts from the previous time something was drawn to the screen.

def draw():
screen.clear()
screen.draw.filled_rect(sky, "skyblue")
screen.draw.filled_rect(ground, "mediumspringgreen")
runner.draw()
cactus.draw()

Another thing to keep in mind is that the order in which you draw things matters. If you draw the background after the runner and the cactus, the background will be placed over top of the other Actors, hiding them from view.

Step 6: Runner Movement

In this game, our runner will not actually move at all. The runner will stay in the same spot on the screen as the scenery moves by. To make the player look like they are running, we will add an animation and make the obstacles move past them. Let's start with the animation.

To animate a player, we just need to cycle through various images being shown by the actor. To do this, we will need to make a couple of new Actor-specific variables. Actor-specific variables are similar to global variables in that they can be used anywhere, but they allow each Actor to have its specific values for the variables. To create Actor-specific variables, you use the variable representing the actor and declare the new variable like so: actor.new_variable = 12.

We will create images and index variables for the runner Actor. images will store a list of all the image filenames that we want to cycle through. index will store the list index value of the image we are currently showing. We need to declare these values towards the top of our program with the runner actor definition.

runner = Actor("p1_walk01")
runner.pos = (100, 400)
runner.index = 0
runner.images = [
"p1_walk01",
"p1_walk02",
"p1_walk03",
"p1_walk04",
"p1_walk05",
"p1_walk06",
"p1_walk07",
"p1_walk08",
"p1_walk09",
"p1_walk10",
"p1_walk11",
]

To make the Actor cycle through the various images, we need to add some code to update(). Every time update() is run, we need to increment the runner.index and use that new index to assign the new image from runner.images to runner.image.

def update():
runner.index += 1
runner.image = runner.images[runner.index]

However, if we run the code above, we would get an index error eventually. That's because if we add 1 to runner.index each time, we would eventually surpass the number of images in runner.images. To fix this, we need to use modulo and % the index value by the total length of the list of images.

def update():
runner.index = (runner.index + 1) % len(runner.images)
runner.image = runner.images[runner.index]

Another way of doing this is shown below:

def update():
runner.index += 1
if runner.index >= len(runner.images):
runner.index = 0
runner.image = runner.images[runner.index]

Now, your runner should be running!

Step 7: Runner Jumping and Gravity

To allow the runner to jump, we must add gravity. This will require the addition of a few more variables. Make a global variable called gravity and initialize its value to 1. Create a new Actor-specific variable for runner called velocity_y and initialize it to 0.

Next, we need to use the on_key_down() event listener to make the runner jump whenever the space bar is pressed. We want to set the runner.velocity_y to -20 whenever the space bar is pressed and the current velocity is 0.

def on_key_down(key):
if key == keys.SPACE and runner.velocity_y == 0:
runner.velocity_y = -20

Now, we need to apply that velocity to move the position of the runner inside update(). We set the velocity to a negative number so that when added to the y position, it moves the runner up. If we just added the velocity to the position each time update() executed, our runner would keep going up and up infinitely.

runner.y += runner.velocity_y

We need a way to continually decrease the velocity (change in y position) each time update() executes. To do this, we will add gravity to runner.velocity_y each time. That way, the velocity value gets less negative, before becoming positive and causing the runner to fall back down to earth.

runner.y += runner.velocity_y
runner.velocity_y += gravity

If we run the code just with this, we notice that deceleration upwards and acceleration downwards works, similar to gravity. However, our runner never stops when it hits the ground. What we need to do is add a conditional statement to detect once the runner has landed back on the ground and set the velocity to 0. It would look something like this:

# handle player jumps
# change the y-position by the velocity
runner.y += runner.velocity_y
# if player has hit the ground, set it back to 0
if runner.y >= 400:
runner.velocity_y = 0
runner.y = 400
# otherwise, continue to add gravity
else:
runner.velocity_y += gravity

Now, your player should "run" and jump.

Step 8: Cactus Spawning

In this game, we will want to spawn cacti. We are going to do this inside update(). However, we don't want to create a new cactus every time update() executes. Instead, let's make it happen every 60 times update() executes. We will need to create a global variable called cactus_countdown to keep track of how many times update() has executed.

Initialize cactus_countdown to be 50. In update, decrement cactus_countdown each time. Once cactus_countdown reaches 0, we want to create a new cactus. Create a new global variable called cacti to store all the cactus Actor. Each time we create a new cactus, we will append it to cacti It should look something like this:

def update():
global cactus_countdown
cactus_countdown -= 1
if cactus_countdown == 0:
cactus = Actor("cactus_medium")
cactus.bottomleft = (WIDTH, 420)
cacti.append(cactus)
cactus_countdown = 60

We set the bottomleft position to start at the right edge of the screen at the correct height for the layer to jump over. Then, we must always reset the cactus_countdown timer or it will never reset again.

Add functionality to your draw() function to show the cacti. Now your program should just spawn cacti every 60 frames at the same spot. See the next section for how to make them move!

for cactus in cacti:
cactus.draw()

Step 9: Cactus Movement

Like the last game, we want our objects to move. This simulated the runner movement. We will need to adjust each cactus' position within update() to move them each frame. We want to subtract from it's x-position to make it move left or towards the player.

# move cacti
for cactus in cacti:
cactus.x -= 8

In order to save our computer from eventually crashing, we need to remember to remove cacti that have moved off the screen. To do this, we will check to see if the rightmost position of each cactus is off the left edge of the screen inside update(). If so, we remove it.

# move cacti
for cactus in cacti:
cactus.x -= 8
if cactus.right < 0:
cacti.remove(cactus)

Step 10: Collision

For this game, we want to detect if the runner Actor has collided with any of the Actors in cacti. If so, the player loses. To do so, we should add a for loop to update() that checks for a collision between cacti and any fireball in the cacti list.

for cactus in cacti:  		     	# loop through list of cacti
if runner.colliderect(cactus): # collision with one cactus
runner.pos(-100, -100) # game_over condition - hide runner off screen

Step 11: HUD

Most games have a HUD or heads-up display. The HUD is overlaid on top of the game and displays information to the user about the game's state. In this game, the HUD shows the score. In order to keep track of this value, add this global variable towards the top of your code along with the others.

score = 0

These values will need to be displayed to the screen using the screen.draw.text() method. To use this method, we provide a string and the position. We will insert numerical values into the string using f-strings. The position can be provided using the same anchors we use for Actors. Since we are adding this information to the screen, the code should be placed in draw().

# score will appear in the top left
screen.draw.text(f"Score: {score}", topleft=(10, 10))

This value should now appear on the screen when the program is run, but we haven't added any logic to change the value. We want to increment the score every time the runner gets passed a cactus. We can do this by incrementing the score in our conditional statement that checks to see if a cactus has moved off the screen. Since score is a global integer variable that we are modifying, remember to use the global keyword.

# move cacti
for cactus in cacti:
cactus.x -= 8
if cactus.right < 0:
cacti.remove(cactus)
score += 1

Step 12: Game Over

To handle the lose scenario, we are just going to add a global variable called over. This will be a boolean variable that will be True when the game is over and False when the game is running. Define it at the top with the other global variables and set its initial value to False.

over = False

Now for our lose condition, we will set over to True. Our lose condition is when the runner has collided with a cactus.

# detect collisions
for cactus in cacti:
if runner.colliderect(cactus):
over = True

As opposed to using a game_over() function like our other games, we are only going to refer to the over variable. We can use this to modify what is shown in our draw() function and prevent update() from modifying the game's state. See an example of the draw() function below:

def draw():
screen.clear()
screen.draw.filled_rect(sky, "skyblue")
screen.draw.filled_rect(ground, "mediumspringgreen")
# game over: display message, but don't show actors
if over:
screen.draw.text("Game Over", center=(WIDTH // 2, 270))
screen.draw.text(f"Score: {score}", center=(WIDTH // 2, 300))
# game running: show actors
else:
runner.draw()
for cactus in cacti:
cactus.draw()
screen.draw.text(f"Score: {score}", topleft=(10, 10))

To prevent update() from doing anything while the game is over, we can make the function return early. To do so, add this snippet of code to the top of your update() function.

def update():
global score, over, cactus_countdown
if over:
return

Step 13: Start/Restart

Now, we want to clean up the start of our program and allow the user to restart the game after they lose. We will do this by creating a new function for our selves called start(). start() is responsible for setting the initial state of the game.

In this game, our start should do the following:

  • declare cactus_countdown, score, and over as global so they can be modified
  • set the initial values of cactus_countdown, score, over
  • clear the cacti list
  • set the initial position of runner
  • reset runner.velocity_y to 0
  • reset runner.index to 0

To implement the functionality above, your code should look something like this. It may seem redundant, and that's because it is! We want to reset everything back to its initial state for the user to start the game again.

# called at start or restart of game to reinitialize
def start():
global cactus_countdown, score, over
runner.pos = (100, 400)
runner.velocity_y = 0
runner.index = 0
cactus_countdown = 50
score = 0
over = False
cacti.clear()

Now that over is set to False, update() should actually run and draw() will show the Actors. Add a call to start() at the very end of your program just before pgzrun.go(). This will execute all the code in the function and make sure everything is ready to go before calling the Pygame main loop, which repeatedly calls update() and draw().

start()
pgzrun.go()

Run your program to make sure everything works properly. It should behave the same because we have not added the last step of the restart functionality.

In order to make the program run again, we need to schedule Pygame to call the start() function again. Let's do this when the user presses the space bar again. Modify your on_key_down() event listener.

def on_key_down(key):
if key == keys.SPACE and runner.velocity_y == 0 and not over:
runner.velocity_y = -20
sounds.jump3.play()
if key == keys.SPACE and over:
start()

Let's add instructions to the user on how to restart the game. Modify draw() to show the message:

if over:
screen.draw.text("Game Over", center=(WIDTH // 2, 270))
screen.draw.text(f"Score: {score}", center=(WIDTH // 2, 300))
screen.draw.text("Press space bar to restart.", center=(WIDTH // 2, 330))

You should now have completed game over and restart functionality!

Step 14: Sounds & Music

It is time to add the bells and whistles to our game. We will start by adding music. The music files should be stored in the music directory and must be .mp3 files. To play music, use the music.play() method. To stop the music, use the music.stop() method.

Music reference: https://pygame-zero.readthedocs.io/en/stable/builtins.html#music

For this game, choose your own music file to play! Remember, it must be an mp3 file and use the correct naming conventions. Begin playing the music in the start() function. Stop playing the music when you set over to True after a collision.

music.play("your_song_here")
music.stop()

Now, it's time to add sounds. Sounds differ from music in that they are short audio files that are handled slightly different. They should be stored in the sounds directory and must be .wav or .ogg files. To play sounds, you must use the file name in the method call. For example, if I wanted to play a sound from "thunder.wav", I would say sounds.thunder.play().

Sounds reference: https://pygame-zero.readthedocs.io/en/stable/builtins.html#sounds

We want to add two sounds to the game. We want to play "jump3.wav" whenever the runner jumps. We want to play "gameover.wav" whenever the player loses.

sounds.jump3.play()
sounds.gameover.play()

You should now have a completed game!

Step 15: Fine-Tuning

Even though we are done creating the underlying mechanics, there is a lot of fine-tuning that should be done to make the experience more enjoyable. Try some of these out before submitting your project.

First, try modifying the cactus_countdown. Instead of making it happen at a steady interval of every 60 frames. Let's use randint to choose the value. Try different ranges of values and see what works. Import randint at the top of the program.

from random import randint

...

cactus_countdown = randint(40, 90)

Then, try modifying the initial jump velocity. We started at -20. Test different values and see what works best. You can also modify the cactus velocity, which started at -8.

Finally, try using different sized cactuses. There are three cactus images: "cactus_small", "cactus_medium", "cactus_large". Use choice to randomly choose one of the values. Don't forget to import choice at the top of the program.

from random import choice

...

size = choice(["cactus_small", "cactus_medium"])
cactus = Actor(size)

Step 16: Optional Extensions

  • Add good items. If the player catches one of the good items, they could gain a power up like invincibility.
  • Add power ups or power downs to the game that cause the player to move faster or slower.
  • Add other sounds and music to the game. Search itch.io for more assets.

Step 17: Complete Code


import pgzrun
from random import randint, choice

# set Pygame constants
WIDTH = 800
HEIGHT = 600
TITLE = "Cactus Runner"

# background rectangles
sky = Rect(0, 0, 800, 400)
ground = Rect(0, 400, 800, 200)

# actor definition
runner = Actor("p1_walk01")
runner.pos = (100, 400)
runner.velocity_y = 0
runner.index = 0
runner.images = [
"p1_walk01",
"p1_walk02",
"p1_walk03",
"p1_walk04",
"p1_walk05",
"p1_walk06",
"p1_walk07",
"p1_walk08",
"p1_walk09",
"p1_walk10",
"p1_walk11",
]

# global variables
gravity = 1
cacti = []
cactus_countdown = 50
score = 0
over = True


def start():
global cactus_countdown, score, over
# Reset game variables
runner.pos = (100, 400)
runner.velocity_y = 0
runner.index = 0
cactus_countdown = 50
score = 0
over = False
cacti.clear()


def update():
global score, over, cactus_countdown
# Check game state
if over:
return

# Spawn cacti periodically
cactus_countdown -= 1
if cactus_countdown == 0:
size = choice(["cactus_small", "cactus_medium"])
cactus = Actor(size)
cactus.bottomleft = (WIDTH, 420)
cacti.append(cactus)
cactus_countdown = randint(40, 100)

# Move cacti and remove off-screen cacti
for cactus in cacti:
cactus.x -= 8
if cactus.right < 0:
cacti.remove(cactus)
score += 1

# Handle player jumps and apply gravity
runner.y += runner.velocity_y
if runner.y >= 400:
runner.velocity_y = 0
runner.y = 400
else:
runner.velocity_y += gravity

# Detect collisions with cacti
for cactus in cacti:
if runner.colliderect(cactus):
over = True
sounds.gameover.play()

# Animate player running
runner.index = (runner.index + 1) % len(runner.images)
runner.image = runner.images[runner.index]


def on_key_down(key):
# Player controls and game restart
if key == keys.SPACE and runner.velocity_y == 0 and not over:
runner.velocity_y = -20
sounds.jump3.play()
if key == keys.SPACE and over:
start()


def draw():
# Draw game elements on screen
screen.clear()
screen.draw.filled_rect(sky, "skyblue")
screen.draw.filled_rect(ground, "mediumspringgreen")
if over:
# Display game over and score
screen.draw.text("Game Over", center=(WIDTH // 2, 270))
screen.draw.text(f"Score: {score}", center=(WIDTH // 2, 300))
screen.draw.text("Press space bar to restart.", center=(WIDTH // 2, 330))
else:
# Draw player, cacti, and score during gameplay
runner.draw()
for cactus in cacti:
cactus.draw()
screen.draw.text(f"Score: {score}", topleft=(10, 10))


start()
pgzrun.go() # Must be last line

Step 18: More Projects