9. Tutorial: Maze game

In this chapter we will build a maze game together, step by step. The Python we will use is quite simple: mostly just conditionals and loops. The technique of creating a tilemap is common in games and after seeing it here you should be able to incorporate it into your own projects.

Maze game

Fig. 9.1 Maze game

9.1. Tilemap

A tilemap uses a small number of images (the tiles) and draws them many times to build a much larger game level (the map). This saves you from creating a lot of artwork and makes it very easy to change the design of the level on a whim. Here we create a maze level.

We must create three image files for the tiles: empty.png, wall.png and goal.png and save them in the mu_code/images folder (accessible with the images button in Mu).

They must each be 64×64 pixels. For the player we will use the built in alien image.

Program 9.1 Drawing a tilemap
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
TILE_SIZE = 64
WIDTH = TILE_SIZE * 8
HEIGHT = TILE_SIZE * 8

tiles = ['empty', 'wall', 'goal']

maze = [
    [1, 1, 1, 1, 1, 1, 1, 1],
    [1, 0, 0, 0, 1, 2, 0, 1],
    [1, 0, 1, 0, 1, 1, 0, 1],
    [1, 0, 1, 0, 0, 0, 0, 1],
    [1, 0, 1, 0, 1, 0, 0, 1],
    [1, 0, 1, 0, 1, 0, 0, 1],
    [1, 0, 1, 0, 0, 0, 0, 1],
    [1, 1, 1, 1, 1, 1, 1, 1]
]

player = Actor("alien", anchor=(0, 0), pos=(1 * TILE_SIZE, 1 * TILE_SIZE))

def draw():
    screen.clear()
    for row in range(len(maze)):
        for column in range(len(maze[row])):
            x = column * TILE_SIZE
            y = row * TILE_SIZE
            tile = tiles[maze[row][column]]
            screen.blit(tile, (x, y))
    player.draw()

The filenames of the tile images are stored in a list, tiles. The level design is stored in a list of lists, more commonly called a two dimensional array. There are 8 rows and 8 columns in the array. If you change the size of the array you will need to change the WIDTH and HEIGHT values too. The numbers in the maze array refers to elements in the tiles array. So 0 means empty and 1 means wall, etc.

Tile grid

Fig. 9.2 Tile grid

To draw the maze we use a for loop within another for loop. The outer loop iterates over the rows and the inner loop iterates over the columns, i.e. the elements of the row.

Exercise

Create your own images empty.png, wall.png and goal.png and run the program.

Exercise

The player appears at the top left of the maze at row 1 column 1. Change the pos parameter so he appears at the bottom instead.

Exercise

Change the design of the maze by changing the numbers in the maze array.

Advanced

Make the maze bigger.

9.2. Moving the player

Add this code to the end of the program:

def on_key_down(key):
    row = int(player.y / TILE_SIZE)
    column = int(player.x / TILE_SIZE)
    if key == keys.UP:
        row = row - 1
    if key == keys.DOWN:
        row = row + 1
    if key == keys.LEFT:
        column = column - 1
    if key == keys.RIGHT:
        column = column + 1
    player.x = column * TILE_SIZE
    player.y = row * TILE_SIZE

This function will be called automatically by Pygame, like draw() and update(). However on_key_down() is not called every frame; it is only called when the player presses a key. The key that was pressed is passed to the function in the key parameter.

Exercise

Run the program and move the player. Are there any problems with the movement?

9.3. Restricting where the player can move

Delete the last two lines of the program and replace them with this modified version:

tile = tiles[maze[row][column]]
if tile == 'empty':
    player.x = column * TILE_SIZE
    player.y = row * TILE_SIZE
elif tile == 'goal':
    print("Well done")
    exit()

Exercise

Run the program and check that the player now only moves if the tile is empty.

Exercise

Check that the game ends when the player reaches the goal.

9.4. Animate the movement of the player

First, the alien Actor is bit too big. Draw a new image of size 64×64 pixels and save it as player.png in the images folder. In the program, change the line:

player = Actor("alien", anchor=(0, 0), pos=(1 * TILE_SIZE, 1 * TILE_SIZE))

to

player = Actor("player", anchor=(0, 0), pos=(1 * TILE_SIZE, 1 * TILE_SIZE))

Next, the movement of the Actor is sudden and jerky. Luckily Pygame includes a function to do smooth movement for us automatically. Find these lines of the program:

if tile == 'empty':
      player.x = column * TILE_SIZE
      player.y = row * TILE_SIZE

replace them with:

if tile == 'empty':
    x = column * TILE_SIZE
    y = row * TILE_SIZE
    animate(player, duration=0.1, pos=(x, y))

Exercise

Verify the player image has changed and moves smoothly.

9.5. Create an enemy

We will create a simple enemy that moves up and down. Add this code near the top just above the draw() function.

enemy = Actor("enemy", anchor=(0, 0), pos=(3 * TILE_SIZE, 6 * TILE_SIZE))
enemy.yv = -1

To make the enemy visible, add this line at the end of the draw() function, after the player is drawn:

enemy.draw()

enemy.yv is the velocity in the y-axis direction (up and down). Add these lines to end of the program (still inside the on_key_down() function) to make the enemy move and reverse velocity when it hits a wall.

# enemy movement
row = int(enemy.y / TILE_SIZE)
column = int(enemy.x / TILE_SIZE)
row = row + enemy.yv
tile = tiles[maze[row][column]]
if not tile == 'wall':
    x = column * TILE_SIZE
    y = row * TILE_SIZE
    animate(enemy, duration=0.1, pos=(x, y))
else:
    enemy.yv = enemy.yv * -1
if enemy.colliderect(player):
    print("You died")
    exit()

Exercise

Verify that the enemy moves up and down and kills the player.

Advanced

Make another enemy that moves horizontally (left and right).

Advanced

The collision detection is quite lenient (i.e. buggy) because it only tests for collisions between the enemy and player when a key is pressed. Define a new function called update() and move the collisions detection there so that is called every frame.

9.6. A locked door and a key

We will add two new tiles to the game. Draw images door.png and key.png and save them in images folder.

Find the tiles list near the top and change it to include the new images, and modify the maze with some number 3s and 4s where you want to new tiles to appear. Mine looks like this:

tiles = ['empty', 'wall', 'goal', 'door', 'key']
unlock = 0

maze = [
    [1, 1, 1, 1, 1, 1, 1, 1],
    [1, 0, 0, 0, 1, 2, 0, 1],
    [1, 0, 1, 0, 1, 1, 3, 1],
    [1, 0, 1, 0, 0, 0, 0, 1],
    [1, 0, 1, 0, 1, 0, 0, 1],
    [1, 0, 1, 4, 1, 0, 0, 1],
    [1, 0, 1, 0, 0, 0, 0, 1],
    [1, 1, 1, 1, 1, 1, 1, 1]
]

At the top of the program, create a new variable to store the number of keys the player is carrying:

unlock = 0

Find the if statement where we test for goal:

if tile == 'goal':
     print("Well done")
     exit()

Modify it like this to also test for the key and door tiles. Since we are modifying a global variable inside a function we must declare it.

global unlock
if tile == 'goal':
    print("Well done")
    exit()
elif tile == 'key':
    unlock = unlock + 1
    maze[row][column] = 0 # 0 is 'empty' tile
elif tile == 'door' and unlock > 0:
    unlock = unlock - 1
    maze[row][column] = 0 # 0 is 'empty' tile

9.7. Finished game

Here is the finished game with all the changes included:

Program 9.2 Finished maze game
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
TILE_SIZE = 64
WIDTH = TILE_SIZE * 8
HEIGHT = TILE_SIZE * 8

tiles = ['empty', 'wall', 'goal', 'door', 'key']
unlock = 0

maze = [
    [1, 1, 1, 1, 1, 1, 1, 1],
    [1, 0, 0, 0, 1, 2, 0, 1],
    [1, 0, 1, 0, 1, 1, 3, 1],
    [1, 0, 1, 0, 0, 0, 0, 1],
    [1, 0, 1, 0, 1, 0, 0, 1],
    [1, 0, 1, 4, 1, 0, 0, 1],
    [1, 0, 1, 0, 0, 0, 0, 1],
    [1, 1, 1, 1, 1, 1, 1, 1]
]

player = Actor("player", anchor=(0, 0), pos=(1 * TILE_SIZE, 1 * TILE_SIZE))
enemy = Actor("enemy", anchor=(0, 0), pos=(3 * TILE_SIZE, 6 * TILE_SIZE))
enemy.yv = -1

def draw():
    screen.clear()
    for row in range(len(maze)):
        for column in range(len(maze[row])):
            x = column * TILE_SIZE
            y = row * TILE_SIZE
            tile = tiles[maze[row][column]]
            screen.blit(tile, (x, y))
    player.draw()
    enemy.draw()

def on_key_down(key):
    # player movement
    row = int(player.y / TILE_SIZE)
    column = int(player.x / TILE_SIZE)
    if key == keys.UP:
        row = row - 1
    if key == keys.DOWN:
        row = row + 1
    if key == keys.LEFT:
        column = column - 1
    if key == keys.RIGHT:
        column = column + 1
    tile = tiles[maze[row][column]]
    if tile == 'empty':
        x = column * TILE_SIZE
        y = row * TILE_SIZE
        animate(player, duration=0.1, pos=(x, y))
    global unlock
    if tile == 'goal':
        print("Well done")
        exit()
    elif tile == 'key':
        unlock = unlock + 1
        maze[row][column] = 0 # 0 is 'empty' tile
    elif tile == 'door' and unlock > 0:
        unlock = unlock - 1
        maze[row][column] = 0 # 0 is 'empty' tile

    # enemy movement
    row = int(enemy.y / TILE_SIZE)
    column = int(enemy.x / TILE_SIZE)
    row = row + enemy.yv
    tile = tiles[maze[row][column]]
    if not tile == 'wall':
        x = column * TILE_SIZE
        y = row * TILE_SIZE
        animate(enemy, duration=0.1, pos=(x, y))
    else:
        enemy.yv = enemy.yv * -1
    if enemy.colliderect(player):
        print("You died")
        exit()




9.8. Ideas for extension

However that is not the end! There are many things you could add to this game.

  • Show the player score.

  • Coins that the player collects to increase score.

  • Trap tiles that are difficult to see and kill the player.

  • Treasure chest that is unlocked with the key and increases score.

  • Instead of ending the game, give the player 3 lives.

  • Add more types of tile to the map: water, rock, brick, etc.

  • Change the player image depending on the direction they are moving.