Avatar of Adam Fields

Conway's Game of Life in React

| 3 minutes

John Horton Conway invented the Game of Life in 1970. It was first featured in Martin Gardner’s Mathematical Games column in Scientific American. Searching for it even results in a Google easter egg.

The game of life is a cellular automaton, which is a grid of cells. Each cell can be one of a finite number of states. In this case, the cells are either alive (1) or dead (0). The game progresses in generations, where the state of each cell is determined by the state of its neighbors according to a set of pre-defined rules:

  1. A living cell with < 2 living neighbors dies.
  2. A living cell with 2 || 3 living neighbors survives.
  3. A living cell with > 3 living neighbors dies.
  4. A dead cell with 3 living neighbors becomes alive.

A neighbor is any cell that is adjacent to the current cell, including diagonals. For example:

0 0 0
0 1 0
0 0 0

The cell in the middle has 0 living neighbors.

My Attempt

The first thing I wanted to do was generate the grid:

type Cell = 0 | 1
type Grid = Cell[][]

/**
 * Initializes a new grid of a given size.
 * Just a 2D array of 0s and 1s.
 */
function initGrid(size: number): Grid {
  const grid = []
  for (let i = 0; i < size; i++) {
    const row: Cell[] = []
    for (let j = 0; j < size; j++) {
      // random 0 or 1
      const cell = Math.round(Math.random()) as Cell
      row.push(cell)
    }
    grid.push(row)
  }
  return grid
}

Next was counting living neighbors of a cell:

type Point = [number, number]

/**
 * Calculates the number of living neighbors for a given cell.
 * Neighbors are the 8 cells surrounding the given cell.
 */
const countLivingNeighbors = (grid: Grid, point: Point) => {
  const size = grid.length
  const [x, y] = point
  let count = 0
  for (let i = -1; i <= 1; i++) {
    for (let j = -1; j <= 1; j++) {
      if (!(i === 0 && j === 0)) {
        const newX = x + i
        const newY = y + j
        if (newX >= 0 && newX < size && newY >= 0 && newY < size) {
          const row = grid[newY] as Cell[]
          count += row[newX] as Cell
        }
      }
    }
  }
  return count
}

And updating the grid based on the rules:

const [grid, setGrid] = useState(initGrid(size))
const [generation, setGeneration] = useState(0)
const [running, setRunning] = useState(false)

/** Compares two grids. */
const areGridsEqual = (grid1: Grid, grid2: Grid) => {
  const size = grid1.length
  for (let i = 0; i < size; i++) {
    for (let j = 0; j < size; j++) {
      // if any corresponding cells are different, the grids are not equal
      if (grid1[i][j] !== grid2[i][j]) return false
    }
  }
  return true
}

/** Updates the grid based on the rules. */
const updateGrid = () => {
  // use functional form to access previous state
  setGrid((oldGrid) => {
    // create a new grid derived from the old one
    const newGrid = oldGrid.map((row, y) =>
      row.map((cell, x) => {
        const neighbors = countLivingNeighbors(oldGrid, [x, y])
        if (cell === 1 && (neighbors < 2 || neighbors > 3)) return 0
        if (cell === 0 && neighbors === 3) return 1
        return cell
      })
    )

    // if the new grid is the same as the old grid, stop the game
    if (areGridsEqual(oldGrid, newGrid)) {
      setRunning(false)
      return oldGrid
    }

    // otherwise, increment the generation
    setGeneration((oldGeneration) => oldGeneration + 1)
    return newGrid
  })
}

At this point, we have everything we need to implement the game. The grid itself is just a basic HTML table where each cell has a fixed height, width, and background color. The animation loop uses setInterval:

useEffect(() => {
  // delay between renders for aesthetics
  const DELAY = 150

  // intervals are type `Timeout` in Node and numbers in the browser
  let intervalId: number | null = null
  if (running) {
    intervalId = setInterval(updateGrid, DELAY) as unknown as number
  }

  // clear the interval on unmount
  return () => {
    if (intervalId) clearInterval(intervalId)
  }
}, [running, updateGrid])

Optimizations

The above implementation works. However, given the nested loops the complexity grows with the size of the grid. This becomes a performance issue for large grids.

The LLMs tell me that one solution is to create a separate data structure to store only active cells. An active cell is one that is either alive or has at least 1 living neighbor. The data structure to use is Set<string>, where each string is a "x,y" coordinate. Sets offer reads and writes compared to arrays because their elements are guaranteed to be unique. As the game progresses and the grid becomes more sparse (more zeros), the performance of the game will improve because we’ll be checking fewer cells.

Conclusion

If you’re interested in things like cellular automata, then you should follow me on GitHub and Hugging Face to see what else I’m working on.

Enjoy! 🕹️