A2: Game of Life
This assignment deliberately skips graphics/UI — for now, the focus is entirely on the simulation logic. If you haven’t seen the Game of Life before, playgameoflife.com is a good place to play with an interactive version before you start coding.
Prerequisites: multi-dimensional Python lists, basic Python classes and objects
Learning Objective: Practice designing a class that encapsulates a 2D data representation, and composing that class inside another class rather than inheriting from it. Then see how a handful of simple, local rules — applied simultaneously to every cell — can produce complex, sometimes surprising, global behavior.
Background
Section titled “Background”In 1970, the mathematician John Conway invented a simple game with no players. You set up a starting grid of “alive” and “dead” cells, and then just watch: every cell’s fate in the next generation is decided entirely by how many of its neighbors are currently alive. No dice, no choices, no input after the first generation — the whole future of the grid is determined the moment you set the starting pattern.
Despite these simple rules, the Game of Life produces surprisingly rich behavior. Some patterns die out immediately. Some settle into a stable shape and never change again. Some oscillate forever between a few states. And some, like the famous glider, slide diagonally across the grid forever, unchanged in shape but shifted in position. It turns out that the Game of Life is powerful enough to simulate a general-purpose computer — but you don’t need to know any of that to implement it. You just need to implement the rules correctly, generation after generation.
The rules
Section titled “The rules”The grid is made of cells, each either alive or dead. Every cell has (up to) eight neighbors: the cells directly up, down, left, right, and the four diagonals. This set of eight surrounding cells is called the cell’s Moore neighborhood.
At each time step, every cell’s next state is determined by its current state and how many of its eight neighbors are currently alive:
- A live cell with fewer than two live neighbors dies, as if by underpopulation.
- A live cell with two or three live neighbors survives to the next generation.
- A live cell with more than three live neighbors dies, as if by overpopulation.
- A dead cell with exactly three live neighbors becomes alive, as if by reproduction.
The tricky part is that every cell updates simultaneously, based on the current generation, not on cells you’ve already updated in the new generation. If you update cells one at a time in place, a cell you update early will change the neighbor counts used by cells you update later, which is not what the rules describe. You’ll need to compute the whole next generation from the current one and then switch over, rather than editing the grid as you go.
The Grid class
Section titled “The Grid class”Before you can implement the rules, you need somewhere to put the cells. Write a general-purpose Grid class that knows nothing about the Game of Life specifically — just that it stores a 2D grid of values, loaded from a text file (one line per row, one character per column), with a way to read and write individual cells.
You’ll need some internal representation to actually hold the cells — an attribute, set in __init__, that the rest of the class’s methods read from and write to. The most natural choice is a list of lists: one outer list, where each element is itself a list holding the characters of one row. You can build this with a single loop over the lines of the file. Start with an empty list (say, self.data = []). Then, for each line you read from the file, turn that line into a row: strip off the trailing newline character (line.rstrip("\n")), turn the remaining string into a list of its individual characters (list(...) does this directly, since a string is already a sequence of characters), and append that row to self.data. By the time you’ve read every line, self.data is a list of lists — the outer index selects a row, and the inner index selects a column within that row — which is exactly the shape get_cell(row, col) and set_cell(row, col, value) need in order to index in as self.data[row][col].
class Grid: def __init__(self, filename): # You write this: read the file and store its contents as a 2D structure pass
def get_width(self): # You write this pass
def get_height(self): # You write this pass
def get_cell(self, row, col): # You write this: wrap row and col so indices outside the grid # wrap around to the opposite edge, rather than erroring pass
def set_cell(self, row, col, value): # You write this: same wraparound behavior as get_cell pass
def __str__(self): rows = [] for row in range(self.get_height()): rows.append("".join(self.get_cell(row, col) for col in range(self.get_width()))) return "\n".join(rows)Make get_cell and set_cell treat the grid as a torus: if row or col falls outside [0, height) or [0, width), wrap it back around with % (for example, row % self.get_height()) instead of raising an error. That means the bottom row’s neighbors include the top row, and the rightmost column’s neighbors include the leftmost column — the grid has no edges. This isn’t optional decoration: without it, boundary cells have fewer neighbors than interior cells for no meaningful reason, which makes a pattern behave differently near the edge of your grid than it would on an infinite one. Building the wraparound into Grid means every other method — including the Game of Life rules below — never has to think about edges at all.
__str__ is given to you, fully written, above — it’s what Python calls when you print() an object, so once get_width, get_height, and get_cell work, print(some_grid) will just work, one row per line, with no extra code on your part.
Represent a live cell as the character "X", and a dead cell as "." — that’s what your test pattern files (below) will use.
Composing GameOfLife from a Grid
Section titled “Composing GameOfLife from a Grid”If you’ve seen the concept of inheritance before, you might be tempted to use it here, but we won’t.
GameOfLife isn’t a kind of Grid — it doesn’t make sense to say a game “is a” grid. Instead, a GameOfLife has a grid: it uses one to keep track of which cells are alive, and applies the rules to it each generation. That relationship — one object storing another as an attribute, rather than inheriting from it — is called composition, and it’s often a better fit than inheritance when one class just needs to use another rather than specialize it.
class GameOfLife: def __init__(self, filename): self.grid = Grid(filename)
def count_live_neighbors(self, row, col): # You write this pass
def update_generation(self): # You write this passEvery method below will need to go through self.grid (for example, self.grid.get_cell(row, col)) to read or write a cell, rather than calling self.get_cell(row, col) directly.
count_live_neighbors(row, col)
Section titled “count_live_neighbors(row, col)”Write a method that counts how many of the eight neighbors of (row, col) are currently alive.
Because Grid wraps out-of-bounds indices for you, every cell has exactly eight neighbors — including ones on the edges and corners of the grid. You don’t need any special-case bounds checking here: generate all eight neighbor coordinates (some may come out negative, or past the last row or column) and pass them straight to self.grid.get_cell(...); the wraparound happens inside Grid, not here.
update_generation()
Section titled “update_generation()”Write a method that advances the whole grid by one generation, applying the four rules above to every cell at once.
Since every cell must be updated based on the current generation, you cannot simply call set_cell on self.grid while you’re still reading from it — you’ll overwrite cells you haven’t looked at yet with next-generation values, and corrupt the neighbor counts for cells you check afterward. Instead, decide on the next state for every cell first (for example, by building a second, temporary grid, or a list of (row, col) cells that should change), and only then write all those changes into self.grid.
What to do
Section titled “What to do”- Implement the
Gridclass:__init__,get_width,get_height,get_cell,set_cell. (__str__is already written for you.) - Implement
GameOfLife.__init__, storing aGridbuilt from the given filename. - Implement
count_live_neighbors(row, col). - Implement
update_generation(). - Since we aren’t worrying about graphics yet,
print(self.grid)is how you’ll watch generations go by — no extra code needed, courtesy of__str__. - Test on at least the three patterns below, and describe what you see.
Blinker — a row of three live cells (save as a small test grid file in the format your Grid loader expects):
...XXX...A blinker should oscillate: it flips between the horizontal row shown above and a vertical column of three, forever, every other generation.
Block — a 2x2 square of live cells:
XXXXA block is a still life: each of its four live cells has exactly three live neighbors (the other three cells in the block), which satisfies the survival rule, and every dead cell around the block has too few live neighbors to come alive. A block should never change, generation after generation — verify that by counting neighbors yourself before you trust your code’s output.
Glider — a small diagonally-moving pattern:
.X...XXXXPlace it in a grid that’s reasonably large (at least 10x10) relative to the pattern, with plenty of dead space around it. A glider should move: after four generations, the same three-cell-wide shape reappears, shifted one cell diagonally (down and to the right, for the orientation shown above), and repeats that motion indefinitely. Track the live cells across several cycles of four generations to confirm it’s actually translating, not just changing shape in place.
For all three patterns, leave enough blank padding around the pattern that it doesn’t wrap around and become its own neighbor — remember, your Grid is toroidal, so a pattern placed too close to the edge of a small grid will interact with itself.
Run several generations of each test pattern and print the grid after each step, to confirm your implementation matches the expected behavior described above.
Bonus: GUI for Life
Section titled “Bonus: GUI for Life”This work will not contribute to the grade, but might be fun. Create a graphical user interface for the Life game, using the PyQt library, which you can install using pip. (I’ll let you figure out how to do that.)
You are welcome to use AI to do this part of the work, especially since it is optional and ungraded, but you’ll still need to cite your sources and explain how the code was generated.
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”get_cellandset_cellcorrectly wrap out-of-bounds row/col indices, so the grid behaves as a torus with no edge effects.count_live_neighborsreturns a correct count for every cell, relying onGrid’s wraparound rather than special-casing boundaries.update_generationupdates every cell based on the previous generation, not a mix of old and already-updated cells.- Include test code (as described above) demonstrating the blinker, block, and glider patterns all behaving correctly over several generations.
- Code should be reasonably commented, especially anywhere the neighbor-counting logic isn’t obvious.