Two ways to implement the Game of Life

I started my career writing Smalltalk, a language with perhaps the purest object-oriented style: everything is an object, and objects communicate by sending messages. At the time, I believed this was the natural way to represent reality. I later became more interested in choosing the smallest representation that fits the problem.

To illustrate the difference, here are two implementations of Conway’s Game of Life in Python. The first models every cell as an object.

Object-oriented version

import random


class Cell:
    def __init__(self, x, y, value, field):
        self.x = x
        self.y = y
        self.value = value
        self.field = field

    def next_value(self):
        neighbors = self.field.get_neighbors(self.x, self.y)
        neighbors_alive = sum(cell.value for cell in neighbors)

        if neighbors_alive == 2:
            return self.value
        if neighbors_alive == 3:
            return 1
        return 0


class Field:
    def __init__(self, height, width):
        self.height = height
        self.width = width
        self.cells = [
            [Cell(i, j, random.randint(0, 1), self) for j in range(width)]
            for i in range(height)
        ]

    def step(self):
        self.cells = [
            [Cell(i, j, self.cells[i][j].next_value(), self)
             for j in range(self.width)]
            for i in range(self.height)
        ]

    def get_neighbors(self, x, y):
        neighbors = []
        for dx in range(-1, 2):
            for dy in range(-1, 2):
                nx = x + dx
                ny = y + dy
                outside = not (0 <= nx < self.height and 0 <= ny < self.width)
                if outside or (dx == 0 and dy == 0):
                    continue
                neighbors.append(self.cells[nx][ny])
        return neighbors


field = Field(100, 100)

for _ in range(1000):
    field.step()

This version is intentionally object-heavy. A cell stores its coordinates, value, and a reference to the field. Every generation allocates a new object for every cell, while neighbor counting happens in Python loops. The representation works, but it introduces much more machinery than the rules require.

Array-oriented version

import numpy as np
from scipy.signal import convolve2d

field = np.random.randint(0, 2, size=(100, 100))
kernel = np.ones((3, 3), dtype=int)
kernel[1, 1] = 0

for _ in range(1000):
    neighbors = convolve2d(
        field, kernel, mode="same", boundary="fill", fillvalue=0
    )
    field = (neighbors == 3) | ((field == 1) & (neighbors == 2))

Here the field is naturally represented as a binary matrix. Convolution with a 3 × 3 kernel counts the live neighbors of every cell; the zero in the kernel’s center excludes the cell itself. The final expression directly encodes the rules: a dead cell becomes alive with exactly three live neighbors, while a live cell remains alive with two or three.

The explicit boundary="fill" setting matches the first implementation: cells outside the field are treated as dead.

The array-oriented version is both shorter and generally much faster because it stores values compactly and performs the repeated numerical work inside SciPy’s compiled implementation. It also exposes the structure of the problem: local neighbor aggregation followed by an element-wise rule.

This is not evidence that object-oriented programming is always slow or inappropriate. It is evidence that representation choice matters. When a problem is fundamentally about applying the same local operation to a dense grid, an array is usually a better abstraction than thousands of mutually connected objects.