npm i -D ts-node typescript @types/node
npx ts-node life-terminal.ts
Created
January 12, 2026 19:47
-
-
Save shamansir/1f44a108a08607ce4d28e1ba7220a70e to your computer and use it in GitHub Desktop.
Conway's Life in Terminal, by ChatGPT
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "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