Build a 2-player maze game with Python Part 4

by Dan Duda

Requirements

  • Python 3.7
  • PyCharm Community (optional)
  • Basic Python knowledge

Goto Part 3

Introduction

In part 3 of this tutorial series we utilized the Recursive Back-Track algorithm to generate a random maze. Now we will look at actually drawing our maze to the screen.

Using PyGame to draw rectangles

Let’s first add some color constants to our constants. BACK_COLOR will be the background color for the entire window. We’ll have a WALL_COLOR for the walls and a MAZE_COLOR for the paths. We’ll also have an UNVISITED_COLOR so that when we watch it generate the map we’ll default areas not visited yet to this color.

1
2
3
4
5
# Define the colors we'll need
BACK_COLOR = (100, 100, 100)
WALL_COLOR = (18, 94, 32)
MAZE_COLOR = (255, 255, 255)
UNVISITED_COLOR = (0, 0, 0)

We don’t want the walls to be a single pixel width since that would be very small and hard to see. Let’s make a constant called BLOCK_SIZE that will specify the width and height of the smallest unit we’ll draw. We’ll set it to 8. This in effect will mean that our drawing pixels will be 8 x 8 real pixels. Since the walls will be a single block thick we’ll make the paths 3 times that. So we’ll have a PATH_WIDTH of 3.

Add the following to our global settings constants

1
2
BLOCK_SIZE = 8  # Pixel size/Wall thickness
PATH_WIDTH = 3 # Width of pathway in blocks

Add a “draw” method to our class that takes a color and an x and y coordinate.

1
2
def draw(self, color, x, y):
pg.draw.rect(self.screen, color, (x * BLOCK_SIZE, y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE))

The “draw.rect” method takes a surface, a color, and a rectangle. We pass in our “screen” variable which is our display surafce we create in the “init” method. Next we pass the color that we passed in. And last we construct a rectangle based on the x and y passed in. We give the rectangle a width and height of BLOCK_SIZE since we decided our smallest unit would be that BLOCK_SIZE. Since each of our rectangles will be size BLOCK_SIZE we need to multiply our x and y by it so that we don’t overlap. So if we pass in 0,0 we’ll have an 8X8 rectangle in the top left corner starting at 0,0. In real pixels the bottom right corner of the rectangle would be 7,7. If we then wanted to draw another rectangle next to it to the right we’d pass in 1,0 but to not overlap the real starting point would be 8,0 or x * BLOCK_SIZE.

The following code would draw two rectangles next to each other. The image below should illustrate how our code is working.

1
2
self.draw(WALL_COLOR, 0, 0)
self.draw(WALL_COLOR, 1, 0)


Let’s see this in action by adding some code to our “run_game” method. Alter the “run_game” method to be the following.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def run_game(self):
self.generate_maze()
self.draw(WALL_COLOR, 0, 0)
self.draw(WALL_COLOR, 1, 0)
pg.display.update()

# Main game loop
run = True
while run:
for event in pg.event.get():
if event.type == pg.QUIT:
run = False

pg.quit()

Running our game now should display the following.


Now that we have our smallest unit of BLOCK_SIZE which is 8x8 we can define a cell. We know we want the path size to be PATH_WIDTH which we defined as 3 so each cell would be a path of 3X3 blocks with a wall to the east and south making a full cell 4X4 blocks. When there’s a connection between cells we just draw over the wall part with the maze color. See illustrations below.


So a 2X2 maze would have 4 cells.


And knocking out the walls might look like.


Drawing our maze

We can now start drawing our maze. We’ll stub out a new method in our class and call it “draw_maze”. The first thing we want to do is clear the entire screen with the background color we defined.

1
2
def draw_maze(self):
self.screen.fill(BACK_COLOR)

Next we want to loop through our maze list. We have constants for the maze width and height so we can do the following. This will give us an x and y coordinate of each cell. We also know that each cell is PATH_WIDTH wide so we’ll add a couple more loops to draw the blocks that make up the path. We then determine the path color depending on whether it has been visited yet or not. We then draw the block passing in the color and the x and y position to draw at. We us x * (PATH_WIDTH + 1) to offset us from the x coordinate of the cell to the position where we want to draw. Remember a cell is the width of the path plus 1 block for the wall. So in this case PATH_WIDTH + 1 gives us 4. Each cell is 4 blocks wide.

1
2
3
4
5
6
7
8
9
for x in range(MAZE_WIDTH):
for y in range(MAZE_HEIGHT):
for py in range(PATH_WIDTH):
for px in range(PATH_WIDTH):
cell_index = self.get_cell_index((x, y))
if self.maze[cell_index] & CellProp.Visited.value:
self.draw(MAZE_COLOR, x * (PATH_WIDTH + 1) + px, y * (PATH_WIDTH + 1) + py)
else:
self.draw(UNVISITED_COLOR, x * (PATH_WIDTH + 1) + px, y * (PATH_WIDTH + 1) + py)

For now we’ll ignore handling the paths. It would be nice to watch our code draw out the maze rather than finishing the “generate_maze” method and then displaying the finished product to the screen. We want the option to call the “draw_maze” method after each cell is processed in the “generate_maze” method. It would also be nice to be able to toggle this behavior so we’ll add a constant to our global settings called “SHOW_DRAW”.

1
SHOW_DRAW = True  # Show the maze being created

And now in the “generate_maze” method we’ll add the following code at the bottom of the while loop. Make sure the code is within the while loop since we want it to run after each iteration.

1
2
3
4
5
if SHOW_DRAW:
self.draw_maze()
pg.display.update()
pg.time.wait(500)
pg.event.pump()

If SHOW_DRAW is True we call our “draw_maze” method and then update the display. We then wait half a second to make sure we can see it drawing out the maze in case it runs too fast. I’ve added a call to pg.event.pump() since we’re not currently handling any events. Since this code all happens before our main game loop start to run we need to make sure any internal events are being handled otherwise we could get in a situation where our app appears to hang and we get the dreaded “not responding” responding message.

In our “run_game” method let’s remove the calls to draw from before so that we now have.

1
2
3
4
5
6
7
8
9
10
11
def run_game(self):
self.generate_maze()

# Main game loop
run = True
while run:
for event in pg.event.get():
if event.type == pg.QUIT:
run = False

pg.quit()

Running the app now should look something like this.


One thing you’ll notice is that our walls are the same color as the background. We filled the entire screen with the background color before drawing the maze so that’s the color that shows through between our cells. We defined a wall color in our constants but haven’t used it yet. What we need to do is draw a rectangle the same size as the entire maze with the wall color before we start drawing the cells. Let’s add some more constants to our global settings to help with that.

1
2
3
CELL_SIZE = BLOCK_SIZE * PATH_WIDTH + BLOCK_SIZE  # extra BLOCK_SIZE to include wall to east and south of cell
MAZE_WIDTH_PX = CELL_SIZE * MAZE_WIDTH
MAZE_HEIGHT_PX = CELL_SIZE * MAZE_HEIGHT

Now that we have the width and height of the maze in pixels we can draw our rectangle. Back in the “draw_maze” method let’s add a call to “draw.rect” after we clear the screen.

1
pg.draw.rect(self.screen, WALL_COLOR, (0, 0, MAZE_WIDTH_PX, MAZE_HEIGHT_PX))

Now when we run it we should see something like this.


Another thing we need to handle is the top and left wall. Recall our cells only include the right and bottom walls. First let’s change our maze width and height constants to include the extra block.

1
2
MAZE_WIDTH_PX = CELL_SIZE * MAZE_WIDTH + BLOCK_SIZE  # extra BLOCK_SIZE to include left edge wall
MAZE_HEIGHT_PX = CELL_SIZE * MAZE_HEIGHT + BLOCK_SIZE # extra BLOCK_SIZE to include top edge wall

We add an extra BLOCK_SIZE to both the width and height of our maze so that it includes the extra two walls. But now we also need to start drawing the maze 1 block to the right and one block down since a wall now occupies that space. We can use offsets to accomplish this. We will do this in the “draw” method.

1
2
3
4
5
6
def draw(self, color, x, y):
x_offset = BLOCK_SIZE
y_offset = BLOCK_SIZE
pg.draw.rect(self.screen, color, (x * BLOCK_SIZE + x_offset,
y * BLOCK_SIZE + y_offset,
BLOCK_SIZE, BLOCK_SIZE))

We’re now including an offset so that the drawing is shifted down and over each time.


That’s looking better. But we still need to remove walls where there are pathways. Let’s return to the “draw_maze” method. We’ll add another loop that will check the cell props for a path to the east or the south.

1
2
3
4
5
6
for p in range(PATH_WIDTH):
if self.maze[y * MAZE_WIDTH + x] & CellProp.Path_S.value:
self.draw(MAZE_COLOR, x * (PATH_WIDTH + 1) + p, y * (PATH_WIDTH + 1) + PATH_WIDTH)

if self.maze[y * MAZE_WIDTH + x] & CellProp.Path_E.value:
self.draw(MAZE_COLOR, x * (PATH_WIDTH + 1) + PATH_WIDTH, y * (PATH_WIDTH + 1) + p)

The full listing for “draw_maze” should now look like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def draw_maze(self):
self.screen.fill(BACK_COLOR)
pg.draw.rect(self.screen, WALL_COLOR, (0, 0, MAZE_WIDTH_PX, MAZE_HEIGHT_PX))

for x in range(MAZE_WIDTH):
for y in range(MAZE_HEIGHT):
for py in range(PATH_WIDTH):
for px in range(PATH_WIDTH):
cell_index = self.get_cell_index((x, y))
if self.maze[cell_index] & CellProp.Visited.value:
self.draw(MAZE_COLOR, x * (PATH_WIDTH + 1) + px, y * (PATH_WIDTH + 1) + py)
else:
self.draw(UNVISITED_COLOR, x * (PATH_WIDTH + 1) + px, y * (PATH_WIDTH + 1) + py)

for p in range(PATH_WIDTH):
if self.maze[y * MAZE_WIDTH + x] & CellProp.Path_S.value:
self.draw(MAZE_COLOR, x * (PATH_WIDTH + 1) + p, y * (PATH_WIDTH + 1) + PATH_WIDTH)

if self.maze[y * MAZE_WIDTH + x] & CellProp.Path_E.value:
self.draw(MAZE_COLOR, x * (PATH_WIDTH + 1) + PATH_WIDTH, y * (PATH_WIDTH + 1) + p)

Running it now we should see it draw a correct maze.


Looking good so far but let’s center the maze on our screen. We’ll add another constant for the top-left corner of the maze so we know where to start drawing.

1
MAZE_TOP_LEFT_CORNER = (SCREEN_WIDTH // 2 - MAZE_WIDTH_PX // 2, SCREEN_HEIGHT // 2 - MAZE_HEIGHT_PX // 2)

The rest of our changes will be in our two drawing methods.

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
def draw_maze(self):
self.screen.fill(BACK_COLOR)
pg.draw.rect(self.screen, WALL_COLOR, (MAZE_TOP_LEFT_CORNER[0], MAZE_TOP_LEFT_CORNER[1],
MAZE_WIDTH_PX, MAZE_HEIGHT_PX))

for x in range(MAZE_WIDTH):
for y in range(MAZE_HEIGHT):
for py in range(PATH_WIDTH):
for px in range(PATH_WIDTH):
cell_index = self.get_cell_index((x, y))
if self.maze[cell_index] & CellProp.Visited.value:
self.draw(MAZE_COLOR, x * (PATH_WIDTH + 1) + px, y * (PATH_WIDTH + 1) + py)
else:
self.draw(UNVISITED_COLOR, x * (PATH_WIDTH + 1) + px, y * (PATH_WIDTH + 1) + py)

for p in range(PATH_WIDTH):
if self.maze[y * MAZE_WIDTH + x] & CellProp.Path_S.value:
self.draw(MAZE_COLOR, x * (PATH_WIDTH + 1) + p, y * (PATH_WIDTH + 1) + PATH_WIDTH)

if self.maze[y * MAZE_WIDTH + x] & CellProp.Path_E.value:
self.draw(MAZE_COLOR, x * (PATH_WIDTH + 1) + PATH_WIDTH, y * (PATH_WIDTH + 1) + p)

def draw(self, color, x, y):
x_offset = MAZE_TOP_LEFT_CORNER[0] + BLOCK_SIZE
y_offset = MAZE_TOP_LEFT_CORNER[1] + BLOCK_SIZE
pg.draw.rect(self.screen, color, (x * BLOCK_SIZE + x_offset,
y * BLOCK_SIZE + y_offset,
BLOCK_SIZE, BLOCK_SIZE))

We change line 3 where we draw the wall colored rectangle to use our new constant for the top-left corner. In lines 24 and 25 we change the offsets to include the new constant as well.

Running it we should see a nicely centered maze.


To finish out part 4 of this series let’s make some minor changes to get this ready to be a playable game. Firstly let’s change some of our global settings. We’ll increase the screen size and make a larger maze. I’ve found that a 36 X 30 maze will just fit a 1200 X 1024 screen and is challenging enough to make a good game.

1
2
3
4
5
SCREEN_WIDTH = 1200
SCREEN_HEIGHT = 1024
# Maze Size: 36 X 30 is max size for screen of 1200 X 1024
MAZE_WIDTH = 36 # in cells
MAZE_HEIGHT = 30 # in cells

We’ll move our call to “generate_maze” into an “initialize” method. Don’t worry, we’ll be adding more to this method in part 5.

1
2
def initialize(self):
self.generate_maze()

And our “run_game” method should now look like this:

1
2
3
4
5
6
7
8
9
10
11
def run_game(self):
self.initialize()

# Main game loop
run = True
while run:
for event in pg.event.get():
if event.type == pg.QUIT:
run = False

pg.quit()

And now that we are generating a large maze let’s comment out or remove the timeout call we have in the “generate_maze” method.

1
2
3
4
5
if SHOW_DRAW:
self.draw_maze()
pg.display.update()
#pg.time.wait(500)
pg.event.pump()

And now running it again produces something like this:


Download code: maze.zip

Goto Part 5