Conway's Game of Life
Learning Objectives
- You can represent a grid of cells as a 2D list and draw it.
- You can compute the next generation of a cellular automaton from the current one.
- You can explain why the next grid must be built separately rather than edited in place.
- You can count a cell’s living neighbours while staying inside the grid’s bounds.
This chapter is a breather. After three games that asked you to track moving objects and collisions, here is one with no player-controlled movement. You set up a grid of cells, press go, and watch. From three tiny rules, patterns emerge that glide across the screen, oscillate forever, or settle into still shapes.
You have met Conway’s Game of Life earlier in the chapter on Multidimensional Lists for Grid-Shaped Data, as an example of a 2D list. Now you will see how to make it run, and how to implement the rules that make it so interesting.
The Rules
The world is a grid. Each cell is either alive or dead. Time moves in steps called generations, and at each step, every cell looks at its eight neighbours and follows three rules to decide whether it is alive in the next generation:
- A living cell with 2 or 3 living neighbours stays alive. (Fewer, and it dies of loneliness; more, and it dies of overcrowding.)
- A dead cell with exactly 3 living neighbours becomes alive. (Three neighbours reproduce.)
- Every other cell is dead in the next generation.
That is the entire game. There is no goal, no score, and no player-controlled movement, although you can pause and reset the simulation. The interest is entirely in what these three rules produce, which turns out to be astonishingly rich — shapes that move, shapes that pulse, shapes that spawn other shapes. It is one of the most famous demonstrations in computing that simple rules can produce complex behaviour.
The rules are a function of the whole grid. To decide a cell’s next state, you need its current state and the current state of its eight neighbours. This is the key idea that makes Life different from everything else in this part: each frame does not nudge a few objects, it computes an entirely new grid from the old one.
The Grid as a 2D List
A grid of cells is a list of rows, where each row is a list of cells, and each cell is a boolean — True for alive, False for dead. The grid is a 2D list, and the rules are applied to every cell in that list. The first step is to make an empty grid:
COLS = 40ROWS = 30
def empty_grid(): return [[False for _ in range(COLS)] for _ in range(ROWS)]The nested list comprehension reads outward: for each of ROWS rows, make a list of COLS False values. The result is a grid you index as grid[row][col] — row first, then column.
To start with something interesting, we fill the grid randomly — each cell alive with roughly one-in-four odds:
def random_grid(): return [[pyxel.rndi(0, 3) == 0 for _ in range(COLS)] for _ in range(ROWS)]pyxel.rndi(0, 3) gives a number from 0 to 3, and == 0 is True one time in four, so each cell starts alive about a quarter of the time. That is enough density for patterns to develop without the whole grid immediately filling up or dying out.
Drawing the grid is two nested loops — the same loops that built it — drawing a small square for each living cell:
def draw(self): pyxel.cls(0) for row in range(ROWS): for col in range(COLS): if self.grid[row][col]: pyxel.rect(col * 4, row * 4, 4, 4, 11)Each cell is a 4-by-4 square, so col * 4 and row * 4 convert grid coordinates into pixel coordinates. Dead cells are simply not drawn — the black background shows through.
Counting Neighbours, Carefully
To apply the rules, each cell needs to know how many of its eight neighbours are alive. The eight neighbours are the cells one step away in each direction: up, down, left, right, and the four diagonals. We visit them with two small loops over the offsets -1, 0, 1:
def count_neighbours(grid, row, col): count = 0 for dr in (-1, 0, 1): for dc in (-1, 0, 1): if dr == 0 and dc == 0: continue # this is the cell itself, not a neighbour r = row + dr c = col + dc if 0 <= r < ROWS and 0 <= c < COLS and grid[r][c]: count = count + 1 return countTwo details carry the weight here. The if dr == 0 and dc == 0: continue skips the offset (0, 0), which is the cell itself — a cell is not its own neighbour. And the bounds check 0 <= r < ROWS and 0 <= c < COLS is essential: cells on the edge of the grid have neighbours that would be off the grid, and grid[-1] or grid[ROWS] would either wrap unexpectedly or raise an IndexError. Checking that r and c are inside the grid before reading grid[r][c] treats off-grid neighbours as dead, which is the standard convention.
The Step: Build a New Grid, Don’t Edit the Old One
Here is the subtle part, and the one real lesson of the chapter. To compute the next generation, you must build a brand-new grid and leave the current one untouched until you are done. You cannot update cells in place.
Why not? The rules say every cell looks at its neighbours’ current states. If you update cell (5, 5) and then move on to (5, 6), the neighbour count for (5, 6) would include the already-updated (5, 5) — it would be looking at the future, not the present. The whole generation must be computed from a single, consistent snapshot of the present. So you read from the old grid and write to a new one:
def step(grid): new_grid = empty_grid() for row in range(ROWS): for col in range(COLS): n = count_neighbours(grid, row, col) if grid[row][col]: new_grid[row][col] = (n == 2 or n == 3) else: new_grid[row][col] = (n == 3) return new_gridRead it against the three rules. If the cell is alive (grid[row][col]), it stays alive when n is 2 or 3. If it is dead, it comes alive only when n is exactly 3. Every other case leaves new_grid at its default False. The function reads the old grid throughout and returns the new one, which the caller then makes current.
This “read from old, write to new” pattern is everywhere in computing, not just in Life. Any time the next state of a thing depends on the current state of its neighbours — simulations, image filters, certain physics calculations — you compute into a fresh structure rather than editing in place. Editing in place corrupts the snapshot the calculation depends on. It is worth recognising the pattern now, because it returns in places that look nothing like a grid of cells.
Running It
The game runs the step on a timer and lets the player pause with space and reset with R. Running the step every frame would be dizzyingly fast, so we slow it to every sixth frame:
def update(self): if pyxel.btnp(pyxel.KEY_SPACE): self.running = not self.running if pyxel.btnp(pyxel.KEY_R): self.grid = random_grid() if self.running and pyxel.frame_count % 6 == 0: self.grid = step(self.grid)self.running = not self.running flips the pause flag — a tidy way to toggle a boolean with a single key. The frame_count % 6 timer is the same idea as the clicker’s once-a-second timer, just faster.
The Complete Program
import pyxel
COLS = 40ROWS = 30CELL = 4
def empty_grid(): return [[False for _ in range(COLS)] for _ in range(ROWS)]
def random_grid(): return [[pyxel.rndi(0, 3) == 0 for _ in range(COLS)] for _ in range(ROWS)]
def count_neighbours(grid, row, col): count = 0 for dr in (-1, 0, 1): for dc in (-1, 0, 1): if dr == 0 and dc == 0: continue r = row + dr c = col + dc if 0 <= r < ROWS and 0 <= c < COLS and grid[r][c]: count = count + 1 return count
def step(grid): new_grid = empty_grid() for row in range(ROWS): for col in range(COLS): n = count_neighbours(grid, row, col) if grid[row][col]: new_grid[row][col] = (n == 2 or n == 3) else: new_grid[row][col] = (n == 3) return new_grid
class App: def __init__(self): self.grid = random_grid() self.running = True pyxel.init(COLS * CELL, ROWS * CELL, title="Game of Life") pyxel.run(self.update, self.draw)
def update(self): if pyxel.btnp(pyxel.KEY_SPACE): self.running = not self.running if pyxel.btnp(pyxel.KEY_R): self.grid = random_grid() if self.running and pyxel.frame_count % 6 == 0: self.grid = step(self.grid)
def draw(self): pyxel.cls(0) for row in range(ROWS): for col in range(COLS): if self.grid[row][col]: pyxel.rect(col * CELL, row * CELL, CELL, CELL, 11)
if __name__ == "__main__": App()Run it and watch. You will see regions die out, regions stabilise into unchanging blocks, and — if you are lucky with the random start — small shapes that march steadily across the screen. Those moving shapes are called gliders, and the fact that they exist at all, falling out of three rules about neighbours, is the reason this little simulation has fascinated people for over fifty years.
Famous Starting Patterns
A random grid is fun, but Life’s most famous patterns come from carefully chosen starts. A blinker is three living cells in a row — it flips between horizontal and vertical forever. A block is a 2-by-2 square — it never changes. A glider is a five-cell shape that crawls diagonally across the grid, one cell every four generations. If you replace random_grid() with code that places a glider by hand — setting five specific cells to True — you can watch a single shape travel the whole screen. Looking up the glider’s five coordinates and placing them is a satisfying exercise, and a good test that your step function is exactly right: get one neighbour rule wrong and the glider falls apart instead of gliding.
Conway's Life with classes
0 / 40 points
Refactor Conway’s Game of Life from the chapter into a class-based Pyxel program.
Keep the chapter mechanics:
- the world is a grid of living and dead cells,
- each generation is computed from the previous generation,
- the old grid is not edited in place while computing the next grid,
- space pauses or resumes the simulation,
Rcreates a fresh random grid,- and the grid advances on a frame timer.
Game rules to implement:
- the default grid is
30rows by40columns, - a cell has up to eight neighbours: horizontal, vertical, and diagonal,
- positions outside the grid count as dead,
- a live cell survives with
2or3live neighbours and dies otherwise, - a dead cell becomes live with exactly
3live neighbours, - each generation must be built from the old grid before replacing it,
- the simulation starts in the running state,
- the default step interval is
6frames, - and resetting with
Rreplaces the whole grid with a new random grid.
The mechanics must live in model classes that can be tested without Pyxel. The Pyxel App should create random grids, translate input into method calls, and draw the current grid.
Required classes:
| Class | Contract |
|---|---|
LifeGrid(rows=30, cols=40, cells=None) | stores rows, cols, and a 2D boolean grid |
LifeGrid.is_alive(self, row, col) | returns whether a cell is alive |
LifeGrid.set_alive(self, row, col, alive=True) | sets one cell |
LifeGrid.count_neighbours(self, row, col) | counts living neighbours inside bounds |
LifeGrid.next_generation(self) | returns a new LifeGrid computed by Conway’s rules |
LifeGrid.to_rows(self) | returns a copy of the 2D boolean rows |
LifeGame(grid=None, running=True, step_interval=6) | owns grid, running, and step_interval |
LifeGame.toggle_running(self) | flips the running flag |
LifeGame.reset(self, grid) | replaces the grid |
LifeGame.tick(self, frame_count) | advances only when running and frame_count is divisible by step_interval |
App | owns one LifeGame, handles Pyxel input and drawing |
Pyxel integration requirements:
App.__init__()creates a randomLifeGrid,- random grid creation may use
pyxel.rndi, App.update()toggles running onpyxel.KEY_SPACE,App.update()replaces the grid onpyxel.KEY_R,App.update()callsgame.tick(pyxel.frame_count)every frame,App.draw()reads the grid and draws one square for each living cell,- do not call
pyxelfromLifeGridorLifeGame, - do not start the app when the file is imported,
- and guard the launch with
if __name__ == "__main__": App().
Check Your Understanding
- How is the grid represented, and what does
grid[row][col]give you? - In
count_neighbours, why is theif dr == 0 and dc == 0: continueline necessary? - Why must
stepwrite into a new grid instead of changing the existing grid in place? What goes wrong if it edits in place? - What is the bounds check
0 <= r < ROWS and 0 <= c < COLSprotecting against? - Conway’s Life has no player-controlled movement and no score. In what sense is it still “a program with a game loop”?
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.