Apr 17, 2020

In memory of John Conway, we explore a modified version of the famous "Game of Life" in this week's Riddler Classic. I implemented the Game in python, which ended up being so much fun that many of the features aren't strictly required to solve the problem. Instead, they were amusing diversions that helped me explored this surprisingly nuanced game. It's probably just as Conway would have intended.

Riddler Nation was deeply saddened to hear of the loss of John Conway last week. It is only fitting that this week’s Classic is a spin on Conway’s Game of Life.In the most common version of the game, there is an infinite grid of square cells, which are initially either alive or dead. Each square has eight neighbors — the eight squares that surround it. And after every step in time, or “tick,” all the cells are simultaneously updated according to the following rules:

- A living cell with two or three living neighbors remains living.
- A living cell with any other number of living neighbors dies (due to under- or overpopulation).
- A dead cell with exactly three living neighbors comes alive (due to reproduction).
These relatively simple rules lead to some startlingly complex, emergent behaviors. For example, some formations of living cells are known as “oscillators,” which change form from one tick to the next, ultimately returning back to their original formation.

Now suppose we were to replace the infinite grid with a finite grid that has periodic boundary conditions, so that cells in the first row are neighbors with cells in the last row, and cells in the first column are neighbors with cells in the last column. If there are three rows and N columns, what is the smallest value of N that can support an oscillator?

**The smallest grid with an oscillation has just three rows and four columns, and there are two variations.** Each of these grids oscillates every other evolution, as shown in the gifs below.

One way of completing this puzzle was to build a "Game of Life" simulator, then test all the different starting grids we might expect. This turned out to be a fun programming challenge. I implemented the game using a class called `Grid`

, which tracks alive and dead cells in an array and provides many different utility methods to evaluate the game.

Each `Grid`

object takes a starting array of cells, coded by zeros and ones. The `__repr__`

object of each `Grid`

returns a unicode representation of the alive (black) and dead (white) cells. For example, this code snippet creates a `Grid`

with four alive cells.

```
cells = np.array([[0, 1, 0], [1, 0, 1], [0, 1, 0]])
Grid(cells)
```

⬜⬛⬜

⬛⬜⬛

⬜⬛⬜

There are also a few convenience constructors. For example, we can create a randomly-generated `Grid`

by calling the `random`

classmethod.

```
Grid.random(size=(5, 10), random_state=42)
```

⬜⬛⬜⬜⬜⬛⬜⬜⬜⬛

⬜⬜⬜⬜⬛⬜⬛⬛⬛⬜

⬛⬜⬛⬛⬛⬛⬛⬛⬛⬛

⬜⬜⬛⬛⬛⬜⬛⬜⬜⬜

⬜⬜⬛⬛⬛⬛⬛⬜⬛⬛

Once we have a Grid object, we can move forward in time using the `evolve`

method. This method moves forward by `n`

ticks and returns the updated `Grid`

, following the well established rules of the Game.

```
grid = Grid.random(size=(5, 10), random_state=42)
grid.evolve(1)
```

⬛⬜⬛⬜⬜⬜⬜⬜⬜⬛

⬜⬛⬛⬜⬜⬜⬜⬜⬜⬜

⬜⬛⬛⬜⬜⬜⬜⬜⬜⬛

⬛⬜⬜⬜⬜⬜⬜⬜⬜⬜

⬛⬛⬜⬜⬜⬜⬛⬛⬛⬛

All of this is implemented behind the scenes, as it were, by using a `padded_cells`

array that handles the wrapping logic specified in the problem. The `padded_cells`

array links the top row of cells to the bottom, the left to the right, and each corner to its opposite corner. To calculate each cell's "score" (the number of live neighbors), we use the `convolve2d`

function from `scipy`

, which is typically used to scan images and group boxes of pixels together in a process called convolution. (It's commonly used in deep learning for image recognition in Convolutional Neural Networks.) Once we know each cell's "score", we can simulate whether it lives or dies in the next evolution.

There are a few other convenience methods, like plotting the `Grid`

and creating a .gif out of its evolutions.

For example, it can be fun to view the evolution of a larger grid to see what patterns emerge. This code generates a 30x30 cell array and lets it run for 100 iterations. You can see several static clumps of cells, and a few oscillators - notably the "blinker".

However, my favorite feature, which has very little to do with the original problem, is a special constructor. You may have seen the xkcd tribute to John Conway. Calling `Grid.conway()`

creates a Grid that follows this pattern.

```
Grid.conway()
```

⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜

⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜

⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜

⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜

⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜

⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜

⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜

⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜

⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜

⬜⬜⬜⬜⬜⬜⬛⬛⬛⬜⬜⬜⬜⬜⬜

⬜⬜⬜⬜⬜⬜⬛⬜⬛⬜⬜⬜⬜⬜⬜

⬜⬜⬜⬜⬜⬜⬛⬜⬛⬜⬜⬜⬜⬜⬜

⬜⬜⬜⬜⬜⬜⬜⬛⬜⬜⬜⬜⬜⬜⬜

⬜⬜⬜⬜⬛⬜⬛⬛⬛⬜⬜⬜⬜⬜⬜

⬜⬜⬜⬜⬜⬛⬜⬛⬜⬛⬜⬜⬜⬜⬜

⬜⬜⬜⬜⬜⬜⬜⬛⬜⬜⬛⬜⬜⬜⬜

⬜⬜⬜⬜⬜⬜⬛⬜⬛⬜⬜⬜⬜⬜⬜

⬜⬜⬜⬜⬜⬜⬛⬜⬛⬜⬜⬜⬜⬜⬜

⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜

⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜

⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜

And the .gif of this Grid's evolution matches the original comic. It was a fantastic tribute.

This code technically solves the Riddler Classic, but I had much more fun trying to implement a more complete version of the Game of Life that accepts a grid of any size. It still implements the wrapping (see `Grid.padded_cells`

), and also implements a few visualization methods, like plotting and creating .gifs.

```
"""Solution to the Riddler Classic from April 17, 2020"""
from itertools import product
from typing import Iterator, Optional, Tuple
import gif # https://github.com/maxhumber/gif, `pip install gif`
from matplotlib import pyplot as plt
import numpy as np
from scipy.signal import convolve2d
class Grid:
"""
Grid object representing Conway's Game of Life. Can model the Riddler
variation of the game by passing `wrapped=True`, which will connect the
edges of the grid with the opposite sides, creating a continuous surface.
Parameters
----------
cells : np.ndarray with integer or boolean dtype, specifies the size of the
grid and alive cells (1) and dead cells (0)
wrapped : boolean, default True, indicates whether the Riddler wrapping
conditions apply to this Grid. If True, then the grid "wraps" so
edges connect to opposite edges and corners connect to opposite
corners. If False, the grid is assumed to be infinite and empty
outside of the array passed when the Grid is instantiated.
Examples
--------
>>> cells = np.array([[0, 1, 1, 0, 0],
... [0, 0, 0, 1, 1],
... [1, 1, 0, 1, 0]])
>>> Grid(cells)
⬜⬛⬛⬜⬜
⬜⬜⬜⬛⬛
⬛⬛⬜⬛⬜
>>> Grid(cells) == Grid(cells)
True
>>> # initialize a random grid, but set a seed for reproducibility
>>> grid = Grid.random((3, 5), random_state=42)
>>> grid
⬜⬛⬜⬜⬜
⬛⬜⬜⬜⬛
⬜⬜⬜⬜⬛
>>> # iterate one step and return a new Grid object
>>> grid.evolve(1)
⬜⬜⬜⬜⬛
⬛⬜⬜⬜⬛
⬜⬜⬜⬜⬛
"""
def __init__(self, cells: np.ndarray, wrapped: bool = True) -> None:
self.cells = cells
self.wrapped = wrapped
def __repr__(self) -> str:
"""Represent each element of the grid as a 'pixel'"""
read_row = lambda row: "".join("⬛" if x else "⬜" for x in row)
return "\n".join(read_row(row) for row in self.cells)
def __eq__(self, other) -> bool:
"""Two Grids are equal if they have the same `cells` array"""
return (self.cells == other.cells).all()
@classmethod
def conway(cls, wrapped: bool = False):
"""
Specific constructor that creates a Grid object in the shape of the xkcd
tribute to John Conway, found here: https://xkcd.com/2293/
Examples
--------
>>> Grid.conway()
⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬛⬛⬛⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬛⬜⬛⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬛⬜⬛⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬜⬛⬜⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬛⬜⬛⬛⬛⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬛⬜⬛⬜⬛⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬜⬛⬜⬜⬛⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬛⬜⬛⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬛⬜⬛⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
"""
x = [6, 7, 8, 6, 8, 6, 8, 7, 4, 6, 7, 8, 5, 7, 9, 7, 10, 6, 8, 6, 8]
y = [
9, 9, 9, 10, 10, 11, 11, 12, 13, 13, 13,
13, 14, 14, 14, 15, 15, 16, 16, 17, 17
]
cells = np.zeros(shape=(21, 15))
cells[y, x] = 1
return Grid(cells, wrapped=wrapped)
@classmethod
def random(
cls,
size: Tuple[int, int],
wrapped: bool = True,
random_state: Optional[int] = None
):
"""
Create a randomly-initialized Grid of a given size
Examples
--------
>>> grid = Grid.random((3, 5), random_state=42)
>>> grid
⬜⬛⬜⬜⬜
⬛⬜⬜⬜⬛
⬜⬜⬜⬜⬛
"""
np.random.seed(random_state)
return Grid(np.random.randint(2, size=size), wrapped=wrapped)
@property
def padded_cells(self) -> np.ndarray:
"""
Helper attribute that adds a padding layer around the cells in the grid.
This padded array is used later for calculating the score of each cell,
and also handles the wrapping methodology if applicable.
Examples
--------
>>> grid = Grid.random((3, 5), random_state=123)
>>> grid
⬜⬛⬜⬜⬜
⬜⬜⬛⬛⬜
⬛⬛⬜⬛⬜
>>> grid.padded_cells
array([[0, 1, 1, 0, 1, 0, 1],
[0, 0, 1, 0, 0, 0, 0],
[0, 0, 0, 1, 1, 0, 0],
[0, 1, 1, 0, 1, 0, 1],
[0, 0, 1, 0, 0, 0, 0]])
>>> Grid(grid.padded_cells, wrapped=True)
⬜⬛⬛⬜⬛⬜⬛
⬜⬜⬛⬜⬜⬜⬜
⬜⬜⬜⬛⬛⬜⬜
⬜⬛⬛⬜⬛⬜⬛
⬜⬜⬛⬜⬜⬜⬜
>>> grid = Grid.random((3, 5), random_state=123, wrapped=False)
>>> Grid(grid.padded_cells)
⬜⬜⬜⬜⬜⬜⬜
⬜⬜⬛⬜⬜⬜⬜
⬜⬜⬜⬛⬛⬜⬜
⬜⬛⬛⬜⬛⬜⬜
⬜⬜⬜⬜⬜⬜⬜
"""
# first we create an array with an extra padding layer
rows, cols = self.cells.shape
padded = np.zeros(shape=(rows + 2, cols + 2), dtype=int)
# copy the original cell values into the middle
padded[1:-1, 1:-1] = self.cells
# if we aren't wrapping the grid, just return this padded array
if not self.wrapped:
return padded
# otherwise, handle wrapping conditions
# first, copy each edge onto the opposite (padded) side
padded[0, 1:-1] = self.cells[-1] # bottom to top
padded[-1, 1:-1] = self.cells[0] # top to bottom
padded[1:-1, 0] = self.cells[:, -1] # right to left
padded[1:-1, -1] = self.cells[:, 0] # left to right
# copy each corner to the opposite corner
padded[0, 0] = self.cells[-1, -1]
padded[0, -1] = self.cells[-1, 0]
padded[-1, 0] = self.cells[0, -1]
padded[-1, -1] = self.cells[0, 0]
return padded
def cell_score(self):
"""
Returns the number of living neighbor cells for each cell on the grid.
Examples
--------
>>> grid = Grid.random((3, 5), random_state=123)
>>> grid
⬜⬛⬜⬜⬜
⬜⬜⬛⬛⬜
⬛⬛⬜⬛⬜
>>> grid.cell_score()
array([[3, 3, 5, 3, 3],
[3, 4, 4, 2, 3],
[2, 3, 5, 2, 3]])
"""
# the convolution scanner is a 3x3 array of ones, expect a middle zero
scanner = np.ones(shape=(3, 3), dtype=int)
scanner[1, 1] = 0
return convolve2d(self.padded_cells, scanner, mode="same")[1:-1, 1:-1]
def evolve(self, steps: int = 1):
"""
Move the simulation forward by n steps, default 1
Examples
--------
>>> grid = Grid.random((3, 5), random_state=123)
>>> grid
⬜⬛⬜⬜⬜
⬜⬜⬛⬛⬜
⬛⬛⬜⬛⬜
>>> grid.evolve(1)
⬛⬛⬜⬛⬛
⬛⬜⬜⬛⬛
⬛⬛⬜⬛⬛
>>> grid.evolve(2)
⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜
"""
def _evolve(grid):
"Iterate a single step"
scores = grid.cell_score()
evolution = np.zeros_like(grid.cells)
# live cells stay alive if they have exactly 2 or 3 alive neighbors
idx = (grid.cells == 1) & (scores > 1) & (scores < 4)
evolution[idx] = 1
# live cells die if they have any other number of neighbors
idx = (grid.cells == 1) & (scores < 2) & (scores > 3)
evolution[idx] = 0
# dead cells spawn if they have exactly three live neighbors
idx = (grid.cells == 0) & (scores == 3)
evolution[idx] = 1
return Grid(evolution, wrapped=self.wrapped)
grid = self
for _ in range(steps):
grid = _evolve(grid)
return grid
def period(self, limit: int = 20) -> int:
"""
If this Grid produces an identical grid after `n` evolutions, then
its period is equal to `n`. If it does not, then return -1.
`n` may be one for a grid that doesn't change after an evolution.
Parameters
----------
limit : int, the maximum number of evolutions to search for a pattern
Examples
--------
>>> cells = np.zeros(shape=(4, 4), dtype=int)
>>> cells[1:-1, 1:-1] = 1
>>> grid = Grid(cells)
>>> grid
⬜⬜⬜⬜
⬜⬛⬛⬜
⬜⬛⬛⬜
⬜⬜⬜⬜
>>> grid.period()
1
>>> cells = np.zeros(shape=(5, 5), dtype=int)
>>> cells[2, 1:-1] = 1
>>> grid = Grid(cells)
>>> grid
⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜
⬜⬛⬛⬛⬜
⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜
>>> grid.period()
2
"""
for i in range(1, limit + 1):
if self == self.evolve(i):
return i
return -1
def plot(self):
"""Plot the grid in matplotlib"""
_, ax = plt.subplots(figsize=(7, 7))
plt.axis('equal')
ax.pcolor(
self.cells[::-1], edgecolors="0.92", linewidth=1.2, cmap="Blues"
)
ax.xaxis.set_visible(False)
ax.yaxis.set_visible(False)
for s in ["top", "bottom", "left", "right"]:
ax.spines[s].set_visible(False)
return ax
def create_gif(self, frames: int, filename: str, duration: int):
"""
Creates a .gif file with each frame moving forward one evolution.
Adds a few extra frames of the starting position to the beginning.
Uses the `gif` library: https://github.com/maxhumber/gif
Parameters
----------
frames : int, the number of evolutions to simulate
filename : str, the name of the file, e.g. "life.gif"
duration : int, the delay between frames, in microseconds
"""
@gif.frame
def _plot(grid):
return grid.plot()
start = [_plot(self.evolve(0)) for _ in range(5)]
images = [_plot(self.evolve(n)) for n in range(frames)]
gif.save(start + images, filename, duration=duration)
def grid_generator(columns: int, rows: int = 3) -> Iterator[Grid]:
"""
Generator function that yields all permutations of Grids
that could be made with a (rows x columns) shape.
Examples
--------
>>> list(grid_generator(2, 1))
[⬜⬜, ⬜⬛, ⬛⬜, ⬛⬛]
"""
generator = product((0, 1), repeat=rows*columns)
for cells in generator:
yield Grid(np.array(cells).reshape(rows, columns))
if __name__ == "__main__":
import doctest
doctest.testmod()
```