Games and Programming in the Wild

A Maze


Learning Objectives

  • You can represent a level as a 2D grid of tiles, written as a list of strings.
  • You can convert between grid coordinates (row, column) and pixel coordinates.
  • You can move a player one tile at a time and block movement into walls.
  • You can detect when the player reaches a goal tile.

The Game of Life used a grid as a simulation — every cell updated by a rule. This chapter uses a grid as space: a maze the player walks through, where some tiles are walls you cannot pass and one tile is the exit. The grid is the same 2D-list idea, but the question it answers is different. In Life we asked “what is the next state of this cell?” Here we ask “can the player stand on this tile?”

This is how a great many games represent their world. A platformer’s level, a strategy game’s map, a dungeon’s floor plan — all are grids of tiles, where the tile at a position tells you what is there. Learning to treat a grid as a map you move across is one of the most reusable ideas in the part.

Drawing a Level as Text

The neat trick that makes maze levels pleasant to work with is writing them as a list of strings, one string per row, where each character is a tile. A # is a wall, a . is open floor, P marks where the player starts, and G is the goal:

LEVEL = [
"###########",
"#P..#.....#",
"#.#.#.###.#",
"#.#...#...#",
"#.#####.#.#",
"#.......#G#",
"###########",
]

You can see the maze in the source code. This is a real advantage: editing a level is editing text, and a wrong wall is visible at a glance. Multidimensional Lists for Grid-Shaped Data introduced mazes as grid-shaped data and showed how to address a grid by row and column. Here you are meeting a compact text encoding for that idea: each string is one row of the maze. It doubles as a level editor — to design a new maze, you type a new block of characters.

From Characters to a Grid of Walls

Working with the characters directly every frame would be awkward, so at startup we read the level once and turn it into something easier to query: a 2D list of booleans saying whether each tile is a wall, plus the starting positions of the player and the goal. This is a parsing step, the same kind of “read the raw form, build the structured form” move from the From Rows to Records chapter:

def load_level(self):
self.walls = []
for row, line in enumerate(LEVEL):
wall_row = []
for col, ch in enumerate(line):
wall_row.append(ch == "#")
if ch == "P":
self.player_row = row
self.player_col = col
elif ch == "G":
self.goal_row = row
self.goal_col = col
self.walls.append(wall_row)
self.won = False

The function enumerate gives both the index and the value as we loop — here, the row number and the row’s text, then the column number and the character. wall_row.append(ch == "#") stores True for walls and False for everything else. When we hit a P or G, we record the position. After this runs, self.walls[row][col] is True exactly where there is a wall, and the player and goal positions are known.

Notice that the player’s position is stored as a row and column, not as pixel coordinates. The player lives on the grid, moving one whole tile at a time. This is the defining choice of a tile-based game, and it makes the wall logic simple: to check whether the player can move somewhere, we just ask whether that grid square is a wall.

Grid Coordinates Versus Pixel Coordinates

There are now two coordinate systems in play, and keeping them straight is the main bookkeeping of a tile game. Grid coordinates are (row, col) — which square. Pixel coordinates are (x, y) — where on the screen. To draw a tile, you convert grid to pixels by multiplying by the tile size:

TILE = 12 # each tile is 12 pixels across
pixel_x = col * TILE
pixel_y = row * TILE

So the tile at row 3, column 5 is drawn at pixel (5 * 12, 3 * 12) = (60, 36). The conversion is always this multiply, and it is worth saying the direction out loud each time: column maps to x, row maps to y. Mixing them up — drawing at (row * TILE, col * TILE) — produces a maze that looks transposed and wrong, and it is one of the most common bugs in tile games.

Moving One Tile, If the Tile Is Open

The player moves a whole tile per key press, so we use btnp (one move per press) rather than btn. But before moving, we check whether the destination is a wall. The clean way to write this is a single helper that takes a direction as a row-and-column change:

def try_move(self, d_row, d_col):
new_row = self.player_row + d_row
new_col = self.player_col + d_col
if not self.walls[new_row][new_col]:
self.player_row = new_row
self.player_col = new_col

The name try_move is deliberate: it attempts the move and only applies it if the target tile is not a wall. If self.walls[new_row][new_col] is True, the body does nothing — the player stays put, having walked into a wall.

We compute the new proposed state, check it, and only update the actual state if the check passes. This is a common pattern in games: compute a candidate new state, check it, and apply it if it is valid. It keeps the actual state consistent and avoids having to “undo” a move if it turns out to be illegal.

The four arrow keys each call try_move with the right direction:

def update(self):
if self.won:
return
if pyxel.btnp(pyxel.KEY_LEFT):
self.try_move(0, -1)
if pyxel.btnp(pyxel.KEY_RIGHT):
self.try_move(0, 1)
if pyxel.btnp(pyxel.KEY_UP):
self.try_move(-1, 0)
if pyxel.btnp(pyxel.KEY_DOWN):
self.try_move(1, 0)

Left and right change the column (d_col of -1 or +1); up and down change the row. Up is -1 because row 0 is at the top and rows increase downward — the same upside-down y from the game-loop chapter, now in grid form.

Because the maze is enclosed by a solid border of #, the player can never reach the edge of the grid, so self.walls[new_row][new_col] never goes out of bounds. If your level did not have a solid border, you would need the same bounds check that count_neighbours used in the previous chapter — check new_row and new_col are inside the grid before indexing. The border wall is doing that job for us here, which is a common and deliberate level-design choice.

Reaching the Goal

Winning is reaching the goal tile, which is a simple comparison of the player’s grid position to the goal’s:

if self.player_row == self.goal_row and self.player_col == self.goal_col:
self.won = True

Because everything is tile-aligned, this is an exact equality check — no distance maths, no overlap test. The player is either standing on the goal square or not. This exactness is one of the quiet pleasures of tile-based games: positions are whole numbers, so “are these in the same place?” is just ==.

The Complete Game

import pyxel
LEVEL = [
"###########",
"#P..#.....#",
"#.#.#.###.#",
"#.#...#...#",
"#.#####.#.#",
"#.......#G#",
"###########",
]
TILE = 12
class App:
def __init__(self):
self.load_level()
width = len(LEVEL[0]) * TILE
height = len(LEVEL) * TILE
pyxel.init(width, height, title="Maze")
pyxel.run(self.update, self.draw)
def load_level(self):
self.walls = []
for row, line in enumerate(LEVEL):
wall_row = []
for col, ch in enumerate(line):
wall_row.append(ch == "#")
if ch == "P":
self.player_row = row
self.player_col = col
elif ch == "G":
self.goal_row = row
self.goal_col = col
self.walls.append(wall_row)
self.won = False
def try_move(self, d_row, d_col):
new_row = self.player_row + d_row
new_col = self.player_col + d_col
if not self.walls[new_row][new_col]:
self.player_row = new_row
self.player_col = new_col
def update(self):
if self.won:
return
if pyxel.btnp(pyxel.KEY_LEFT):
self.try_move(0, -1)
if pyxel.btnp(pyxel.KEY_RIGHT):
self.try_move(0, 1)
if pyxel.btnp(pyxel.KEY_UP):
self.try_move(-1, 0)
if pyxel.btnp(pyxel.KEY_DOWN):
self.try_move(1, 0)
if (self.player_row == self.goal_row
and self.player_col == self.goal_col):
self.won = True
def draw(self):
pyxel.cls(0)
for row in range(len(self.walls)):
for col in range(len(self.walls[row])):
if self.walls[row][col]:
pyxel.rect(col * TILE, row * TILE, TILE, TILE, 5)
# goal
pyxel.rect(self.goal_col * TILE + 2, self.goal_row * TILE + 2,
TILE - 4, TILE - 4, 10)
# player
pyxel.rect(self.player_col * TILE + 2, self.player_row * TILE + 2,
TILE - 4, TILE - 4, 11)
if self.won:
pyxel.text(20, 4, "YOU WIN!", 7)
if __name__ == "__main__":
App()

The window size is computed from the level — len(LEVEL[0]) columns wide, len(LEVEL) rows tall, each TILE pixels. Change the level text and the window resizes to fit. Try drawing your own maze in the LEVEL strings; as long as you keep one P, one G, and a solid border of #, it will work.

Maze with classes

0 / 40 points

Refactor the maze game from the chapter into a class-based Pyxel program.

Keep the chapter mechanics:

  • the level is written as a list of strings,
  • # means wall, P marks the player start, and G marks the goal,
  • the player moves one grid tile per key press,
  • movement into walls is blocked,
  • and reaching the goal wins the game.

Game rules to implement:

  • each character in the level is one tile,
  • # tiles are walls,
  • . tiles are open floor,
  • P is the player’s starting tile and is otherwise treated as open floor,
  • G is the goal tile and is otherwise treated as open floor,
  • out-of-bounds positions count as walls,
  • each arrow-key press attempts one move by one row or one column,
  • blocked moves leave the player in the same tile,
  • entering the goal tile sets won to True,
  • and after winning, further movement attempts should not move the player.

The mechanics must live in model classes that can be tested without Pyxel. The Pyxel App should parse the level through the model, translate input into model method calls, and draw the current model state.

Required classes:

ClassContract
MazeLevel(lines)parses the level strings and stores walls, start, goal, width, and height
MazeLevel.in_bounds(self, row, col)returns whether a grid position is inside the level
MazeLevel.is_wall(self, row, col)returns True for wall tiles and out-of-bounds positions
MazeLevel.tile_to_pixel(self, row, col, tile_size)returns (x, y) pixel coordinates for a grid position
Player(row, col)stores the player’s grid position
Player.try_move(self, d_row, d_col, level)moves if the destination is open and returns whether movement happened
MazeGame(lines)owns level, player, and won
MazeGame.try_move(self, d_row, d_col)moves the player and updates won when the goal is reached
Appowns one MazeGame, handles Pyxel input and drawing

Pyxel integration requirements:

  • App.__init__() builds MazeGame(LEVEL) and sizes the window from the level dimensions,
  • App.update() maps pyxel.btnp arrow-key presses to game.try_move(...),
  • App.draw() draws walls by querying game.level,
  • App.draw() draws the goal and player from grid positions converted with tile_to_pixel,
  • do not call pyxel from MazeLevel, Player, or MazeGame,
  • do not start the app when the file is imported,
  • and guard the launch with if __name__ == "__main__": App().

Check Your Understanding

  1. Why is it convenient to write the level as a list of strings?
  2. What is the difference between grid coordinates and pixel coordinates, and how do you convert from one to the other?
  3. Why is the player’s position stored as a row and column rather than as pixel x and y?
  4. How does try_move prevent the player from walking through walls?
  5. Why can the win check be a simple == comparison rather than a distance or overlap test?

AI Study Aid

Create a chapter diagram

We're looking into to what extent AI-generated diagrams could help with studying.

Use this study aid to generate an AI-generated visual summary of the material you just studied. Each diagram style emphasizes the content in a different way, so you can choose the focus that feels most useful.

Using the diagram generator is voluntary and does not affect your course progress. We encourage you to try it out and see if it helps, but it's totally up to you! Your answers help us understand how to make better study aids in the future.

Diagrams tried: 0