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 = FalseThe 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 * TILEpixel_y = row * TILESo 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_colThe 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, soself.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 thatcount_neighboursused in the previous chapter — checknew_rowandnew_colare 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 = TrueBecause 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,Pmarks the player start, andGmarks 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,Pis the player’s starting tile and is otherwise treated as open floor,Gis 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
wontoTrue, - 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:
| Class | Contract |
|---|---|
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 |
App | owns one MazeGame, handles Pyxel input and drawing |
Pyxel integration requirements:
App.__init__()buildsMazeGame(LEVEL)and sizes the window from the level dimensions,App.update()mapspyxel.btnparrow-key presses togame.try_move(...),App.draw()draws walls by queryinggame.level,App.draw()draws the goal and player from grid positions converted withtile_to_pixel,- do not call
pyxelfromMazeLevel,Player, orMazeGame, - do not start the app when the file is imported,
- and guard the launch with
if __name__ == "__main__": App().
Check Your Understanding
- Why is it convenient to write the level as a list of strings?
- What is the difference between grid coordinates and pixel coordinates, and how do you convert from one to the other?
- Why is the player’s position stored as a row and column rather than as pixel
xandy? - How does
try_moveprevent the player from walking through walls? - 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.