A4: Pac-man Value Function
Prerequisites: the Game of Life project (this assignment reuses the Grid class you built there)
Learning Objective: See how precomputing one grid of “distance to a target” values turns an expensive search problem into a cheap, repeated lookup — and get a first, informal look at the idea behind breadth-first search before we cover it formally.
Background
Section titled “Background”Suppose you’re writing a simple version of Pac-man, and you want a ghost to chase Pac-man through the maze. One approach: every time the ghost needs to move, search for a shortest path from the ghost to Pac-man, and take the first step of that path. That works, but it means re-running a search for every ghost, every single move — wasteful, especially if you have several ghosts all chasing the same target.
Here’s a better idea. Instead of asking “what’s the shortest path from this ghost to Pac-man,” flip the question around: “for every open square in the maze, how many steps would it take to reach Pac-man’s square?” If you compute that once, as a grid of numbers the same shape as the maze, then every ghost can chase Pac-man using the same grid: at each step, a ghost just looks at its neighboring squares and moves to whichever one has the smallest number. No further searching required — the hard work was done once, up front.
This grid of “cost to reach the target” numbers is the value function of the maze (the terminology is borrowed from reinforcement learning, where a value function says how good it is to be in a given state — here, “good” just means “close to Pac-man”). You’ll build it as a class that reuses the Grid class from the Game of Life project to store the maze.
What a value function is, precisely
Section titled “What a value function is, precisely”Given a maze (a grid of open squares and walls) and a single target square, the value function is a grid of the same width and height as the maze, where the value at (row, col) is:
0, if(row, col)is the target itself;- the minimum number of moves needed to get from
(row, col)to the target, moving one square at a time (up, down, left, or right — no diagonals) through open squares, otherwise; - some sentinel value, like
Noneor-1, if(row, col)can’t reach the target at all (for example, it’s walled off in its own pocket of the maze).
This is (a simplified form of) breadth-first search
Section titled “This is (a simplified form of) breadth-first search”You may not immediately see how to compute this without trying every possible path to the target, which sounds expensive. Here’s the trick: work outward from the target, one “ring” of distance at a time, rather than searching from any one starting square toward the target.
- The target has cost 0.
- Every open neighbor of the target has cost 1.
- Every open, not-yet-labeled neighbor of those squares has cost 2.
- And so on, outward, layer by layer, until every reachable square has a cost.
Each square only needs to be visited once: as soon as a square gets a cost, that cost is final (the first time you reach a square, expanding outward layer by layer, is necessarily the shortest way to reach it). This layer-by-layer expansion is exactly what breadth-first search does — you’ll see it again, in a more general form, later in the course. For now, you can implement it with a queue: a list you add new squares to at one end and take them from the other, so that you finish labeling everything at distance before you start labeling anything at distance .
Reusing the Grid class
Section titled “Reusing the Grid class”Reuse the Grid class you wrote for the Game of Life project — it already knows how to load a 2D grid of cells from a text file and provides:
get_width()/get_height()get_cell(row, col)— returns the contents of a cell (for example, whether it’s a wall)
Note that get_cell wraps at the edges, the same way it did for Game of Life — a row or column just past one edge of the grid is treated as the opposite edge, rather than out of bounds. That’s not a bug to work around; the real Pac-man’s maze wraps the same way (walk off the left edge, appear on the right). If you don’t want a particular maze to wrap, surround it with a border of wall cells so nothing open ever sits on the boundary.
Create a new class, ValueFunction, that has a Grid (the same composition pattern as GameOfLife, not inheritance):
class ValueFunction: def __init__(self, filename): self.grid = Grid(filename)
def compute_value_function(self, target_row, target_col): # Returns a list of lists of ints (or None for walls/unreachable cells), # the same width and height as the maze, where costs[target_row][target_col] == 0. passWhat to do
Section titled “What to do”- Implement
compute_value_function(target_row, target_col), using the layer-by-layer approach described above. Build a queue, seed it with the target square (at cost 0), and repeatedly take a square off the queue, look at its open, not-yet-labeled neighbors, give each of them a cost one more than the current square’s, and add them to the queue. - Write a
print_value_function(costs)function (plain text — no graphics needed) that prints the grid of costs, so you can visually check your work. Print something clearly distinct (like#) in place of walls, and something like.in place of the sentinel value, for any open square that turned out to be unreachable. - Test on at least two mazes: a simple open rectangle with no walls (where the costs should look like a diamond radiating out from the target), and a maze with at least one wall that forces costs to route around it (where the shortest distance is not just the straight-line distance).
- Write
choose_ghost_move(row, col, costs), which takes a ghost’s current position and an already-computed cost grid, and returns the neighboring open square with the smallest cost (this is the one-step “chase” policy — no searching, just a lookup at each of up to four neighbors). - Put a ghost down anywhere reachable in one of your test mazes, and repeatedly call
choose_ghost_moveto walk it toward the target, printing its position each step. Confirm that it reaches the target in exactly as many moves as its starting square’s cost — if it doesn’t, something is wrong with eithercompute_value_functionorchoose_ghost_move.
Feedback
Section titled “Feedback”At the top of your main Python file, add a comment that answers these questions:
- About how long did the assignment take you?
- What was hard?
- What was easy?
- Was anything in the assignment unclear?
We use your answers to improve the assignment.
Grading and requirements
Section titled “Grading and requirements”compute_value_functionlabels every reachable open square with the correct shortest number of moves to the target, and correctly leaves unreachable squares (walled off from the target) with the sentinel value.- The algorithm expands outward layer by layer (each square is labeled once, with a cost based on an already-labeled neighbor) rather than, say, repeatedly rescanning the whole grid.
- Test output demonstrates both a wall-free maze and a maze where at least one wall forces a detour.
choose_ghost_movealways moves to a strictly lower-cost neighbor, and a ghost walked with it reaches the target in exactlycosts[start_row][start_col]steps.
Challenge problems (no extra points, not required)
Section titled “Challenge problems (no extra points, not required)”- Multiple targets. Extend
compute_value_functionto accept a list of target squares (say, several pieces of fruit) and compute, for every square, the distance to the nearest one. (Hint: what should the queue be seeded with?) - Multiple ghosts, one target. Place several ghosts in the maze and walk them all toward the same target using the same value function grid, one
choose_ghost_movecall per ghost per turn. - Compare to Manhattan distance. For a maze with walls, compare each square’s true value-function cost to the Manhattan distance to the target. Where do they differ most, and why?