Skip to content

Instantly share code, notes, and snippets.

@statico
Last active March 3, 2026 21:52
Show Gist options
  • Select an option

  • Save statico/6be620d98837c786bc4913cb060e85b9 to your computer and use it in GitHub Desktop.

Select an option

Save statico/6be620d98837c786bc4913cb060e85b9 to your computer and use it in GitHub Desktop.
starfield.ts
#!/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