desert night

[github]

the milky way over a lone acacia on moonless dunes, meteors falling

scenes 200×100 15 fps 43 colours, on a canvas 20.9 kB

use it

html

<script type="module" src="https://ascii.rest/ascii.js"></script>

<ascii-art piece="desert-night"></ascii-art>

No install and no build step. ascii.js defines the <ascii-art> tag, loads the piece from ascii.rest, plays it while it is on screen, and holds the first frame for anyone who prefers reduced motion.

react

// npm install github:bas3line/ascii
import { Ascii } from "ascii.rest/react";
import { desertNight } from "ascii.rest/pieces";

<Ascii piece={desertNight} />

A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="desert-night", to fetch the piece only when it mounts.

astro

---
// npm install github:bas3line/ascii
import Ascii from "ascii.rest/astro";
---

<Ascii piece="desert-night" />

Renders the first frame on the server, so the page is whole before any script runs, then plays the piece once it loads.

typescript

// npm install github:bas3line/ascii
import { mount } from "ascii.rest";
import { desertNight } from "ascii.rest/pieces";

const el = document.querySelector<HTMLCanvasElement>("canvas")!;
const stop = mount(el, desertNight);

mount plays a piece in a <pre>, or on a <canvas> for the coloured ones, and returns a function that stops it. Every piece, option and frame is typed.

desert-night.ts

[js] [github]
/*
 * desert night: a moonless desert under the milky way. The galaxy's bright
 * core sits low over a lone acacia on a dune crest and arches up across the
 * sky, split by its dark dust lane; a distant town warms the far horizon and
 * catches the dunes' faces. Stars twinkle, sand glints along the ridges, and
 * now and then a meteor crosses.
 *
 * The dunes are a heightfield raymarched once, keeping each cell's depth,
 * light and shadow. The sky is drawn with a random dither so its faint glow
 * reads as star dust, softened toward an ordered one inside the band; the sand
 * keeps an ordered dither so its slopes read as smooth surfaces. Each cell is
 * one dot, in the palette colour nearest its hue.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "desert night",
  category: "scenes",
  note: "the milky way over a lone acacia on moonless dunes, meteors falling",
  cols: 200,
  rows: 100,
  cell: 1,
  fps: 15,
  ground: "#04060c",
  palette: [
    "#0a1024", "#101830", "#16213f", "#1e2b50", "#283864", "#34477a", "#45598f", "#5a6fa6", "#7488bd",
    "#93a5d2", "#b6c3e4", "#d8e0f2", "#f4f6fb",
    "#fff3dc", "#ffe2b4", "#f5c98e", "#e2a86e", "#c4864f", "#9c6440", "#74492f",
    "#2a2230", "#3d2f3a", "#56404a", "#735358", "#946a62", "#b6836c",
    "#4a3b48", "#6b5562", "#8a6f7a", "#a88590", "#c9a3a3", "#e2bfb4",
    "#2a2448", "#3d3466", "#57498a", "#7a68a8",
    "#ffd8a8", "#cfe0ff", "#a9c4ff",
    "#ff9a52", "#e07a3e",
    "#0d0f1a", "#151827",
  ],
} satisfies Meta;

const W = 200, H = 100;
const K = 0.62;
const HZ = 64; // eye level, in rows
const CAM = 6;
const HMAX = 16; // no dune is taller
const TOWN = [30, 66]; // the far glow, just under the horizon
const CORE = [152, 38]; // the galaxy's bright centre
const ARC = [50, 209.8, 199.8]; // the milky way's arch: centre and radius
const ARC_S = [-1.035, 0.8]; // its angle at the core, and the sweep to the west edge
const ACACIA = 150;
const DOTS = " ·•●";
const COVER = [0, 0.3, 0.6, 1];
const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map((v) => v / 16 - 0.47);

function hash(x: number, y: number): number {
  let h = Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263);
  h = Math.imul(h ^ (h >>> 13), 1274126177);
  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}

function noise(x: number, y: number): number {
  const xi = Math.floor(x), yi = Math.floor(y);
  const fx = x - xi, fy = y - yi;
  const u = fx * fx * (3 - 2 * fx), v = fy * fy * (3 - 2 * fy);
  const a = hash(xi, yi), b = hash(xi + 1, yi), c = hash(xi, yi + 1), d = hash(xi + 1, yi + 1);
  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}

function fbm(x: number, y: number, octaves: number): number {
  let s = 0, n = 0, amp = 0.5, f = 1;
  for (let i = 0; i < octaves; i++) {
    s += amp * noise(x * f + i * 31.7, y * f);
    n += amp;
    amp *= 0.5;
    f *= 2;
  }
  return s / n;
}

const clamp = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
const smooth = (a: number, b: number, v: number) => {
  const k = clamp((v - a) / (b - a));
  return k * k * (3 - 2 * k);
};
const mix = (a: number, b: number, k: number) => a + (b - a) * k;
const hex = (s: string) => [1, 3, 5].map((i) => parseInt(s.slice(i, i + 2), 16) / 255);

// Where a point sits across the dunes' wave: 0 at a trough, rising gently up
// the windward side to the crest at 0.72, then dropping down the slip face.
function phase(x: number, z: number): number {
  const p = x * 0.9 + z * 0.42 + 26 * fbm(x * 0.014, z * 0.014, 3);
  const q = p / 19;
  return q - Math.floor(q);
}

// The tall dune the acacia stands on: a sharp crest that snakes toward us from
// its summit, a gentle face on the town's side and a steep one on the other.
const crestX = (z: number) => {
  const k = clamp((z - 22) / 52);
  return -4 + 26 * k * k * (3 - 2 * k) + 5 * Math.sin((z - 22) * 0.09);
};
function ridge(x: number, z: number): number {
  if (z < 8) return 0;
  const hc = z <= 74 ? 0.5 + 13 * Math.pow(clamp((z - 12) / 62), 1.25) : 13.5 * (1 - (z - 74) / 9);
  const dx = x - crestX(z);
  return hc - (dx < 0 ? -dx * 0.36 : dx * 0.8);
}

function dunes(x: number, z: number): number {
  const u = phase(x, z);
  const prof = u < 0.72 ? Math.pow(u / 0.72, 1.5) : Math.pow((1 - u) / 0.28, 0.75);
  const rg = ridge(x, z);
  // the field lies low around the big dune so its faces stay clean
  const amp = (1 + 4.5 * fbm(x * 0.008 + 5, z * 0.008, 2)) * (1 - 0.8 * smooth(-3, 3, rg));
  return Math.max(prof * amp, rg) + 0.3 * fbm(x * 0.05, z * 0.05, 2);
}

function march(u: number, v: number): number {
  let z = 3, prev = z;
  for (let i = 0; i < 300 && z < 600; i++) {
    const y = CAM + v * z;
    if (v > 0 && y > HMAX) return 0;
    const gap = y - dunes(u * z, z);
    if (gap < 0) {
      let a = prev, b = z;
      for (let j = 0; j < 7; j++) {
        const m = (a + b) / 2;
        if (CAM + v * m - dunes(u * m, m) < 0) b = m;
        else a = m;
      }
      return b;
    }
    prev = z;
    z += Math.max(0.25, gap * 0.5) + z * 0.004;
  }
  return 0;
}

export default function desertNight(): Frame {
  const P = meta.palette.map(hex);
  const N = W * H;
  const out: string[] = new Array(N);

  const lut = new Uint8Array(32768).fill(255);
  const nearest = (r: number, g: number, b: number): number => {
    const k = (Math.min(31, (r * 31.99) | 0) << 10) | (Math.min(31, (g * 31.99) | 0) << 5) | Math.min(31, (b * 31.99) | 0);
    if (lut[k] !== 255) return lut[k];
    let best = 0, bd = 1e9;
    for (let i = 0; i < P.length; i++) {
      const dr = P[i][0] - r, dg = P[i][1] - g, db = P[i][2] - b;
      const d = 0.3 * dr * dr + 0.5 * dg * dg + 0.2 * db * db;
      if (d < bd) (bd = d), (best = i);
    }
    return (lut[k] = best);
  };

  const unit = (v: number[]) => {
    const n = Math.hypot(...v);
    return v.map((c) => c / n);
  };
  // the town's light comes in low from ahead and to the left
  const L = unit([-0.8, 0.32, 0.5]);
  // the galaxy's cool fill, from up and to the right
  const G = unit([0.6, 0.5, 0.4]);

  // --- the dunes, raymarched once ------------------------------------------
  const SKY = 0, SAND = 1, TREE = 2;
  const mat = new Uint8Array(N);
  const depth = new Float32Array(N);
  const sR = new Float32Array(N), sG = new Float32Array(N), sB = new Float32Array(N);
  const crest = new Float32Array(N);
  const top = new Int16Array(W).fill(H);
  for (let r = 0; r < H; r++) {
    const v = ((HZ - (r + 0.5)) / 100) * K;
    for (let x = 0; x < W; x++) {
      const u = ((x + 0.5 - 100) / 100) * K;
      const z = march(u, v);
      if (!z) continue;
      const k = r * W + x;
      mat[k] = SAND;
      depth[k] = z;
      if (r < top[x]) top[x] = r;
      const px = u * z, py = CAM + v * z;
      const e = 0.2;
      const hx = (dunes(px + e, z) - dunes(px - e, z)) / (2 * e);
      const hz = (dunes(px, z + e) - dunes(px, z - e)) / (2 * e);
      const nl = Math.hypot(hx, 1, hz);
      const nx = -hx / nl, ny = 1 / nl, nz = -hz / nl;
      let lit = Math.max(0, nx * L[0] + ny * L[1] + nz * L[2]);
      if (lit > 0) {
        // a soft shadow: how close the ray to the light passes over the sand
        let sh = 1;
        for (let s = 0.6; s < 90; s += 0.5 + s * 0.05) {
          const qx = px + L[0] * s, qy = py + L[1] * s + 0.05, qz = z + L[2] * s;
          if (qy > HMAX) break;
          sh = Math.min(sh, (qy - dunes(qx, qz)) / (0.08 * s));
          if (sh <= 0) break;
        }
        lit *= clamp(sh);
      }
      lit = Math.min(1, Math.pow(lit, 1.4) * 1.45); // faces square to the light stand out
      // brightest along the crests, shading down each face into the trough
      const ph = phase(px, z);
      const onRidge = ridge(px, z) >= dunes(px, z) - 0.35;
      const dxc = px - crestX(z);
      const fall = onRidge ? 0.5 + 0.5 * Math.exp(-Math.abs(dxc) / 7) : 0.55 + 0.45 * smooth(0.2, 0.72, ph);
      const near = 0.72 + 0.28 * smooth(5, 25, z);
      const reach = (0.55 + 0.45 * Math.exp(-Math.abs(x - TOWN[0]) / 90)) * fall * near * smooth(300, 120, z);
      // starlight from the galaxy's side, and wind ripples across every slope
      const fill = 0.045 * Math.max(0, nx * G[0] + ny * G[1] + nz * G[2]);
      const ripple = (fbm(px * 0.6 + z * 0.2, z * 0.6, 2) - 0.5) * 0.05;
      const amb = (0.02 + 0.03 * ny + ripple) * near;
      let cr = amb * 0.7 + fill * 0.45 + lit * 1.0 * reach;
      let cg = amb * 0.62 + fill * 0.65 + lit * 0.63 * reach;
      let cb = amb * 1.35 + fill * 1.2 + lit * 0.53 * reach;
      // the big dune's shadowed brink catches a line of starlight
      if (onRidge && z < 76 && dxc > 0) {
        const cellW = z * K * 0.01;
        const rim = smooth(2.2 * cellW, 0.6 * cellW, dxc) * smooth(10, 20, z);
        cr += 0.1 * rim;
        cg += 0.14 * rim;
        cb += 0.24 * rim;
      }
      // distance thins the light into the horizon's haze
      const haze = clamp(1 - Math.exp(-z / 260)) * mix(0.7, 0.92, smooth(120, 300, z));
      const hg = Math.exp(-Math.abs(x - TOWN[0]) / 40) * 0.18;
      cr = mix(cr, 0.07 + hg, haze);
      cg = mix(cg, 0.085 + hg * 0.6, haze);
      cb = mix(cb, 0.15 + hg * 0.3, haze);
      sR[k] = cr;
      sG[k] = cg;
      sB[k] = cb;
      const big = ridge(px, z) > 0.5 && z < 76 ? smooth(0.9, 0, Math.abs(dxc)) : 0;
      crest[k] = Math.max(smooth(0.07, 0, Math.abs(ph - 0.72)) * smooth(260, 30, z), big) * (0.3 + 0.7 * smooth(0, 0.2, lit));
    }
  }

  // --- the acacia, on the crest under the galaxy's core ---------------------
  const bark = new Float32Array(N); // warm light from the core caught on the canopy's top
  {
    const base = top[ACACIA];
    const canopyTop = base - 16;
    // an umbrella: a low lumpy dome on top, thinning to the tips, tufts below
    const SPAN = 17;
    const canopy = (x: number, px: number, py: number) => {
      const q = (px - ACACIA) / SPAN;
      if (Math.abs(q) > 1.08) return false;
      const topY = canopyTop + 0.4 + 2.6 * q * q + 1.3 * (noise(px * 0.3, 7.1) - 0.5);
      const botY =
        canopyTop + 3.8 + 1.4 * (1 - q * q) - 1.4 * smooth(0.8, 1.08, Math.abs(q)) +
        1.2 * (noise(px * 0.45, 3.3) - 0.5) + (hash(x, 51) < 0.2 ? 0.9 : 0);
      return py > topY && py < botY;
    };
    // limbs: from the fork up and out to the canopy
    const fork: [number, number] = [ACACIA + 0.3, base - 5];
    const limbs: [[number, number], [number, number], number][] = [
      [[ACACIA, base + 1], fork, 1.3],
      [fork, [ACACIA - 9, canopyTop + 3], 0.8],
      [fork, [ACACIA + 1.5, canopyTop + 2], 0.75],
      [fork, [ACACIA + 10, canopyTop + 3.2], 0.8],
      [fork, [ACACIA - 4, canopyTop + 2.6], 0.55],
      [[ACACIA - 4, base - 9], [ACACIA - 14, canopyTop + 3.5], 0.55],
      [[ACACIA + 3, base - 8], [ACACIA + 15, canopyTop + 3.6], 0.5],
    ];
    for (let r = Math.max(0, canopyTop - 3); r <= base + 1; r++) {
      for (let x = ACACIA - 26; x <= ACACIA + 26; x++) {
        if (x < 0 || x >= W) continue;
        const px = x + 0.5, py = r + 0.5;
        let on = canopy(x, px, py);
        for (const [[ax, ay], [bx, by], w] of limbs) {
          const lx = bx - ax, ly = by - ay;
          const t = clamp(((px - ax) * lx + (py - ay) * ly) / (lx * lx + ly * ly));
          const dx = px - (ax + lx * t), dy = py - (ay + ly * t);
          if (dx * dx + dy * dy < (w * (1 - 0.4 * t)) ** 2) on = true;
        }
        if (on) mat[r * W + x] = TREE;
      }
    }
    // the canopy's upper edge, rimmed by the bulge behind it
    for (let r = 1; r < H; r++) {
      for (let x = 0; x < W; x++) {
        const k = r * W + x;
        if (mat[k] !== TREE || mat[k - W] !== SKY) continue;
        const dx = x + 0.5 - CORE[0], dy = (r - CORE[1]) * 1.3;
        bark[k] = 0.25 + 0.75 * Math.exp(-Math.sqrt(dx * dx + dy * dy) / 18);
      }
    }
  }

  // --- the town's lights, a few pinpricks along the far horizon -------------
  const lamps: [number, number, number][] = [];
  for (let x = TOWN[0] - 9; x <= TOWN[0] + 9 && lamps.length < 5; x++) {
    const r = top[x];
    if (r >= H || depth[r * W + x] < 140 || hash(x, 91) > 0.45) continue;
    if (lamps.some(([k]) => Math.abs((k % W) - x) < 2)) continue;
    lamps.push([r * W + x, hash(x, 92) * 6.28, 0.7 + hash(x, 93) * 1.4]);
  }

  // --- the sky: gradient, the town's glow and the milky way ----------------
  const kR = new Float32Array(N), kG = new Float32Array(N), kB = new Float32Array(N);
  const air = new Float32Array(N);
  const band = new Float32Array(N);
  for (let r = 0; r < H; r++) {
    for (let x = 0; x < W; x++) {
      const k = r * W + x;
      const y = r + 0.5;
      const v = clamp(y / HZ);
      // a saturated navy, so the dither's specks read as colour
      let cr = 0.02 + 0.03 * v * v, cg = 0.035 + 0.045 * v * v, cb = 0.1 + 0.08 * v * v;
      // the town: an amber dome rising from below the horizon, its warmth
      // taking over from the navy rather than greying it
      const tx = x + 0.5 - TOWN[0], ty = (y - TOWN[1]) * 1.9;
      const td = Math.sqrt(tx * tx + ty * ty);
      const glow = Math.exp(-td / 16) * 0.4 + Math.exp(-td / 60) * 0.22;
      const cool = 1 - 0.85 * clamp(Math.exp(-td / 22) * 1.5);
      cr = cr * cool + glow;
      cg = cg * cool + glow * 0.5;
      cb = cb * cool + glow * 0.16;
      // airglow, a faint blue sheen low down, kept off the town; it drifts each frame
      // it peaks a little above the horizon, where the air below thins it, and
      // varies along the skyline so it never lies as one flat strip
      const along = 0.72 + 0.5 * fbm(x * 0.025 + 3, y * 0.04, 2);
      air[k] = Math.pow(smooth(0.3, 0.94, v), 3) * (1 - 0.3 * smooth(0.92, 1.02, v)) * 0.32 * cool * along;
      // the milky way, along an arc from the core up and over to the west
      const ax = x + 0.5 - ARC[0], ay = y - ARC[1];
      const d = Math.sqrt(ax * ax + ay * ay) - ARC[2];
      const sRaw = (ARC_S[0] - Math.atan2(ay, ax)) / ARC_S[1]; // 0 at the core end
      const s = clamp(sRaw);
      const end = sRaw < 0 ? Math.exp(-((sRaw / 0.05) ** 2)) : 1; // the band ends at the core
      const laneEnd = sRaw < 0 ? Math.exp(-((sRaw / 0.11) ** 2)) : 1;
      const w = 9 + 8 * Math.pow(1 - s, 1.4);
      const lane0 = (noise(s * 9, 3.3) - 0.5) * w * 0.5 * smooth(0, 0.25, s);
      let b = Math.exp(-((d / w) ** 2));
      const clump = fbm(x * 0.09, y * 0.09, 4);
      b *= 0.35 + 1.1 * smooth(0.3, 0.75, clump);
      b *= (0.55 + 0.55 * (1 - s)) * end;
      // the core's bulge
      const cx = x + 0.5 - CORE[0], cy = (y - CORE[1]) * 1.3;
      const cd = Math.sqrt(cx * cx + cy * cy);
      const core = Math.exp(-cd / 6) * 0.5 + Math.exp(-cd / 22) * 0.25;
      b += core;
      // the dust lane, a crisp rift through the bulge, and its filaments
      const nearCore = Math.exp(-cd / 16);
      const lane =
        Math.exp(-(((d - lane0) / (w * 0.12)) ** 2)) *
        mix((0.55 + 0.6 * fbm(x * 0.12, y * 0.12, 3)) * (0.55 + 0.45 * (1 - s)), 1, nearCore) *
        laneEnd;
      const fil = smooth(0.58, 0.78, fbm(x * 0.16 + 9, y * 0.16, 3)) * 0.6;
      b *= clamp(1 - mix(0.92, 0.97, nearCore) * lane) * (1 - fil * Math.exp(-((d / (w * 1.3)) ** 2)));
      band[k] = Math.exp(-((d / (w * 1.2)) ** 2)) * end + core;
      // warm toward the core, cool along the arm
      const warm = clamp(Math.exp(-cd / 26) * 1.2 + (1 - s) * 0.25);
      cr += b * mix(0.62, 1.0, warm) * 0.75;
      cg += b * mix(0.68, 0.9, warm) * 0.75;
      cb += b * mix(0.95, 0.74, warm) * 0.75;
      kR[k] = cr;
      kG[k] = cg;
      kB[k] = cb;
    }
  }

  // per-cell dither and floor: random in open sky, half ordered in the band
  const dith = new Float32Array(N);
  const floor0 = new Float32Array(N);
  for (let r = 0; r < H; r++) {
    for (let x = 0; x < W; x++) {
      const k = r * W + x;
      const bay = BAYER[(r & 3) * 4 + (x & 3)];
      if (mat[k] === SKY) {
        // the band and the glow low down get a wider, half-ordered dither, so
        // their gradients fade out instead of stopping at an edge
        const inb = clamp(band[k]);
        const soft = Math.max(inb, clamp(air[k] / 0.2));
        const rnd = (hash(x * 3 + 1, r * 5 + 2) - 0.5) * mix(0.6, 0.94, soft);
        dith[k] = mix(rnd, bay, 0.5 * soft);
        floor0[k] = 0.1 * inb;
      } else {
        dith[k] = bay;
        floor0[k] = mat[k] === SAND ? 0.02 : 0;
      }
    }
  }

  // --- stars: thick in the band, a few bright ones everywhere ---------------
  const stars: [number, number, number[], number, number][] = [];
  for (let r = 0; r < HZ + 2; r++) {
    for (let x = 0; x < W; x++) {
      const k = r * W + x;
      if (mat[k] !== SKY) continue;
      const p = 0.015 + 0.12 * clamp(band[k]);
      if (hash(x * 7 + 3, r * 13 + 1) > p) continue;
      const m = Math.pow(hash(x * 5 + 1, r * 3 + 7), 12);
      const bright = (0.2 + 0.95 * m) * smooth(HZ + 1, HZ - 6, r + 0.5);
      const c = hash(x * 11, r * 17 + 5);
      const tint = c < 0.2 ? [0.78, 0.86, 1] : c < 0.9 ? [1, 1, 1] : [1, 0.82, 0.6];
      stars.push([k, bright, tint, 1.5 + hash(x, r * 9) * 4, hash(r, x * 3) * 6.28]);
    }
  }

  const FR = new Float32Array(N), FG = new Float32Array(N), FB = new Float32Array(N);
  const floor = new Float32Array(N);
  const AIR0 = Math.floor(HZ * 0.3);

  // meteor 0 is already falling; meteor n starts somewhere in its 7 second slot
  const meteor = (n: number): [number, number, number, number, number, number] => {
    const start = n === 0 ? -0.35 : 7 * (n - 1) + 2.5 + hash(n, 71) * 2.5;
    const x0 = n === 0 ? 199 : 30 + hash(n, 72) * 140;
    const y0 = n === 0 ? 4 : 4 + hash(n, 73) * 18;
    // each one heads across the sky rather than straight off its nearer edge
    const a = n === 0 ? 2.65 : (x0 > 100 ? 2.6 : 0.55) + (hash(n, 75) - 0.5) * 0.4;
    return [start, x0, y0, Math.cos(a), Math.sin(a), 52 + hash(n, 76) * 30];
  };

  return (t, { color } = {}) => {
    FR.set(kR);
    FG.set(kG);
    FB.set(kB);
    floor.set(floor0);
    // the airglow drifts slowly along the horizon
    for (let r = AIR0; r < HZ + 2; r++) {
      for (let x = 0; x < W; x++) {
        const k = r * W + x;
        if (mat[k] !== SKY) continue;
        const a = air[k] * (0.86 + 0.28 * noise((x - t * 0.3) * 0.03, r * 0.08));
        FR[k] += a * 0.3;
        FG[k] += a * 0.5;
        FB[k] += a * 1.2;
      }
    }
    for (let k = 0; k < N; k++) {
      const m = mat[k];
      if (m === SAND) {
        FR[k] = sR[k];
        FG[k] = sG[k];
        FB[k] = sB[k];
        const c = crest[k];
        if (c > 0) {
          // sand glinting along the brink of each slip face
          const h = hash(k, 5);
          const g = c * Math.pow(Math.max(0, Math.sin(t * (0.8 + 2.2 * h) + h * 40)), 8) * 0.5;
          FR[k] += g;
          FG[k] += g * 0.85;
          FB[k] += g * 0.7;
        }
      } else if (m === TREE) {
        const e = bark[k];
        FR[k] = e * 0.42;
        FG[k] = e * 0.3;
        FB[k] = e * 0.2;
        floor[k] = e > 0 ? 0.1 : 0;
      }
    }
    for (const [k, b, tint, rate, ph] of stars) {
      const tw = b > 0.35 ? 0.72 + 0.28 * Math.sin(t * rate + ph) : 0.6 + 0.4 * Math.sin(t * rate + ph);
      const s = b * tw;
      FR[k] += s * tint[0];
      FG[k] += s * tint[1];
      FB[k] += s * tint[2];
      floor[k] = 0.28;
    }
    for (const [k, ph, rate] of lamps) {
      const g = 0.42 + 0.14 * Math.sin(t * rate + ph) * Math.sin(t * rate * 2.3 + ph * 3);
      FR[k] = g;
      FG[k] = 0.85 * g;
      FB[k] = 0.66 * g;
      floor[k] = 0.12;
    }
    // a meteor, if one is crossing
    const n = Math.floor((t - 2.5) / 7) + 1;
    for (let i = Math.max(0, n - 1); i <= n + 1; i++) {
      const [start, x0, y0, dx, dy, speed] = meteor(i);
      const age = t - start;
      if (age < 0 || age > 0.9) continue;
      const fadeIn = smooth(0, 0.12, age), fadeOut = smooth(0.9, 0.6, age);
      const hx = x0 + dx * speed * age, hy = y0 + dy * speed * age * 0.75;
      const len = Math.min(28, speed * age);
      for (let j = 0; j < len * 2; j++) {
        const f = j / (len * 2);
        const px = Math.round(hx - dx * f * len), py = Math.round(hy - dy * f * len * 0.75);
        if (px < 0 || px >= W || py < 0 || py >= H) continue;
        const k = py * W + px;
        if (mat[k] !== SKY) continue;
        const s = (1 - f) * fadeIn * fadeOut * 1.25;
        FR[k] = Math.max(FR[k], s * 0.9);
        FG[k] = Math.max(FG[k], s * 0.96);
        FB[k] = Math.max(FB[k], s);
        floor[k] = Math.max(floor[k], s > 0.12 ? 0.4 : 0);
      }
    }

    for (let r = 0; r < H; r++) {
      const edge = smooth(H + 1, H - 12, r + 0.5);
      for (let x = 0; x < W; x++) {
        const k = r * W + x;
        const cr = FR[k], cg = FG[k], cbl = FB[k], fl = floor[k];
        const peak = Math.max(cr, cg, cbl, 1e-4);
        const sky = mat[k] === SKY;
        const level = clamp(fl + (1 - fl) * Math.pow(peak, sky ? 0.92 : 0.75)) * edge;
        const step = Math.max(0, Math.min(3, Math.round(level * 3 + dith[k])));
        out[k] = DOTS[step];
        if (color) {
          const want = step ? Math.min(1, (level + 0.06) / COVER[step]) : 0;
          const s = (0.3 + 0.7 * want) / peak;
          color[k] = nearest(clamp(cr * s), clamp(cg * s), clamp(cbl * s));
        }
      }
    }
    const lines: string[] = [];
    for (let r = 0; r < H; r++) lines.push(out.slice(r * W, (r + 1) * W).join(""));
    return lines.join("\n");
  };
}