Build your own games with Python

What are we making?

In this project you’ll learn how to make your own game in Python. We’ll start with simple text games (like quizzes and adventures) and later you can move on to games with graphics like the famous Snake! game.

You’ll learn how to:

Along the way you will also learn how to:

To make these games you will need some kind of Python app. If you are not sure get someone to help you set up Thonny or another Python app on your computer. You can use a website like the raspberry pi editor to get started but some of the exercises won’t work in an online editor.

There are also some other great Python game projects on the raspberry pi website

Chapter 1 - A basic quiz game

Step 1 - Ask a question (and remember the answer)

Try this:

input("What is your favourite colour?  > ")

This asks a question - but it doesn’t save the answer anywhere. This is better:

player_answer =input("What is your favourite colour?  > ")
print("Your favourite colour is", player_answer)

Your turn: Think of some more questions and add more code to ask the player the questions and show their answers. You can try things like:

Step 2 - Check the answer (right or wrong)

Games need to react to what the player types. We do this with if and else.

Here is an example so how if and else work. Important things to notice:

if player_answer=="the correct answer":
    # This block of code only runs if the answers match
    print("Well done!")
else:
    #This block of code only runs if the answers don’t match
    print("Wrong!")

Try a quiz question:

player_answer=input("What is Batman’s favourite colour?  > ")
if player_answer=="black": 
    print("Well done!")
else:
    print("Wrong!")

Your turn: Make 3 quiz questions of your own. Examples:

Step 3 - keep score

Now let’s give the player points for correct answers:

# Start with the score at 0
total_score=0
# First question
player_answer = input("What is 2 + 2?  > ")
if player_answer == "4":
    print("Well done!")
    total_score = total_score+1
else:
    print("Wrong!")
    total_score = total_score + 0
# More questions can go here
print("Game Over!")
print("Your points: ", total_score)

Things to notice:

Your turn: Add scores into your quiz game. If the score isn’t changing the way you expect, you can add in prints to see what Python is actually doing print("Score is now:" total_score)

Step 4 - Tidy up (stop writing the same code over and over)

If you add more questions to your quiz you will keep writing similar code again and again. It is better to get Python to do the boring things for you. A function is a way to make a mini-program you can use several times.

# A function to ask a question and give points
def quiz_question( question, correct_answer):
    player_answer=input(question)
    if player_answer==correct_answer:
        print("Well done!")
        return 1
    else:
        print("Wrong!")
        return 0

# Use the function
total_score=0
total_score+=quiz_question("What is Batman’s favourite colour? > ", "black")
total_score+=quiz_question("What is Barbie’s favourite colour? > ", "pink")
print("You got", total_score, "points")

Things to notice:

Your turn: Add your own questions using the function (much less typing)

Bonus challenges: Make a smarter quiz_question function that can do things like:

Step 5 - Keep your questions in a list (Super organised)

A list can store all your questions and answers together:

# Make a quiz list
quiz = [
    ("What is Batman's favourite colour? >", "black"),
    ("What is Barbie's favourite colour? >", "pink")
]

# Ask the questions using a loop:
for (question, answer) in quiz:
    total_score+=quiz_question(question, answer)
print("You got", total_score, "points")

Things to notice:

Your turn: Use a list of 5 questions in your program

Chapter 2 - Give the player more control

We will let the player give us special instructions as well as answers. Here are the instructions:

We will have to make several changes to our quiz game. You can do this in one go (see the examples at the end of this project) but you will learn more and be a more successful games programmer if you do this bit by bit and keep checking things work. Make sure you keep saving your program so you don’t lose what you are doing.

Step 6 - Get instructions from the player

Change the program so our quiz_question function can give back more information.
In the function there is code to return the number of points like this

        return 1

Now we need to return points and an instruction like this:

        return (1, "answer")

When we use the function we need to handle the points and the instruction. Instead of this line:

    total_score += quiz_question(question, answer)

We will need:

    (points, instruction) = quiz_question(question, answer)
    total_score += points

Things to notice:

Your turn: Change you program so that your function sends back a score and the word “answer”. There are two return lines to change. Change the line where we use the function so we can get the score and the instruction and then use the score to update the players total. We aren’t doing anything with the instruction yet. That’s ok for this step. Test your quiz now and check it works just as before.

Step 7 - Allow valid instructions

In our quiz_question function we can have two results (or branches): right or wrong. Now we are going to need four results: right, wrong, pass or leave. Let’s change our function to allow this.

    if player_answer=="leave":
        print("OK. Come back and play another time.")
        return (0,"leave")
    elif player_answer=="pass":
        print("OK. Skip this question")
        return (0, "pass")
    elif player_answer==correct_answer:
        print("Well done!")
        return (1, "answer")
    else:
        print("Wrong! You lose a point")
        return (-1, "answer")

Important things to notice:

Your turn: Make these changed inside your quiz loop. The program gives the player the right messages but the leave instruction doesn’t stop the game yet. That’s ok. Check the rest of your quiz game is still working.

Step 8 - Leave the game

We can add two lines to handle the leave instruction and finish the game early like this:

    if instruction=="leave":
        break

Your turn: Can you work out where these lines go into your game? Try it and see what happens. If it doesn’t work, try again.

Clue: break statements are used to escape out of loops early.
If you need help look at the examples at the end of this project.

Chapter 3 - More players

Now we will let more players take the quiz. We will keep a board of high-scores and let new players add their nickname or gamer tag when they get a new high-score. Just like Chapter 2 you can look at the example at the end of the project but you won’t learn very much from copying that. If you want to learn more go through these steps and check your game works each time.

Step 9 - Put each quiz round into a function

As we want to do the quiz several times we can put it in a function. Use these hints to reorganise your code:

# Keep your quiz questions list as before
quiz = [
    # Your quiz questions and answers
]

# Keep your quiz_question function
def quiz_question( question, correct_answer):
    # Your function code as before

# Add a new quiz_round function
def quiz_round():
    # All of your other code moves into here, but you will have to change the indentation

    # pass back the final score
    return total_score

# Play a round of the quiz
quiz_round()

Things to notice:

Your turn: Add in the quiz_round function and reorganise your code. If you get stuck look at the examples at the end of the project. We haven’t added anything for high scores and players yet but check the game still works.

Step 10 - Play many quizzes

We can now put the quiz_round into a loop to play many times like this:

keep_playing=True
while keep_playing:
    print("Welcome to the amazing quiz game!")
    gamer_tag = input("Type in your nickname or gamer tag > ")
    print("OK,", gamer_tag, "let's play.")
    player_score=quiz_round()
    if input("Is there another player? > ")=="no":
        keep_playing=False

Things to notice:

Your turn: We are not keeping a score board yet but check your game works for several players now

Step 11 - Add in score board

We used a list to store our questions and answers. Now we can use a list to store our scoreboard. Add this code near where you have stored your question and answer list:

# Start with an empty scoreboard list
scoreboard = []

Next, we add a function to show the scoreboard. Put this near your other functions.

# Show the scoreboard
def show_scoreboard():
    print("Top Scores")
    position = 1
    for (gamer_tag, score) in scoreboard:
        print (position, ":",gamer_tag,"=",score)
        position+=1

Things to notice:

Then we need a function to add a new score to the board:

def add_new_score(gamer_tag, score):
    scoreboard.append((gamer_tag, score))

Things to notice:

Finally, we can use these parts in the keep_playing loop.

        # Show the scoreboard
        show_scoreboard()
        # Add new score to the scoreboard
        add_new_score(gamer_tag, player_score)

Your turn: Put the new code in your quiz game. Double check the indentations as it is easy to get this wrong. If you get stuck look at the examples at the end of this project

Step 12 - Improve the scoreboard

You might have noticed some problems with the scoreboard:

Let’s fix these by changing our show_scoreboard function. First let’s skip showing the scoreboard if there is nothing in there.

# Show the scoreboard
def show_scoreboard():
    if len(scoreboard) > 0:
        print("Top Scores")
        position = 1
        for (gamer_tag, score) in scoreboard:
            print (position, ":",gamer_tag,"=",score)
            position+=1
    else:
        print("No top score yet")

Things to notice:

Your turn: Try this small change and check it works.

Now let’s fix the order. Python knows how to sort lists so we can add in:

        scoreboard.sort()

Things to notice:

Your turn: You need to put it in the right place in the show_scoreboard function. Try it in some different places to see what happens. If you get stuck check out the examples at the end of the project.

The sort() function works but it isn’t sorting in the right way. Luckily, we can give sort some more information to get it right. First, we make a helper function so sort can find the score rather than the gamer_tag.

def scoreboard_sort_key(sb):
    (gamer_tag, score) = sb
    return score

Now we can give some more parameters to sort to get it right.

        scoreboard.sort(reverse=True, key=scoreboard_sort_key)

Things to notice:

Finally, we can only show the top scores. Inside our show_scoreboard we have a position variable that counts up each time we show a line in the board. We can check this number and break out of the loop.

        if position > 10:
            break

Your turn: See if you can find the right place to insert this code and check it all works. If you get stuck look at the examples at the end of the project.

Chapter 4 - Saving and loading games

When we leave the game, we lose the scoreboard and it starts empty the next time we play. Instead, we can save the scoreboard information in a file and load it back in the next time we play.

We will make some more functions to work with files:

Step 13 - Save the scoreboard

Since the scoreboard is a simple table with two columns we can save it with comma-separated-values (.csv file).

We can use Python’s csv module to help with this.

At the start of our program we need to add in the module like this:

import csv

Here is a function to save the scoreboard:

def save_scoreboard():
    with open("quiz-scores.csv", "w", newline="") as file:
        writer=csv.writer(file)
        writer.writerows(scoreboard)

Things to notice:

After we have updated the scoreboard we can save it in the file with:

        save_scoreboard()

Your turn: Update your game by adding in these bits of code on the right places and check that it works. If you get stuck, you can check the examples at the end of the project. You should be able to see the file is created after the first game. You can open the file with a text editor or spreadsheet app to inspect the data.

Step 14 - Load the scoreboard

Loading the scoreboard looks very similar:

def load_scoreboard():
    with open("quiz-scores.csv", "r") as file:
        global scoreboard
        scoreboard=[]
        reader=csv.reader(file)
        for (gamer_tag, score) in reader:
            scoreboard.append((gamer_tag, int(score)))

Things to notice:

We can use this function with:

        load_scoreboard()

Your turn: Put these bits of code in the right places and check they work. If you get stuck look at the examples.

Watch out We made the save function first so we already had a scoreboard file when we added the load. The program won’t work if the file is not there. The example code at the end of the project adds some extra code, a try except block, to fix this if you really need it. A try except block lets your program cope with problems instead of stopping - in this case it lets us carry on if the file is missing.

Chapter 5 - An adventure game

Let’s use what we have learnt to make a new game. This is going to be a text adventure game. It is up to you what sort of adventure it will be. It could be a dungeon adventure with wizards, knights and dragons. It could be a wildlife adventure with animals and scientists. Use your own hobbies, interests and imagination.

Step 15 - The game world

The game world will live in a dictionary. A dictionary is like a list, but you can use labels to access each item.

game_world = {
    # Each place or event has a label and a value
    "wilderness": ...value...,
    "castle": ...value...,
    "end" : ...value...,
    "killed" : ...value
}

We can also use dictionaries to hold the details about the places and events.

# Main dictionary to hold the whole world
game_world = {
    # Each place or event has a label and a smaller dictionary for things like the title and description
    "wilderness": {
        "title": "Wilderness",
        "description": "You stand alone in the barren wilderness. There is a castle in the distance",
    },
    "castle": {
        "title": "Castle",
        "description": "You enter the castle and are confronted by the evil dragon!",
    },
    "end" : {
        "title": "The End",
        "description": "You return to your village"
    },
    "killed" : {
        "title": "The End", 
        "description": "Without your own magic you are no match for the dragon!",
    }
}

We will also add a list of actions you can take and where the action will take you. If we have got to the end of the story there are no more actions.

game_world = {
    "wilderness": {
        "title": "Wilderness",
        "description": "You stand alone in the barren wilderness. There is a castle in the distance",
        "actions": [
            ("Return home", "end"),
            ("Enter the castle", "castle")
        ]
    },
    "castle": {
        "title": "Castle",
        "description": "You enter the castle and are confronted by the evil dragon!",
        "actions": [
            ("Fight the dragon", "killed"),
            ("Run away", "wilderness")
        ]
    },
    "end": {
        "title": "The End",
        "description": "You return to your village"
    },
    "killed": {
        "title": "The End", 
        "description": "Without your own magic you are no match for the dragon!",
    }
}

Your turn: Make your own game_world and add in a few places and events that fit your story. Add in actions to link them together.

Step 16 - The game loop

Big Step: This step is harder than usual - take your time and don’t be afraid to get help

Next, we will program our game loop. Although the loop will be short and simple we can create exciting, complex adventures just by putting more into the game_world dictionary.

# Set the player position to the beginning of adventure
player_position = "wilderness"

# Game status
keep_playing = True

# Game loop
while keep_playing:
    # Use player_position as a key to get the details of the place
    place=game_world[player_position]
    # Use keys to extract the details we want
    title=place["title"]
    description=place["description"]
    print(title)
    print("~~~~~~~~~~~~~~~~~~")
    print(description)
    print()
    if "actions" in place:
        # There is an action list so we can use it to ask the player what to do
        actions = place["actions"]
        print("What do you want to do next?")
        # Loop through the available actions. 
        # action_number will count up from 0 and stop depending on the number of available actions in the list.
        for action_number in range(len(actions)):
            # actions is our list of available actions.
            # actions[action_number] picks out one action
            # each actions has two parts.            
            (description, destination) = actions[action_number]
            print(action_number, ":", description)
        # Convert the player input into a number. The text "1" becomes the number 1
        choice = int(input("Type the action number >"))
        print()
        # actions[choice] picks out the chosen action
        # actions[choice][1] picks out the second part of the pair which will be the destination for the player
        player_position = actions[choice][1]
    else:
        # No actions so this must be the end of the game
        if input("Play again? > ") == "no":
            # Leave the game
            keep_playing=False
        else:
            # Go back to the start position
            player_position="wilderness"
print("Game over. Good luck!")

Things to notice:

Your turn: Try your game world with this game loop. If your player ends up in the wrong place, try adding prints to check player_position and actions at the start of each loop.

Bonus challenge: Have you noticed what happens if you don’t type one of the valid action numbers? Can you design a helper function to fix these problems. The function will keep asking the player for an input until they enter something valid. It will then return the value as an integer. There are many different ways to do this in Python. You can use an internet search to find some examples and use these as clues to help you define the function and use it.

Step 17 - Finding and using items in the adventure

The game is quite fixed at the moment. Each time you visit a place or get into a situation the actions and results will be the same. We can make the game more interesting by placing things in the game_world that the player can collect and use to change how the adventure unfolds. For example, a magic sword might help the player defeat the dragon and reach the next part of the adventure. Maybe we can make friends with the dragon with some food.

First, lets re-organise our game_world to include items. We can add in items and provide special actions if a player has an item.

game_world = {
    # There is a magic sword in the wilderness. If you have the sword it changes what happens in the castle.
    "wilderness": {
        "title": "Wilderness",
        "description": "You stand alone in the barren wilderness. There is a castle in the distance",
        "item": "magic sword", # Give the player an extra action to collect the item
        "actions": [
            ("Return home", "end"),
            ("Enter the castle", "castle")
        ]
    },
    "castle": {
        "title": "Castle",
        "description": "You enter the castle and are confronted by the evil dragon!",
        "actions": [ # Use these actions if the player doesn't have the special item
            ("Fight the dragon", "killed"),
            ("Run away", "wilderness")
        ],
        "magic sword": [ # If the player has the magic sword use these actions instead
            ("Fight the dragon", "win"),
            ("Run away", "wilderness")
        ]
    },
    "end": {  # If there are no available actions we know the game has reached the end
        "title": "The End",
        "description": "You return to your village"
    },
    "killed": {
        "title": "The End", 
        "description": "Without your own magic you are no match for the dragon!",
    },
    "win": {  # New ending that requires the magic sword
        "title": "A New Hero",
        "description": "The dragon is powerful but with the magic sword you win the battle and save your village!",
    }
}

Your turn: Re-organise your game world to include one or two items. Add in a special list of actions in the places where they will be used. Your program doesn’t use this new information yet so let’s fix that next.

Step 18 - Collect items

We can use a list to keep track of the items that the player has.

player_items = []

If a location has an item we can add a special action that lets us take it.

if "item" in place:
    print(len(actions), ": Pick up the", place["item"])

We need to check if the player has picked this action and then take the item

if choice==len(actions):
    player_items.append(place["item"])
    del place["item"]
else:

Your turn: Find the right places to add in this new code and try your game. You should be able to take the items and then they are no longer there. If you get stuck, you can look at the examples for some clues. You can’t use the items yet, but we will do that next.

Bonus challenge: You might notice that the first player gets the items. If you play again the items have gone. See if you can fix this. There are several ways you could try:

Step 19 - Use the items

Now we need to check if the items we have give us new possibilities.

for carried_item in player_items:
    if carried_item in place:
        # There are special actions for one of the items we have. Let's use them instead.
        actions=place[carried_item]
        break

Your turn: Find the right place for this code and try your game. If you get stuck use the examples for clues.

Step 20 - Invent and code your own features

Make a list of features you want in your adventure. Work out some small steps you can take to add in the code and check it works before adding the next small part. Here are some ideas:

Chapter 6 - Graphics

In this chapter we will start making games with graphics. This will be the hardest chapter so far - don’t worry it it takes longer. That just means you are learning more!

We will make use of a beginner’s game library called pygame. There are many games libraries you can use which are good at different things. pygame is good at helping you to learn basic game skills and python coding. These are good skills you can keep using if you move on to more advanced game libraries or game engines like Unity.

Your python editor will have instructions for how to install libraries like pygame and or you can search for help and guides online. If you are using Thonny there is a package manager under the tools menu so you can search for pygame and install it.

Step 21 - A simple game template

Let’s start with a basic game template. This project uses the example in the pygame documents.

import pygame

# pygame setup
pygame.init()
screen = pygame.display.set_mode((1280, 720))
clock = pygame.time.Clock()
running = True

# Setup the start position for the game

while running:
    # poll for events
    # pygame.QUIT event means the user clicked X to close your window
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

    # Handle any user input

    # Perform other work such as game effects or non-player characters

    # Fill the screen with a colour to wipe away anything from last frame
    screen.fill("purple")

    # Draw your game screen

    # flip() the display to put your work on screen. Before this your work will be hidden
    pygame.display.flip()

    # Control how fast your game updates.
    # 20 frames per second is quite slow for modern games but is easier to control when we are starting.
    clock.tick(20)

pygame.quit()

Your turn: Run this template to check things are working. You will just see a purple game window and you can close the window to stop the game.

Step 22 - Our first graphics game

Let’s fill in some more of the template. This will draw a circle and let you move it around with the keyboard arrow keys.

import pygame

# pygame setup
pygame.init()
screen = pygame.display.set_mode((1280, 720))
clock = pygame.time.Clock()
running = True

# Setup the start position for the game
player_pos = pygame.Vector2(screen.get_width()/2, screen.get_height()/2)

while running:
    # poll for events
    # pygame.QUIT event means the user clicked X to close your window
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

    # Handle any user input
    keys = pygame.key.get_pressed()
    if keys[pygame.K_UP]:
        player_pos.y -= 40
    if keys[pygame.K_DOWN]:
        player_pos.y += 40
    if keys[pygame.K_LEFT]:
        player_pos.x -= 40
    if keys[pygame.K_RIGHT]:
        player_pos.x += 40

    # Perform other work such as game effects or non-player characters

    # Fill the screen with a colour to wipe away anything from last frame
    screen.fill("purple")

    # Draw your game screen
    pygame.draw.circle(screen, "red", player_pos, 40)

    # flip() the display to put your work on screen
    pygame.display.flip()

    # Control how fast your game updates.
    # 20 frames per second is quite slow for modern games but is easier to control when we are starting.
    clock.tick(20)

pygame.quit()

Your turn: Start with this simple example, experiment and make changes to learn about how things work. Here are some ideas:

Step 23 - More game controls

Let’s jump right in and keep experimenting. If you get stuck the pygame website has great help guides or check the example at the end of the project.

Your Turn:

Step 24 - Using images

Coloured shapes are ok to get basic games working but it can be fun to use images.

If you have an image file you can use it in pygame like this:

your_image_variable = pygame.image.load("your_image_filename")

You can use the image as the screen background like this.

screen.blit(your_image_variable, (0,0))

The (0,0) tells pygame where you want the top-left part of the image to appear on the screen. You can use the image as the player like this.

screen.blit(your_image_variable, player_pos, pygame.Rect((0,0), (40,40)))

We have put the top-left part of the image at the player position. We’ve also given pygame a rectangle so that the image is cut down to our player size. We make rectangle with pygame.Rect and set the start position and size of the rectangle. pygame has functions to stretch and shrink images. Investigate pygame.transform if you need this.

Your turn:

Chapter 7 - A catching and dodging game

We’ll make a game where the player moves their piece on the screen to avoid or capture things. We will adapt the game you started in Chapter 6. This chapter explains the steps to take but doesn’t tell you exactly how to make the changes. You will have to work things out, try things and fix problems. If you get stuck, the examples at the end of the project or an internet search should help you fix the problem.

Step 25 - Get the player ready

We can set the player to start on the left-hand side of the screen, and we will use a rectangle to show their position. We could use a different shape, but a rectangle is good because we have already seen how to change this to an image if you wanted to improve the basic game later. Pygame also provides handy functions that work with rectangles.

# Start position for player
# The top left of the player will be 80 steps across and halfway down the screen
# The player will be 80 steps wide and 80 steps high
player_pos = pygame.Rect((80,screen.get_height() / 2), (80,80))

We can draw the rectangle like this.

pygame.draw.rect(screen, "white", player_pos)

Your turn: Make some more changes to the program so that we just have two keys which can move the player up and down. We won’t move left of right in this game. It can be annoying if the player moves off the edge of the screen. If you want to stop this, you can “clamp” the player into the screen area using a pygame helper function like this:

player_pos.clamp_ip(screen.get_rect())

Step 26 - Send targets across the screen

Now we will set up another rectangle that will be a target we can send across the screen. We don’t want the targets to be predictable so we can use randomness to make the game harder.

We can import the random module near the top of our game and use the randint function to set an unpredictable position.

import random

    ...

target_pos = pygame.Rect((screen.get_width(),random.randint(0,screen.get_height())), (80,80))

Inside out game loop we can move the target a small amount each time and draw it. It will appear to move across the screen as the game runs.

# Tells pygame to move the target some steps right and down. We use minus numbers to go left and up.
target_pos.move_ip((-1,0))

    ...

pygame.draw.rect(screen, "yellow", target_pos)

If the target gets to the other side of the screen, we can reset it to a new position.

if target_pos.x < 0:
    target_pos = pygame.Rect((screen.get_width(), random.randint(0, screen.get_height())), (80,80))

Your turn: Add in the code for the target and check that it works. You may notice some things that are not quite right. Fixing these can be your bonus challenge:

Step 27 - Catch the target and score points

Let’s create a variable for our score.

player_score = 0

To display the score we will use pygame fonts. There is a lot we can do with text in pygame, but we will start with something simple. Near the start of the game we need to setup the font we want to use.

# None means we will let pygame choose the text style, 48 is the size of the text
score_font = pygame.font.SysFont(None, 48)

When we are creating our display in the game loop we can show the score

    # Create a text message to show our score
    score_message = "Score: " + str(player_score)
    # Use our font to turn some text into an image
    score_image = score_font.render(score_message, True, "black")
    # Place the image on our screen
    screen.blit(score_image, (10,0))

Things to notice:

When we hit a target we can add a point and reset the next target. We can use pygame’s rectangle helper functions for this.

# Check of the player and target rectangles have collided
if target_pos.colliderect(player_pos):
    player_score+=1
    target_pos = ....

Your turn: Update your game and watch your score go up as to catch the targets.

Step 28 - Watch out for the hazard!

If we repeat the work we have done for the target, but with some very small changes we can add in a hazard that we must avoid:

Your turn: Add in a hazard piece. Perhaps it moves faster than the target. Check if it hits the player and end the games.

Bonus challenge: At the end of the game display a message and wait for the player to close the window. You can use a template string to include the final score in the game over message.

Step 29 - Add your own features to the game

With the building blocks in this chapter, you can make almost any 2D or flat screen game. In the next chapter we will work through a snake game but almost anything is possible.

Your turn: Here are some things you could try yourself before we move on:

Chapter 8 - S N A K E !

In this chapter we will use the building blocks from our first simple game and make our own version of Snake - it is one of the first video games and is so old that your parents, or even your grand-parents, may have played it!

Step 30 - Get a new game template ready

Go back to the first graphics game in Step 22. We will just have a circle that moves around with the keys.

Next use the instructions in Step 25 to switch to using rectangles.

Now is a good time to tidy up some things. The code has lots of copies of numbers like 40 and 80. We will probably need to change the scale and speed of the game later. This is hard to do because we have to change the numbers in lots of places. We can set size variable and use that for graphics sizes and movements.

We can set and use the size like this. This is just a sample and there are several other places where you should swap out a number for our new variable:

step_size = 20
player_pos = pygame.Rect((screen.get_width()/2, screen.get_height()/2), (step_size, step_size))

    ...

if keys[pygame.K_UP]:
    player_pos.y -= step_size

The first thing we will change is to make the rectangle keep moving in a direction. We will setup a direction variable and use it to move the rectangle.

# Record direction as steps right and steps down. Negative numbers move left and up
snake_direction = pygame.Vector2(step_size,0)

    ...

player_pos.move_ip(snake_direction)

Your turn: Make these changes and test the game. You should have a square you can move but it drifts across the screen on its own

Step 31 - Change direction

Now we will use keys to control the direction of the snake. We can setup some direction variables and use them to change the direction when the player presses the right keys. A dictionary can do this:

directions = {
    "UP" : pygame.Vector2(0,-step_size)
    "DOWN" : pygame.Vector2(0, step_size)
    "LEFT" : pygame.Vector2(-step_size,0)
    "RIGHT" : pygame.Vector2(step_size,0)
}

We can use it like this:

snake_direction = "RIGHT"

    ...

if keys[pygame.K_UP]:
    snake_direction = "UP"

    ...

player_pos.move_ip(directions[snake_direction])

Your turn: Add in a direction dictionary and change the player controls to set the direction.

Step 32 - A longer snake

One square doesn’t make a good snake so lets make a list of squares.

# Set the initial size of the snake and the position of its head
initial_snake_length = 5
snake_head_pos = pygame.Rect((screen.get_width()/2, screen.get_height()/2, (step_size, step_size)))

# List to hold the snake
snake_body = []

# Build a list of rectangles to store our snake
for counter in range(1,initial_snake_length+1):
    snake_body.append(snake_head_pos.move(-snake_direction*counter))

    ...

# When needed draw the snake body
for body_part_rect in snake_body:
    pygame.draw.rect(screen, "grey", body_part_rect)

Things to notice:

Your turn: Make these changes and get them working in your game. You game may still be moving and drawing player_pos so you should also make changes so that you are moving and drawing snake_head_pos. Now we have a body but the head leaves it behind when it moves! Lets fix that next.

Step 33 - Body follow head

Big Step: This step is harder than usual - take your time and don’t be afraid to get help

Now we move the body along behind the head. Every time the head moves we will add a copy of it to the front of the body and remove the end of the tail. We can use Python list functions like insert() and pop() to do this.

    # Shift snake body
    snake_body.insert(0, snake_head_pos.copy())
    snake_body.pop()

Your turn: Find the right place to add in the new code and get the body to follow the head. Take care where this code goes. If you move the body after moving the head the body will overlap with the head. If you want to check what is going on you can add prints and you can also slow down the game clock.tick(2) to make it easier to spot problems

Step 34 - Watch out, don’t crash!

In the snake game you will lose if you crash into yourself or the sides of the screen. Lets add that to our game using the pygame rectangle helper functions.

    # detect crash with your own body
    if snake_head_pos.collidelist(snake_body) == -1:
        pass
    else:
        running = False
    
    # detect crash with the edge of the screen
    if screen.get_rect().contains(snake_head_pos):
        pass
    else:
        running = False

Things to notice:

Your turn: Make the changes to game and test the game finishes correctly.

Bonus challenge: Add a game over screen like you did for your previous game and give the player a chance to play again.

Step 35 - Feed the snake

In this step we can add in some food for the snake to eat. We will place the food in a random place, making sure we don’t drop it on top of the snake! If the snakes head reaches the food we will place some new food at another location, give the player a point and make the snake grow longer.

# Make sure we import the random library
import random

    ...

# Function to get a safe place for the food
def find_food_pos():
    # Make a rectangle of the right size
    try_pos = pygame.Rect((0,0),(step_size, step_size))
    while True:
        # Set the rectangle to a random position
        try_pos.topleft = (
            random.randint(0, screen.get_width()-step_size),
            random.randint(0, screen.get_height()-step_size)
        )
        # Check for a collision
        if try_pos.collidelist(snake_body) == -1:
            # No collision so return this rectangle 
            return try_pos
    # We keep going around the loop trying random positions until we find one that doesn't collide

    ...

# Set the first food position
food_pos = find_food_pos()
# Start score at zero
player_score = 0

    ...


    # Check for food
    if snake_head_pos.colliderect(food_pos):
        player_score+=1
        # Copy the last part of the snake to make it longer
        snake_body.append(snake_body[-1].copy())
        food_pos = find_food_pos()

    ...


    # Draw the food
    pygame.draw.rect(screen, "green", food_pos)

Here are some things to notice. These are often good ideas when you are making your own programs:

Your turn: Work out how to add in this new code and test your game.

Bonus challenge: Add in some more features to your game as you did in the earlier chapters. You could:

Chapter 9 - A more realistic snake

Now we are going to change our rectangles into images. This will seem complicated but it is worth learning as you can use similar ideas if you want to make other types of games.

Step 36 - Generate some images

First we need some images to use. In the examples there is a program to which uses pygame to draw some images for our snake and save them to some files: one for the head and another for the body.

Your turn: Use the example program to generate the two image files. Open the files in a photo or image viewer so see what we have

Bonus challenge: Modify the progam to change the images. For example you can use different colours.

Step 37 - Use a single head and body image

I this step we will replace the head and body rectangles with images. At first, this won’t look great as the head and body won’t turn around as the snake moves but we can fix that later.

At the beginning of our program we can load our image and make any changes we need

# Load the images from the image file
head_image=pygame.image.load("headimage.png")
body_image=pygame.image.load("bodyimage.png")

# Resize the images to the size we need. The files contain several tiles so we need to make room for these
head_image=pygame.transform.scale(head_image, (4 * step_size, step_size)) # Head has one row of 4 tiles
body_image=pygame.transform.scale(body_image, (3 * step_size, 2 * step_size)) # Body has two rows of 3 tiles

# Tell pygame about the background colour so it can remove this when we use the images
head_image.set_colorkey("white")
body_image.set_colorkey("white")

In our game loop we can replace our rectangles with images like this:


    ...

        screen.blit(head_image, snake_head_pos, pygame.Rect((0,0),(step_size,step_size)))

    ...

        screen.blit(body_image, body_part_rect, pygame.Rect((0,0),(step_size,step_size)))

    ...

Your turn: Add the image code into your program and test to check your game is now using images.

Bonus challenge: Find or make a background image for the game, load it, scale it and draw it in place of the purple background. The example for Chapter 6 includes some code you can use to help you.

Step 38 - Get the head moving in the right direction

Let’s get the head of the snake to turn in the right direction. The head_image contains 4 tiles for the four directions. We can use the direction to pick the right tile. One way to do this is with a dictionary that stores the position of the tile we want against the four directions.

head_tiles = {
    "UP": (0,0),
    "LEFT": (step_size,0),
    "DOWN": (step_size*2,0),
    "RIGHT": (step_size*3,0)
}

We can use this to pick the right tile to show on the screen. We can change the code that draws the head to this:

        tile_pos = head_tiles[snake_direction]
        screen.blit(head_image, snake_head_rect, pygame.Rect(tile_pos,(step_size,step_size)))

Your turn: Add in the code to show the right head tile and test your game. It might be quite hard to see what is going on. You can change the step_size = 40 and clock.tick(5) to have bigger images that change slowly.

Step 39 - Draw the right body images

We will draw the body sections in the same way. The body is a bit more complicated because there are more tiles. First lets try to get the body right when the snake moves in a straight line. We can use another dictionary to hold the tile positions and change the body list to hold the body part position and the tile position together.

body_tiles = {
    "UP": (0,0),
    "LEFT": (0,step_size),
    "DOWN": (0,0),
    "RIGHT": (0,step_size)
}
    ...

# List to hold the snake
snake_body = []
snake_tiles = []

# Build a list of rectangles to store our snake
for counter in range(1, initial_snake_length+1):
    snake_body.append(snake_head_pos.move(directions[snake_direction]*-counter))
    snake_tiles.append(body_tiles[snake_direction])

    ...

# Shift snake body
snake_body.insert(0, snake_head_pos.copy())
snake_tiles.insert(0, body_tiles[snake_direction]) 
snake_body.pop()
snake_tiles.pop()

    ...

# Copy the last part of the snake to make it longer
for counter in range(5):
    snake_body.append(snake_body[-1].copy())
    snake_tiles.append(snake_tiles[-1])

    ...

# Draw the body with the right tiles
for counter in range(len(snake_body)):
    screen.blit(body_image, snake_body[counter], pygame.Rect(snake_tiles[counter],(step_size,step_size)))

Your turn: Make the changes to pick better body image tiles and test your game

Bonus Challenge: Find or make an image to use for the snake’s food. Load and scale the image and use it in place of the food rectangle.

Step 40 - Draw the snake corners

Big Step: This step is harder than usual - take your time and don’t be afraid to get help

Finally we need to add in the corners. To pick the right tiles we need two bits of information - the direction of the snake body and the direction of the snake head. There are 16 combinations (4 body directions x 4 head directions) so we can make a bigger dictionary to store these.

The table below shows what each body segment should look like depending on the two directions. When both directions are the same the segment is straight. When they differ it needs to be a corner:

Head → UP Head → DOWN Head → LEFT Head → RIGHT
Body was going UP │ straight │ straight ┐ corner ┌ corner
Body was going DOWN │ straight │ straight ┘ corner └ corner
Body was going LEFT └ corner ┌ corner ─ straight ─ straight
Body was going RIGHT ┘ corner ┐ corner ─ straight ─ straight

Each corner shape shows which two sides of the square the snake enters and exits from. For example, ┘ connects the top and left sides — so if the snake was going down (entering from the top) and then turns left (exiting to the left), ┘ is the right tile to use.

Note: The body_tiles dictionary maps these combinations to pixel positions inside the body image file. If the corners look wrong when you run your game, check which tile is at which position in your image and adjust the pixel offsets to match.

# Store combinations of body and head direction to pick the correct body tile
body_tiles = {
    "UP": {
        "UP":(0,0),
        "LEFT": (step_size*2,0),
        "DOWN": (0,0),
        "RIGHT":(step_size,0)
    },
    "LEFT": {
        "UP":(step_size, step_size),
        "LEFT": (0,step_size),
        "DOWN": (step_size,0),
        "RIGHT": (0,step_size)
    },
    "DOWN": {
        "UP":(0,0),
        "LEFT": (step_size*2,step_size),
        "DOWN": (0,0),
        "RIGHT": (step_size,step_size)
    },
    "RIGHT": {
        "UP":(step_size*2,step_size),
        "LEFT": (0,step_size),
        "DOWN": (step_size*2,0),
        "RIGHT": (0,step_size)
    }
}

We know what tile to pick at the start as the snake is in a line


    ...

    snake_tiles.append(body_tiles[snake_direction][snake_direction])

    ...

As we play the game we can keep a note of the old directions and use that to pick the right tile

# Setup the start position for the game
snake_direction = "RIGHT"
old_direction = snake_direction

    ...

# Save the old direction before we check which keys are pressed
old_direction = snake_direction

    ...

# Use the old and new directions to pick the tile
snake_tiles.insert(0, body_tiles[old_direction][snake_direction]) 

    ...

Your turn: Find the right places to change your code and check the snake curves around the corners in the right places

Bonus challenge: Find or make an image of the food which has several tiles, e.g. a bug with moving legs. Keep changing the food tiles to make it look like the bug is wriggling!

Step 41 - Give the snake a tail

This whole step is a kind of bonus challenge. You have all the building blocks needed in your code but you will need to work out what to change. Here are some suggestions for how to add the tail.

You might have to change some other things along the way.

Your turn: Use what you have learnt to add the tail to the snake

Chapter 10 - Physical effects like bouncing and gravity

We will leave the Snake game behind for a while and explore some features that will be useful in other types of game. Let’s look at how to make things bounce and fall which we could use in platform type games.

Step 42 - Bouncing off the walls

Starting with our simple game template from step 22. We will start the ball in a random position and get it moving in a random direction. Unlike in the snake game the ball can move diagonally too. When the it hits one of the walls we will flip the direction so that it bounces.

# Import the random library
import random

    ...

# Setup the random start position and direction for the game
size = 20
max_speed = 40
player_pos = pygame.Vector2(
    random.randint(screen.get_width()/4, screen.get_width()/4*3), 
    random.randint(screen.get_height()/4, screen.get_height()/4*3)
)

player_direction = pygame.Vector2(
    random.randint(-max_speed, max_speed), 
    random.randint(-max_speed, max_speed)
)

    ...

# Update the position each time in the game loop
player_pos+=player_direction

# Detect if we've reached a wall and then bounce.
if (player_pos.x < 0 or
   player_pos.x > screen.get_width() or
   player_pos.y < 0 or
   player_pos.y > screen.get_height()):
    player_direction = -player_direction

    ...

# Draw the player
    pygame.draw.circle(screen, "red", player_pos, size)

Things to notice:

Your turn: Setup the simple game template. Find the right places for the new code and see what it does. You will notice there are a few things that aren’t quite right. That’s ok. We can fix them next.

The direction has two components. One for up-down motion and one for left-right motion. We should only flip one part of the direction depending upon what wall we hit. Also, we are checking the centre of the ball against the sides of the screen. We should consider the size of the ball too. Here is some better detect and bounce code

# Detect if we've reached a wall and then bounce.
if (player_pos.y-size < 0 or
    player_pos.y+size > screen.get_height()):
    player_direction.y = -player_direction.y

if (player_pos.x-size < 0 or
       player_pos.x+size > screen.get_width()):
    player_direction.x = -player_direction.x

Things to notice:

Your turn: Make the changes and check the ball bounces more realistically

Step 43 - Get down with gravity

Gravity makes things accelerate towards the ground. We can do something similar by making repeated small changes to the ball’s direction.

# Gravity level
gravity = 1

    ...

# Gravity effect
player_direction.y+=gravity

Your turn: Find the right places for this new code and see what happens.

Bonus challenge: Gravity physics is quite complicated and our simple code doesn’t perfectly create the real effects. Your may see strange behaviour like the ball drifting off the bottom of the screen or the ball bouncing higher and higher over time. You can make things more realistic with some changes.

Step 44 - Throwing the ball

Our game template still has some code for handling key presses. Try these with your bouncing ball game. We can change this slightly so that we can throw the ball.

Your turn: Change you code so that the key presses change the ball direction instead of the ball position.

Bonus Challenge: Look at the code for the snake game. Use the code that makes a list for the body and follow the head as it moves. This will create a trail behind the ball.

Step 45 - Throw with the mouse

pygame has functions for working with the mouse. Let’s use these to throw the ball. Each time in our game loop we will remember the position of the mouse. If the player clicks the mouse button we can calculate how far the mouse has moved and use that to throw the ball.

# Variable to remember the mouse position
old_mouse = pygame.Vector2(0,0)

    ...

# If mouse is clicked throw the ball
if pygame.mouse.get_pressed()[0]:
    new_mouse=pygame.Vector2(pygame.mouse.get_pos())
    player_direction = new_mouse - old_mouse

# Remember the new position
old_mouse=pygame.Vector2(pygame.mouse.get_pos())

Your turn: Add in the new code and test throwing the ball with a mouse click. You can use prints or pygame.font.render to display the mouse position if need to check the values

Step 46 - Lots of balls

We can use a list to hold the position and direction of lots balls and use loops to update and draw all of them each time. We can even use a loop to throw all of them at the same time!

We can setup the balls like this:

num_of_balls = 5
balls = []

for ball in range(num_of_balls):
    ball_pos = pygame.Vector2(
        random.randint(screen.get_width()/4, screen.get_width()/4*3),
        random.randint(screen.get_height()/4, screen.get_height()/4*3)
    )

    ball_direction = pygame.Vector2(
        random.randint(-max_speed, max_speed),
        random.randint(-max_speed, max_speed)
    )

    balls.append((ball_pos, ball_direction))

We can draw the balls like this

for ball_position, ball_direction in balls:
    pygame.draw.circle(screen, "red", ball_position, size)

Important: In places where we are updating the position and direction we need to be careful. Look at these examples

# Example 1
for ball_position, ball_direction in balls:
    new_position = ball_position+ball_direction
    ball_position = new_position  

# Example 2
for ball_position, ball_direction in balls:
    new_position = ball_position+ball_direction
    ball_position.x, ball_position.y = new_position.x, new_position.y

These look like they are just two ways to do the same thing but there is a difference that is hard to spot. Try them and see. In both examples ball_position points to a pygame.Vector2. To start with it points to one of the vectors in the list but in example 1 we change it to point to the new_position vector. The one in the list isn’t updated, we just aren’t pointing to it any more. In the second example we update the details inside the vector without changing ball_position so we are actually changing the vector in the list, which is what we want. pygame provides an update() function that works like the second example and so do the special *= and += operators.

# Example 3 - works ok in pygame
for ball_position, ball_direction in balls:
    new_position = ball_position+ball_direction
    ball_position.update(new_position)

# Example 4 - works ok in pygame
for ball_position, ball_direction in balls:
    ball_position += ball_direction

Your turn: Use these clues to change your code to handle more balls. Anywhere that you see player_pos or player_direction in your code is a clue that you may need to wrap in inside a ball loop, just as we did for drawing the balls.

Bonus challenge:

Step 47 - Bouncing against the balls

Big Step: This step is harder than usual - take your time and don’t be afraid to get help

At the moment the balls ignore each other. Let’s make them bump and bounce into each other. We will need to check each ball against the others. We can use a loop within a loop like this:

for first_ball in balls:
    for second_ball in balls:
        # If first_ball hits second_ball then bounce them

This is almost right but there are a couple of problems:

Here is a different way to manage the loops

for first_index in range(len(balls)-1):
    for second_index in range(first_index+1, len(balls)):
        # If balls[first_index] hits balls[second_index] then bounce them

Next we need to find out if the balls touch. The distance from the centre of the ball to its edge is size. If the distance between the centre of two balls is less than size * 2 they must have hit. Draw a diagram to help if this doesn’t make sense. pygame provides some functions to help with this: first_position.distance_to(second_position).

Finally we need create a bounce effect. The precise way to do this is complicated. We can create a fake bounce by reflecting the direction of the ball in the direction of the line between the balls. It won’t be scientifically correct but it should look ok for a simple game.

Your turn: With these clues see if you can use the double loop, check if the two balls have hit and swap their directions if they do. You can play the game with just two balls and add in some prints to check things are working as you expect. If you get stuck you can check the example at the end of the project.

Bonus challenge: When the balls hit adjust their positions so they don’t overlap inside each other

Step 48 - Get creative

Think of a game that might use motion like this and have a try a making it. Start with something while this is still new. Here are some ideas you could try.

Your turn: Make a simple game using what you have learnt and the examples in this chapter.

Chapter 11 - Another classic game: Pac-Man

In this chapter we will learn some more game building blocks my making a classic Pac-Man game. You can look online to see some images of what the game looks like and some of the common rules.

Step 49 - A map of tiles

Once again we can start with simple game template from step 22

We can setup a map as a grid of letters and use different letters to represent different things e.g.

We can setup the map in a list of strings:

game_map = [
    "wwwwwwwwwwwwwwwwwwwwwwwwwwww",
    "wwwwwwwwwwwwwwwwwwwwwwwwwwww",
    "wwwwwwwwwwwwwwwwwwwwwwwwwwww",    
    "w............ww............w",
    "w.wwww.wwwww.ww.wwwww.wwww.w",
    "wow  w.w   w.ww.w   w.w  wow",
    "w.wwww.wwwww.ww.wwwww.wwww.w",
    "w..........................w",
    "w.wwww.ww.wwwwwwww.ww.wwww.w",
    "w.wwww.ww.wwwwwwww.ww.wwww.w",
    "w......ww....ww....ww......w",
    "wwwwww.wwwww.ww.wwwww.wwwwww",
    "     w.wwwww.ww.wwwww.w     ",
    "     w.ww..........ww.w     ",
    "wwwwww.ww.wwwxxwww.ww.wwwwww",
    "      ....w      w....      ",
    "wwwwww.ww.wwwwwwww.ww.wwwwww",
    "     w.ww..........ww.w     ",
    "     w.wwwww.ww.wwwww.w     ",
    "wwwwww.wwwww.ww.wwwww.wwwwww",
    "w......ww....ww....ww......w",
    "w.wwww.ww.wwwwwwww.ww.wwww.w",
    "w.wwww.ww.wwwwwwww.ww.wwww.w",
    "w..........................w",
    "w.wwww.wwwww.ww.wwwww.wwww.w",
    "wow  w.w   w.ww.w   w.w  wow",
    "w.wwww.wwwww.ww.wwwww.wwww.w",
    "w............ww............w",
    "wwwwwwwwwwwwwwwwwwwwwwwwwwww",
    "wwwwwwwwwwwwwwwwwwwwwwwwwwww",
    "wwwwwwwwwwwwwwwwwwwwwwwwwwww",
]

When we draw our game screen we can use the map to draw the right shapes, symbols or images in the right place. Here is part of the code to do this.

for row_num in range(len(game_map)):
    for column_num in range(len(game_map[row_num])):
        topleft = pygame.Vector2(column_num*tile_size, row_num*tile_size)
        match game_map[row_num][column_num]:
            case ' ':
                pygame.draw.rect(
                    screen,
                    "black",
                    pygame.Rect(topleft, (tile_size, tile_size))
                )                
            case 'w':
                pygame.draw.rect(
                    screen,
                    "blue",
                    pygame.Rect(topleft, (tile_size, tile_size))
                )
            case '.':
                grid_rect = pygame.Rect(topleft, (tile_size, tile_size))
                pygame.draw.rect(
                    screen,
                    "black",
                    grid_rect
                )
                pygame.draw.circle(
                    screen,
                    "white",
                    grid_rect.center,
                    tile_size/4
                )

                ...

Things to notice:

Your turn: Use the simple game template to setup and draw the game_map. Add in the missing symbols to complete the map.

Bonus challenge:

Step 50 - Move the player on the grid

Important note: Keep a copy of your game from Step 50 as we will need it later.

At the moment the player can move anywhere, through wall or off the board completely. One way we can fix this is set our player position using a map position and then convert them into pygame positions. We do something like this to draw the game map. Here are some hints about the code to use.

# Set the player position to a space on the map. Pick a start point in your map that isn't a wall!
player_pos = pygame.Vector2(13,17)

    ...


# Calculate a new position when keys are pressed
        new_pos = player_pos+pygame.Vector2(-1,0) # Move left

# Handle any portals where we reach the edge and jump over to the other side
if new_pos.x < 0:
    new_pos.x = len(game_map[0])-1
if new_pos.x == len(game_map[0]):
    new_pos.x=0
if new_pos.y < 0:
    new_pos.y = len(game_map)-1
if new_pos.y == len(game_map):
    new_pos.y=0

    ...

# Only use the new position if we do not end up in a wall
# pygame.Vector2 can hold numbers with fractions
# game_map indexes must be integers so we use int() to make sure.
if game_map[int(new_pos.y)][int(new_pos.x)]!='w':
    player_pos.update(new_pos)

    ...

# Convert the position and draw the player
player_topleft = player_pos*tile_size
player_centre = player_topleft+pygame.Vector2(tile_size,tile_size)/2
pygame.draw.circle(screen, "yellow", player_centre, tile_size/2)

The code is already getting quite messy so it would be a good idea to use some helper functions like this:

def moved_position(x,y):
    return player_pos+pygame.Vector2(x,y)

def left_position():
    return moved_position(-1,0)

This can make you main code easier like this:

if keys[pygame.K_LEFT]:
    new_pos = left_position()

You can also make similar small helpers to wrap around the edges, check what is in the map and draw the player.

Your turn: Use the hints to update the code and test the way the player moves.

Step 51 - Smooth player moves

The player moves between the wall but it is quite fast and jerky. We can make smaller smoother moves using pygame positions but will have to take care as the players piece could cross over more than one tile on the game map. Let’s work through this in some small steps.

Go back to your game from step 50 that you saved earlier. We can set up the player position as a pygame rectangle and use that to draw it.

# Helper function to find the rectangle for a map position.
# You may have made something like this already so you can keep using it instead
def map_tile_rect(map_pos):
    x,y = map_pos
    return pygame.Rect((x*tile_size, y*tile_size), (tile_size,tile_size))

# Setup initial player position. Make sure it is not in a wall!
player_pos = map_tile_rect((13,17))

# function to draw the player
def draw_pacman():
    pygame.draw.circle(screen, "yellow", player_pos.center, tile_size/2)

    ...

# Use the function after drawing the map
    draw_pacman()

Next we can start to make a functions that will move the pacman and keep all the complex code in one place.

Your turn: Check the player starts in a good place and moves smoothly. You can increase the clock.tick() and reduce the how much we add or subtract on the key presses to get the effect you want. The player will run through the walls but we will fix that later.

Step 52 - Make things organised and simple

Next we can make some functions to handle moves. Initially these won’t do very much but when we add in more checks and calculations we only have to do them in one place.

move_size = 3
directions={
    "UP": (0,-move_size),
    "DOWN": (0, move_size),
    "LEFT": (-move_size,0),
    "RIGHT": (move_size,0)
}

player_direction = "RIGHT"

def can_move(current_pos, direction):
    return True

def move_pacman(dir):
    player_pos.move_ip(directions[dir])

You can use these functions when you check which keys are pressed like this:

if keys[pygame.K_LEFT] and can_move(player_pos, "LEFT"):
    player_direction = "LEFT"    

And keep moving the player like this:

if can_move(player_pos, player_direction):
    move_pacman(player_direction)

Your turn: Make these changes and test what happens. The player will keep moving in the direction we want but still go through walls.

Step 53 - Avoiding the walls

Let’s start to add in the wall checks and wrap the player at the edges of the screen. We can update the move_pacman function once and it will work for all the moves we make. We will just use the top left corner of the player to start with. This isn’t quite right but it is a good start. We will need to convert from pygame position (dots on the screen) to game map position (tiles on the map). We can do this with integer division.

def pos_map_tile(pos):
    return (pos.x//tile_size, pos.y//tile_size)

def get_map_tile(map_pos):
    x,y=map_pos
    return game_map[y][x]

def wrap_pos_ip(pos):
    if pos.x < 0:
        pos.x+= screen.get_width()
    if pos.x >= screen.get_width():
        pos.x = pos.x-screen.get_width()
    if pos.y < 0:
        pos.y+= screen.get_height()
    if pos.y >= screen.get_height():
        pos.y = pos.y-screen.get_height()

def moved_pos(current_pos, dir):
    new_pos = current_pos.move(directions[dir])
    wrap_pos_ip(new_pos)
    return new_pos

def can_move(current_pos, dir):
    if get_map_tile(pos_map_tile(moved_pos(current_pos, dir)))=='w':
        return False
    else:
        return True

def move_pacman(dir):
    player_pos.update(moved_pos(player_pos, dir))

Your turn: Find the right places for this code and test the game. The player can still drift a little over the wall but the top left should stay within the playing area. Add in some prints of font.render to see some of the values if you are not sure what is going on inside the functions.

Finally, we can check the four corners of the pacman to make sure they are valid positions.

corners=[
    (0,0),
    (0,tile_size-1),
    (tile_size-1,0),
    (tile_size-1, tile_size-1)
]

def can_move(current_pos, dir):
    new_pos = moved_pos(current_pos, dir)
    for corner_move in corners:
        corner_pos = new_pos.move(corner_move)
        wrap_pos_ip(corner_pos)
        if get_map_tile(pos_map_tile(corner_pos))=='w':
            return False
    return True

Things to notice:

Your turn: Make the changes and pacman should smoothly move inside the maze.

Bonus challenge: When the player wraps around the screen it moves smoothly off the right hand side of the screen but pops in and out on the left side. Can you fix this so that when the player is half off the side of the screen the other part of them appears on the other side. Clue: Sometime you draw the player twice with different positions!

Step 54 - Collect the dots

Lets add another feature. When the player passes over one of the small dots they score a point and the dot is removed. We’ve already covered displaying a scope in step 27 so you can go back and get a reminder if you need it. We can put all of our dot code in a function and use it in our game loop. We are only going to let the player have the dot if they are perfectly on the right square and not while they are moving between squares. We can use more integer division to check this - the % gives the remainder from integer division. Use print to try some examples if you are not sure what this means.

# Function to update a position in the map
def set_map_tile(map_pos, code):
    x,y=map_pos
    game_row = game_map[y]
    game_map[y] = game_row[:x]+code+game_row[x+1:]

# If the player is on a dot remove the dot and update the score
def check_dots():
    global player_score
    # Check if player is fully on one square.
    if player_pos.x % tile_size == 0 and player_pos.y % tile_size == 0:
        # Check if the player is on a dot
        if get_map_tile(pos_map_tile(player_pos)) == '.':
            # Update score and remove dot
            set_map_tile(pos_map_tile(player_pos), ' ')
            player_score+=1

# Draw score function. Don't forget to setup your score variable and load the font at the start
def draw_score():
    # Use our font to turn some text into an image
    score_image = score_font.render(
        f"Score: {player_score}",
        True,
        "white"
    )
    # Place the image on our screen
    screen.blit(score_image, map_tile_rect((1,1)))

Things to notice:

Your turn: Add in the new feature and check that it works.

Step 55 - Win a level

Big Step: This step is harder than usual - take your time and don’t be afraid to get help

If pacman eats all of the dots we win the round move on to the next level. The steps we need to take are:

Here are some hints about what changes to make.

We will need to add in some variables for the new things we need like dots, levels and a working copy of the map.

# Variables for the state of the game
playing = True
game_level = 1
dots_remaining = 0
working_map = []

We will need to refresh the working copy of the map and get things ready for a new level

# Function to reset the level and build the working map
def reset_working_map():
    global dots_remaining, working_map
    dots_remaining = 0
    working_map = []
    for row in game_map:
        # Convert the string into a list of characters and add it to working_map
        working_map.append(list(row))
        # Check each character in the row and count the dots
        for code in row:
            if code == '.':
                dots_remaining+=1

Lots of our code is working with the game_map but this is now our template and not the working copy. In most cases we can swap game_map for working_map but in some places we will need to make other changes e.g. set_map_tile can be simpler now

def set_map_tile(map_pos, code):
    x,y = map_pos
    working_map[y][x]=code

We will need to show the player what level they are on. We could write a number with font.render. The original pacman game used images so we can do something similar.

# Draw levels
def draw_levels():
    drawing_row = len(working_map)-2
    drawing_col = len(working_map[drawing_row])-2
    level_rect = map_tile_rect((drawing_col, drawing_row))
    for level in range(game_level):
        pygame.draw.rect(screen, "red", level_rect)
        level_rect.move_ip(-tile_size, 0)

As the player moves we can keep track of how may dots are left

# Update dot count when updating the score
def check_dots():
    global player_score, dots_remaining
    # Check if player is fully on one square.
    if player_pos.x % tile_size == 0 and player_pos.y % tile_size == 0:
        # Check if the player is on a dot
        if get_map_tile(pos_map_tile(player_pos)) == '.':
            # Update score and remove dot
            set_map_tile(pos_map_tile(player_pos), ' ')
            player_score+=1
            dots_remaining-=1

The level will be over when the dots are all gone

# Finish the level when the dots are gone
if dots_remaining == 0:
    running = False

Most of our code will need to be put into a function for playing a level and then used in a level loop

def play_level():
    # Our main game loop code will move in here

    ...

# Main loop for the levels
while playing:
    play_level()
    if dots_remaining == 0:
        # Finished a level 
        game_level+=1
    else:
        # Must have been some other reason for finishing the level so stop
        playing = False

pygame.quit()

Your turn: Use the hints to add in game levels

Step 56 - Add in a ghost

Now we can add in the ghosts to hunt the player, although to start with they won’t be very clever hunters. We already have lots of the building blocks we need from the code to handle the players.

The components for the ghosts will be:

Here are some hints for the code:

# Variables for the ghost information
ghosts = []

    ...

# Helper function to setup ghosts
def new_ghost(pos, colour, direction):
    return {
        "pos": map_tile_rect(pos),
        "col": colour,
        "dir": direction
    }

    ...

# Setup ghosts whe we reset a level. Carefully pick the start column and row to fit with your game map
def reset_working_map():

    ...

    global ghosts
    ghosts = []
    ghosts.append(new_ghost((13,13), "red", "LEFT"))
    ghosts.append(new_ghost((12,15), "cyan", "RIGHT"))
    ghosts.append(new_ghost((13,15), "pink", "UP"))
    ghosts.append(new_ghost((14,15), "orange", "DOWN"))

    ...


# Function to find a new valid direction for the ghosts
def get_random_direction(ghost):
    # Make a list of directions
    dir_list = list(directions)
    # Mix the list of directions
    random.shuffle(dir_list)
    # Now pick the first valid one
    for dir in dir_list:
        if can_move(ghost["pos"], dir):
            return dir

# Function we can use in our game loop to move the ghosts. Return true if we touch the player
def move_ghosts():
    for ghost in ghosts:
        pos = ghost["pos"]
        dir = ghost["dir"]
        if pos.x % tile_size == 0 and pos.y % tile_size == 0:
            # Fully on a tile so check directions
            dir = get_random_direction(ghost)
        # Keep moving
        if can_move(pos, dir):
            pos.update(moved_pos(pos, dir))
        # Check for player
        if pos.colliderect(player_pos):
            return True
        #save the direction for next time
        ghost["dir"]=dir
    return False

    ...

# Draw the ghosts
def draw_ghosts():
    for ghost in ghosts:
        pygame.draw.rect(screen, ghost["col"], ghost["pos"])

    ...

# Use the functions in the game loop
if move_ghosts():
    running = False

draw_ghosts()

Your turn: Uses these hints to add randomly moving ghosts into your game

Step 57 - Better looking pacman and ghosts

We’ll make the ghosts a bit smarter later. First lets get rid of the boring shapes and use better images. We have already seen how to use images in the Snake game and change the image based on direction. We will go further this time and use multiple images to animate our characters.

First we need to find or make some images. You can use the program in the examples to create a basic image file. The single file has 6 sets of images. 1 for pacman and 5 for ghosts. Each set has four rows for the four directions and each row has four variants to show in sequence to animate the characters.

Look back at chapter 9 to see how we can pick out an image from a set and draw it instead of a plain rectangle. Use those steps as a guide.

Your turn:

Step 58 - Smarter ghosts

Now let’s make the ghost moves a bit smarter.

The first thing we can improve is to stop the ghosts hovering over the same spot. There are many things we could do but here is a simple one to try first. If the ghosts current direction is clear we can remove the opposite direction before shuffling.

# dictionary of opposite directions
opposite_dir = {
    "UP": "DOWN",
    "DOWN": "UP",
    "LEFT": "RIGHT",
    "RIGHT": "LEFT"
}

def get_random_direction(ghost):
    # Make a list of directions
    dir_list = list(directions)

    # Remove opposite direction if the current direction is clear
    if can_move(ghost["pos"], ghost["dir"]):
        dir_list.remove(opposite_dir[ghost["dir"]])

    # Mix the list of directions
    random.shuffle(dir_list)

    # Now pick the first valid one
    for dir in dir_list:
        if can_move(ghost["pos"], dir):
            return dir

Your turn: Try these changes and check that the ghost movements are better

We can make the ghosts even smarter by getting them to aim at the player. We can do that by calculating a score for each direction and picking the one with best score. The score could be the vertical distance + horizontal distance to the player. The smaller the distance the better. Here are some hints for this sort of code.

def rect_distance(rect1, rect2):
    return abs(rect1.x - rect2.x)+abs(rect1.y - rect2.y)

def dir_sort_key(item):
    dir, score = item
    return score

    dir_list = list(directions)
    
    # Remove opposite direction if the current direction is clear
    if can_move(ghost["pos"], ghost["dir"], ghost["speed"]):
        dir_list.remove(opposite_dir[ghost["dir"]])
    
    # Get a list of valid directions with their distance scores
    dist_list = []
    for dir in dir_list:


def get_shortest_direction(ghost):
    dir_list = list(directions)
    
    # Remove opposite direction if the current direction is clear
    if can_move(ghost["pos"], ghost["dir"], ghost["speed"]):
        dir_list.remove(opposite_dir[ghost["dir"]])
    
    # Get a list of valid directions with their distance scores
    dist_list = []
    for dir in dir_list:
        if can_move(ghost["pos"], dir):
            dist_list.append((dir, rect_distance(player_pos, moved_pos(ghost["pos"], dir))))

    # Sort by distance
    dist_list.sort(key=dir_sort_key)

    # Return the shorted direction
    dir,_ = dist_list[0]
    return dir

Things to notice:

Your turn: Make the changes and test your smarter ghosts. Are they too smart and spoil the game?

Bonus challenge: Try out some adjustments to make the game challenging but not too hard to win. Here are some ideas:

Step 59 - Lives, power-ups and game states

This step is like a big bonus challenge. Add more features to your game by re-using the building blocks that you already have. Here are some suggestions but you can invent your own.

Chapter 12 - Mazes, rules and problem solving

In previous chapters we have made maps and mazes, used simple game rules and solved problems such as getting the ghosts to chase or escape from the pacman player. In this chapter we will add in more advanced examples such as:

Step 60 - A plan for a game with mazes

Let’s start with the simple game template back from step 22 and use a grid of tiles for our maze. We’ll keep things simple by drawing shapes but later we can swap these for the images that reflect the game e.g. stone for the wall of a dungeon adventure or trees and shrubs for a wilderness or jungle.

Your turn: We haven’t given you any hints yet or example code so try to use what you have learnt from the other chapters. Make a list of small steps you could take such as:

You can put this list into the game template as comments like this:

# Variables needed
# ----------------
# size of the tiles in the maze
# width and height of the maze e.g. how many tiles across and how many tiles down

# Helper functions
# ----------------
# use the map of the maze to draw a grid on screen with gaps if there is a path between the tiles

Bonus challenge: Keep going and start to put in the code that you need. If you need more help you can look at the next steps but, remember, there are many different ways to code this maze game. The next steps are just examples and they are not better or more correct than the code you make for yourself.

Step 61 - Building blocks for a game with mazes

You can skip this step if you have already have your own working code from step 60.

Here are some examples you could use for the variables we need. It’s ok if your own variables have different names or a structure. For example, you could hold the map size as a pair of values (10,10) or as a pygame.Vector2.

tile_size = 60
wall_thickness = 5
door_width = 40
maze_width = 10
maze_height = 10
maze_map = []
wall_colour = "grey"
floor_colour = "white"
player_colour = "red"
player_pos = pygame.Vector2(0,0)

directions = {
    "UP": pygame.Vector2(0,-1),
    "DOWN": pygame.Vector2(0,1),
    "LEFT": pygame.Vector2(-1,0),
    "RIGHT": pygame.Vector2(1,0),
}

opposites = {
    "DOWN": "UP",
    "UP": "DOWN",
    "LEFT": "RIGHT",
    "RIGHT": "LEFT"
}

Here are some helper functions. It’s ok if you have made different helper functions with different names and parameters.

def maze_tile_rect(x,y):
    return pygame.Rect((x*tile_size, y*tile_size), (tile_size, tile_size))

def tile_floor_rect(x,y):
    return pygame.Rect((
        x*tile_size+wall_thickness,
        y*tile_size+wall_thickness
    ), (
        tile_size-2*wall_thickness,
        tile_size-2*wall_thickness
    ))

def tile_door_rect(x,y,dir):
    tile_rect = maze_tile_rect(x,y)
    door_edge = (tile_size-door_width)//2
    match dir:
        case "UP":
            tile_rect.width=door_width
            tile_rect.height=wall_thickness
            return tile_rect.move((door_edge,0))
        case "DOWN":
            tile_rect.width=door_width
            tile_rect.height=wall_thickness
            return tile_rect.move((door_edge,tile_size-wall_thickness))
        case "LEFT":
            tile_rect.width=wall_thickness
            tile_rect.height=door_width
            return tile_rect.move((0,door_edge))
        case "RIGHT":
            tile_rect.width=wall_thickness
            tile_rect.height=door_width
            return tile_rect.move((tile_size-wall_thickness,door_edge))

def make_initial_map():
    # Make a list of columns
    # Each column has a row of tiles
    # Each tile shows if there is a door (True or False) in each direction
    for column in range(maze_width):
        row_list = []
        for row in range(maze_height):
            tile_details={}
            for dir in directions:
                tile_details[dir]=False
            row_list.append(tile_details)
        maze_map.append(row_list)

def inside_maze(x,y):
    if x<0 or y<0:
        return False
    if x>=maze_width or y>=maze_height:
        return False
    return True

def can_move(x,y,dir):
    return maze_map[x][y][dir]

def make_door(x,y,dir):
    next_x, next_y = directions[dir]
    # Set the door for the tile to True
    maze_map[int(x)][int(y)][dir]=True
    # If the other side is in the maze also set that to True
    if inside_maze(x+next_x, y+next_y):
        maze_map[int(x+next_x)][int(y+next_y)][opposites[dir]]=True

def draw_maze():
    for column in range(maze_width):
        for row in range(maze_height):
            pygame.draw.rect(screen, wall_colour, maze_tile_rect(column, row))
            pygame.draw.rect(screen, floor_colour, tile_floor_rect(column, row))
            for dir in directions:
                if maze_map[column][row][dir]:
                    pygame.draw.rect(screen, floor_colour, tile_door_rect(column, row, dir))

def draw_player():
    x,y=player_pos
    player_tile = maze_tile_rect(x,y)
    pygame.draw.circle(screen, player_colour, player_tile.center, tile_size/3)

Your turn: If you need to, add these variables and functions into your simple game template and use them to create a maze, draw it and let the player move around. Use make_door() a few times, otherwise the player will be stuck on one tile with nowhere to go!

Step 62 - First attempt at a random maze

We can make a maze by randomly picking tiles and directions and making doors. Try the hints below and see what the maze is like to explore

import random

    ...

number_of_doors = 50

    ...

def design_maze():
    for door in range(number_of_doors):
        dir_list = list(directions)
        make_door(random.randrange(maze_width), random.randrange(maze_height), dir_list[random.randrange(4)])

Your turn: Experiment with the random maze. Try different numbers of doors to see what happens.

Step 63 - Better maze maker

Our first attempt made some interesting patterns but sometime the player will be stuck in a dead end and lots of the tiles are cut-off and can’t be reached from the players position. Let’s try something else. Instead of picking the tiles at random we’ll start at the players positions and follow through the random doors to the next tile. We can keep note of which tiles we visit so that we don’t go back into the same tile twice.

def make_tile_tracker():
    tiles=[]
    for col in range(maze_width):
        col=[]
        for row in range(maze_width):
            col.append(False)
        tiles.append(col)
    return tiles

def explore_tile(pos,tracker):
    tracker[int(pos.x)][int(pos.y)]=True
    dir_list = list(directions)
    random.shuffle(dir_list)
    for dir in dir_list:
        next_pos = pos+directions[dir]
        # Only visit the next tile if it is inside the maze
        if inside_maze(next_pos.x, next_pos.y):
            # Only visit if we haven't already been there
            if not tracker[int(next_pos.x)][int(next_pos.y)]:
                # Make a door and explore further
                make_door(pos.x, pos.y, dir)
                explore_tile(next_pos, tracker)

def design_maze():
    tiles_visited=make_tile_tracker()
    start_pos=pygame.Vector2(0,0)
    explore_tile(start_pos, tiles_visited)

Your turn: Add in these updates and see what the maze looks like now

Things to notice:


def animate_designer(pos):
    for event in pygame.event.get():
        pass
    draw_maze()
    pygame.draw.circle(screen, "white", maze_tile_rect(pos.x, pos.y).center, tile_size/4)
    pygame.display.flip()
    clock.tick(60) 

    ...

    random.shuffle(dir_list)
    for dir in dir_list:
        animate_designer(pos)
        next_pos = pos+directions[dir]
        # Only visit the next tile if it is inside the maze

Your turn:

Step 64 - A maze solver

With some small changes we can turn our maze designer into a maze solver. First we will make a random solver and then use recursion to make something smarter.

The first step is to use something called a generator to automatically make moves for our player. A generator is a special type of function. Normal functions just produce one result. Generators produce a list of results. Here is a simple random move generator

# A function with a yield statement defines a generator
def move_generator():
    dir_list = list(directions)
    while True:
        yield dir_list[random.randrange(4)]

# We actually get a generator we can use by calling the function
player_move = move_generator()

# We can also get the next move when we want
dir = next(player_move)

Your turn: Setup the move generator before the game loop and then use next() to get the moves inside the game loop. Check that the move is valid before moving the player.

Our random solver doesn’t get very far as it moves back in the maze as often as it moves forward. Let’s use something smarter as we did for making the maze.

def explorer_generator(pos, tracker):
    tracker[int(pos.x)][int(pos.y)]=True
    dir_list = list(directions)
    random.shuffle(dir_list)
    for dir in dir_list:
        # Only visit the next tile if there is a door
        if can_move(dir):
            next_pos = pos+directions[dir]
            # Only visit if we haven't already been there
            if not tracker[int(next_pos.x)][int(next_pos.y)]:
                # move and explore further
                yield dir
                yield from explorer_generator(next_pos, tracker)
                yield opposites[dir]    

def move_generator():
    tiles_visited=make_tile_tracker()
    start_pos=pygame.Vector2(0,0)
    yield from explorer_generator(start_pos, tiles_visited)

Things to notice:

Your turn: Update your game with the smarter generator and see what

Bonus challenge: You may notice that the player explores the whole map, gets back to the beginning and then produces an error. We haven’t given the player any way to win. Change the game so that far corner of the maze from where the player starts is the exit. Check if the player has reached the exit and leave the game.

Step 65 - Have fun with mazes and solvers

What games could you make with these building blocks? Here are some ideas that you can try out

Examples - Chapter 1

# Make a quiz list
quiz = [
    ("What is Batman's favourite colour", "black"),
    ("What is Barbie's favourite colour", "pink")
]

# A function to ask a question and give points
def quiz_question( question, correct_answer):
    player_answer=input(question + "? > ")
    if player_answer==correct_answer:
        print("Well done!")
        return 1
    else:
        print("Wrong!")
        return 0

# Start with no points
total_score=0
# Ask the questions using a loop:
for (question, answer) in quiz:
    total_score+=quiz_question(question, answer)

# Only use the word points if there is more than one
message = "points"
if total_score==1:
    message = "point"
print("You got", total_score, message)

Examples - Chapter 2

# Make a quiz list
quiz = [
    ("What is Batman's favourite colour", "black"),
    ("What is Barbie's favourite colour", "pink")
]

# A function to ask a question and give points
def quiz_question( question, correct_answer):
    player_answer=input(question + "? > ")
    if player_answer=="leave":
        print("OK. Come back and play another time.")
        return (0,"leave")
    elif player_answer=="pass":
        print("OK. Skip this question")
        return (0, "pass")
    elif player_answer==correct_answer:
        print("Well done!")
        return (1, "answer")
    else:
        print("Wrong! You lose a point")
        return (-1, "answer")

# Start with no points
total_score=0

# Ask the questions using a loop:
for (question, answer) in quiz:
    (points, instruction)=quiz_question(question, answer)
    total_score+=points
    # Finish early if the player users the leave instruction
    if instruction=="leave":
        break

# Only use the word points if there is more than one
message = "points"
if total_score==1:
    message = "point"
print("You got", total_score, message)

Examples - Chapter 3

# Make a quiz list
quiz = [
    ("What is Batman's favourite colour", "black"),
    ("What is Barbie's favourite colour", "pink")
]

# Start with an empty scoreboard list
scoreboard = []

# A function to ask a question and give points
def quiz_question( question, correct_answer):
    player_answer=input(question + "? > ")
    if player_answer=="leave":
        print("OK. Come back and play another time.")
        return (0,"leave")
    elif player_answer=="pass":
        print("OK. Skip this question")
        return (0, "pass")
    elif player_answer==correct_answer:
        print("Well done!")
        return (1, "answer")
    else:
        print("Wrong! You lose a point")
        return (-1, "answer")

# Show the scoreboard
def scoreboard_sort_key(sb):
    (gamer_tag, score) = sb
    return score

def show_scoreboard():
    if len(scoreboard) > 0:
        print("Top Scores")
        position = 1
        scoreboard.sort(reverse=True, key=scoreboard_sort_key)
        for (gamer_tag, score) in scoreboard:
            print (position, ":",gamer_tag,"=",score)
            position+=1
            if position > 10:
                break
    else:
        print("No top score yet")

def add_new_score(gamer_tag, score):
    global scoreboard
    scoreboard.append((gamer_tag, score))

def quiz_round():
    # Start with no points
    total_score=0

    # Ask the questions using a loop:
    for (question, answer) in quiz:
        (points, instruction)=quiz_question(question, answer)
        total_score+=points
        # Finish early if the player users the leave instruction
        if instruction=="leave":
            break

    # Only use the word points if there is more than one
    message = "points"
    if total_score==1:
        message = "point"
    print("You got", total_score, message)
    return total_score

keep_playing=True
while keep_playing:
    print("Welcome to the amazing quiz game!")
    show_scoreboard()
    gamer_tag = input("Type in your gamer tag > ")
    print("OK,", gamer_tag, "let's play.")
    player_score=quiz_round()
    add_new_score(gamer_tag, player_score)
    show_scoreboard()
    if input("Is there another player? > ")=="no":
        keep_playing=False

Examples - Chapter 4

import csv

# Make a quiz list
quiz = [
    ("What is Batman's favourite colour", "black"),
    ("What is Barbie's favourite colour", "pink")
]

# Start with an empty scoreboard list
scoreboard = []

def load_scoreboard():
    global scoreboard
    try:
        with open("quiz-scores.csv", "r") as file:
            scoreboard=[]
            reader=csv.reader(file)
            for (gamer_tag, score) in reader:
                scoreboard.append((gamer_tag, int(score)))
    except FileNotFoundError:
        # No scoreboard file so start a new board
        scoreboard=[]  

def save_scoreboard():
    with open("quiz-scores.csv", "w", newline="") as file:
        writer=csv.writer(file)
        writer.writerows(scoreboard)

# A function to ask a question and give points
def quiz_question( question, correct_answer):
    player_answer=input(question + "? > ")
    if player_answer=="leave":
        print("OK. Come back and play another time.")
        return (0,"leave")
    elif player_answer=="pass":
        print("OK. Skip this question")
        return (0, "pass")
    elif player_answer==correct_answer:
        print("Well done!")
        return (1, "answer")
    else:
        print("Wrong! You lose a point")
        return (-1, "answer")

# Show the scoreboard
def scoreboard_sort_key(sb):
    (gamer_tag, score) = sb
    return score

def show_scoreboard():
    if len(scoreboard) > 0:
        print("Top Scores")
        position = 1
        scoreboard.sort(reverse=True, key=scoreboard_sort_key)
        for (gamer_tag, score) in scoreboard:
            print (position, ":",gamer_tag,"=",score)
            position+=1
            if position > 10:
                break
    else:
        print("No top score yet")

def add_new_score(gamer_tag, score):
    scoreboard.append((gamer_tag, score))

def quiz_round():
    # Start with no points
    total_score=0

    # Ask the questions using a loop:
    for (question, answer) in quiz:
        (points, instruction)=quiz_question(question, answer)
        total_score+=points
        # Finish early if the player users the leave instruction
        if instruction=="leave":
            break

    # Only use the word points if there is more than one
    message = "points"
    if total_score==1:
        message = "point"
    print("You got", total_score, message)
    return total_score

keep_playing=True
load_scoreboard()
while keep_playing:
    print("Welcome to the amazing quiz game!")
    show_scoreboard()
    gamer_tag = input("Type in your gamer tag > ")
    print("OK,", gamer_tag, "let's play.")
    player_score=quiz_round()
    add_new_score(gamer_tag, player_score)
    show_scoreboard()
    if input("Is there another player? > ")=="no":
        keep_playing=False
        save_scoreboard()

Examples - Chapter 5

game_world = {
    # There is a magic sword in the wilderness. If you have the sword it changes what happens in the castle.
    "wilderness": {
        "title": "Wilderness",
        "description": "You stand alone in the barren wilderness. There is a castle in the distance",
        "item": "magic sword", # Give the player an extra action to collect the item
        "actions":[
            ("Return home", "end"),
            ("Enter the castle", "castle")
        ]
    },
    "castle": {
        "title": "Castle",
        "description": "You enter the castle and are confronted by the evil dragon!",
        "actions": [ # Use these actions if the player doesn't have the special item
            ("Fight the dragon", "killed"),
            ("Run away", "wilderness")
        ],
        "magic sword": [ # If the player has the magic sword use these actions instead
            ("Fight the dragon", "win"),
            ("Run away", "wilderness")
        ]
    },
    "end" : {  # If there are no available actions we know the game has reached the end
        "title": "The End",
        "description": "You return to your village"
    },
    "killed" : {
        "title": "The End", 
        "description": "Without your own magic you are no match for the dragon!",
    },
    "win": {  # New ending that requires the magic sword
        "title": "A New Hero",
        "description": "The dragon is powerful but with the magic sword you win the battle and save your village!",
    }
}

def get_choice(max_choice):
    while True:
        response=input("Type the action number> ")
        if response.isdigit():
            choice = int(response)
            if choice <= max_choice:
                return choice
        print("Enter a number between 0 and", max_choice)

# Set the player position to the beginning of adventure
player_position = "wilderness"
player_items=[]

# Game status
keep_playing = True

# Game loop
while keep_playing:
    place=game_world[player_position]
    title=place["title"]
    description=place["description"]
    print(title)
    print("~~~~~~~~~~~~~~~~~~")
    print(description)
    print()
    if "actions" in place:
        actions = place["actions"]
        for (carried_item, location) in player_items:
            if carried_item in place:
                # There are special actions for one of the items we have. Let's use them instead.
                actions=place[carried_item]
                break
        print("What do you want to do next?")
        # Loop through the available actions.
        # action_number will count up from 0 and stop depending on the number of available actions in the list.
        for action_number in range(len(actions)):
            # actions is our list of available actions.
            # actions[action_number] picks out one action
            # actions[action_number][0] picks out the first part of the pair which will be the action description
            print(action_number, ":", actions[action_number][0])
        max_action = len(actions)-1
        if "item" in place:
            print(len(actions), ": Pick up the", place["item"])
            max_action = len(actions)
        # Convert the player input into a number. The text "1" becomes the number 1
        choice = get_choice(max_action)
        print()
        # actions[choice] picks out the chosen action
        # actions[choice][1] picks out the second part of the pair which will be the destination for the player
        # This code includes the bonus challenge.
        # The position of the item is saved so that they can be reset before the next game
        if choice==len(actions):
            player_items.append((place["item"],player_position))
            del place["item"]
        else:
            player_position = actions[choice][1]
    else:
        # No actions so this must be the end of the game
        if input("Play again? > ")=="no":
            # Leave the game
            keep_playing=False
        else:
            # Go back to the start position
            for (item, location) in player_items:
                game_world[location]["item"]=item
            player_position="wilderness"
print("Game over. Good luck!")

Examples - Chapter 6

import pygame

# pygame setup
pygame.init()
screen = pygame.display.set_mode((1280, 720))
clock = pygame.time.Clock()
running = True

# Setup the start position for the game
player_pos = pygame.Vector2(screen.get_width()/2, screen.get_height()/2)

# Set the shape and colour at the start
shape="circle"
colour="red"

# Load a background and player image to use
background_image=pygame.image.load("background.jpg")
player_image=pygame.image.load("player.jpg")

# Optional - resize the player image
player_image=pygame.transform.scale(pygame.image.load("player.jpg"), (160,80))

# Optional - remove and unwanted background colour from the image
player_image.set_colorkey("white")

while running:
    # poll for events
    # pygame.QUIT event means the user clicked X to close your window
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

    # Jump to the mouse position while the mouse button is pressed
    if pygame.mouse.get_pressed()[0]:
        player_pos=pygame.Vector2(pygame.mouse.get_pos())
        
    # Handle any user input
    keys = pygame.key.get_pressed()
    if keys[pygame.K_UP]:
        player_pos.y -= 40
    if keys[pygame.K_DOWN]:
        player_pos.y += 40
    if keys[pygame.K_LEFT]:
        player_pos.x -= 40
    if keys[pygame.K_RIGHT]:
        player_pos.x += 40
        
    # q or ESC quits the game
    if keys[pygame.K_q] or keys[pygame.K_ESCAPE]:
        running=False
        
    # c switches the circle colour
    if keys[pygame.K_c]:
        if colour=="red":
            colour="blue"
        else:
            colour="red"
            
    # s switches the shape
    if keys[pygame.K_s]:
        if shape=="circle":
            shape="image"
        else:
            shape="circle"

    # Fill the screen with a colour to wipe away anything from last frame
    screen.fill("purple")

    # Draw your game screen
    screen.blit(background_image, (0,0))

    # Check the shape variable and draw the correct player
    if shape == "circle":
        pygame.draw.circle(screen, colour, player_pos, 40)
    else:
        screen.blit(player_image, player_pos-(40,40), pygame.Rect((0,0),(80,80)))

    # flip() the display to put your work on screen
    pygame.display.flip()

    # Control how fast your game updates.
    # 20 frames per second is quite slow for modern games but is easier to control when we are starting.
    clock.tick(20)

pygame.quit()

Examples - Chapter 7

import pygame
import random

# Game settings
icon_size = 80
default_screen = (1280, 720)
target_speed = 20
hazard_speed = 40

# pygame setup
pygame.init()
screen = pygame.display.set_mode(default_screen)
clock = pygame.time.Clock()
running = True

def reset_target():
    return pygame.Rect(
        (screen.get_width(),random.randint(0,screen.get_height() - icon_size)),
        (icon_size, icon_size)
    )
# Setup the start position for the game
player_pos = pygame.Rect((icon_size, screen.get_height() / 2), (icon_size, icon_size))
target_pos = reset_target()
hazard_pos = reset_target()

score_font = pygame.font.SysFont(None, 48)
player_score = 0

while running:
    # poll for events
    # pygame.QUIT event means the user clicked X to close your window
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        
    # Handle any user input
    keys = pygame.key.get_pressed()
    if keys[pygame.K_UP]:
        player_pos.y -= 40
    if keys[pygame.K_DOWN]:
        player_pos.y += 40
    if keys[pygame.K_LEFT]:
        pass # ignore left
    if keys[pygame.K_RIGHT]:
        pass # ignore right
    
    player_pos.clamp_ip(screen.get_rect())
    # q or ESC quits the game
    if keys[pygame.K_q] or keys[pygame.K_ESCAPE]:
        running=False
    
    hazard_pos.move_ip((-hazard_speed,0))
    if hazard_pos.right < 0:
        hazard_pos = reset_target()
    if hazard_pos.colliderect(player_pos):
        running = False

    target_pos.move_ip((-target_speed,0))
    if target_pos.x < 0:
        target_pos = reset_target()
    if target_pos.colliderect(player_pos):
        player_score+=1
        target_pos = reset_target()
    
    # Fill the screen with a colour to wipe away anything from last frame
    screen.fill("purple")

    pygame.draw.rect(screen, "yellow", target_pos)
    pygame.draw.rect(screen, "red", hazard_pos)
    pygame.draw.rect(screen, "white", player_pos)
    score_message = "Score: "+str(player_score)
    score_image=score_font.render(score_message, True, "black")
    screen.blit(score_image, (10,0))

    # flip() the display to put your work on screen
    pygame.display.flip()

    # Control how fast your game updates.
    # 20 frames per second is quite slow for modern games but is easier to control when we are starting.
    clock.tick(20)

over_image = score_font.render("GAME OVER", True, "black")
screen.blit(
    over_image,
    ((screen.get_width()-over_image.get_rect().width)/2,
    (screen.get_height()-over_image.get_rect().height)/2)
)
pygame.display.flip()
running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
             running = False
             
    keys = pygame.key.get_pressed()
    if keys[pygame.K_q] or keys[pygame.K_ESCAPE]:
        running = False
pygame.quit()

Examples - Chapter 8

import pygame
import random

# pygame setup
pygame.init()
screen = pygame.display.set_mode((1280, 720))
clock = pygame.time.Clock()
running = True

step_size = 20

directions={
    "UP": pygame.Vector2(0,-step_size),
    "DOWN": pygame.Vector2(0, step_size),
    "LEFT": pygame.Vector2(-step_size,0),
    "RIGHT": pygame.Vector2(step_size,0)
}
    
# Setup the start position for the game
snake_direction = "RIGHT"

# Set the initial size of the snake and the position of its head
initial_snake_length = 5
snake_head_pos = pygame.Rect((screen.get_width()/2, screen.get_height()/2), (step_size, step_size))

# List to hold the snake
snake_body = []

# Build a list of rectangles to store our snake
for counter in range(1,initial_snake_length+1):
    snake_body.append(snake_head_pos.move(directions[snake_direction]*-counter))
    
def find_food_pos():
    try_pos = pygame.Rect((0,0),(step_size, step_size))
    while True:
        try_pos.topleft = (
            random.randint(0, screen.get_width()-step_size),
            random.randint(0, screen.get_height()-step_size)
        )
        if try_pos.collidelist(snake_body) == -1:
            return try_pos

food_pos = find_food_pos()

score_font = pygame.font.SysFont(None, 48)
player_score = 0

while running:
    # poll for events
    # pygame.QUIT event means the user clicked X to close your window
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

    # Handle any user input
    keys = pygame.key.get_pressed()
    if keys[pygame.K_UP]:
        snake_direction = "UP"
    if keys[pygame.K_DOWN]:
        snake_direction = "DOWN"
    if keys[pygame.K_LEFT]:
        snake_direction = "LEFT"
    if keys[pygame.K_RIGHT]:
        snake_direction = "RIGHT"

    # Perform other work such as game effects or non-player characters
        
    # Shift snake body
    snake_body.insert(0, snake_head_pos.copy())
    snake_body.pop()
    
    snake_head_pos.move_ip(directions[snake_direction])
    
    # detect crash with your own body
    if snake_head_pos.collidelist(snake_body) == -1:
        pass
    else:
        running = False
    
    # detect crash with the edge of the screen
    if screen.get_rect().contains(snake_head_pos):
        pass
    else:
        running = False

    if snake_head_pos.colliderect(food_pos):
        player_score+=1
        # Copy the last part of the snake to make it longer
        for counter in range(5):
            snake_body.append(snake_body[-1].copy())
        food_pos = find_food_pos()

    # Fill the screen with a colour to wipe away anything from last frame
    screen.fill("purple")

    # Draw your game screen
    for body_part_rect in snake_body:
        pygame.draw.rect(screen, "grey", body_part_rect)
    pygame.draw.rect(screen, "white", snake_head_pos)
    pygame.draw.rect(screen, "green", food_pos)
    
    score_message = "Score: "+str(player_score)
    score_image=score_font.render(score_message, True, "black")
    screen.blit(score_image, (10,0))


    # flip() the display to put your work on screen
    pygame.display.flip()

    # Control how fast your game updates.
    # 20 frames per second is quite slow for modern games but is easier to control when we are starting.
    clock.tick(20)

pygame.quit()

Examples - Chapter 9

Part 1 - A snake image generator

import pygame
from math import pi

# Initialize pygame
pygame.init()

# Set the height and width of the screen
tile_size=200
screen_size = [tile_size*4, tile_size*3]
screen = pygame.display.set_mode(screen_size)

pygame.display.set_caption("Snake game image maker")

# Loop until the user clicks the close button.
done = False
clock = pygame.time.Clock()

def make_head_images(size):
    img = pygame.Surface((size,size))
    img.fill("white")
    pygame.draw.ellipse(img, "green", [size/4,size/4,size/2,size/2*3])
    pygame.draw.polygon(img, "yellow", [[size*(.5-.05),size],[size/2,size/2],[size*(.5+.05),size]])
    pygame.draw.ellipse(img, "red", [size/20*6,size/2,size/20*3,size/20*6])
    pygame.draw.ellipse(img, "red", [size/20*11,size/2,size/20*3,size/20*6])
    pygame.draw.arc(img, "black", [0,0,size/2,size/2], 0, pi/2, int(size/20))
    pygame.draw.arc(img, "black", [size/2,0,size/2,size/2], pi/2, pi, int(size/20))
    imgs = pygame.Surface((size*4,size))
    imgs.blit(img,[0,0])
    imgs.blit(pygame.transform.rotate(img, 90), [size,0])
    imgs.blit(pygame.transform.rotate(img, 180), [size*2,0])
    imgs.blit(pygame.transform.rotate(img, 270), [size*3,0])
    imgs.set_colorkey("white")
    return imgs

def make_body_images(size):
    imgs = pygame.Surface((size*3,size*2))
    imgs.fill("white")
    
    
    pygame.draw.circle(imgs, "green", [size*2,size], int(size/4)*3, int(size/2))
    
    
    pygame.draw.circle(imgs, "yellow", [size*2,size], int(size/2+int(size/20)), int(size/10))
    
    pygame.draw.rect(imgs, "green", [size/4,0, size/2, size])
    pygame.draw.rect(imgs, "yellow", [size/2-int(size/20),0, size/10, size])
    
    pygame.draw.rect(imgs, "green", [0,size+size/4, size, size/2])
    pygame.draw.rect(imgs, "yellow", [0,size+size/2-int(size/20), size, size/10])
    imgs.set_colorkey("white")
    return imgs

head_images = make_head_images(tile_size)
body_images = make_body_images(tile_size)

pygame.image.save(head_images, "headimages.png")
pygame.image.save(body_images, "bodyimages.png")

while not done:
    # This limits the while loop to a max of 60 times per second.
    # Leave this out and we will use all CPU we can.
    clock.tick(60)

    for event in pygame.event.get():  # User did something
        if event.type == pygame.QUIT:  # If user clicked close
            done = True  # Flag that we are done so we exit this loop

    # Clear the screen and set the screen background
    screen.fill("purple")

    # This draws a triangle using the polygon command
    screen.blit(head_images, (0,0))
    screen.blit(body_images, (0,head_images.get_rect().height))
    
    pygame.display.flip()

# Be IDLE friendly
pygame.quit()

Part 2 - Snake with images

import pygame
import random

# pygame setup
pygame.init()
screen = pygame.display.set_mode((1280, 720))
clock = pygame.time.Clock()
running = True

step_size = 20

# Load the images from the image file
head_image=pygame.image.load("headimages.png")
body_image=pygame.image.load("bodyimages.png")

# Resize the images to the size we need. The files contain several tiles so we need to make room for these
head_image=pygame.transform.scale(head_image, (4 * step_size, step_size)) # Head has one row of 4 tiles
body_image=pygame.transform.scale(body_image, (3 * step_size, 2 * step_size)) # Body has two rows of 3 tiles

# Tell pygame about the background colour so it can remove this when we use the images
head_image.set_colorkey("white")
body_image.set_colorkey("white")

score_font = pygame.font.SysFont(None, 48)

directions={
    "UP": pygame.Vector2(0,-step_size),
    "DOWN": pygame.Vector2(0, step_size),
    "LEFT": pygame.Vector2(-step_size,0),
    "RIGHT": pygame.Vector2(step_size,0)
}

head_tiles = {
    "UP": (0,0),
    "LEFT": (step_size,0),
    "DOWN": (step_size*2,0),
    "RIGHT": (step_size*3,0)
}

body_tiles = {
    "UP": {
        "UP":(0,0),
        "LEFT": (step_size*2,0),
        "DOWN": (0,0),
        "RIGHT":(step_size,0)
    },
    "LEFT": {
        "UP":(step_size, step_size),
        "LEFT": (0,step_size),
        "DOWN": (step_size,0),
        "RIGHT": (0,step_size)
    },
    "DOWN": {
        "UP":(0,0),
        "LEFT": (step_size*2,step_size),
        "DOWN": (0,0),
        "RIGHT": (step_size,step_size)
    },
    "RIGHT": {
        "UP":(step_size*2,step_size),
        "LEFT": (0,step_size),
        "DOWN": (step_size*2,0),
        "RIGHT": (0,step_size)
    }
}

def find_food_pos(snake_body):
    try_pos = pygame.Rect((0,0),(step_size, step_size))
    while True:
        try_pos.topleft = (
            random.randint(0, screen.get_width()-step_size),
            random.randint(0, screen.get_height()-step_size)
        )
        if try_pos.collidelist(snake_body) == -1:
            return try_pos

def play_game():
    # Setup the start position for the game
    snake_direction = "RIGHT"
    old_direction = snake_direction

    # Set the initial size of the snake and the position of its head
    initial_snake_length = 5
    snake_head_pos = pygame.Rect((screen.get_width()/2, screen.get_height()/2), (step_size, step_size))

    # List to hold the snake
    snake_body = []
    snake_tiles = []

    # Build a list of rectangles to store our snake
    for counter in range(1, initial_snake_length+1):
        snake_body.append(snake_head_pos.move(directions[snake_direction]*-counter))
        snake_tiles.append(body_tiles[snake_direction][snake_direction])
        
    food_pos = find_food_pos(snake_body)

    player_score = 0

    running = True
    while running:
        # poll for events
        # pygame.QUIT event means the user clicked X to close your window
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False

        # Save the old direction before we check which keys are pressed
        old_direction = snake_direction
        
        # Handle any user input
        keys = pygame.key.get_pressed()
        if keys[pygame.K_UP]:
            snake_direction = "UP"
        if keys[pygame.K_DOWN]:
            snake_direction = "DOWN"
        if keys[pygame.K_LEFT]:
            snake_direction = "LEFT"
        if keys[pygame.K_RIGHT]:
            snake_direction = "RIGHT"

        # Perform other work such as game effects or non-player characters
        # Shift snake body
        snake_body.insert(0, snake_head_pos.copy())
        snake_tiles.insert(0, body_tiles[old_direction][snake_direction]) 
        snake_body.pop()
        snake_tiles.pop()

        snake_head_pos.move_ip(directions[snake_direction])
        
        # detect crash with your own body
        if snake_head_pos.collidelist(snake_body) == -1:
            pass
        else:
            running = False
        
        # detect crash with the edge of the screen
        if screen.get_rect().contains(snake_head_pos):
            pass
        else:
            running = False

        if snake_head_pos.colliderect(food_pos):
            player_score+=1
            # Copy the last part of the snake to make it longer
            for counter in range(5):
                snake_body.append(snake_body[-1].copy())
                snake_tiles.append(snake_tiles[-1])
            food_pos = find_food_pos(snake_body)

        # Fill the screen with a colour to wipe away anything from last frame
        screen.fill("purple")

        # Draw your game screen
        for counter in range(len(snake_body)):
            screen.blit(
                body_image,
                snake_body[counter],
                pygame.Rect(snake_tiles[counter],(step_size,step_size))
            )
        tile_pos = head_tiles[snake_direction]
        screen.blit(head_image, snake_head_pos, pygame.Rect(tile_pos,(step_size,step_size)))
        pygame.draw.rect(screen, "green", food_pos)
        
        score_message = "Score: "+str(player_score)
        score_image=score_font.render(score_message, True, "black")
        screen.blit(score_image, (10,0))


        # flip() the display to put your work on screen
        pygame.display.flip()

        # Control how fast your game updates.
        # 20 frames per second is quite slow for modern games but is easier to control when we are starting.
        clock.tick(20)
    return player_score

def create_text_image(message_list):
    max_width = 0
    total_height = 0
    image_list = []
    for message,colour in message_list:
        next_image = score_font.render(message, True, colour)
        image_list.append(next_image)
        max_width = max(max_width, next_image.get_rect().width)
        total_height+=next_image.get_rect().height + 10
    whole_image = pygame.Surface((max_width, total_height), flags=pygame.SRCALPHA)
    whole_image.fill((0,0,0,0))
    current_y=0
    for image in image_list:
        whole_image.blit(image, ((max_width - image.get_rect().width)/2,current_y))
        current_y+=image.get_rect().height + 10
    return whole_image
    
def show_final_screen(score):        
    over_image = create_text_image([
        ("GAME OVER", "black"),
        ("Final Score: "+str(score), "blue"),
        ("Play Again? Y/N", "red")
    ])
    screen.blit(
        over_image,
        ((screen.get_width()-over_image.get_rect().width)/2,
        (screen.get_height()-over_image.get_rect().height)/2)
    )
    pygame.display.flip()
        
running = True
while running:
    final_score = play_game()
    show_final_screen(final_score)
    waiting = True
    while waiting:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                 running = False
                 waiting = False
             
        keys = pygame.key.get_pressed()
        if keys[pygame.K_q] or keys[pygame.K_ESCAPE] or keys[pygame.K_n]:
            running = False
            waiting = False
        if keys[pygame.K_y]:
            waiting = False

pygame.quit()

Examples - Chapter 10

import pygame
import random
import math

# pygame setup
pygame.init()
screen = pygame.display.set_mode((1280, 720))
clock = pygame.time.Clock()

# Status of the game
running = True
old_mouse = pygame.Vector2(0,0)
use_gravity = True
use_friction = True
use_collision = True

# Game settings
size = 40
max_speed = 40
gravity = 5
num_of_balls = 20

# Empty list of balls
balls = []

# Reset function
def reset():
    global balls
    
    balls=[]
    for ball in range(num_of_balls):
        ball_pos = pygame.Vector2(
            random.randint(screen.get_width()/4, screen.get_width()/4*3),
            random.randint(screen.get_height()/4, screen.get_height()/4*3)
        )

        random_right = 
        random_down = 
        ball_direction = pygame.Vector2(
            random.randint(-max_speed, max_speed),
            random.randint(-max_speed, max_speed)
        )

        balls.append((ball_pos, ball_direction))

#Setup the game
reset()

while running:
    # poll for events
    # pygame.QUIT event means the user clicked X to close your window
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
            
    # Handle any user input
    keys = pygame.key.get_pressed()
    if keys[pygame.K_q] or keys[pygame.K_ESCAPE]:
        running = False
    if keys[pygame.K_q] or keys[pygame.K_ESCAPE]:
        running = False
    if keys[pygame.K_r]:
        reset()
    if keys[pygame.K_g]:
        use_gravity = not use_gravity
    if keys[pygame.K_f]:
        use_friction = not use_friction
    if keys[pygame.K_c]:
        use_collision = not use_collision

    # Perform other work such as game effects or non-player characters

    # Work repeated for every ball is inside this loop
    for ball_position, ball_direction in balls:    

        if use_friction:
            ball_direction*=0.98
        if use_gravity:
            ball_direction.y+=gravity

        ball_position+=ball_direction

        # Detect if we've reached a wall and then bounce
        if (ball_position.y-size < 0 or
            ball_position.y+size > screen.get_height()):
            ball_direction.y = -ball_direction.y

        if (ball_position.x-size < 0 or
               ball_position.x+size > screen.get_width()):
            ball_direction.x = -ball_direction.x
            
        # Don't let the ball go outside the edges
        if ball_position.y+size > screen.get_height():
            ball_position.y = screen.get_height()-size
        if ball_position.y-size < 0:
            ball_position.y = size
        if ball_position.x+size > screen.get_width():
            ball_position.x = screen.get_width()-size
        if ball_position.x-size < 0:
            ball_position.x = size
        
            
        # If mouse is clicked throw the ball
        if pygame.mouse.get_pressed()[0]:
            new_mouse=pygame.Vector2(pygame.mouse.get_pos())
            ball_direction.update(new_mouse - old_mouse)
        
    if use_collision:
        # Loop in a loop to check each pair of balls once
        for first_index in range(len(balls)-1):
            for second_index in range(first_index+1, len(balls)):
                # If balls[first_index] hits balls[second_index] then bounce them
                first_pos, first_dir = balls[first_index]
                second_pos, second_dir = balls[second_index]
                distance = first_pos.distance_to(second_pos)
                overlap = size*2-distance
                if overlap > 0:
                    # Balls overlap so they must have hit
                    
                    # Swap directions
                    old_first_dir = first_dir.copy()
                    first_dir.update(second_dir)
                    second_dir.update(old_first_dir)

                    # Make sure the balls don't overlap
                    # Get a line between the centre of the balls
                    shift_vector = first_pos-second_pos
                    # If the balls have the same centre add some randomness
                    if shift_vector.length()==0:
                        shift_vector=pygame.Vector2(
                            random.randint(1,max_speed),
                            random.randint(1, max_speed)
                        )
                    # Move each one away by half the overlap
                    shift_vector.scale_to_length(overlap/2)
                    first_pos+=shift_vector
                    second_pos-=shift_vector

    # Remember the new position
    old_mouse=pygame.Vector2(pygame.mouse.get_pos())

    # Fill the screen with a colour to wipe away anything from last frame
    screen.fill("purple")

    # Draw your game screen
    for ball_position, ball_direction in balls:
        pygame.draw.circle(screen, "red", ball_position, size)


    # flip() the display to put your work on screen
    pygame.display.flip()

    # Control how fast your game updates.
    # 20 frames per second is quite slow for modern games but is easier to control when we are starting.
    clock.tick(20)

pygame.quit()

Examples - Chapter 11

Part 1 - Pacman image generator

import pygame
from math import pi

# Initialize pygame
pygame.init()

# Set the height and width of the screen
tile_size=50
screen_size = [tile_size*4, tile_size*24]
screen = pygame.display.set_mode(screen_size)

pygame.display.set_caption("Pacman image maker")

# Loop until the user clicks the close button.
done = False
clock = pygame.time.Clock()

def make_pacman_image(size, mouth_angle, open_factor):
    img = pygame.Surface((size,size))
    img.fill("purple")
    pygame.draw.circle(
        img,
        "yellow",
        (size/2, size/2),
        size/2
    )
    pygame.draw.polygon(
        img,
        "purple",
        [
            (size/2, size/2),
            (size, size/2*(1-open_factor)),
            (size, size/2*(1+open_factor))
        ]
    )
    return pygame.transform.rotate(img, mouth_angle)

def make_pacman_images(size):
    imgs = pygame.Surface((size*4,size*4))
    img = pygame.Surface((size,size))
    img.fill("purple")
    x=0
    y=0
    for ag in [0,90,180,270]:
        for of in [0,.5,1,.5]:
            imgs.blit(make_pacman_image(size, ag, of),[x,y])
            x+=size
        y+=size
        x=0
    imgs.set_colorkey("purple")
    return imgs

def draw_eyes(img, size, eye_offset):
    down=.4*size
    out=.15*size
    x,y=eye_offset
    pygame.draw.circle(
        img,
        "white",
        (size/2-out, down),
        size/6
    )
    pygame.draw.circle(
        img,
        "black",
        (size/2-out+x, down+y),
        size/8
    )
    pygame.draw.circle(
        img,
        "white",
        (size/2+out, down),
        size/6
    )
    pygame.draw.circle(
        img,
        "black",
        (size/2+out+x, down+y),
        size/8
    )

def draw_skirt(img, size, skirt_offset):
    ripple=.1*size
    out=.15*size
    pygame.draw.circle(
        img,
        "purple",
        (size/2-out+skirt_offset, size),
        ripple
    )
    pygame.draw.circle(
        img,
        "purple",
        (size/2+out+skirt_offset, size),
        ripple
    )
        
    
def make_ghost_image(size, colour, eye_offset, skirt_offset):
    img = pygame.Surface((size,size))
    img.fill("purple")
    pygame.draw.circle(
        img,
        colour,
        (size/2, size/3),
        size/3
    )
    pygame.draw.rect(
        img,
        colour,
        ((size/6,size/3),(size*2/3, size*4/6))
    )
    draw_eyes(img, size, eye_offset)
    draw_skirt(img, size, skirt_offset)
    return img

def make_ghost_images(size, colour):
    imgs = pygame.Surface((size*4,size*4))
    img = pygame.Surface((size,size))
    img.fill("purple")
    x=0
    y=0
    for eox, eoy in [(1,0),(0,-1),(-1,0),(0,1)]:
        for so in [0,size/10,0,-size/10]:
            imgs.blit(make_ghost_image(size, colour, (eox*size/15, eoy*size/15), so),[x,y])
            x+=size
        y+=size
        x=0
    imgs.set_colorkey("purple")
    return imgs

def make_ghosts_images(size):
    imgs = pygame.Surface((size*4, size*24))
    imgs.fill("purple")
    for y,colour in [
        (4, "blue"),
        (8, "red"),
        (12, "cyan"),
        (16, "pink"),
        (20, "orange")]:
        imgs.blit(make_ghost_images(size, colour), (0,y*size))
    imgs.set_colorkey("purple")
    return imgs

game_images = pygame.Surface(screen_size)
game_images.fill("purple")
game_images.blit(make_pacman_images(tile_size), (0,0))
game_images.blit(make_ghosts_images(tile_size), (0, 0))

pygame.image.save(game_images, "pacmanimages.png")

while not done:
    # This limits the while loop to a max of 60 times per second.
    # Leave this out and we will use all CPU we can.
    clock.tick(60)

    for event in pygame.event.get():  # User did something
        if event.type == pygame.QUIT:  # If user clicked close
            done = True  # Flag that we are done so we exit this loop

    # Clear the screen and set the screen background
    screen.fill("purple")

    # This draws a triangle using the polygon command
    screen.blit(game_images, (0,0))
    
    pygame.display.flip()

# Be IDLE friendly
pygame.quit()

Part 2 - Pacman Game

import pygame
import os
import random

# Game settings
# =============

tile_size=30
player_move_size = 3
game_FPS = 60
power_up_duration = 15

# Game map
game_map = [
    "wwwwwwwwwwwwwwwwwwwwwwwwwwww",
    "wwwwwwwwwwwwwwwwwwwwwwwwwwww",
    "wwwwwwwwwwwwwwwwwwwwwwwwwwww",
    "w............ww............w",
    "w.wwww.wwwww.ww.wwwww.wwww.w",
    "wow  w.w   w.ww.w   w.w  wow",
    "w.wwww.wwwww.ww.wwwww.wwww.w",
    "w..........................w",
    "w.wwww.ww.wwwwwwww.ww.wwww.w",
    "w.wwww.ww.wwwwwwww.ww.wwww.w",
    "w......ww....ww....ww......w",
    "wwwwww.wwwww.ww.wwwww.wwwwww",
    "     w.wwwww.ww.wwwww.w     ",
    "     w.ww..........ww.w     ",
    "wwwwww.ww.wwwxxwww.ww.wwwwww",
    "      ....wggggggw....      ",
    "wwwwww.ww.wwwwwwww.ww.wwwwww",
    "     w.ww..........ww.w     ",
    "     w.wwwww.ww.wwwww.w     ",
    "wwwwww.wwwww.ww.wwwww.wwwwww",
    "w......ww....ww....ww......w",
    "w.wwww.ww.wwwwwwww.ww.wwww.w",
    "w.wwww.ww.wwwwwwww.ww.wwww.w",
    "w..........................w",
    "w.wwww.wwwww.ww.wwwww.wwww.w",
    "wow  w.w   w.ww.w   w.w  wow",
    "w.wwww.wwwww.ww.wwwww.wwww.w",
    "w............ww............w",
    "wwwwwwwwwwwwwwwwwwwwwwwwwwww",
    "wwwwwwwwwwwwwwwwwwwwwwwwwwww",
    "wwwwwwwwwwwwwwwwwwwwwwwwwwww",
]

directions={
    "UP": (0,-1),
    "DOWN": (0, 1),
    "LEFT": (-1,0),
    "RIGHT": (1,0)
}

opposite_dir = {
    "UP": "DOWN",
    "DOWN": "UP",
    "LEFT": "RIGHT",
    "RIGHT": "LEFT"
}

corners=[
    (0,0),
    (0,tile_size-1),
    (tile_size-1,0),
    (tile_size-1, tile_size-1)
]

# pygame setup
# ============

# pygame uses SDL, tell SDL to create the game window in the middle of the display
os.environ['SDL_VIDEO_CENTERED'] = '1'

pygame.init()
screen = pygame.display.set_mode((tile_size*len(game_map[0]), tile_size*len(game_map)))
clock = pygame.time.Clock()


# Game assets and resources
# =========================

# Load font
score_font = pygame.font.SysFont(None, tile_size-2)

# Load the images from the image file
game_images=pygame.image.load("pacmanimages.png")

# Resize the images to the size we need. The files contain several tiles so we need to make room for these
game_images=pygame.transform.scale(game_images, (4 * tile_size,24 * tile_size))

# Tell pygame about the background colour so it can remove this when we use the images
game_images.set_colorkey("purple")

# Game state
# ==========

player_direction="RIGHT"
animation_offset=0
player_pos=pygame.Rect((0,0),(0,0))
player_score=0
playing=True
game_level=1
player_lives=3
dots_remaining=0
working_map=[]
ghosts=[]
power_up = 0

# Map helper functions
# ====================

def map_tile_rect(map_pos):
    x,y=map_pos
    return pygame.Rect((x*tile_size, y*tile_size),(tile_size,tile_size))

def get_map_tile(map_pos):
    x,y = map_pos
    return working_map[y][x]

def draw_grid_tile(pos):
    match get_map_tile(pos):
        case 'w':
            pygame.draw.rect(screen, "blue", map_tile_rect(pos))
        case '.':
            rect = map_tile_rect(pos)
            pygame.draw.rect(screen, "black", rect)
            pygame.draw.circle(screen, "white", rect.center, tile_size/8)
        case 'o':
            rect = map_tile_rect(pos)
            pygame.draw.rect(screen, "black", rect)
            pygame.draw.circle(screen, "pink", rect.center, tile_size/3)
        case 'x':
            rect = map_tile_rect(pos)
            pygame.draw.rect(screen, "black", rect)
            pygame.draw.rect(screen, "grey", rect.inflate(0,-tile_size/2))
        case _:
            pygame.draw.rect(screen, "black", map_tile_rect(pos))                

def draw_map():
    for row_num in range(len(working_map)):
        for column_num in range(len(working_map[row_num])):
            draw_grid_tile((column_num, row_num))
            
def pos_map_tile(pos):
    return (pos.x//tile_size, pos.y//tile_size)

def set_map_tile(map_pos, code):
    x,y=map_pos
    working_map[y][x]=code

def wrap_pos_ip(pos):
    if pos.x < 0:
        pos.x+= screen.get_width()
    if pos.x >= screen.get_width():
        pos.x = pos.x-screen.get_width()
    if pos.y < 0:
        pos.y+= screen.get_height()
    if pos.y >= screen.get_height():
        pos.y = pos.y-screen.get_height()

def moved_pos(curr_pos, dir, size):
    x,y=directions[dir]
    new_pos = curr_pos.move((x*size, y*size))
    wrap_pos_ip(new_pos)
    return new_pos

def can_move(curr_pos, dir, size, block="wx"):
    new_pos = moved_pos(curr_pos, dir, size)
    for corner_move in corners:
        corner_pos = new_pos.move(corner_move)
        wrap_pos_ip(corner_pos)
        if get_map_tile(pos_map_tile(corner_pos)) in block:
            return False
    return True

def rect_distance(rect1, rect2):
    return abs(rect1.x - rect2.x)+abs(rect1.y - rect2.y)

# Image helper functions
# ======================

def calc_image_rect(first_image_offset, direction, animation_step):
    return pygame.Rect(
        (animation_step*tile_size, first_image_offset+dir_offset[direction]),
        (tile_size,tile_size)
    )

dir_offset={
    "RIGHT": 0,
    "UP": tile_size,
    "LEFT": 2*tile_size,
    "DOWN": 3*tile_size
}

def create_text_image(message_list):
    max_width = 0
    total_height = 0
    image_list = []
    for message,colour in message_list:
        next_image = score_font.render(message, True, colour)
        image_list.append(next_image)
        max_width = max(max_width, next_image.get_rect().width)
        total_height+=next_image.get_rect().height + 10
    whole_image = pygame.Surface((max_width, total_height), flags=pygame.SRCALPHA)
    whole_image.fill((0,0,0,0))
    current_y=0
    for image in image_list:
        whole_image.blit(image, ((max_width - image.get_rect().width)/2,current_y))
        current_y+=image.get_rect().height + 10
    return whole_image
    
# Setup functions
# ===============

def new_ghost(pos, image_offset, direction, type, speed):
    return {
        "pos": map_tile_rect(pos),
        "img": image_offset,
        "dir": direction,
        "type": type,
        "speed": speed
    }

def reset_working_map():
    global dots_remaining, working_map, ghosts
    dots_remaining = 0
    working_map = []
    for row in game_map:
        # Convert the string into a list of characters and add it to working_map
        working_map.append(list(row))
        # Check each character in the row and count the dots
        for code in row:
            if code == '.':
                dots_remaining+=1
                
    ghosts = []
    ghosts.append(new_ghost((13,13), 8*tile_size, "LEFT", "random", 2))
    ghosts.append(new_ghost((12,15), 12*tile_size, "RIGHT", "random", 3))
    ghosts.append(new_ghost((13,15), 16*tile_size, "UP", "shortest", 1))
    ghosts.append(new_ghost((14,15), 20*tile_size, "DOWN", "shortest", 2))

# Game functions
# ==============

def draw_score():
    # Use our font to turn some text into an image
    score_image = score_font.render(
        f"Score: {player_score}",
        True,
        "white"
    )
    # Place the image on our screen
    screen.blit(score_image, map_tile_rect((1,1)))
    
def move_pacman(dir):
    player_pos.update(moved_pos(player_pos, dir, player_move_size))
    
# function to draw the player
def draw_pacman():
    screen.blit(game_images, player_pos, calc_image_rect(0, player_direction, animation_step))
    if screen.get_width()-player_pos.x<tile_size:
        screen.blit(
            game_images,
            player_pos.move((-screen.get_width(),0)),
            calc_image_rect(0, player_direction, animation_step)
        )
    if screen.get_height()-player_pos.y<tile_size:
        screen.blit(
            game_images,
            player_pos.move((0, -screen.get_height())),
            calc_image_rect(0, player_direction, animation_step)
        )
    
def check_dots():
    global player_score, dots_remaining
    # Check if player is fully on one square.
    if player_pos.x % tile_size == 0 and player_pos.y % tile_size == 0:
        # Check if the player is on a dot
        if get_map_tile(pos_map_tile(player_pos)) == '.':
            # Update score and remove dot
            set_map_tile(pos_map_tile(player_pos), ' ')
            player_score+=1
            dots_remaining-=1
            
def check_pellets():
    global player_score, power_up
    
    if player_pos.x % tile_size == 0 and player_pos.y % tile_size == 0:
        # Check if the player is on a pellet
        if get_map_tile(pos_map_tile(player_pos)) == 'o':
            # Update score and remove dot
            set_map_tile(pos_map_tile(player_pos), ' ')
            player_score+=10
            power_up = power_up_duration * game_FPS
                
def draw_levels():
    drawing_row = len(working_map)-2
    drawing_col = len(working_map[drawing_row])-2
    level_rect = map_tile_rect((drawing_col, drawing_row))
    pygame.draw.rect(
        screen,
        "black",
        pygame.Rect(level_rect.move((-4*tile_size,0)).topleft, (5*tile_size, tile_size))
    )
    for level in range(game_level):
        pygame.draw.circle(screen, "red", level_rect.center, tile_size/2)
        level_rect.move_ip(-tile_size, 0)
        
def draw_lives():
    drawing_row = len(working_map)-2
    drawing_col = 1
    level_rect = map_tile_rect((drawing_col, drawing_row))
    pygame.draw.rect(screen, "black", pygame.Rect(level_rect.topleft, (3*tile_size, tile_size)))
    for level in range(player_lives):
        pygame.draw.circle(screen, "yellow", level_rect.center, tile_size/2)
        level_rect.move_ip(tile_size, 0)
        
def draw_power_up():
    if power_up > 0:
        power_col = "red"
        if power_up > 5 * game_FPS:
            power_col = "orange"
        power_image = score_font.render(
            str(power_up*10//game_FPS/10),
            True,
            power_col
        )
        # Place the image on our screen
        screen.blit(power_image, map_tile_rect((len(game_map[0])-3,1)))
        
        
def get_random_direction(ghost):
    # Make a list of directions
    dir_list = list(directions)
    # Remove opposite direction if the current direction is clear
    if can_move(ghost["pos"], ghost["dir"], ghost["speed"]):
        dir_list.remove(opposite_dir[ghost["dir"]])
    # Mix the list of directions
    random.shuffle(dir_list)
    # Now pick the first valid one
    for dir in dir_list:
        if can_move(ghost["pos"], dir, ghost["speed"]):
            return dir
        
def dir_sort_key(item):
    dir, score = item
    return score

def get_by_distance(ghost, rev):
    dir_list = list(directions)
    
    # Remove opposite direction if the current direction is clear
    if can_move(ghost["pos"], ghost["dir"], ghost["speed"]):
        dir_list.remove(opposite_dir[ghost["dir"]])
    
    # Get a list of valid directions with their distance scores
    dist_list = []
    for dir in dir_list:
        if can_move(ghost["pos"], dir, ghost["speed"]):
            dist_list.append((dir, rect_distance(player_pos, moved_pos(ghost["pos"], dir, ghost["speed"]))))
    # Sort by distance
    dist_list.sort(reverse=rev, key=dir_sort_key)
    # Return the shorted direction
    dir,_ = dist_list[0]
    return dir

def get_shortest_direction(ghost):
    return get_by_distance(ghost, False)

def run_away_direction(ghost):
    return get_by_distance(ghost, True)

def move_ghosts():
    global player_score
    
    for ghost in ghosts:
        pos = ghost["pos"]
        dir = ghost["dir"]
        if pos.x % tile_size == 0 and pos.y % tile_size == 0:
            # Fully on a tile so check directions
            # Leave the centre area first
            if get_map_tile(pos_map_tile(pos)) in "gx" and can_move(pos, "UP", ghost["speed"],"w"):
                dir = "UP"
            # Run away during power ups
            elif power_up > 0:
                dir = run_away_direction(ghost)
            # Use the ghost type
            elif ghost["type"]=="random":
                dir = get_random_direction(ghost)
            else:
                dir = get_shortest_direction(ghost)
        # Keep moving
        if can_move(pos, dir, ghost["speed"], "w"):
            pos.update(moved_pos(pos, dir, ghost["speed"]))
        # Check for player
        if pos.colliderect(player_pos):
            if power_up > 0:
                # Eaten by player in power up mode
                player_score += 50
                ghost["pos"]=map_tile_rect((13,15))
            else:
                # Catch player, end this round
                return True
        #save the direction for next time
        ghost["dir"]=dir
    return False

def draw_ghosts():
    for ghost in ghosts:
        image = ghost["img"]
        if power_up > 0:
            image = 4*tile_size
        screen.blit(game_images, ghost["pos"], calc_image_rect(image, ghost["dir"], animation_step))
        
def show_game_over():        
    over_image = create_text_image([
        ("GAME OVER", "white"),
        ("Final Score: "+str(player_score), "cyan"),
        ("Play Again? Y/N", "yellow")
    ])
    screen.blit(
        over_image,
        ((screen.get_width()-over_image.get_rect().width)/2,
        (screen.get_height()-over_image.get_rect().height)/2)
    )
    draw_lives()
    pygame.display.flip()

# Main game loop
# ==============

def play_level():
    global player_pos, player_direction, animation_step, power_up
    # Setup initial player position. Make sure it is not in a wall!
    player_pos = pygame.Rect((13*tile_size, 17*tile_size), (tile_size,tile_size))
    player_direction = "RIGHT"
    running = True
    reset_working_map()
    loop_count=0
    power_up=0
    
    while running:
        loop_count+=1
        if power_up > 0:
            power_up-=1
        animation_step = (loop_count//10)%4
        # poll for events
        # pygame.QUIT event means the user clicked X to close your window
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False

        # Handle any user input
        keys = pygame.key.get_pressed()
        if keys[pygame.K_UP] or keys[pygame.K_w]:
            if can_move(player_pos, "UP", player_move_size):
                player_direction = "UP"
        if keys[pygame.K_DOWN] or keys[pygame.K_s]:
            if can_move(player_pos, "DOWN", player_move_size):
                player_direction = "DOWN"
        if keys[pygame.K_LEFT] or keys[pygame.K_a]:
            if can_move(player_pos, "LEFT", player_move_size):
                player_direction = "LEFT"
        if keys[pygame.K_RIGHT] or keys[pygame.K_d]:
            if can_move(player_pos, "RIGHT", player_move_size):
                player_direction = "RIGHT"
        if keys[pygame.K_q] or keys[pygame.K_ESCAPE]:
            running = False

        # Perform other work such as game effects or non-player characters
        if can_move(player_pos, player_direction, player_move_size):
            move_pacman(player_direction)
        
        check_dots()
        check_pellets()
        if dots_remaining == 0:
            running = False
            
        if move_ghosts():
            running = False

        
        # Fill the screen with a colour to wipe away anything from last frame
        screen.fill("purple")

        # Draw your game screen
        draw_map()
        
        draw_ghosts()
                
        draw_pacman()
        
        draw_score()
        
        draw_levels()
        
        draw_lives()
        
        draw_power_up()
        
        # flip() the display to put your work on screen
        pygame.display.flip()

        # Control how fast your game updates.
        # 20 frames per second is quite slow for modern games but is easier to control when we are starting.
        clock.tick(game_FPS)
        
# Main loop for the levels
# ========================

while playing:
    play_level()
    if dots_remaining == 0:
        game_level+=1
    else:
        player_lives-=1
        if player_lives == 0:
            show_game_over()
            waiting = True
            while waiting:
                for event in pygame.event.get():
                    if event.type == pygame.QUIT:
                         playing = False
                         waiting = False
                     
                keys = pygame.key.get_pressed()
                if keys[pygame.K_q] or keys[pygame.K_ESCAPE] or keys[pygame.K_n]:
                    playing = False
                    waiting = False
                if keys[pygame.K_y]:
                    waiting = False
                    game_level=1
                    player_lives=3
                    player_score=0

pygame.quit()

Examples - Chapter 12

import pygame
import random

# pygame setup
pygame.init()
screen = pygame.display.set_mode((1280, 720))
clock = pygame.time.Clock()
running = True

tile_size = 40
wall_thickness = 5
door_width = 10
maze_width = 30
maze_height = 20
maze_map = []
wall_colour = "black"
floor_colour = "grey"
player_colour = "red"
player_pos = pygame.Vector2(0,0)

directions = {
    "DOWN": (0,1),
    "RIGHT": (1,0),
    "UP": (0,-1),
    "LEFT": (-1,0)
}

opposites = {
    "DOWN": "UP",
    "UP": "DOWN",
    "LEFT": "RIGHT",
    "RIGHT": "LEFT"
}

# pygame setup
pygame.init()
screen = pygame.display.set_mode((maze_width*tile_size, maze_height*tile_size))
clock = pygame.time.Clock()
running = True

def maze_tile_rect(x,y):
    return pygame.Rect((x*tile_size, y*tile_size), (tile_size, tile_size))

def tile_floor_rect(x,y):
    return pygame.Rect((
        x*tile_size+wall_thickness,
        y*tile_size+wall_thickness
    ), (
        tile_size-2*wall_thickness,
        tile_size-2*wall_thickness
    ))

def tile_door_rect(x,y,dir):
    tile_rect = maze_tile_rect(x,y)
    door_edge = (tile_size-door_width)//2
    match dir:
        case "UP":
            tile_rect.width=door_width
            tile_rect.height=wall_thickness
            return tile_rect.move((door_edge,0))
        case "DOWN":
            tile_rect.width=door_width
            tile_rect.height=wall_thickness
            return tile_rect.move((door_edge,tile_size-wall_thickness))
        case "LEFT":
            tile_rect.width=wall_thickness
            tile_rect.height=door_width
            return tile_rect.move((0,door_edge))
        case "RIGHT":
            tile_rect.width=wall_thickness
            tile_rect.height=door_width
            return tile_rect.move((tile_size-wall_thickness,door_edge))

def make_initial_map():
    # Make a list of columns
    # Each column has a row of tiles
    # Each tile shows if there is a door (True or False) in each direction
    for column in range(maze_width):
        row_list = []
        for row in range(maze_height):
            tile_details={}
            for dir in directions:
                tile_details[dir]=False
            row_list.append(tile_details)
        maze_map.append(row_list)

def inside_maze(x,y):
    if x<0 or y<0:
        return False
    if x>=maze_width or y>=maze_height:
        return False
    return True

def can_move(dir):
    return maze_map[int(player_pos.x)][int(player_pos.y)][dir]

def make_door(x,y,dir):
    next_x, next_y = directions[dir]
    # Set the door for the tile to True
    maze_map[int(x)][int(y)][dir]=True
    # If the other side is in the maze also set that to True
    if inside_maze(x+next_x, y+next_y):
        maze_map[int(x+next_x)][int(y+next_y)][opposites[dir]]=True

def draw_maze():
    for column in range(maze_width):
        for row in range(maze_height):
            pygame.draw.rect(screen, wall_colour, maze_tile_rect(column, row))
            pygame.draw.rect(screen, floor_colour, tile_floor_rect(column, row))
            for dir in directions:
                if maze_map[column][row][dir]:
                    pygame.draw.rect(screen, floor_colour, tile_door_rect(column, row, dir))

def draw_player():
    x,y=player_pos
    player_tile = maze_tile_rect(x,y)
    pygame.draw.circle(screen, player_colour, player_tile.center, tile_size/3)

def make_tile_tracker():
    tiles=[]
    for col in range(maze_width):
        col=[]
        for row in range(maze_width):
            col.append(False)
        tiles.append(col)
    return tiles

def animate_designer(pos):
    for event in pygame.event.get():
        pass
    draw_maze()
    pygame.draw.circle(screen, "white", maze_tile_rect(pos.x, pos.y).center, tile_size/4)
    pygame.display.flip()
    clock.tick(60)    

def explore_tile(pos,tracker):
    tracker[int(pos.x)][int(pos.y)]=True
    dir_list = list(directions)
    random.shuffle(dir_list)
    for dir in dir_list:
        animate_designer(pos)
        next_pos = pos+directions[dir]
        # Only visit the next tile if it is inside the maze
        if inside_maze(next_pos.x, next_pos.y):
            # Only visit if we haven't already been there
            if not tracker[int(next_pos.x)][int(next_pos.y)]:
                # Make a door and explore further
                make_door(pos.x, pos.y, dir)
                explore_tile(next_pos, tracker)

def design_maze():
    tiles_visited=make_tile_tracker()
    start_pos=pygame.Vector2(0,0)
    explore_tile(start_pos, tiles_visited)

make_initial_map()
design_maze()

# A function with a yield statement defines a generator
def explorer_generator(pos, tracker):
    tracker[int(pos.x)][int(pos.y)]=True
    dir_list = list(directions)
    random.shuffle(dir_list)
    for dir in dir_list:
        # Only visit the next tile if there is a door
        if can_move(dir):
            next_pos = pos+directions[dir]
            # Only visit if we haven't already been there
            if not tracker[int(next_pos.x)][int(next_pos.y)]:
                # move and explore further
                yield dir
                yield from explorer_generator(next_pos, tracker)
                yield opposites[dir]    

def move_generator():
    tiles_visited=make_tile_tracker()
    yield from explorer_generator(player_pos, tiles_visited)

# We actually get a generator we can use by calling the function
player_move = move_generator()

while running:
    # poll for events
    # pygame.QUIT event means the user clicked X to close your window
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

    dir = next(player_move)
    if can_move(dir):
        player_pos+=directions[dir]
        if player_pos.x==maze_width-1 and player_pos.y==maze_height-1:
            running=False
    else:
        print("Error. Tried to move:", dir)

    # Perform other work such as game effects or non-player characters

    # Fill the screen with a colour to wipe away anything from last frame
    screen.fill("purple")
    
    draw_maze()

    draw_player()

    # flip() the display to put your work on screen
    pygame.display.flip()

    # Control how fast your game updates.
    # 20 frames per second is quite slow for modern games but is easier to control when we are starting.
    clock.tick(20)

pygame.quit()