Skip to content

Instantly share code, notes, and snippets.

@shamansir
Created January 12, 2026 19:47
Show Gist options
  • Select an option

  • Save shamansir/1f44a108a08607ce4d28e1ba7220a70e to your computer and use it in GitHub Desktop.

Select an option

Save shamansir/1f44a108a08607ce4d28e1ba7220a70e to your computer and use it in GitHub Desktop.
Conway's Life in Terminal, by ChatGPT
npm i -D ts-node typescript @types/node
npx ts-node life-terminal.ts
#!/usr/bin/env ts-node
import { performance } from "node:perf_hooks";
// ------------ Terminal control (ANSI) ------------
const CSI = "\x1b[";
const ESC = "\x1b";
function write(s: string) {
process.stdout.write(s);
}
function enterAltScreen() {
// Alternate buffer + save cursor (DEC private modes)
write(CSI + "?1049h");
}
function exitAltScreen() {
write(CSI + "?1049l");
}
function hideCursor() {
write(CSI + "?25l");
}
function showCursor() {
write(CSI + "?25h");
}
function clearScreen() {
write(CSI + "2J" + CSI + "H");
}
function resetAttrs() {
write(CSI + "0m");
}
function cursorHome() {
write(CSI + "H");
}
function setFgRGB(r: number, g: number, b: number) {
write(`${CSI}38;2;${r};${g};${b}m`);
}
function setBgRGB(r: number, g: number, b: number) {
write(`${CSI}48;2;${r};${g};${b}m`);
}
// Optional: mouse tracking off (some terminals may have it on)
function disableMouseTracking() {
write(CSI + "?1000l"); // X10
write(CSI + "?1002l"); // button-event
write(CSI + "?1003l"); // any-event
write(CSI + "?1006l"); // SGR
write(CSI + "?1015l"); // urxvt
}
// ------------ Grid / simulation ------------
type RGB = { r: number; g: number; b: number };
// Requested “virtual” grid size:
const VIRTUAL_W = 640;
const VIRTUAL_H = 480;
// We will render at the terminal’s actual size, but keep a virtual simulation grid.
// If your terminal is huge, it can become 1:1.
function getRenderSize() {
const cols = Math.max(10, process.stdout.columns ?? 80);
const rows = Math.max(5, process.stdout.rows ?? 24);
// We’ll use the whole terminal area.
// If you want margins, subtract a few rows/cols here.
return { cols, rows };
}
// Conway’s Life on a grid (boolean state)
class Life {
w: number;
h: number;
a: Uint8Array;
b: Uint8Array;
constructor(w: number, h: number) {
this.w = w;
this.h = h;
this.a = new Uint8Array(w * h);
this.b = new Uint8Array(w * h);
}
idx(x: number, y: number) {
return y * this.w + x;
}
seedRandom(density = 0.18) {
for (let i = 0; i < this.a.length; i++) {
this.a[i] = Math.random() < density ? 1 : 0;
}
}
// A couple of classic patterns can help it look nice on start
seedGliders(count = 10) {
const glider = [
[0, 1],
[1, 2],
[2, 0],
[2, 1],
[2, 2],
];
for (let k = 0; k < count; k++) {
const ox = (Math.random() * (this.w - 3)) | 0;
const oy = (Math.random() * (this.h - 3)) | 0;
for (const [dx, dy] of glider) {
this.a[this.idx(ox + dx, oy + dy)] = 1;
}
}
}
step() {
const w = this.w, h = this.h;
const a = this.a, b = this.b;
for (let y = 0; y < h; y++) {
const ym1 = (y - 1 + h) % h;
const yp1 = (y + 1) % h;
for (let x = 0; x < w; x++) {
const xm1 = (x - 1 + w) % w;
const xp1 = (x + 1) % w;
const n =
a[ym1 * w + xm1] + a[ym1 * w + x] + a[ym1 * w + xp1] +
a[y * w + xm1] + a[y * w + xp1] +
a[yp1 * w + xm1] + a[yp1 * w + x] + a[yp1 * w + xp1];
const i = y * w + x;
const alive = a[i] === 1;
// Conway rules
b[i] = alive
? (n === 2 || n === 3 ? 1 : 0)
: (n === 3 ? 1 : 0);
}
}
// swap buffers
this.a = b;
this.b = a;
}
}
// Map virtual sim cell to render cell.
// If terminal is smaller, multiple virtual cells fold into one render cell (sampling).
function sampleLifeToRender(life: Life, rx: number, ry: number, rW: number, rH: number): number {
// nearest-neighbor sampling
const sx = Math.min(life.w - 1, Math.floor((rx / rW) * life.w));
const sy = Math.min(life.h - 1, Math.floor((ry / rH) * life.h));
return life.a[sy * life.w + sx];
}
function colorFor(cell: number): { ch: string; fg: RGB; bg: RGB } {
if (cell) {
return {
ch: "█", // full block for “pixel-ish” look
fg: { r: 180, g: 255, b: 180 },
bg: { r: 0, g: 0, b: 0 },
};
}
return {
ch: " ",
fg: { r: 0, g: 0, b: 0 },
bg: { r: 0, g: 0, b: 0 },
};
}
// ------------ Renderer (fast-ish) ------------
class Renderer {
cols: number;
rows: number;
// To reduce ANSI spam, we track last frame and only emit changes.
lastCh: Uint16Array;
lastFg: Uint32Array;
lastBg: Uint32Array;
constructor(cols: number, rows: number) {
this.cols = cols;
this.rows = rows;
const n = cols * rows;
this.lastCh = new Uint16Array(n);
this.lastFg = new Uint32Array(n);
this.lastBg = new Uint32Array(n);
}
resize(cols: number, rows: number) {
this.cols = cols;
this.rows = rows;
const n = cols * rows;
this.lastCh = new Uint16Array(n);
this.lastFg = new Uint32Array(n);
this.lastBg = new Uint32Array(n);
}
packRGB(c: RGB): number {
return (c.r << 16) | (c.g << 8) | c.b;
}
render(life: Life) {
const { cols, rows } = this;
const n = cols * rows;
// Move cursor home; we’ll paint within the screen, no scrolling.
cursorHome();
let out = "";
let curFg = -1;
let curBg = -1;
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const i = y * cols + x;
const cell = sampleLifeToRender(life, x, y, cols, rows);
const { ch, fg, bg } = colorFor(cell);
const chCode = ch.charCodeAt(0);
const fgP = this.packRGB(fg);
const bgP = this.packRGB(bg);
// If same as last frame, we can still output it, but better: skip by emitting nothing
// The catch: to “skip”, we must still move cursor. We’ll instead do a simple “diff emit”
// by using cursor positioning only when needed. That’s more complex.
//
// Practical compromise: always paint full screen. It’s simpler, more portable.
//
// For 60 FPS, full repaint is fine at normal terminal sizes (like 120×30).
// At extreme sizes, throughput becomes the bottleneck anyway.
if (fgP !== curFg) {
const r = (fgP >> 16) & 255, g = (fgP >> 8) & 255, b = fgP & 255;
out += `${CSI}38;2;${r};${g};${b}m`;
curFg = fgP;
}
if (bgP !== curBg) {
const r = (bgP >> 16) & 255, g = (bgP >> 8) & 255, b = bgP & 255;
out += `${CSI}48;2;${r};${g};${b}m`;
curBg = bgP;
}
out += ch;
// store last (not used in this full repaint path, but kept for future diff upgrade)
this.lastCh[i] = chCode;
this.lastFg[i] = fgP;
this.lastBg[i] = bgP;
}
// New line (safe because we’re in alt buffer and painting exactly rows lines)
if (y < rows - 1) out += "\n";
}
out += CSI + "0m"; // reset attributes at end
write(out);
}
}
// ------------ Main loop / input blocking / cleanup ------------
let running = true;
function installInputHandlers(onExit: () => void) {
const stdin = process.stdin;
stdin.setEncoding("utf8");
stdin.resume();
if (stdin.isTTY) stdin.setRawMode(true);
// “Block” input: we consume it, and only act on exit keys
stdin.on("data", (chunk: string) => {
// Ctrl+C or q or ESC to exit
if (chunk === "\u0003" || chunk === "q" || chunk === "\u001b") {
onExit();
}
// ignore everything else
});
// Also handle kill signals
process.on("SIGINT", onExit);
process.on("SIGTERM", onExit);
}
function restoreTerminal() {
try {
resetAttrs();
showCursor();
disableMouseTracking();
if (process.stdin.isTTY) process.stdin.setRawMode(false);
process.stdin.pause();
// Clear the alt screen content before leaving it (optional)
clearScreen();
exitAltScreen();
} catch {
// ignore
}
}
function main() {
enterAltScreen();
hideCursor();
disableMouseTracking();
clearScreen();
const sim = new Life(VIRTUAL_W, VIRTUAL_H);
sim.seedRandom(0.14);
sim.seedGliders(18);
const { cols, rows } = getRenderSize();
const renderer = new Renderer(cols, rows);
const exitNow = () => {
if (!running) return;
running = false;
restoreTerminal();
process.exit(0);
};
installInputHandlers(exitNow);
// Watch for terminal resize
process.stdout.on("resize", () => {
const s = getRenderSize();
renderer.resize(s.cols, s.rows);
clearScreen();
});
// 60 FPS loop with drift correction
const targetDt = 1000 / 60;
let next = performance.now();
const tick = () => {
if (!running) return;
const now = performance.now();
if (now >= next) {
// Catch up if lagging, but don’t spiral
const steps = Math.min(4, Math.floor((now - next) / targetDt) + 1);
for (let i = 0; i < steps; i++) sim.step();
renderer.render(sim);
next += steps * targetDt;
}
setImmediate(tick);
};
tick();
}
main();
{
"devDependencies": {
"@types/node": "^25.0.6",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment