I build maze solving game using Pygame
: Check it out
What Technologies I used
- Python (Programming Language)
- Pytest (Unit testing)
- Pygame (Visualization & Keyboard Events)
- Pygbag (Python to wasm tool for Web Distribution)
- Github Pages (Hosting)
- Github Action (CI pipeline)
- Github (Version Tracking)
How did I build It
Codebase
Design
The game is built using the MVC (Model-View-Controller) architecture and is based on Wesley Werner’s repository. I highly recommend checking out his repository for better understanding.
- Important Files
main.py
: Entry point for the game applicationsrc
: Containing all of the python source codelogger.py
: Defines the logger of the gameevent.py
: Defines different type of event in the game environmentmodel.py
: Defines the Game Engine of gameview.py
: Defines the Visual of the gamemaze_game
: Contains the logic and maze generation code.maze_visualization
: Contains helper function for game visuals
test
: Contains the test written for the game
Maze Generation
I used the Randomized Prim’s algorithm for generating the maze, which is a popular choice for maze generation. I followed Orestis Zekai’s article on maze generation using Python, which was helpful for the basic setup. However, I had some difficulty following the final parts of the article, so I implemented my own version. You can find the final version of my implementation in this file on Github.
Maze Board Value Enum
The MazeGameObject
enum improves the readability of the code and creates a layer of abstraction between the values on the game board and the maze generation code.
class MazeGameObject(Enum):
PATH = 0
WALL = 1
GOAL = 2
EMPTY = -1
PLAYER_TILE = 7
VISITED_TILE = 3
Maze Generation Code breakdown
- Initialize the maze with Empty object values (Exactly same as Zekai)
def init_maze(height, width):
maze = []
for _ in range(height):
maze_row = [MazeGameObject.EMPTY.value for _ in range(width)]
maze.append(maze_row)
return maze
- Pick a start position by randomly selecting a location in the maze that does not lie on the edge. (Exactly same as Zekai)
from random import random
def get_start_pos(row, col):
start_row, start_col = int(random() * (row-2))+1, int(random() * (col-2))+1
return start_row, start_col
- Function fills the empty cells with wall value (Exactly same as Zekai)
def fill_walls(maze)
for i, _ in enumerate(maze):
for j in range(len(maze[0])):
if maze[i][j] == MazeGameObject.EMPTY.value:
maze[i][j] = MazeGameObject.WALL.value
- To determine the number of paths surrounding a given cell in the maze, we examine the adjacent cells using
if
statements. If the cells are within the grid’s boundaries and have the same value as the path, thes_cell_count
is incremented.
def get_surrounding_cell_count(cell, maze):
s_cell_count = 0
if (cell[0] > 0 and
maze[cell[0] - 1][cell[1]] == MazeGameObject.PATH.value):
s_cell_count += 1
if (cell[0] < len(maze) - 1 and
maze[cell[0] + 1][cell[1]] == MazeGameObject.PATH.value):
s_cell_count += 1
if (cell[1] > 0 and
maze[cell[0]][cell[1] - 1] == MazeGameObject.PATH.value):
s_cell_count += 1
if (cell[1] < len(maze[0]) - 1 and
maze[cell[0]][cell[1] + 1] == MazeGameObject.PATH.value):
s_cell_count += 1
return s_cell_count
- To create entry and exit points for the maze after it’s generated, we break the top and bottom walls of the maze into paths that are adjacent to existing paths. This creates a long and winding path that leads from the entrance to the exit of the maze. This step is important because it ensures that there is a clear path from the entrance to the exit, and that the maze is solvable.
def create_entry_exit(maze):
row, col = len(maze), len(maze[0])
start_point, exit_point = (0, 0), (row - 1, col - 1)
# Set entrance and exit
for i in range(col):
if maze[1][i] == MazeGameObject.PATH.value:
maze[0][i] = MazeGameObject.PLAYER_TILE.value
start_point = (0, i)
break
for i in range(col - 1, 0, -1):
if maze[row - 2][i] == MazeGameObject.PATH.value:
maze[row - 1][i] = MazeGameObject.GOAL.value
exit_point = (row - 1, i)
break
return start_point, exit_point, maze
Generation the maze
The first step in generating the maze is to initialize the board using the
init_maze
function.maze = init_maze(n_row, n_col)
We need to determine the starting position of the maze, which we achieve using the get_start_pos function. This function ensures that the starting position is always a path cell and returns its coordinates. We then set the start_pos variable to these coordinates.
start_pos = get_start_pos(maze) maze[start_pos[0]][start_pos[1]] = MazeGameObject.PATH.value
We assign the surrounding cells to the
start_pos
to the value of aWALL
. Then we add the defined walls the our queue, in my case a set namedwall_list
maze[start_pos[0] - 1][start_pos[1]] = MazeGameObject.WALL.value maze[start_pos[0]][start_pos[1] - 1] = MazeGameObject.WALL.value maze[start_pos[0]][start_pos[1] + 1] = MazeGameObject.WALL.value maze[start_pos[0] + 1][start_pos[1]] = MazeGameObject.WALL.value wall_list = set() wall_list.add((start_pos[0] - 1, start_pos[1])) wall_list.add((start_pos[0], start_pos[1] - 1)) wall_list.add((start_pos[0], start_pos[1] + 1)) wall_list.add((start_pos[0] + 1, start_pos[1]))
After the initialization steps, the maze generation algorithm repeats the following steps until all the walls in the maze have been processed:
- Pick a random wall from the
wall_list
. - Count the number of surrounding cells of the wall that are paths using the
get_surrounding_cell_count
function. - If the count is greater than 1, remove the wall from the
wall_list
since it has been processed. - If the count is 1, we process the wall and remove the wall from the
wall_list
. - We continue this process until the
wall_list
is empty.
- Pick a random wall from the
After all the cells are processed, we fill the remaining cells with walls using the fill_walls function.
Finally, we create an entry and exit in the maze using the
create_entry_exit
function.from random import choices while wall_list: rand_wall = choices(list(wall_list), k=1)[0] s_cell_count = get_surrounding_cell_count(rand_wall, maze) if s_cell_count < 2: ### Wall Processing block wall_list.remove(rand_wall) fill_walls(maze) entry_point, exit_point, maze = create_entry_exit(maze) return entry_point, exit_point, maze
The “Row case” and “Col case” refer to the two possibilities for which direction the wall being processed is oriented: either horizontally (a “row”) or vertically (a “column”).
- For each of these cases, there are two sub-cases, based on whether the wall is at the edge of the grid or not.
- If the wall being processed is not at the edge of the grid, the code checks the cells on either side of the wall to see if one of them is a path and the other empty (i.e. possible connected). If so, the wall is removed from the wall list, meaning it is no longer available to be broken down.
- If the wall being processed is at the edge of the grid, the code only checks the cells on the side that is not at the edge. This is because the cells on the other side are already known to be walls, since they lie outside the bounds of the grid.
Row Cases: Case 1: Case 2: Path Empty | | Wall Wall | | Empty Path Col Cases: Case 1: Path - Wall - Empty Case 2: Empty - Wall - Path
if (rand_wall[0] > 0 and rand_wall[0] + 1 < n_row): # row case if (maze[rand_wall[0] - 1][rand_wall[1]] == MazeGameObject.WALL.value and maze[rand_wall[0] + 1][rand_wall[1]] == MazeGameObject.PATH.value): # Case 2 maze[rand_wall[0]][rand_wall[1]] = MazeGameObject.PATH.value maze[rand_wall[0] - 1][rand_wall[1]] = MazeGameObject.WALL.value maze[rand_wall[0]][rand_wall[1] - 1] = MazeGameObject.WALL.value maze[rand_wall[0]][rand_wall[1] + 1] = MazeGameObject.WALL.value wall_list.add((rand_wall[0] - 1, rand_wall[1])) wall_list.add((rand_wall[0], rand_wall[1] + 1)) wall_list.add((rand_wall[0], rand_wall[1] - 1)) if (maze[rand_wall[0] + 1][rand_wall[1]] == MazeGameObject.WALL.value and maze[rand_wall[0] + 1][rand_wall[1]] == MazeGameObject.PATH.value): # Case 1 # Similar logic as case 2 block if (rand_wall[1] > 0 and rand_wall[1] + 1 < n_col): ### similar logic as above row cases
Maze Visualization
Visualizing the maze requires two main functions, draw_maze
and draw_tile
which are defined here. The function communicates using two data classes called Tile
and ScreenSize
. This approach makes the visualization independent of the value of the tile and treats each tile equally. In other words, the function doesn’t need to know what the tile represents in terms of the maze structure, it only needs to know the dimensions of the tile and the screen size to properly display it on the screen. This separation of concerns makes the code easier to maintain and modify in the future, as changes to the tile value won’t affect the visualization code.
from dataclasses import dataclass
@dataclass
class Tile:
"""Maze tile data object definition."""
tile_color: Color
border_color: Color
tile_space: ScreenSize
@dataclass
class ScreenSize:
"""Screen size dataclass"""
width: int
height: int
top_left_x: int
top_left_y: int
In the draw_maze
function, the first step is to calculate the total dimensions and define the screen size of the maze in pixels. The maze will occupy all of the space on the screen except for 50 pixels on the left and right sides, and 75 pixels on the top and 25 pixels on the bottom. Once we have the maze size in pixels, we need to compute the size of individual tiles using integer division.
Next, we iterate through the grid and set the appropriate tile color based on the grid cell value. We calculate the tile’s top-left coordinates by multiplying the index with the tile size and adding the maze board’s top-left coordinates. Then, we call the draw_tile
function to render the tile on the screen.
board = maze.get_board() # maze generated previously
row, col = len(board), len(board[0])
maze_board_top, maze_board_left = 75, 50
maze_screen_width = self.screen_width - 100
maze_screen_height = self.screen_height - 100
tile_width, tile_height = maze_screen_width // col, maze_screen_height // row
for i in range(row):
for j in range(col):
if board[i][j] == MazeGameObject.WALL.value:
tile_color = WALL_COLOR
elif board[i][j] == MazeGameObject.GOAL.value:
tile_color = GOAL_COLOR
elif board[i][j] == MazeGameObject.VISITED_TILE.value:
tile_color = VISITED_COLOR
elif board[i][j] == MazeGameObject.PLAYER_TILE.value:
tile_color = PLAYER_COLOR
else:
tile_color = PATH_COLOR
tile_space = ScreenSize(tile_width, tile_height,
maze_board_left + j * tile_width,
maze_board_top + i * tile_height)
tile = Tile(tile_color, TILE_BORDER_COLOR, tile_space)
self.draw_tile(tile)
def draw_tile(self, tile):
boarder_thickness = int(tile.tile_space.width * 0.1)
pygame.draw.rect(
self.screen, tile.tile_color,
pygame.Rect(tile.tile_space.top_left_x, tile.tile_space.top_left_y,
tile.tile_space.width, tile.tile_space.height), 0)
if boarder_thickness > 0:
pygame.draw.rect(
self.screen, tile.border_color,
pygame.Rect(tile.tile_space.top_left_x,
tile.tile_space.top_left_y, tile.tile_space.width,
tile.tile_space.height), boarder_thickness)
Maze Animation
Final Thoughts
This project to build a maze game using pygame was a valuable experience for me, and I highly recommend it as a way to learn pygame and prototype game ideas. Pygame provides a great environment for individuals to develop simple and small games, and the process can be very enjoyable. I hope you found my explanation of the project informative and enjoyable.