04 — Conway's Game of Life¶
Example file:
examples/04_conway.py
Loss curves, training visualizations — all very serious. Let's take a break and build something fun: Conway's Game of Life, running entirely on the GPU, displayed in zero-copy, with buttons and sliders to play god.
More importantly, this chapter shows that create_tensor isn't only
for neural networks. Any GPU computation can be displayed through
Vultorch's shared memory — simulations, procedural generation, physics,
anything that lives on CUDA.
What we're building¶
A 256×256 cellular automaton with a control panel:
| Area | Content |
|---|---|
| Left | Controls — play/pause, step, speed slider, pattern presets, color pickers |
| Right | Grid — the simulation, pixel-perfect (filter="nearest") |
Everything runs on the GPU. The display tensor uses create_tensor for
zero-copy — the grid never touches the CPU.
New friends¶
| New thing | What it does | Why it matters |
|---|---|---|
filter="nearest" |
Shows each pixel as a sharp square, no blurring | Without it, bilinear interpolation smudges cell boundaries |
side="left" sidebar |
Places a panel on the left, taking 22% of the window | Gives you a permanent control strip next to your visualization |
@panel.on_frame |
Per-panel widget callback | Widget calls (button, slider, color_picker) go inside here |
panel.button(label) |
A clickable button | Returns True on the frame it was clicked |
with panel.row(): |
Puts the next widgets on the same line | Without it, widgets stack vertically (one per line). Use row() to put two buttons side-by-side. It's a Python with block — everything inside the block goes on one line |
panel.color_picker |
An RGB color picker widget | Click the colored square to open a palette |
| Circular padding + conv2d | GPU-parallel neighbour count | The whole simulation is one convolution |
The simulation trick¶
Counting neighbours in Conway's Game of Life is just a 2D convolution with a 3×3 kernel of all ones (center zero):
PyTorch's F.conv2d does this in one GPU kernel call — no loops, no
per-cell logic. Circular padding wraps the edges so gliders fly off one
side and reappear on the other.
kernel = torch.tensor([[1, 1, 1],
[1, 0, 1],
[1, 1, 1]], dtype=torch.float32, device=device)
padded = F.pad(inp, (1, 1, 1, 1), mode='circular')
neighbours = F.conv2d(padded, kernel.reshape(1, 1, 3, 3)).squeeze()
Then the rules are just two boolean masks:
survive = (grid == 1) & ((neighbours == 2) | (neighbours == 3))
birth = (grid == 0) & (neighbours == 3)
grid[:] = (survive | birth).float()
Full code¶
import torch
import vultorch
if not torch.cuda.is_available():
raise RuntimeError("This example needs CUDA")
device = "cuda"
# ── Grid parameters ───────────────────────────────────────────────
GRID_H, GRID_W = 256, 256
# ── View + panels ─────────────────────────────────────────────────
view = vultorch.View("04 - Conway's Game of Life", 1024, 768)
grid_panel = view.panel("Grid")
ctrl_panel = view.panel("Controls", side="left", width=0.22)
# ── Display tensor (RGBA, zero-copy) ─────────────────────────────
display = vultorch.create_tensor(GRID_H, GRID_W, channels=4,
device=device, name="grid",
window=view.window)
canvas = grid_panel.canvas("grid", filter="nearest")
canvas.bind(display)
# ── Simulation state ─────────────────────────────────────────────
grid = torch.zeros(GRID_H, GRID_W, dtype=torch.float32, device=device)
state = {
"running": False,
"generation": 0,
"speed": 1,
"prob": 0.3,
"alive_color": (0.0, 1.0, 0.4),
"dead_color": (0.05, 0.05, 0.08),
}
def randomize():
grid[:] = (torch.rand(GRID_H, GRID_W, device=device) < state["prob"]).float()
state["generation"] = 0
def clear():
grid.zero_()
state["generation"] = 0
def step_simulation():
kernel = torch.tensor([[1, 1, 1],
[1, 0, 1],
[1, 1, 1]], dtype=torch.float32, device=device)
inp = grid.unsqueeze(0).unsqueeze(0)
k = kernel.unsqueeze(0).unsqueeze(0)
padded = torch.nn.functional.pad(inp, (1, 1, 1, 1), mode='circular')
neighbours = torch.nn.functional.conv2d(padded, k).squeeze()
survive = (grid == 1) & ((neighbours == 2) | (neighbours == 3))
birth = (grid == 0) & (neighbours == 3)
grid[:] = (survive | birth).float()
state["generation"] += 1
def grid_to_display():
alive_r, alive_g, alive_b = state["alive_color"]
dead_r, dead_g, dead_b = state["dead_color"]
display[:, :, 0] = dead_r + (alive_r - dead_r) * grid
display[:, :, 1] = dead_g + (alive_g - dead_g) * grid
display[:, :, 2] = dead_b + (alive_b - dead_b) * grid
display[:, :, 3] = 1.0
randomize()
@view.on_frame
def update():
if state["running"]:
for _ in range(state["speed"]):
step_simulation()
grid_to_display()
@ctrl_panel.on_frame
def draw_controls():
ctrl_panel.text(f"Generation: {state['generation']}")
ctrl_panel.text(f"Alive cells: {int(grid.sum().item())}")
ctrl_panel.text(f"FPS: {view.fps:.1f}")
ctrl_panel.separator()
with ctrl_panel.row():
label = "Pause" if state["running"] else "Play"
if ctrl_panel.button(label, width=80):
state["running"] = not state["running"]
if ctrl_panel.button("Step", width=80):
step_simulation()
with ctrl_panel.row():
if ctrl_panel.button("Randomize", width=80):
randomize()
if ctrl_panel.button("Clear", width=80):
clear()
ctrl_panel.separator()
state["speed"] = ctrl_panel.slider_int("Speed", 1, 20, default=1)
state["prob"] = ctrl_panel.slider("Cell Probability", 0.05, 0.8,
default=0.3)
ctrl_panel.separator()
ctrl_panel.text("Colors")
state["alive_color"] = ctrl_panel.color_picker(
"Alive", default=(0.0, 1.0, 0.4))
state["dead_color"] = ctrl_panel.color_picker(
"Dead", default=(0.05, 0.05, 0.08))
ctrl_panel.separator()
ctrl_panel.text("Patterns")
with ctrl_panel.row():
if ctrl_panel.button("Glider", width=80):
clear()
grid[1, 2] = 1; grid[2, 3] = 1
grid[3, 1] = 1; grid[3, 2] = 1; grid[3, 3] = 1
if ctrl_panel.button("Pulsar", width=80):
clear()
# ... place pulsar pattern ...
if ctrl_panel.button("Gosper Gun", width=100):
clear()
# ... place Gosper glider gun ...
ctrl_panel.separator()
ctrl_panel.text_wrapped(
"Click Play to start, or Step to advance one generation. "
"Use Randomize to reset with random cells."
)
view.run()
(The full example file includes helper functions for all pattern placements.)
What just happened?¶
-
Grid — a plain
(256, 256)float32 CUDA tensor.1.0= alive,0.0= dead. No classes, no fancy data structures — just a tensor. -
Simulation —
step_simulation()usesF.conv2dwith circular padding to count neighbours, then applies the birth/survive rules with boolean masks. The entire generation runs in two GPU kernels. -
Display —
create_tensorallocates shared Vulkan/CUDA memory.grid_to_display()lerps between dead and alive colors and writes into it. Zero copy to screen. -
Controls —
@ctrl_panel.on_framedraws all widgets inside the Controls panel.panel.button(),panel.slider_int(),panel.color_picker(), andwith panel.row()keep the layout compact. State lives in a plain Python dict.
Key takeaways¶
-
create_tensoris for everything — not just neural networks. Any GPU computation that produces an image-like tensor can be displayed with zero copy. -
filter="nearest"— crucial for pixel-art / grid simulations. Without it, bilinear interpolation blurs cell boundaries. Think of it likeplt.imshow(data, interpolation='nearest')— same idea, you want to see the actual pixels. -
Convolution = neighbour counting — a cute trick that replaces nested Python loops with a single GPU kernel. The game runs at hundreds of FPS even at large grid sizes.
-
Panel widgets — inside
@panel.on_frameyou callpanel.button(),panel.slider_int(),panel.color_picker(). Each call creates one interactive element. They stack top-to-bottom automatically, like lines ofprint()output. No positioning code needed. -
with panel.row():— by default widgets go one-per-line. Wrap several widget calls inwith panel.row():to put them on the same line instead. It's just a Pythonwithblock — nothing exotic. -
Pattern presets — the Glider, Pulsar, and Gosper Gun buttons demonstrate how to set initial conditions by writing directly into the grid tensor.
Tip
Crank the Speed slider to 20 and watch the grid evolve at 20 generations per frame. On a modern GPU you'll still hold 60+ FPS.
Note
The grid wraps around thanks to mode='circular' padding.
Gliders that fly off the right edge reappear on the left.