The Game of Life in spacetime¶
Stack the Game of Life's generations, and the glider gun's gliders are staircases. They climb at c/4, a quarter of Life's top speed.
examples/life_spacetime.py
"""The Game of Life in spacetime: stack the generations and gliders become staircases.
Conway's Game of Life: a live cell survives with two or three live neighbours, a dead one comes
alive with exactly three. Bill Gosper's glider gun (1970) is a pattern that fires a glider every
30 generations — the first proof that a finite pattern can grow forever. Stack each generation
on top of the one before, so height is time, and every cell becomes a column that lasts as long
as the cell does: the gun is a tower that repeats every 30 layers, the blocks that hold it in
place are pillars, and each glider is a staircase leaning away at one cell per four generations
in each direction — the speed of light of this universe is one cell per generation; gliders
travel at a quarter of it, diagonally.
"""
import numpy as np
import manimgx as m
GUN = [
(5, 1),
(5, 2),
(6, 1),
(6, 2),
(5, 11),
(6, 11),
(7, 11),
(4, 12),
(8, 12),
(3, 13),
(9, 13),
(3, 14),
(9, 14),
(6, 15),
(4, 16),
(8, 16),
(5, 17),
(6, 17),
(7, 17),
(6, 18),
(3, 21),
(4, 21),
(5, 21),
(3, 22),
(4, 22),
(5, 22),
(2, 23),
(6, 23),
(1, 25),
(2, 25),
(6, 25),
(7, 25),
(3, 35),
(4, 35),
(3, 36),
(4, 36),
]
SIZE = 64 # the board
GENERATIONS = 126
CELL = 0.105 # a cell's side on screen
LAYER = 0.042 # the height of one generation
STOPS = ["#9d4edd", "#4361ee", "#4cc9f0", "#80ffdb", "#ffd166", "#ff7b54"]
FOCUS = np.array([-0.2, 1.05, 0.0]) # the middle of the action: the gun and its gliders
def colormap(values: np.ndarray, stops: list[str]) -> np.ndarray:
rgb = np.array([m.ManimColor(s).to_rgb() for s in stops])
x = np.clip(values, 0, 1) * (len(stops) - 1)
i = np.minimum(x.astype(int), len(stops) - 2)
f = (x - i)[:, None]
out = np.ones((len(values), 4))
out[:, :3] = rgb[i] * (1 - f) + rgb[i + 1] * f
return out
def history() -> np.ndarray:
"""Every generation of the gun on the board: (GENERATIONS, SIZE, SIZE) booleans."""
board = np.zeros((SIZE, SIZE), bool)
for r, c in GUN:
board[r + 3, c + 2] = True
frames = []
for _ in range(GENERATIONS):
frames.append(board.copy())
padded = np.pad(board, 1)
neighbours = sum(
padded[1 + dy : SIZE + 1 + dy, 1 + dx : SIZE + 1 + dx].astype(int)
for dy in (-1, 0, 1)
for dx in (-1, 0, 1)
if (dy, dx) != (0, 0)
)
board = (neighbours == 3) | (board & (neighbours == 2))
return np.array(frames)
def runs(frames: np.ndarray) -> np.ndarray:
"""Every stretch of generations a cell stays alive: rows (row, column, first, last)."""
alive = np.pad(frames, ((1, 1), (0, 0), (0, 0)))
starts = np.argwhere(alive[1:-1] & ~alive[:-2])
ends = np.argwhere(alive[1:-1] & ~alive[2:])
starts = starts[np.lexsort((starts[:, 0], starts[:, 2], starts[:, 1]))]
ends = ends[np.lexsort((ends[:, 0], ends[:, 2], ends[:, 1]))]
return np.column_stack([starts[:, 1], starts[:, 2], starts[:, 0], ends[:, 0]])
# the six faces of a unit box, as corner indices (bit 0: x, bit 1: y, bit 2: z), outward
BOX_FACES = np.array(
[[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]]
)
def columns(
spans: np.ndarray, now: float, depth: float
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""The columns alive between generations now − depth and now, the oldest shown at height 0:
vertices, triangles, colors (by generation)."""
first = np.maximum(spans[:, 2], now - depth)
last = np.minimum(spans[:, 3] + 1, now + 1)
keep = last > first
s, first, last = spans[keep], first[keep], last[keep]
base = max(now - depth, 0.0)
x0 = (s[:, 1] - SIZE / 2) * CELL
y0 = (SIZE / 2 - s[:, 0]) * CELL
inset = 0.08 * CELL
lo = np.stack([x0 + inset, y0 - CELL + inset, (first - base) * LAYER], 1)
hi = np.stack(
[
x0 + CELL - inset,
y0 - inset,
np.maximum((last - base) * LAYER, (first - base) * LAYER + 0.012),
],
1,
)
bits = np.array([[(k >> 0) & 1, (k >> 1) & 1, (k >> 2) & 1] for k in range(8)])
corners = lo[:, None, :] + bits[None] * (hi - lo)[:, None, :] # (columns, 8, 3)
quads = corners[:, BOX_FACES] # (columns, 6, 4, 3)
verts = quads.reshape(-1, 3)
faces = np.arange(len(s) * 6)[:, None] * 4
tris = np.concatenate([faces + [0, 1, 2], faces + [0, 2, 3]])
generation = np.where(
bits[BOX_FACES][..., 2] == 1, last[:, None, None] - 1, first[:, None, None]
)
rows = colormap((generation / GENERATIONS).reshape(-1), STOPS)
return verts, tris, rows
class LifeSpacetime(m.ThreeDScene):
def construct(self) -> None:
spans = runs(history())
now = m.ValueTracker(0.0)
depth = m.ValueTracker(
0.0
) # how many past generations stand below the present one
stack = m.MeshMobject(*columns(spans, 0.0, 0.0)[:2], shade_in_3d=True)
def rebuild(mob: m.Mobject) -> None:
assert isinstance(mob, m.MeshMobject)
g = float(np.floor(now.get_value()))
verts, tris, rows = columns(spans, g, float(np.floor(depth.get_value())))
mob.points, mob.triangles = verts, tris
mob.paint = mob.paint.but(fill=rows)
rebuild(stack)
stack.add_updater(rebuild)
board = m.Square(
side_length=SIZE * CELL,
stroke_color=m.GREY_D,
stroke_width=1.5,
fill_color="#0b0f1a",
fill_opacity=1,
)
board.shift(0.001 * m.IN)
title = m.Text("The Game of Life in spacetime", font_size=38).to_corner(m.UL)
subtitle = m.Text(
"Gosper's glider gun: a glider every 30 generations", font_size=22
).set_color(m.GREY_B)
subtitle.next_to(title, m.DOWN, aligned_edge=m.LEFT, buff=0.12)
generation = m.Integer(0, font_size=30)
generation.add_updater(lambda d: d.set_value(int(now.get_value())))
counter = (
m.VGroup(m.Text("generation", font_size=26), generation)
.arrange(m.RIGHT, buff=0.15)
.to_corner(m.UR)
)
self.add_fixed_in_frame_mobjects(title, subtitle, counter)
# 0–6 s: from above: the gun, one generation at a time
self.set_camera_orientation(
phi=0,
theta=-90 * m.DEGREES,
focal_distance=60,
zoom=1.9,
frame_center=np.array([-1.3, 2.35, 0.0]),
)
self.add(board, stack)
self.play(now.animate.set_value(34), run_time=5.5, rate_func=m.linear)
# 6–11 s: keep every generation, each one on top of the last: height is time
up = (
m.Text("height = time", font_size=26)
.set_color(m.YELLOW)
.next_to(counter, m.DOWN, aligned_edge=m.RIGHT, buff=0.25)
)
self.add_fixed_in_frame_mobjects(up)
self.remove(up)
self.move_camera(
phi=62 * m.DEGREES,
theta=-65 * m.DEGREES,
focal_distance=18,
zoom=1.0,
frame_center=FOCUS + np.array([0, 0, 1.2]),
added_anims=[
now.animate.set_value(54),
depth.animate.set_value(54),
m.FadeIn(up),
],
run_time=5,
rate_func=m.linear,
)
# 11–24 s: the tower grows; gliders lean away as staircases
self.begin_ambient_camera_rotation(rate=0.12)
self.play(
now.animate.set_value(GENERATIONS - 1),
depth.animate.set_value(GENERATIONS - 1),
self.camera.frame.animate.move_to(FOCUS + np.array([0, 0, 2.7])),
self.camera.zoom_tracker.animate.set_value(0.88),
run_time=12,
rate_func=m.linear,
)
# 24–30 s: the poster
closing = m.Text(
"each glider climbs one cell every four generations: c/4", font_size=26
).to_edge(m.DOWN, buff=0.35)
self.add_fixed_in_frame_mobjects(closing)
self.remove(closing)
self.play(m.FadeIn(closing), run_time=1)
self.wait(5.8)
if __name__ == "__main__":
LifeSpacetime().render("life_spacetime.mp4")