Introduction: Dodging Fire (Pygame Zero Beginner 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: "Dodging Fire". In this game, fireballs spawn from the sides with random trajectories. 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 second 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 = "Dodging Fire"

Step 4: Actor/Scene Movement

In this game, there are three key Actors: the background scene, the robot player, and the fireball. 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

Start by making the background scene Actor and setting it to the "water" image.

bg = Actor("water")

We need to set the initial position of the background. We will set it to the center. We can combine this with the step above or individually set the pos. pos represents the position of center of the Actor. To get the exact center of the screen, divide the WIDTH and HEIGHT by two.

bg = Actor("water", (WIDTH // 2, HEIGHT // 2))
# OR
bg = Actor("water")
bg.pos = (WIDTH // 2, HEIGHT // 2)

Positions are provided and set as tuples. Tuples are a list-like data type that stores a collection of values; however, it cannot be changed. Tuples are written with parentheses as opposed to square brackets. However, we can retrieve their values in the same way using indexing with square brackets. See the example below.

new_pos = (300, 400)
new_pos[0] # gives us the x coordinate
new_pos[1] # gives us the y coordinate
new_pos[0] = 100 # cannot be done! Can only assign new tuple
new_pos = (100, 400) # correctly makes new assignment

Next, create the robot Actor and set it to the "robot_idle" image. In a future step, we will get it to show the left and right versions of the image. Set the actor's position to be centered, but standing at the very bottom of the screen. The graphic below shows all the ways we can get or set the position of an Actor using tuples.

robot = Actor("robot_idle")
robot.midbottom = (WIDTH // 2, HEIGHT)

We can also get or set the Actor's position by just changing a single x or y coordinate. We can do this using the anchors shown below. y, top, and bottom would give the y-coordinate of that location of the actor. x, right, left would give the x-coordinate of that location of the actor.

Finally, create a fireball Actor and set it to the "fireball" image. There will be many instances of this fireball, but for now we will start with one.

fireball = Actor("fireball")

We want the fireball to appear at a random position. To do this, we will need to import the randint() function by adding from random import randint to the top of our program. Then we can use randint(a, b) by supplying two values identifying the start and end of the range of values from which we want to choose a number randomly. See the example below that choose a random x-coordinate between 0 and WIDTH and a y-coordinate between 0 and HEIGHT.

from random import randint

...

fireball.x = randint(0, WIDTH)
fireball.y = randint(0, HEIGHT)

Remember, pos and x and y represent the center of the actor. With the above code, we could randomly generate the coordinate (0, 0), which would center the fireball at this location placing it half off the screen. We can adjust for this by adding a buffer of 20 pixels like shown below.

fireball.x = randint(20, WIDTH - 20)
fireball.y = randint(20, HEIGHT - 20)

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()
bg.draw()
robot.draw()
fireball.draw()

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

Step 5: Basic Movement

We want to be able to move our robot character with the keyboard. There are many different ways to go about it based on the behavior you want. For this game, we want the player to move while the button is pressed.

To make this happen, we will add code to our update() function since it is called consistently. The on_key_down() and on_key_up() event listeners are only triggered once when a button is pressed and released. By adding a conditional statement to update() to check if the button is pressed, it will have the effect of continually moving while the button is pressed. Even though we don't have a loop in our program, update() behaves somewhat like a loop because it is called endlessly.

To check if a key on the keyboard is pressed in update(), we use the keyboard.KEY_NAME syntax, which is a boolean expression that tells us whether a button is pressed. The various keyboard KEY_NAME constants are described here.

def update():
if keyboard.LEFT:
# do something
elif keyboard.RIGHT:
# do something
if keyboard.UP:
# do something
elif keyboard.DOWN
# do something

To move the robot, we need to modify its position. The best way to do that is to add or remove by the same consistent value. We will store this value in a global variable called velocity.

By storing the value in a global variable, we only need to change the value in the variable to increase the robot's velocity, as opposed to multiple places in the code. It also allows us to modify velocity from anywhere in the code if we want the player to speed up due to a power-up or some other effect. Below show's an example of how to define and use the variable velocity.

# define global variables towards the top of your program
velocity = 5

...

# modify the player's position within update()
# add code below to corresponding keyboard conditionals
robot.x -= velocity # moves Actor left
robot.y += velocity # moves Actor down

We want our actor to move up, down, left, and right when the arrow keys are pressed by the value set in velocity.

Step 6: Bounding Player Movement

At this point, your player robot should be moving up, down, left, and right while the arrow key buttons are pressed. But you've probably noticed that the robot can move beyond the extents of the screen and never stops. That is because the actor's x and y coordinates are unaware of the screen size! We will have to implement this logic ourselves.

To do this, we will only allow the robot to move in update() if the key is pressed AND it is not at the edge of the screen. We will need to modify our movement conditionals within update().

We can do this by checking if the Actor's position is beyond the extents of the screen. We want to make sure that when moving horizontally, it does not go beyond the left edge of our screen (x must be greater than 0) or the right edge of our screen (x must be less than WIDTH). When moving vertically, the Actor cannot go beyond the top edge of the screen (y must be greater than 0) or the bottom edge of our screen (y must be less than HEIGHT). We can modify our conditional statements to look like this.

def update():
if keyboard.LEFT and robot.x > 0:
robot.x -= velocity
elif keyboard.RIGHT and robot.x < WIDTH:
robot.x += velocity
elif keyboard.UP and robot.y > 0:
robot.y -= velocity
elif keyboard.DOWN and robot.y < HEIGHT:
robot.y += velocity

However, pos doesn't tell us if the edge of the actor is beyond the extents of the screen. It only tells us if the center is off the screen. To check the edges, we need to use one of the other anchor points for an Actor listed below. There are a few ways to go about this, but the following way makes the most sense to me.

robot.left    # gets Actor's leftmost x position
robot.right # gets Actor's rightmost x position
robot.top # gets Actor's topmost y position
robot.bottom # gets Actor's bottommost y position

Substitute these values into the conditional statements shown above to prevent the robot from moving off the screen. No part of the image should move beyond the extent of the screen.

Step 7: Basic Animation Movement

Now, we want to investigate how to modify which image is being shown for a specific Actor. We will animate the player or create a sprite as it is known in video game design.

To change the image we can use the following code, where image is the parameter we want to change and the value is a String value representing the file name of the image from the images directory that we want to change it to:

robot.image = "robot_left"

To check which image is currently being shown, we can retrieve the value using robot.image and compare its value within the condition of an if statement. See the example below

if player.image == "p1_stand":
player.image = "p1_walk"
elif player.image == "p1_walk":
player.image = "p1_stand"

In this case, we have three robot images: robot_idle, robot_left, robot_right. We want to modify which way the robot is facing based on which direction the robot is moving. If it is moving left, show the left facing image. If it is moving right, show the right facing image. If it is moving up or down or standing still, show the front facing image.

There are a number of ways to do this. We have our on_key_down() and on_key_up() event listeners, which are triggered when a keyboard button is pressed and released, respectively. The way I would approach this is to add the following code to the corresponding conditional statements in update() to update the image based on which keyboard key is pressed.

robot.image = "robot_left"
robot.image = "robot_right"

By adding that code to your update(), it should show the image moving left and right when you press the corresponding button. However, the Actor would never return to the front facing image. To handle this case, I would use the on_key_up() event listener to return the Actor to its front facing image when the left or right buttons are released.

# called when a keyboard button is released
def on_key_up(key):
# change to forward facing image when left/right keys released
if key == keys.LEFT or key == keys.RIGHT:
robot.image = "robot_idle"

Step 8: Fireball Spawning

In this game, we will want to spawn many fireballs. To be able to use the same section of code repeatedly, it is best to place this code inside a function, so we can call it repeatedly. Move your code that creates a fireball actor inside a function called spawn_fireball().

def spawn_fireball():
fireball = Actor("fireball")
fireball.x = random.randint(20, WIDTH - 20)
fireball.y = random.randint(20, HEIGHT - 20)
fireballs.append(fireball)

Notice the last line in the function. It appends the new fireball Actor to a list of Actors called fireballs. Remember, a list is a collection of items that we want to group together, so we don't have to keep track of individual variables for every item. It will be helpful to keep a list of all the fireball Actors, so we can draw each of them on the screen and detect if the player has collected any of them. To do this, we will need to define a global list variable called fireballs. This will need to go towards the top of your program along with the definition of velocity.

# global variables
velocity = 5
fireballs = []

Now that we have created a function to create a new fireball Actor and add it to the list fireballs, we can schedule that function to be called at a regular interval using Clock. The following line of code shows how to schedule a callback to spawn_fireball to occur every 0.5 seconds.

Clock reference: https://pygame-zero.readthedocs.io/en/stable/builtins.html#clock

# clock.schedule_interval(callback_function, interval)
clock.schedule_interval(spawn_fireball, 0.5)

Place this line of code just before pgzrun.go() outside all functions. That ensures that Pygame will schedule the callback to occur just before repeatedly calling update() and draw().

If you run the program at this point, you still won't see any fireballs on the screen. That's because we haven't drawn them yet in the draw() function. There are a few more steps involved here since we are drawing an entire list of Actors and not just a single actor. You will need to create a for loop to get each individual Actor, then you can draw them one at a time. It should look something like this.

for fireball in fireballs:
fireball.draw()

Now, you should have fireballs showing up at random locations on the screen every half a second!

Step 9: Fireball Movement

Like the last game, we want our objects to move. We will need to adjust the fireball position within update() to move them about the screen each frame.

First, let's start by making them spawn just to the left of the screen To do this, we will need to adjust the x-position we set for each new fireball. Instead of making it random, let's start at a fixed x-coordinate of 0. We still want the y-coordinate to be random so that it adjusts where they fall from.

fireball.x = 0
fireball.y = randint(100, HEIGHT - 100)

Now, let's make the fireballs move. We are going to make new Actor-specific variables. They 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 velocity_x and velocity_y variables for each fireball. This ensure each fireball will have its own unique speed and direction. velocity_x represents the change in the x-coordinate of the fireball each frame. velocity_y represents the change in the y-coordinate of the fireball each frame.

def spawn_fireball():
fireball = Actor("fireball")
# set initial position
fireball.x = 0
fireball.y = randint(100, HEIGHT - 100)
# creates new actor variables for velocity
fireball.velocity_x = randint(1, 3)
fireball.velocity_y = randint(-3, 3)
# append to list of fireballs
fireballs.append(fireball)

We randomly generate the x and y components of the velocity to change how they move. Since the starting x-coordinate is 0, we make the velocity a positive value so it moves onto the screen. To update the fireball's position, we need to increment both the x and y positions by velocity_x and velocity_y inside update(). We will need to use our for loop again to visit each fireball in the list of fireballs.

# for each fireball
for fireball in fireballs:
# advance fireballs to new position
fireball.x += fireball.velocity_x
fireball.y += fireball.velocity_y

However, we will also want the fireballs to sometimes start on the right side of the screen. That would mean that the x-coordinate should start at WIDTH and velocity should be negative. See the modification to the code below to make the fireballs move right to left and left to right.

def spawn_fireball():
fireball = Actor("fireball")
# set initial position
fireball.x = randint(0, 1) * WIDTH
fireball.y = randint(100, HEIGHT - 100)
# creates new actor variables for velocity
if fireball.x == 0:
fireball.velocity_x = randint(1, 3)
else:
fireball.velocity_x = randint(-3, -1)
fireball.velocity_y = randint(-3, 3)
# append to list of fireballs
fireballs.append(fireball)

Run the game and you should start to see fireballs moving across the screen. But what happens when the fireballs move off the screen? Based on our current code, we are still advancing its position and drawing it. Our program is still keeping track of every fireball, despite the fact that it is not visible on the screen. This is taking up a lot of computer's memory and could eventually crash the program, so we should help it out!

We want to add code to remove each fireball once it has gone below the screen. To do this, we can add some additional logic to our for loop above to remove the fireball. We will add a conditional statement to check the extremes of the fireballs to see if they've gone beyond the extents of the screen.

# for each fireball
for fireball in fireballs:
# advance to new position
fireball.x += fireball.velocity_x
fireball.y += fireball.velocity_y
# if top of fireball is off the screen
if fireball.top > HEIGHT or fireball.bottom < 0 or fireball.left > WIDTH or fireball.right < 0:
fireballs.remove(fireball)

When you run your program again, you shouldn't see a difference, although your computer will thank you! At a certain point, it would have crashed your program by generating too many fireballs.

To rotate an actor, we can add to a parameter called angle. This represents the angle of rotation in degrees between 0 and 360. Inside update(), we can continually add 10 degrees to the angle to make it look like the fireball is spinning.

# for each fireball
for fireball in fireballs:
# advance fireballs to new position
fireball.x += fireball.velocity_x
fireball.y += fireball.velocity_y
# rotate the image by 10 degrees each time
fireball.angle += 10

Step 10: Collision

To detect collision between two Actors, we use the .colliderect() method. This returns True if there's a collision and False otherwise. It used as shown below:

unicorn = Actor("unicorn")
monster = Actor("monster")

...
def update():
if unicorn.colliderect(monster):
# game over

Oftentimes, we will have a collection of similar Actors stored in a list. It can be helpful to loop through the list and find out if there's been a collision with any item in the list. We do something like the example below:

enemies = []     		 # create list of enemies
player = Actor("p1") # create player Actor
bat = Actor("bat") # create bat enemy Actor
spider = Actor("spider") # create spider enemy Actor
enemies.append(bat) # add to list of enemies
enemies.append(spider) # add to list of enemies

...

def update():
for enemy in enemies: # loop through list of enemies
if player.colliderect(enemy): # collision with one enemy
enemies.remove(enemy) # defeated enemy, remove from list

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

for fireball in fireballs:       	  # loop through list of fireballs
if player.colliderect(fireball): # collision with one fireball
robot.pos(-100, -100) # game_over condition - hide robot 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 only the time elapsed. In order to keep track of this value, add this global variable towards the top of your code along with the others.

timer = 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().

# timer will appear in the top center
screen.draw.text(f"Time: {timer}", midtop=(WIDTH // 2, 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. To implement a timer, we should schedule another interval callback using clock, similar to what we did for spawn_fireball().

Create a function called increment_timer(). Add 1 to timer every time the function is called. Since timer is a global integer variable that we are modifying, we need to use the global keyword. The function should look something like this.

def increment_timer():
global timer # mark as global
timer += 1 # increment counter

To schedule the callback to this function to occur every second and make the time tick up on the screen, we will add the following line of code just before pgzrun.go():

clock.schedule_interval(increment_timer, 1.0)

Run the code to make sure the game functions as expected. Next, we will add logic to detect when the player has lost and allow them the opportunity to start over again.

Step 12: Game Over

To handle the lose scenario, we are going to add a game_over() function. This is not a function that is built into Pygame, but rather, a function we will call once we've determined that the player has lost. We can use it to halt the timer, stop creating new objects, display a message, etc.

Before we define the function, we will need to add another 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

In this game, our game_over() function should stop the interval timers for spawn_fireball and increment_timer and set over to True. Note: over should be declared as global since it is a boolean variable that we are modifying.

# called during lose condition to trigger game_over
def game_over():
global over
# set over to True
over = True
# stop interval timers
clock.unschedule(increment_timer)
clock.unschedule(spawn_fireball)

Defining a function does nothing until we call it! We want to call our game_over() function when the robot has collided with a fireball. We should do this inside update() after the collision detection.

if not over:  # if over == False
game_over()

Why did we write if not over? if not over means that game_over will only be called when the over variable is set to False. It prevents us from calling game_over() over and over and over again once the player has lost. We only want to call game_over() once. We can check if we've called it before, because we only change over to False within game_over().

We also want to display a "Game Over!" message once the player has lost. Since we are updating the screen, we will add this code to draw(). We can check the value of the over variable in a condition. If it is True, we can write a message on the center of the screen.

if over:  # same as "if over == True"
screen.draw.text("Game Over!", center=(WIDTH // 2, HEIGHT // 2))


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 timer, velocity, over as global so they can be modified
  • set the initial values of timer, velocity, over
  • clear the fireballs list
  • schedule the increment_timer interval
  • schedule the spawn_fireball interval
  • spawn the first fireball
  • set the robot's initial position

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 timer, score, lives, velocity, fireball_interval, over
robot.pos = (WIDTH // 2, HEIGHT // 2)
over = False
timer = 0
velocity = 5
fireballs.clear()
clock.schedule_interval(increment_timer, 1.0)
clock.schedule_interval(spawn_fireball, 0.5)
spawn_fireball()

Now that we have moved the clock.schedule_interval() calls to start(), we can remove those lines from the end of our program.

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() method to kick back off the game after some delay. We will do this by calling the clock.schedule_unique() method and telling it to call start after 5.0 seconds.

# clock.schedule_unique(callback, delay)
clock.schedule_unique(start, 5.0)

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, play the music file called "sneaking_around.mp3". Begin playing the music in the start() function. Stop playing the music in the game_over() function.

music.play("sneaking_around")
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 "bomb_explosion.wav" whenever the robot runs into a fireball. We want to play "gameover.wav" whenever the player loses.

sounds.bomb_explosion.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 velocity of the robot to find the optimal speed for moving around that makes the game challenging.

Then, play with the parameters for the fireballs. You can try to increase the range of random velocity values generated to dial in the difficulty.

Another thing you can tweak is the rate at which the fireballs spawn. To do this, I created one final global variable called fireball_interval. Instead of using a fixed interval of 0.5 seconds between each fireball, I used this variable that I made smaller over time to increase the frequency of the fireballs.

clock.schedule_interval(spawn_fireball, fireball_interval)

In the last game, we could make the coin frequency accelerate every time one was collected. In this game, we will want the fireball frequency to increase over time. To do this, we will need to schedule an additional timer in start() and remember to unschedule it in game_over().

clock.schedule_interval(speed_up, 1.0)
clock.unschedule(speed_up)

To start, I set fireball_interval to 1.0 seconds. Every time the timer triggered speed_up(), I would reduce the interval by 5%. I did this by modifying fireball_interval, unscheduling the previous interval, and scheduling the new interval. Try this out and see how it works. Try adjusting by different percentages and see how it changes the difficulty. Keep in mind that setting an interval to 0 or a negative number will crash your game, so try to avoid this!

# speed up the game
def speed_up():
global fireball_interval
fireball_interval -= fireball_interval * 0.05
clock.unschedule(spawn_fireball)
clock.schedule_interval(spawn_fireball, fireball_interval)


Step 16: Optional Extensions

  • Add good items. If the player catches one of the good items, they could gain a power up like invincibility. Look through the images directory to see some of the assets available to you.
  • As opposed to auto-restarting the game, add a button that only allows the user to play it again only when pressed.
  • Add other sounds and music to the game. Search itch.io for more assets.
  • Add power ups or power downs to the game that cause the player to move faster or slower.
  • Improve the up and down animations to make it look like the player is moving by cycling through left and right animations as they move up.

Step 17: Completed Code


import pgzrun # program must always start with this
from random import randint

# set Pygame constants
WIDTH = 480
HEIGHT = 720
TITLE = "Dodging Fire"

# define actors
bg = Actor("water", (WIDTH // 2, HEIGHT // 2))
robot = Actor("robot_idle")

# declare and initialize global variables
velocity = 5
fireballs = []
timer = 0
fireball_interval = 1.0
over = False


# called at start or restart of game to reinitialize
def start():
global timer, velocity, fireball_interval, over
robot.pos = (WIDTH // 2, HEIGHT // 2)
over = False
timer = 0
velocity = 5
fireball_interval = 1.0
fireballs.clear()
# schedule functions to run at specific intervals
clock.schedule_interval(increment_timer, 1.0)
clock.schedule_interval(spawn_fireball, fireball_interval)
clock.schedule_interval(speed_up, 1.0)
spawn_fireball()
# start music
music.play("sneaking_around")


# speed up the game
def speed_up():
global fireball_interval
fireball_interval -= fireball_interval * 0.05
clock.unschedule(spawn_fireball)
clock.schedule_interval(spawn_fireball, fireball_interval)


# called during lose condition to trigger game_over and schedule start again
def game_over():
global over
over = True # set over to True
# schedule start to run in 5 seconds
clock.schedule_unique(start, 5.0)
# stop timers, will be scheduled again
clock.unschedule(increment_timer)
clock.unschedule(spawn_fireball)
clock.unschedule(speed_up)
fireballs.clear()
# stop music
music.stop()
# sounds.gameover.play()
clock.schedule_unique(game_over_sound, sounds.bomb_explosion.get_length())


# callback function for scheduling game over sound
def game_over_sound():
sounds.gameover.play()


# scheduled callback for incrementing counter every second
def increment_timer():
global timer
timer += 1


# creates new enemy actor at a random location
def spawn_fireball():
fireball = Actor("fireball")
fireball.x = randint(0, 1) * WIDTH
fireball.y = randint(100, HEIGHT - 100)
if fireball.x == 0:
fireball.velocity_x = randint(1, 3)
else:
fireball.velocity_x = randint(-3, -1)
fireball.velocity_y = randint(-3, 3)
fireballs.append(fireball)


# displays the new frame
def draw():
screen.clear()
bg.draw()
robot.draw()
for fireball in fireballs:
fireball.draw()

# display game timer
screen.draw.text(f"Time: {timer}", midtop=(WIDTH // 2, 10))
# display "Game Over" message when the game ends
if over:
screen.draw.text("Game Over", center=(WIDTH // 2, HEIGHT // 2))


# updates game state between drawing of each frame
def update():
global score, lives
# check keyboard inputs for movement
if keyboard.LEFT and robot.left > 0:
robot.x -= velocity
robot.image = "robot_left"
elif keyboard.RIGHT and robot.right < WIDTH:
robot.x += velocity
robot.image = "robot_right"
elif keyboard.UP and robot.top > 0:
robot.y -= velocity
elif keyboard.DOWN and robot.bottom < HEIGHT:
robot.y += velocity

for fireball in fireballs:
# move fireballs and rotate them
fireball.x += fireball.velocity_x
fireball.y += fireball.velocity_y
fireball.angle += 10
# remove fireballs if they're off-screen
if (
fireball.top > HEIGHT
or fireball.bottom < 0
or fireball.left > WIDTH
or fireball.right < 0
):
fireballs.remove(fireball)

for fireball in fireballs:
# check collision between robot and fireballs
if robot.colliderect(fireball):
fireballs.remove(fireball)
sounds.bomb_explosion.play()
if not over:
game_over()


# called when a keyboard button is released
def on_key_up(key):
# change to forward facing image when left/right keys released
if key == keys.LEFT or key == keys.RIGHT:
robot.image = "robot_idle"


start()
pgzrun.go()

Step 18: More Projects

Project-Based Learning Contest

Participated in the
Project-Based Learning Contest