Skip to content

Multiple Worlds and Game Over Reset

This is the big one: an infinite runner with a character-select screen, a running score, a Game Over screen, and a reset that puts everything back for the next round. The secret to keeping all of that manageable isn't more code — it's smaller functions.


Overview

In this lab you'll build an infinite runner and organize it into modes — costume, play, and game over — each with its own drawing and behavior. Along the way you'll animate a running character from a list of images, spawn obstacles on a timer, and write a reset() function that restarts the game cleanly.

Builds on: Tiled Images, Animated Motion, and Health — helper functions, lists of Actors, and collidelist().

Before you start

Open Thonny and start a new program named runner.py. Save it in your pygame-zero folder, next to your images folder.

You've got it when…

  • The game opens on a costume screen where Space changes characters and Enter starts the game.
  • The runner animates, jumps with Space, and dodges cacti that arrive on a timer.
  • Hitting an obstacle shows Game Over with the final score still visible.
  • Pressing Enter from Game Over resets everything and returns to the costume screen.

Collaboration & AI

Work: On your own. Ask a neighbor for a hand if you're stuck, but the program you submit should be yours.

AI — AIAS Level 1, No AI: The whole point of this lab is learning to organize a bigger program yourself. What the levels mean.


New Images

This lab requires a lot of new images — three full costumes plus a cactus. If you completed Getting Set Up for Pygame Zero, they are already in your images folder and you can skip these steps.

  1. Download the zip file of images.

    Download runner-images.zip

  2. Double-click the zip file to open it. It will create a new folder next to the zip file with the same name.

  3. Move the images from the new folder into your game's images folder.


Overview of the Game

We're going to make an infinite runner type game. Dino Jump is an example of this type of game.

Dino Jump, the classic infinite runner.


Making Functions

We're going to focus on breaking up our code into a number of small functions to organize our game. This will help us add new behaviors, such as showing different screens for selecting a character and resetting the game after a Game Over screen is shown.

  1. Let's start a new game with the code below.

    runner.py
    import pgzrun
    
    WIDTH = 800
    HEIGHT = 600
    
    runner = Actor("ninja0")
    runner.x = 100
    runner.y = 400
    
    def draw():
        screen.fill("skyblue")
        screen.draw.filled_rect(Rect(0, 400,800, 200), (88, 242, 152))
        runner.draw()
    
    pgzrun.go()
    

    Try your program. Your screen should look like this.

    The ninja standing on green ground under a blue sky.


Modes

When we're done, we want our game to have three modes:

  • costume — this mode will show a start screen where the player can choose a character.
  • play — this mode will show the game itself.
  • game over — this mode will show a Game Over screen with an option to reset.

We'll need to keep track of which mode we're currently in, to know how to update and what to draw. Let's create a variable for this.

  1. Add the highlighted line. We'll call the normal game mode play.

    runner = Actor("ninja0")
    runner.x = 100
    runner.y = 400
    runner.mode = "play"
    
  2. Now let's take the code from our draw() function and put it in a helper function that will be used to draw the game when in play mode. We'll name this function draw_game().

    def draw_game():
        screen.fill("skyblue")
        screen.draw.filled_rect(Rect(0, 400,800, 200), (88, 242, 152))
        runner.draw()
    
  3. Finally, we need to call our draw_game() function from the draw() function, but only if we are in play mode. Replace the code in the draw() function so it looks like this.

    def draw():
        if runner.mode == "play":
            draw_game()
    

    Try your program. Your screen should look like it did before.


Animation

Let's add a list of images to animate the character running. We'll also need to keep track of which image is currently shown, so we know which to move to on the next frame.

  1. Add the highlighted lines.

    runner = Actor("ninja0")
    runner.x = 100
    runner.y = 400
    runner.mode = "play"
    runner.images = ["ninja0", "ninja1", "ninja2", "ninja3", "ninja4", "ninja5",
                     "ninja6", "ninja7", "ninja8", "ninja9"]
    runner.img_num = 0
    
  2. Add this function to change to the next image in the list.

    def next_runner_image():
        runner.image = runner.images[runner.img_num % len(runner.images)]
        runner.img_num += 1
    

    Something new!

    Instead of writing out the line to add one like this:

    runner.img_num = runner.img_num + 1
    

    we'll use this shorter version:

    runner.img_num += 1
    

    These two lines do exactly the same thing! The += operator is built into Python because we do addition so often as programmers. You can also subtract one like this:

    runner.img_num -= 1
    
  3. Call the next_runner_image() function from the update() function.

    def update():
        next_runner_image()
    

    Try your program. Your character should be animated to move like it is running.


Keyboard Input

Now we'll make the character jump when Space is pressed. We'll use the built-in on_key_down() function to capture key presses.

  1. Define variables for the level of the ground and gravity. Add the highlighted lines.

    import pgzrun
    
    WIDTH = 800
    HEIGHT = 600
    GROUND = 400
    GRAVITY = 1
    
  2. Define a variable on the runner to keep track of vertical speed. Add the highlighted line.

    runner = Actor("ninja0")
    runner.x = 100
    runner.y = 400
    runner.mode = "play"
    runner.images = ["ninja0", "ninja1", "ninja2", "ninja3", "ninja4", "ninja5",
                     "ninja6", "ninja7", "ninja8", "ninja9"]
    runner.img_num = 0
    runner.speed_y = 0
    
  3. Add the built-in on_key_down() function to listen for key presses. We only want to jump if we're in play mode.

    def on_key_down(key):
        if runner.mode == "play":
            if key == keys.SPACE and runner.y == GROUND:
                runner.speed_y = -15
    
  4. Add a helper function to handle gravity.

    def apply_gravity():
        runner.y += runner.speed_y
        runner.speed_y += GRAVITY
        if runner.y > GROUND:
            runner.speed_y = 0
            runner.y = GROUND
    
  5. Finally, call the apply_gravity() function from the update() function, but only if we're in play mode. Add the highlighted lines.

    def update():
        next_runner_image()
    
        if runner.mode == "play":
            apply_gravity()
    

    Try your program. The character should jump when Space is pressed, then fall back to the ground.


Obstacles

Next we'll add obstacles to jump over, and a timeout to keep track of how often new obstacles appear on the screen.

  1. Add the highlighted lines to create a list to hold obstacles and a timeout.

    runner = Actor("ninja0")
    runner.x = 100
    runner.y = 400
    runner.mode = "play"
    runner.images = ["ninja0", "ninja1", "ninja2", "ninja3", "ninja4", "ninja5",
                     "ninja6", "ninja7", "ninja8", "ninja9"]
    runner.img_num = 0
    runner.speed_y = 0
    runner.obstacles_timeout = 0
    
    obstacles = []
    
  2. Create a helper function to handle creating obstacles and moving them from right to left on the screen.

    def do_obstacles():
        # obstacles
        runner.obstacles_timeout += 1
        if runner.obstacles_timeout > 50:
            c = Actor('cactus')
            c.x = 850
            c.y = 430
            obstacles.append(c)
            runner.obstacles_timeout = 0
    
        for o in obstacles.copy():
            o.x -= 8
            if o.x < 0:
                obstacles.remove(o)
    
  3. Call the helper function from the update() function. It should only be called when we're in play mode. Add the highlighted line.

    def update():
        next_runner_image()
        if runner.mode == "play":
            apply_gravity()
            do_obstacles()
    
  4. Finally, add code to draw each obstacle in our drawing helper function. Add the highlighted lines.

    def draw_game():
        screen.fill("skyblue")
        screen.draw.filled_rect(Rect(0, 400,800, 200), (88, 242, 152))
        runner.draw()
        for o in obstacles:
            o.draw()
    

    Try your program. You should see obstacles appear at the timeout interval and move across the screen.

    A cactus sliding toward the running ninja.


Score

We'll keep score by counting each frame. The longer the player goes without hitting an obstacle, the higher the score.

  1. Create a variable to keep track of the score, starting from zero. Add the highlighted line.

    runner = Actor("ninja0")
    runner.x = 100
    runner.y = 400
    runner.mode = "play"
    runner.images = ["ninja0", "ninja1", "ninja2", "ninja3", "ninja4", "ninja5",
                     "ninja6", "ninja7", "ninja8", "ninja9"]
    runner.img_num = 0
    runner.speed_y = 0
    runner.obstacles_timeout = 0
    runner.score = 0
    
  2. Add one to the score in the update() function for each frame, but only if we're in the play mode. Add the highlighted line.

    def update():
        next_runner_image()
        if runner.mode == "play":
            apply_gravity()
            do_obstacles()
            runner.score += 1
    
  3. Add a helper function to draw the score on the screen.

    def draw_score():
        screen.draw.text(f"Score: {runner.score}", (30, 30), color="white", fontsize=60)
    
  4. Finally, call the helper function from the draw() function. Add the highlighted line.

    def draw():
        if runner.mode == "play":
            draw_game()
            draw_score()
    

    Try your program. The score should be constantly increasing, shown in the upper-left corner.

    The score climbing in the corner of the game.


Game Over and Reset

When the character touches an obstacle, the game ends. We want to show a Game Over screen and provide an option to reset and start a new game.

When the game ends, we'll move from the play mode to the game over mode.

  1. Create a helper function to check if the character has collided with an obstacle. If so, change the mode to game over.

    def check_damage():
        if runner.collidelist(obstacles) >= 0:
            runner.mode = "game over"
    
  2. Call the helper function from the update() function. Add the highlighted line.

    def update():
        next_runner_image()
        if runner.mode == "play":
            apply_gravity()
            do_obstacles()
            runner.score += 1
            check_damage()
    
  3. Create a helper function to draw the screen when we're in the game over mode.

    def draw_game_over():
        screen.fill("black")
        screen.draw.text("GAME OVER", center=(WIDTH / 2, HEIGHT / 2),
                         color="white", fontsize=60)
        screen.draw.text("Press Return to restart", center=(WIDTH / 2, HEIGHT / 2 + 50),
                         color="white", fontsize=60)
    
  4. Call the helper function from the draw() function when we're in the game over mode. Add the highlighted lines.

    def draw():
        if runner.mode == "play":
            draw_game()
            draw_score()
        elif runner.mode == "game over":
            draw_game_over()
    

    Try your program. You should see the Game Over screen when the player collides with an obstacle.

    The black Game Over screen.

    The game ends correctly, but we'd like to show the final score. This is why we wrapped the score-drawing code up in its own function — now we can call it from multiple modes (play and game over) without repeating code.

  5. Add the highlighted line.

    def draw():
        if runner.mode == "play":
            draw_game()
            draw_score()
        elif runner.mode == "game over":
            draw_game_over()
            draw_score()
    

    Try your program. Now the final score should remain visible on the Game Over screen.

    Game Over with the final score still showing.

Now we need to add the code to restart the game when the player presses the Return key in game over mode. Let's create a function that "puts everything back" to the way it should be when a new game starts. This function will:

  • Set the score back to zero.
  • Set the runner's vertical speed back to zero.
  • Clear the list of obstacles, and reset the timeout for new obstacles to appear.
  • Change the mode from game over back to play.
  1. Add the function below to your program.

    def reset():
        runner.score = 0
        runner.speed_y = 0
        obstacles.clear()
        runner.obstacles_timeout = 0
        runner.mode = "play"
    
  2. Next, let's call this helper function when Enter is pressed, but only if we're in the game over mode. Add the highlighted lines.

    def on_key_down(key):
        if runner.mode == "play":
            if key == keys.SPACE and runner.y == GROUND:
                runner.speed_y = -15
        elif runner.mode == "game over":
            if key == keys.RETURN:
                reset()
    

    Try your program. You should be able to start a new game from the Game Over screen by pressing Enter.


Changing Characters

That was a lot of work! But now we have a structure that lets us make other large modifications easily, such as adding additional modes. Let's add a mode for a screen that appears before the game starts, where the player chooses a costume.

Important concept

We're not going to create different Actors. We'll keep our single Actor named runner, but let the player choose which list of images that Actor wears. That's why we call the new mode costume — we're really just picking a costume for our Actor!

  1. First, set the starting mode for our program to the new one we'll create now. Make the highlighted update, changing the first mode from play to costume.

    runner = Actor("ninja0")
    runner.x = 100
    runner.y = 400
    runner.mode = "costume"
    runner.images = ["ninja0", "ninja1", "ninja2", "ninja3", "ninja4", "ninja5",
                     "ninja6", "ninja7", "ninja8", "ninja9"]
    runner.img_num = 0
    runner.speed_y = 0
    runner.obstacles_timeout = 0
    runner.score = 0
    
  2. Next, we'll create a new helper function to draw the costume screen. Add this to your code.

    def draw_costume():
        screen.fill("black")
        runner.draw()
        screen.draw.text("Press Space to select a player",
                         centerx=WIDTH / 2, top=20, color="white", fontsize=60)
        screen.draw.text("Press Return to start",
                         center=(WIDTH / 2, HEIGHT / 2), color="white", fontsize=60)
    
  3. Now update the draw() function to call the helper function when we're in costume mode. Add the highlighted lines.

    def draw():
        if runner.mode == "play":
            draw_game()
            draw_score()
        elif runner.mode == "game over":
            draw_game_over()
            draw_score()
        elif runner.mode == "costume":
            draw_costume()
    

    Try your program. It should draw the costume screen to start, now.

    The costume-select screen with the runner on black.

  4. Let's write a helper function to change to the next costume based on the costume we're currently wearing. We'll use the startswith() function, which is built into Python, to see what the Actor's current image name starts with. Add this to your code.

    def change_costume():
        if runner.image.startswith("ninja"):
            runner.images = ["dino0", "dino1", "dino2", "dino3", "dino4", "dino5",
                             "dino6", "dino7"]
        elif runner.image.startswith("dino"):
            runner.images = ["knight0", "knight1", "knight2", "knight3", "knight4",
                             "knight5", "knight6", "knight7", "knight8", "knight9"]
        elif runner.image.startswith("knight"):
            runner.images = ["ninja0", "ninja1", "ninja2", "ninja3", "ninja4", "ninja5",
                             "ninja6", "ninja7", "ninja8", "ninja9"]
    
  5. Finally, we'll handle the keyboard keys for the new costume mode. We'll call the helper function if Space is pressed, or start the game by changing to play mode if Enter is pressed. Add the highlighted lines.

    def on_key_down(key):
        if runner.mode == "play":
            if key == keys.SPACE and runner.y == GROUND:
                runner.speed_y = -15
        elif runner.mode == "game over":
            if key == keys.RETURN:
                reset()
        elif runner.mode == "costume":
            if key == keys.SPACE:
                change_costume()
            elif key == keys.RETURN:
                runner.mode = "play"
    
  6. One more thing: after a Game Over, the reset should send the player back to the costume screen. Make the highlighted update in reset().

    def reset():
        runner.score = 0
        runner.speed_y = 0
        obstacles.clear()
        runner.obstacles_timeout = 0
        runner.mode = "costume"
    

Something You Should See

We're done with the game — but let's have a look at our two main functions, update() and draw():

def update():
    next_runner_image()
    if runner.mode == "play":
        apply_gravity()
        do_obstacles()
        runner.score += 1
        check_damage()

def draw():
    if runner.mode == "play":
        draw_game()
        draw_score()
    elif runner.mode == "game over":
        draw_game_over()
        draw_score()
    elif runner.mode == "costume":
        draw_costume()

Notice how small and tidy they are? Dividing our programs into many small functions lets us do bigger, more complex things. Consider how easy it would be to add a fourth mode to these two functions if we wanted it.

Let's also look at our draw_score() function:

def draw_score():
    screen.draw.text(f"Score: {runner.score}", (30, 30), color="white", fontsize=60)

Why did we make a whole function for a single line of code? Have another look at the draw() function above — we call draw_score() from two places. If you wanted to change the font or the color of the score, you would only need to update it in one place.

The takeaway

Functions are useful for:

  • Simplifying complex code.
  • Code reuse.

Challenges

Try these on your own once the game works.

  • Make the obstacles appear at random times, so they aren't always spaced out the same.
  • Make the runner appear somewhere in the middle of the screen in costume mode, but in the correct location again during play mode.
  • Create a list of different obstacles, including the cactus, and randomly choose from it each time we add a new obstacle to the screen.

Turn It In

  • Submit your runner.py file in Google Classroom.

How It's Graded

This lab is worth up to 4 points. One score covers everything you turn in.

Score What it looks like
4 — Excellent All three modes work end to end: the costume screen cycles ninja, dino, and knight with Space and starts on Enter; the runner animates, jumps, and dodges timed cacti while the score climbs; Game Over keeps the final score on screen; and reset() puts everything back for the next round. Meanwhile update() and draw() stay short lists of helper calls — the actual point of the lab.
3 — Above Average All three modes work with a slip — a reset that forgets one thing (a lingering cactus, a score that carries over), or a costume that doesn't cycle back around.
2 — Average The game plays, but the mode system is incomplete — no costume screen, or no way back from Game Over.
1 — Below Average The runner animates and little else works.
0 — Failing Nothing submitted, or the program doesn't run.