game of life
Conway's Game of Life running in a canvas, wrapping at the edges. Click or drag to draw — it pauses while you do.
view source
import { useEffect, useRef, useState } from 'react';
const CELL = 12;
const LIVE = '#7cf29c';
// 5x7 pixel glyphs, one string per row, '#' = alive.
const BLANK_GLYPH = ['.....', '.....', '.....', '.....', '.....', '.....', '.....'];
const GLYPHS: Record<string, string[]> = {
w: ['#...#', '#...#', '#...#', '#.#.#', '#.#.#', '##.##', '#...#'],
r: ['####.', '#...#', '#...#', '####.', '#.#..', '#..#.', '#...#'],
k: ['#...#', '#..#.', '#.#..', '##...', '#.#..', '#..#.', '#...#'],
s: ['.####', '#....', '#....', '.###.', '....#', '....#', '####.'],
p: ['####.', '#...#', '#...#', '####.', '#....', '#....', '#....'],
c: ['.####', '#....', '#....', '#....', '#....', '#....', '.####'],
};
const GLYPH_HEIGHT = 7;
const GLYPH_GAP = 1;
function textPattern(word: string): { cols: number; rows: number; cells: boolean[][] } {
const letters = word.split('').map((ch) => GLYPHS[ch] ?? BLANK_GLYPH);
const widths = letters.map((rows) => rows[0]?.length ?? 0);
const cols = widths.reduce((a, b) => a + b, 0) + GLYPH_GAP * (letters.length - 1);
const cells = Array.from({ length: GLYPH_HEIGHT }, () => Array<boolean>(cols).fill(false));
let offset = 0;
letters.forEach((glyph, i) => {
const width = widths[i];
for (let y = 0; y < GLYPH_HEIGHT; y++) {
for (let x = 0; x < width; x++) {
if (glyph[y]?.[x] === '#') cells[y][offset + x] = true;
}
}
offset += width + GLYPH_GAP;
});
return { cols, rows: GLYPH_HEIGHT, cells };
}
function makeGrid(cols: number, rows: number, density = 0.18): boolean[][] {
return Array.from({ length: rows }, () =>
Array.from({ length: cols }, () => Math.random() < density),
);
}
function makeTextGrid(cols: number, rows: number, word: string): boolean[][] | null {
const pattern = textPattern(word);
if (cols < pattern.cols + 2 || rows < pattern.rows + 2) return null;
const grid = Array.from({ length: rows }, () => Array<boolean>(cols).fill(false));
const offsetX = Math.floor((cols - pattern.cols) / 2);
const offsetY = Math.floor((rows - pattern.rows) / 2);
for (let y = 0; y < pattern.rows; y++) {
for (let x = 0; x < pattern.cols; x++) {
if (pattern.cells[y][x]) grid[offsetY + y][offsetX + x] = true;
}
}
return grid;
}
function step(grid: boolean[][]): boolean[][] {
const rows = grid.length;
const cols = grid[0]?.length ?? 0;
return grid.map((row, y) =>
row.map((alive, x) => {
let neighbors = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
const ny = (y + dy + rows) % rows;
const nx = (x + dx + cols) % cols;
if (grid[ny][nx]) neighbors++;
}
}
if (alive) return neighbors === 2 || neighbors === 3;
return neighbors === 3;
}),
);
}
export default function GameOfLife() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const reseedRef = useRef<() => void>(() => {});
const toggleRunningRef = useRef<() => void>(() => {});
const [running, setRunning] = useState(true);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let cols = 0;
let rows = 0;
let grid: boolean[][] = [];
let frame = 0;
let raf = 0;
let isRunning = true;
let isPainting = false;
let paintValue = true;
let firstSeed = true;
const playAt = performance.now() + 2000;
const seed = () => {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
cols = Math.floor(rect.width / CELL);
rows = Math.floor(rect.height / CELL);
grid = (firstSeed ? makeTextGrid(cols, rows, 'wrkspc') : null) ?? makeGrid(cols, rows);
firstSeed = false;
};
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = LIVE;
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
if (grid[y][x]) {
ctx.fillRect(x * CELL, y * CELL, CELL - 1, CELL - 1);
}
}
}
};
const tick = () => {
frame++;
if (isRunning && frame % 6 === 0 && performance.now() >= playAt) {
grid = step(grid);
draw();
}
raf = requestAnimationFrame(tick);
};
const cellAt = (clientX: number, clientY: number) => {
const rect = canvas.getBoundingClientRect();
const x = Math.floor((clientX - rect.left) / CELL);
const y = Math.floor((clientY - rect.top) / CELL);
if (x < 0 || y < 0 || x >= cols || y >= rows) return null;
return { x, y };
};
const paint = (clientX: number, clientY: number) => {
const cell = cellAt(clientX, clientY);
if (!cell) return;
grid[cell.y][cell.x] = paintValue;
draw();
};
const pause = () => {
if (!isRunning) return;
isRunning = false;
setRunning(false);
};
const onPointerDown = (e: PointerEvent) => {
const cell = cellAt(e.clientX, e.clientY);
if (!cell) return;
pause();
isPainting = true;
paintValue = !grid[cell.y][cell.x];
grid[cell.y][cell.x] = paintValue;
draw();
};
const onPointerMove = (e: PointerEvent) => {
if (!isPainting) return;
paint(e.clientX, e.clientY);
};
const stopPainting = () => {
isPainting = false;
};
seed();
draw();
raf = requestAnimationFrame(tick);
reseedRef.current = () => {
firstSeed = false;
seed();
draw();
isRunning = true;
setRunning(true);
};
toggleRunningRef.current = () => {
isRunning = !isRunning;
setRunning(isRunning);
};
canvas.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', stopPainting);
const onResize = () => seed();
window.addEventListener('resize', onResize);
return () => {
cancelAnimationFrame(raf);
canvas.removeEventListener('pointerdown', onPointerDown);
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', stopPainting);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className="game-of-life">
<canvas
ref={canvasRef}
aria-label="Conway's Game of Life — click or drag to draw cells"
role="img"
/>
<div className="controls">
<button type="button" onClick={() => toggleRunningRef.current()}>
{running ? 'pause' : 'play'}
</button>
<button type="button" onClick={() => reseedRef.current()}>
reseed
</button>
</div>
<style>{`
.game-of-life {
position: relative;
width: 100%;
aspect-ratio: 16 / 7;
border: 1px solid #2a2d31;
overflow: hidden;
background: #0e1113;
}
.game-of-life canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
touch-action: none;
}
.game-of-life .controls {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
display: flex;
gap: 0.375rem;
}
.game-of-life button {
font-family: inherit;
font-size: 0.75rem;
color: #8a8f98;
background: rgba(0, 0, 0, 0.4);
border: 1px solid #2a2d31;
padding: 0.25rem 0.5rem;
cursor: pointer;
}
.game-of-life button:hover {
color: #e6e6e6;
}
`}</style>
</div>
);
}