Last active
March 3, 2026 21:52
-
-
Save statico/6be620d98837c786bc4913cb060e85b9 to your computer and use it in GitHub Desktop.
starfield.ts
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 bun | |
| // ── Star Field ────────────────────────────────────────────────────── | |
| // Parallax star field that scrolls right-to-left in the terminal. | |
| // 3 depth layers, 256-color, differential rendering (only redraws | |
| // changed cells). Supports ASCII and Unicode glyph modes, adjustable | |
| // star density, scroll speed, and color saturation — all via hotkeys. | |
| // Made with Claude Code. | |
| // | |
| // Usage: bun starfield.ts [--ascii|--unicode] [--stars N] [--speed N] [--sat N] | |
| // | |
| // PUBLIC DOMAIN | |
| // ── CLI args ──────────────────────────────────────────────────────── | |
| function cliNum(flag: string): number | undefined { | |
| const i = process.argv.indexOf(flag); | |
| if (i === -1 || i + 1 >= process.argv.length) return undefined; | |
| const v = Number(process.argv[i + 1]); | |
| return Number.isFinite(v) && v > 0 ? v : undefined; | |
| } | |
| let mode: "ascii" | "unicode" = process.argv.includes("--ascii") | |
| ? "ascii" | |
| : "unicode"; | |
| const cols = process.stdout.columns || 120; | |
| const rows = process.stdout.rows || 40; | |
| // Default ~50 stars for 80×24, scale linearly with area | |
| const screenArea = cols * rows; | |
| const defaultStars = Math.max(20, Math.round((screenArea / (80 * 24)) * 50)); | |
| let starCount = cliNum("--stars") ?? defaultStars; | |
| let speedMult = cliNum("--speed") ?? 1; | |
| let saturation = Math.min(4, Math.max(0, cliNum("--sat") ?? 1)); // 0–4 | |
| // ── Glyphs & palettes ────────────────────────────────────────────── | |
| const ASCII_GLYPHS = [".", ".", ".", "*", "*", "+"]; | |
| const UNICODE_GLYPHS = ["·", "∗", "⋆", "✦", "✧", "✶", "⁎", "⊹", "★"]; | |
| let glyphs = mode === "ascii" ? ASCII_GLYPHS : UNICODE_GLYPHS; | |
| // 256-color palettes: desaturated (white/gray) and saturated (colored) | |
| // Saturation level 0–4 blends between them: 0=all white, 4=all color | |
| const LAYER_WHITE: number[][] = [ | |
| [240, 242, 244, 246], // far – subtle grays | |
| [249, 250, 251, 252], // mid – bright grays | |
| [255, 255, 231, 253, 254], // near – pure whites | |
| ]; | |
| // 256-color cube: 16 + 36r + 6g + b (r,g,b ∈ 0–5) | |
| const LAYER_COLOR: number[][] = [ | |
| [60, 67, 68, 24, 25, 31, 95], // far – dim blues, teals, mauves | |
| [69, 75, 110, 111, 116, 74, 146, 180, 176], // mid – blues, cyans, lavender, warm | |
| [117, 153, 159, 123, 87, 213, 219, 228, 210], // near – vivid blue, cyan, pink, gold, coral | |
| ]; | |
| function pickColor(layer: number): number { | |
| // saturation 0 → always white, 4 → always colored | |
| return Math.random() * 4 < saturation | |
| ? pick(LAYER_COLOR[layer]) | |
| : pick(LAYER_WHITE[layer]); | |
| } | |
| const BASE_SPEEDS = [1, 2, 4]; // cells per frame per layer | |
| // Proportion of stars per layer (far heavy, near sparse) | |
| const LAYER_WEIGHT = [0.50, 0.33, 0.17]; | |
| // ── ANSI helpers ──────────────────────────────────────────────────── | |
| const CSI = "\x1b["; | |
| const HIDE_CURSOR = `${CSI}?25l`; | |
| const SHOW_CURSOR = `${CSI}?25h`; | |
| const CLEAR = `${CSI}2J`; | |
| const HOME = `${CSI}H`; | |
| const RESET = `${CSI}0m`; | |
| const goto = (r: number, c: number) => `${CSI}${r};${c}H`; | |
| const fg256 = (n: number) => `${CSI}38;5;${n}m`; | |
| // ── Star state ────────────────────────────────────────────────────── | |
| interface Star { | |
| x: number; | |
| y: number; | |
| layer: number; | |
| glyph: string; | |
| color: number; | |
| } | |
| const pick = <T>(a: T[]): T => a[(Math.random() * a.length) | 0]; | |
| function spawnStar(layer: number, xOverride?: number): Star { | |
| return { | |
| x: xOverride ?? cols + ((Math.random() * 20) | 0), | |
| y: 1 + ((Math.random() * rows) | 0), | |
| layer, | |
| glyph: pick(glyphs), | |
| color: pickColor(layer), | |
| }; | |
| } | |
| function populateStars() { | |
| stars.length = 0; | |
| for (let layer = 0; layer < 3; layer++) { | |
| const count = Math.round(starCount * LAYER_WEIGHT[layer]); | |
| for (let i = 0; i < count; i++) { | |
| stars.push(spawnStar(layer, 1 + ((Math.random() * cols) | 0))); | |
| } | |
| } | |
| } | |
| const stars: Star[] = []; | |
| populateStars(); | |
| // ── Dirty-cell differential renderer ──────────────────────────────── | |
| type CellKey = number; | |
| const prevFrame = new Map<CellKey, string>(); | |
| function cellKey(r: number, c: number): CellKey { | |
| return r * 10000 + c; | |
| } | |
| // HUD overlay: text that persists for a few frames, rendered on top | |
| let hudText = ""; | |
| let hudTimer = 0; | |
| function showHud(text: string, frames = 60) { | |
| hudText = text; | |
| hudTimer = frames; | |
| } | |
| function render() { | |
| const buf: string[] = []; | |
| const curFrame = new Map<CellKey, string>(); | |
| // Build current frame from stars | |
| for (const s of stars) { | |
| const c = Math.round(s.x); | |
| if (c < 1 || c > cols || s.y < 1 || s.y > rows) continue; | |
| const k = cellKey(s.y, c); | |
| if (!curFrame.has(k) || s.layer > (curFrame.get(k)!.charCodeAt(0) - 48)) { | |
| curFrame.set(k, `${s.layer}${fg256(s.color)}${s.glyph}`); | |
| } | |
| } | |
| // HUD overlay on row 1 | |
| if (hudTimer > 0) { | |
| hudTimer--; | |
| const padded = hudText.slice(0, cols); | |
| for (let i = 0; i < padded.length; i++) { | |
| curFrame.set(cellKey(1, i + 1), `9${fg256(240)}${padded[i]}`); | |
| } | |
| // Clear rest of row 1 so old stars don't poke through the HUD line | |
| for (let i = padded.length; i < cols; i++) { | |
| curFrame.set(cellKey(1, i + 1), `9 `); | |
| } | |
| } | |
| // Erase cells from last frame no longer present | |
| for (const [k] of prevFrame) { | |
| if (!curFrame.has(k)) { | |
| const r = (k / 10000) | 0; | |
| const c = k % 10000; | |
| buf.push(goto(r, c), " "); | |
| } | |
| } | |
| // Draw new or changed cells | |
| for (const [k, val] of curFrame) { | |
| if (prevFrame.get(k) !== val) { | |
| const r = (k / 10000) | 0; | |
| const c = k % 10000; | |
| buf.push(goto(r, c), val.slice(1)); | |
| } | |
| } | |
| prevFrame.clear(); | |
| for (const [k, v] of curFrame) prevFrame.set(k, v); | |
| buf.push(RESET); | |
| process.stdout.write(buf.join("")); | |
| } | |
| // ── Simulation ────────────────────────────────────────────────────── | |
| function step() { | |
| for (let i = stars.length - 1; i >= 0; i--) { | |
| const s = stars[i]; | |
| s.x -= BASE_SPEEDS[s.layer] * speedMult; | |
| if (s.x < 0) { | |
| stars[i] = spawnStar(s.layer); | |
| } else if (Math.random() < 0.005) { | |
| s.color = pickColor(s.layer); | |
| } | |
| } | |
| } | |
| // ── Adjust helpers ────────────────────────────────────────────────── | |
| function adjustStars(delta: number) { | |
| starCount = Math.max(5, starCount + delta); | |
| // Add or remove stars to match new count | |
| const target = starCount; | |
| while (stars.length < target) { | |
| const layer = Math.random() < 0.5 ? 0 : Math.random() < 0.65 ? 1 : 2; | |
| stars.push(spawnStar(layer)); | |
| } | |
| while (stars.length > target) stars.pop(); | |
| showHud(`stars: ${stars.length}`); | |
| } | |
| function adjustSpeed(delta: number) { | |
| // Smaller steps at low speeds so you can fine-tune | |
| const step = speedMult <= 0.25 ? 0.05 : 0.25; | |
| const d = delta > 0 ? step : -step; | |
| speedMult = Math.max(0.05, Math.min(8, +(speedMult + d).toFixed(2))); | |
| showHud(`speed: ${speedMult}x`); | |
| } | |
| function switchMode(newMode: "ascii" | "unicode") { | |
| if (mode === newMode) return; | |
| mode = newMode; | |
| glyphs = mode === "ascii" ? ASCII_GLYPHS : UNICODE_GLYPHS; | |
| // Re-glyph all existing stars | |
| for (const s of stars) s.glyph = pick(glyphs); | |
| showHud(`mode: ${mode}`); | |
| } | |
| function adjustSaturation(delta: number) { | |
| saturation = Math.max(0, Math.min(4, saturation + delta)); | |
| // Recolor all existing stars to reflect new saturation | |
| for (const s of stars) s.color = pickColor(s.layer); | |
| const labels = ["white", "low", "medium", "high", "vivid"]; | |
| showHud(`saturation: ${labels[saturation]} (${saturation}/4)`); | |
| } | |
| const HELP = | |
| "a/u: mode +/-: stars ]/[: speed s/d: color q: quit"; | |
| function showHelp() { | |
| showHud(HELP, 90); // 3 seconds at 30fps | |
| } | |
| // ── Raw input (no echo, char-at-a-time) ───────────────────────────── | |
| if (process.stdin.isTTY) { | |
| process.stdin.setRawMode(true); | |
| } | |
| process.stdin.resume(); | |
| process.stdin.setEncoding("utf8"); | |
| process.stdin.on("data", (data: string) => { | |
| for (const ch of data) { | |
| switch (ch) { | |
| case "q": | |
| case "\x03": // ctrl-c | |
| cleanup(); | |
| break; | |
| case "a": | |
| switchMode("ascii"); | |
| break; | |
| case "u": | |
| switchMode("unicode"); | |
| break; | |
| case "+": | |
| case "=": | |
| adjustStars(10); | |
| break; | |
| case "-": | |
| case "_": | |
| adjustStars(-10); | |
| break; | |
| case ">": | |
| case "]": | |
| adjustSpeed(0.25); | |
| break; | |
| case "<": | |
| case "[": | |
| adjustSpeed(-0.25); | |
| break; | |
| case "s": | |
| adjustSaturation(1); | |
| break; | |
| case "d": | |
| adjustSaturation(-1); | |
| break; | |
| case "?": | |
| showHelp(); | |
| break; | |
| default: | |
| // Unknown key → flash help | |
| showHelp(); | |
| break; | |
| } | |
| } | |
| }); | |
| // ── Main loop ─────────────────────────────────────────────────────── | |
| process.stdout.write(HIDE_CURSOR + CLEAR + HOME); | |
| function cleanup() { | |
| if (process.stdin.isTTY) process.stdin.setRawMode(false); | |
| process.stdout.write(SHOW_CURSOR + RESET + CLEAR + HOME); | |
| process.exit(0); | |
| } | |
| process.on("SIGINT", cleanup); | |
| process.on("SIGTERM", cleanup); | |
| const FPS = 30; | |
| setInterval(() => { | |
| step(); | |
| render(); | |
| }, (1000 / FPS) | 0); | |
| // Show help on start | |
| showHud( | |
| `${mode} · ${cols}×${rows} · ${stars.length} stars · ${HELP}`, | |
| 90 | |
| ); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment