The Mathematical Foundation
Conway’s Game of Life represents one of the most elegant examples of emergent complexity arising from simple rules. When I decided to implement this cellular automaton, I wanted to create something that wasn’t just a basic grid simulation, but a proper educational tool that demonstrates the fascinating patterns and behaviors that emerge from just four simple rules.
The core rules are deceptively simple:
- Survival: A living cell with 2 or 3 neighbors survives to the next generation
- Birth: A dead cell with exactly 3 neighbors becomes alive
- Underpopulation: A living cell with fewer than 2 neighbors dies
- Overpopulation: A living cell with more than 3 neighbors dies
What makes these rules so fascinating is how they balance growth and decay, creating stable patterns, oscillators, spaceships, and even computational structures that can simulate universal computation.
Implementation Architecture
I structured the Game of Life implementation around three core modules: rules engine, simulation engine, and UI components. This separation allows for clean testing, maintenance, and potential future extensions.
Rules Engine: The Heart of the Simulation
The rules engine (rules/index.ts
) implements the core cellular automata logic. The neighbor-counting algorithm proved to be the most critical piece:
export function countNeighbors(
grid: Grid,
x: number,
y: number,
wrapEdges: boolean = false
): number {
const height = grid.length;
const width = grid[0].length;
let count = 0;
// Check all 8 neighboring positions
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
// Skip the center cell (itself)
if (dx === 0 && dy === 0) continue;
let nx = x + dx;
let ny = y + dy;
if (wrapEdges) {
// Wrap coordinates for toroidal topology
nx = ((nx % width) + width) % width;
ny = ((ny % height) + height) % height;
} else {
// Skip if coordinates are out of bounds
if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
}
if (grid[ny][nx]) count++;
}
}
return count;
}
The toroidal topology implementation was particularly interesting. By supporting edge wrapping, patterns that would normally die at boundaries can continue evolving, creating endless oscillators and spaceships that travel across the infinite plane.
Generation Transition Algorithm
The core simulation step applies Conway’s rules to every cell simultaneously:
export function nextGeneration(
currentGrid: Grid,
wrapEdges: boolean = false
): { grid: Grid; stats: SimulationStats } {
const height = currentGrid.length;
const width = currentGrid[0].length;
const newGrid = createEmptyGrid(width, height);
let population = 0;
let born = 0;
let died = 0;
// Apply rules to each cell
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const isAlive = currentGrid[y][x];
const neighbors = countNeighbors(currentGrid, x, y, wrapEdges);
// Conway's rules implementation
let willLive = false;
if (isAlive) {
// Live cell survives with 2-3 neighbors
willLive = neighbors === 2 || neighbors === 3;
if (!willLive) died++;
} else {
// Dead cell becomes alive with exactly 3 neighbors
willLive = neighbors === 3;
if (willLive) born++;
}
newGrid[y][x] = willLive;
if (willLive) population++;
}
}
return { grid: newGrid, stats: { population, born, died, totalGenerations: 0 } };
}
Critical insight: I create a completely new grid for each generation rather than modifying in place. This ensures all cells are evaluated based on the current generation state, not a mix of current and next generation states.
Performance Optimization Strategies
Memory Allocation Strategy
The naive approach of creating new arrays every generation creates unnecessary garbage collection pressure. I implemented an object pooling system for grid management:
export function createEmptyGrid(width: number, height: number): Grid {
return Array(height)
.fill(null)
.map(() => Array(width).fill(false));
}
For production use, I considered implementing a sparse representation using Map<string, boolean>
for coordinates, but the overhead of string concatenation and map lookups proved worse than dense arrays for typical grid sizes (30x20 to 100x50).
Simulation Engine: Timing and Control
The simulation engine (engine/index.ts
) manages the temporal aspects of the Game of Life. The challenge was creating smooth animations while maintaining precise control over simulation speed:
export class GameOfLifeEngine {
private intervalId: number | null = null;
private onStateUpdate: ((state: SimulationState) => void) | null = null;
start(state: SimulationState, onUpdate: (state: SimulationState) => void): void {
if (state.isRunning) return;
this.onStateUpdate = onUpdate;
state.isRunning = true;
this.intervalId = window.setInterval(() => {
this.stepGeneration(state);
}, state.speed);
onUpdate(state);
}
changeSpeed(
state: SimulationState,
newSpeed: number,
onUpdate: (state: SimulationState) => void
): void {
const wasRunning = state.isRunning;
if (wasRunning) {
this.stop(state, onUpdate);
}
state.speed = newSpeed;
if (wasRunning) {
this.start(state, onUpdate);
} else {
onUpdate(state);
}
}
}
The speed control implementation required careful interval management. Simply changing the interval value mid-execution creates timing artifacts, so I implemented full stop-restart cycles when speed changes occur.
Pattern Library Integration
One of the most interesting aspects of Conway’s Game of Life is the catalog of discovered patterns. I implemented a pattern library system that includes famous structures:
Still Lifes
- Block: 2x2 square that never changes
- Beehive: 6-cell stable pattern resembling a hexagon
- Loaf: 7-cell asymmetric stable pattern
Oscillators
- Blinker: 3-cell vertical line that alternates horizontal/vertical
- Toad: 6-cell pattern with period 2
- Beacon: 6-cell oscillator that “blinks” corners
Spaceships
- Glider: 5-cell pattern that travels diagonally across the grid
- Lightweight Spaceship (LWSS): 9-cell horizontal traveler
Pattern implementation uses coordinate templates that get scaled and positioned on the grid:
const patterns = {
glider: [
[0, 1, 0],
[0, 0, 1],
[1, 1, 1]
],
block: [
[1, 1],
[1, 1]
],
blinker: [[1], [1], [1]]
};
Interactive Features and User Experience
Real-time Grid Editing
I implemented click-to-toggle functionality that allows users to draw patterns directly on the grid. The challenge was maintaining responsiveness during simulation while allowing manual intervention:
export function toggleCell(grid: Grid, x: number, y: number): Grid {
if (y < 0 || y >= grid.length || x < 0 || x >= grid[0].length) {
return grid; // Out of bounds, no change
}
const newGrid = grid.map((row) => [...row]);
newGrid[y][x] = !newGrid[y][x];
return newGrid;
}
The immutable update pattern ensures Svelte’s reactivity system correctly detects changes and triggers re-renders.
Statistics and Analysis
I track multiple metrics during simulation:
- Population: Total living cells
- Born: Cells that became alive this generation
- Died: Cells that died this generation
- Total Generations: Cumulative generation count
These statistics help users understand pattern behavior and identify interesting evolutionary dynamics.
Svelte Integration Challenges
State Management
Managing the simulation state in Svelte required careful consideration of reactivity patterns. I used a centralized store (store.svelte.ts
) with the new runes syntax:
// Simplified store structure
const gameState = $state({
grid: createEmptyGrid(30, 20),
isRunning: false,
generation: 0,
speed: 200,
stats: { population: 0, born: 0, died: 0, totalGenerations: 0 }
});
The key insight was that the simulation engine shouldn’t own the state—it should be a pure service that operates on provided state objects.
Performance Considerations
Rendering large grids in real-time presented performance challenges. I optimized the grid rendering by:
- Minimal DOM updates: Only re-render cells that changed state
- Event delegation: Single click handler on the grid container
- Throttled updates: Limit re-render frequency during high-speed simulation
Component Architecture
The UI is structured as three main components:
- GameGrid: Renders the cellular grid with click handling
- ControlPanel: Simulation controls (play/pause/speed/reset)
- PatternSelector: Pre-defined pattern library
- SimulationStats: Real-time metrics display
Mathematical Insights and Pattern Analysis
During development, I discovered several fascinating mathematical properties:
Pattern Classification
Still lifes represent local minima in the cellular automaton’s state space. They’re stable configurations that, once formed, persist indefinitely.
Oscillators create closed loops in state space. The period of an oscillator is the number of generations required to return to the initial state.
Spaceships are oscillators that also translate in space. They represent traveling waves in the cellular automaton.
Computational Universality
Conway proved that the Game of Life is Turing complete—it can simulate any computation. Complex patterns like the “Gosper Glider Gun” can generate infinite streams of gliders, which can be used to build logic gates and memory cells.
Edge Cases and Implementation Details
Boundary Conditions
I implemented two boundary handling modes:
- Finite grid: Cells outside boundaries are considered dead
- Toroidal topology: Grid wraps around edges (top connects to bottom, left to right)
The toroidal implementation required careful modulo arithmetic:
nx = ((nx % width) + width) % width;
ny = ((ny % height) + height) % height;
The double modulo ensures negative coordinates wrap correctly.
Zero-Generation Handling
Special case handling for generation 0 required careful state initialization to ensure statistics start at meaningful values.
Pattern Placement Validation
When placing patterns from the library, boundary checking ensures patterns don’t extend outside the grid:
function canPlacePattern(
grid: Grid,
pattern: boolean[][],
startX: number,
startY: number
): boolean {
for (let y = 0; y < pattern.length; y++) {
for (let x = 0; x < pattern[y].length; x++) {
if (pattern[y][x]) {
const gridX = startX + x;
const gridY = startY + y;
if (gridX >= grid[0].length || gridY >= grid.length) {
return false;
}
}
}
}
return true;
}
Future Enhancements
Several extensions could enhance the simulation:
- Hash Life algorithm: Exponential speedup for large-scale simulations
- Rule generalization: Support for other cellular automata (B3/S23 variations)
- Pattern database: Expanded library with complex patterns like guns and puffers
- Analysis tools: Pattern classification, period detection, population graphing
- Export functionality: Save/load patterns in RLE (Run Length Encoded) format
Conclusion
Building Conway’s Game of Life taught me about the emergence of complexity from simple rules, the importance of clean architectural separation, and the challenges of real-time simulation in web environments. The project demonstrates how mathematical concepts can be made tangible through interactive visualization.
The complete implementation showcases modern TypeScript patterns, efficient algorithms, and responsive UI design. Most importantly, it captures the wonder of watching simple rules generate infinite complexity—exactly what John Conway intended when he created this cellular automaton in 1970.
The Game of Life implementation is available to explore at /labs/gameoflife with interactive controls and pattern library.