green storm

[docs] [github]

a thunderstorm at sea, the lightning striking in the supabase bolt

scenes 200×100 15 fps 28 colours, on a canvas 21.2 kB [report a bug]

html

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

<ascii-art piece="green-storm"></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 ascii.rest
import { Ascii } from "ascii.rest/react";
import { greenStorm } from "ascii.rest/pieces";

<Ascii piece={greenStorm} />

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

astro

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

<Ascii piece="green-storm" />

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 ascii.rest
import { mount } from "ascii.rest";
import { greenStorm } from "ascii.rest/pieces";

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

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.

terminal

npx ascii.rest green-storm

Plays it in your terminal, in colour, until you press a key. Add --seconds 5 to stop it on its own; npx ascii.rest list shows every piece. In a CLI of your own, play("green-storm", { seconds: 2 }) from ascii.rest/terminal shows it as a splash screen.

green-storm.ts

[js] [github]
/*
 * green storm: a night thunderstorm rolling over a dark sea. Every few seconds
 * a giant strike comes down in the shape of the Supabase bolt, emerald with a
 * white hot centre, lighting the undersides of the clouds and the hills below
 * green, and leaving an afterimage that fades. Smaller jagged bolts flicker in
 * between, and rain slants through all of it.
 *
 * Each frame is shaded in colour cell by cell on a square grid, then drawn as
 * a halftone: every cell is a dot whose size is its brightness, ordered-
 * dithered so gradients read as texture, in the palette colour nearest its hue.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "green storm",
  category: "scenes",
  note: "a thunderstorm at sea, the lightning striking in the supabase bolt",
  cols: 200,
  rows: 100,
  cell: 1,
  fps: 15,
  ground: "#0e0e0e",
  palette: [
    "#1c1c1c", "#242424", "#2e2e2e", "#3a3a3a", "#4a4a4a", "#5e5e5e", "#777777", "#939393", "#b4b4b4", "#d6d6d6",
    "#1d2a24", "#243a30", "#2d4d3e", "#3a6450", "#4d8066", "#67a084",
    "#0f3324", "#14472f", "#1a5e3e", "#22784f", "#2c9663", "#36b578", "#3ecf8e", "#65dba5", "#8fe6be", "#bdf2d8",
    "#e6fbf1", "#ffffff",
  ],
} satisfies Meta;

// The Supabase bolt, as the library's own supabase piece draws it: anything
// not a space is inside. Its cells are text cells, twice as tall as wide.
const INK = String.raw`
                      000000
                     0000000
                   000000000
                 00000000000
                000000000000
              00000000000000
             000000000000000
           00000000000000000
         0000000000000000000
        00000000000000000000000000000000000000000000
      00000000000000000000000000000000000000000000000
     000000000000000000000000000000000000000000000000
   00000000000000000000000000000000000000000000000000
  00000000000000000000000000000000000000000000000000
  000000000000000000000000000000000000000000000000
  00000000000000000000000000000000000000000000000
   00000000000000000000000000000000000000000000
                           000000000000000000
                           00000000000000000
                           000000000000000
                           00000000000000
                           000000000000
                           0000000000
                           000000000
                           0000000
                           000000
`;

const W = 200, H = 100;
const HORIZON = 76;
const BOLT_TOP = 28; // the big strike hangs from the cloud base here down to the horizon
const LOOP = 10.8; // three strikes, then it all comes round again
const STRIKES: [number, number][] = [[0, 100], [3.6, 74], [7.2, 126]]; // [when, centre x]
// The small bolts: [when, seed]. One lands at t = 6, so the still has one.
const FORKS: [number, number][] = [[1.5, 1], [2.95, 2], [5.1, 3], [6.0, 4], [8.8, 5], [9.9, 6]];
const DOTS = " ·•●";
const COVER = [0, 0.3, 0.6, 1]; // how much each dot fills, against the largest
const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map((v) => v / 16 - 0.47);
const EM = [0.243, 0.812, 0.557]; // #3ecf8e

const AIR = 0, HILL = 1, SEA = 2;

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;
}

// Value noise, wrapping every `period` lattice cells in x when period > 0.
function noise(x: number, y: number, period: 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);
  let x0 = xi, x1 = xi + 1;
  if (period) {
    x0 = ((xi % period) + period) % period;
    x1 = (x0 + 1) % period;
  }
  const a = hash(x0, yi), b = hash(x1, yi), c = hash(x0, yi + 1), d = hash(x1, yi + 1);
  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}

function fbm(x: number, y: number, octaves: number, period: number): number {
  let s = 0, n = 0, amp = 0.5, f = 1;
  for (let i = 0; i < octaves; i++) {
    s += amp * noise(x * f, y * f, period * 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);

// Distance from every cell to the nearest cell where `inside` is false, by a
// two-pass chamfer; good enough for glows and a hot core.
function chamfer(inside: Uint8Array): Float32Array {
  const d = new Float32Array(W * H);
  for (let k = 0; k < W * H; k++) d[k] = inside[k] ? 1e4 : 0;
  const A = 1, B = Math.SQRT2;
  for (let r = 0; r < H; r++)
    for (let x = 0; x < W; x++) {
      const k = r * W + x;
      if (!d[k]) continue;
      let v = d[k];
      if (x > 0) v = Math.min(v, d[k - 1] + A);
      if (r > 0) {
        v = Math.min(v, d[k - W] + A);
        if (x > 0) v = Math.min(v, d[k - W - 1] + B);
        if (x < W - 1) v = Math.min(v, d[k - W + 1] + B);
      }
      d[k] = v;
    }
  for (let r = H - 1; r >= 0; r--)
    for (let x = W - 1; x >= 0; x--) {
      const k = r * W + x;
      if (!d[k]) continue;
      let v = d[k];
      if (x < W - 1) v = Math.min(v, d[k + 1] + A);
      if (r < H - 1) {
        v = Math.min(v, d[k + W] + A);
        if (x < W - 1) v = Math.min(v, d[k + W + 1] + B);
        if (x > 0) v = Math.min(v, d[k + W - 1] + B);
      }
      d[k] = v;
    }
  return d;
}

// The strike's light: a hard first flash, two flickers, then nothing.
const flashAt = (a: number) =>
  a < 0 ? 0 : Math.exp(-a / 0.16) + 0.75 * Math.exp(-(((a - 0.33) / 0.07) ** 2)) + 0.5 * Math.exp(-(((a - 0.6) / 0.08) ** 2));
// The afterimage burnt into the eye, fading for a couple of seconds.
const afterAt = (a: number) => (a < 0 ? 0 : Math.exp(-a / 1.8));

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

  // Nearest palette colour, cached on a 32-step cube.
  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);
  };

  // --- the bolt, scaled up from the logo grid -----------------------------
  const grid = INK.slice(1, -1).split("\n");
  const GR = grid.length, GC = Math.max(...grid.map((l) => l.length));
  const ink = (c: number, r: number) => (r >= 0 && r < GR && c >= 0 && c < GC && grid[r][c] && grid[r][c] !== " " ? 1 : 0);
  const scale = (HORIZON - BOLT_TOP) / (GR * 2); // square cells per logo column
  // the logo's lowest point, so the tip lands on the horizon
  let tipCol = 0;
  for (let c = 0; c < GC; c++) if (ink(c, GR - 1)) tipCol += c / grid[GR - 1].trim().length;
  tipCol += 0.5;
  // and its highest, where the channel leaves the cloud
  let topCol = 0;
  for (let c = 0; c < GC; c++) if (ink(c, 0)) topCol += c / grid[0].trim().length;
  topCol += 0.5;
  const bolts = STRIKES.map(([, cx]) => {
    const x0 = cx - (GC * scale) / 2;
    const mask = new Float32Array(N);
    const solid = new Uint8Array(N);
    for (let r = 0; r < H; r++)
      for (let x = 0; x < W; x++) {
        // bilinear over the logo's cells, then a hard threshold for a crisp edge
        const u = (x + 0.5 - x0) / scale - 0.5, v = (r + 0.5 - BOLT_TOP) / (2 * scale) - 0.5;
        const ui = Math.floor(u), vi = Math.floor(v), fu = u - ui, fv = v - vi;
        const a = ink(ui, vi), b = ink(ui + 1, vi), c = ink(ui, vi + 1), d = ink(ui + 1, vi + 1);
        const s = a + (b - a) * fu + (c - a) * fv + (a - b - c + d) * fu * fv;
        mask[r * W + x] = smooth(0.45, 0.55, s);
        solid[r * W + x] = s > 0.5 ? 1 : 0;
      }
    const inner = chamfer(solid);
    const outside = new Uint8Array(N);
    for (let k = 0; k < N; k++) outside[k] = solid[k] ? 0 : 1;
    const outer = chamfer(outside);
    // the channel it fell down: a jagged thread from its top up into the cloud
    const leader = new Float32Array(N);
    let lx = x0 + topCol * scale, ly = BOLT_TOP + 1, dir = 0;
    for (let i = 0; ly > 6; i++) {
      // a zig-zag: lurch one way for a few rows, then the other
      if (i % 4 === 0) dir = (hash(cx, i) > 0.5 ? 1 : -1) * (0.6 + hash(cx, i + 50) * 0.9) - (lx - (x0 + topCol * scale)) * 0.08;
      lx += dir;
      ly -= 0.7;
      for (let dx = -1; dx <= 1; dx++) {
        const k = Math.round(ly) * W + Math.round(lx) + dx;
        leader[k] = Math.max(leader[k], dx ? 0.25 : 1);
      }
    }
    return { mask, inner, outer, leader, tip: x0 + tipCol * scale };
  });

  // --- the small bolts: jagged forks walked down from the cloud -----------
  const forks = FORKS.map(([, seed]) => {
    const glow = new Float32Array(N);
    const sx = 20 + hash(seed, 71) * 160, sy = 24 + hash(seed, 72) * 10;
    const draw = (x: number, y: number, len: number, w: number, depth: number, salt: number) => {
      let dir = (hash(salt, 1) - 0.5) * 0.9;
      for (let i = 0; i < len; i++) {
        // a zig-zag: lurch sideways, then fall, again and again
        if (i % 3 === 0) dir = (hash(salt, i + 7) - 0.5) * 2.2 + (hash(salt, 3) - 0.5) * 0.6;
        x += dir;
        y += 0.8 + hash(salt, i + 40) * 0.5;
        for (let dy = -2; dy <= 2; dy++)
          for (let dx = -2; dx <= 2; dx++) {
            const cx = Math.round(x) + dx, cy = Math.round(y) + dy;
            if (cx < 0 || cx >= W || cy < 0 || cy >= HORIZON) continue;
            const dd = Math.hypot(cx + 0.5 - x, cy + 0.5 - y);
            const v = w * (dd < 0.75 ? 1 : 0.3 * Math.exp(-dd / 1.1));
            const k = cy * W + cx;
            if (v > glow[k]) glow[k] = v;
          }
        if (depth < 2 && hash(salt, i + 90) > 0.9) draw(x, y, len * 0.45, w * 0.7, depth + 1, salt * 7 + i);
      }
    };
    draw(sx, sy, 22 + hash(seed, 73) * 22, 1, 0, seed * 131);
    const cells: number[] = [], vals: number[] = [];
    for (let k = 0; k < N; k++) if (glow[k] > 0.02) cells.push(k), vals.push(glow[k]);
    return { cells: Int32Array.from(cells), vals: Float32Array.from(vals), x: sx, y: sy + 14 };
  });

  // --- the land: low hills on either side of the bay -----------------------
  const mat = new Uint8Array(N);
  const slope = new Float32Array(N);
  const rim = new Float32Array(N); // the ridge line, catching the sky's last light
  const hill = (x: number) =>
    HORIZON - 12 * smooth(66, 8, x) - 7 * smooth(136, 192, x) - 2.5 * fbm(x * 0.07, 5.3, 3, 0) * (smooth(70, 50, x) + smooth(130, 150, x)) + 1.2;
  for (let r = 0; r < H; r++)
    for (let x = 0; x < W; x++) {
      const k = r * W + x, y = r + 0.5;
      const top = hill(x);
      if (y >= HORIZON) mat[k] = SEA;
      else if (y >= top && (x < 76 || x > 128)) {
        mat[k] = HILL;
        // faces turned to the bay catch the most light
        slope[k] = clamp(0.5 + (hill(x - 1) - hill(x + 1)) * 0.8 * (x < 100 ? -1 : 1)) * 0.6 + 0.4 * smooth(top + 2.5, top, y);
        rim[k] = smooth(top + 1.6, top, y);
      }
    }

  // --- the storm clouds: heaps of noise that wrap, so they drift forever ---
  const CW = 640, CH = 52;
  const cover = new Float32Array(CW * CH);
  const tone = new Float32Array(CW * CH);
  const under = new Float32Array(CW * CH);
  // where the deck ends, a little higher or lower along its length
  // The deck's ragged belly: big heaps hanging to different depths.
  const baseAt = (x: number) => 14 + 20 * fbm(x * 0.028, 7.7, 4, CW * 0.028);
  // Billows: folded noise, so every lump is a rounded heap with a crease round it.
  const billow = (x: number, y: number) => {
    const n = fbm(x * 0.032, y * 0.052, 4, CW * 0.032);
    return 1 - Math.abs(n * 2 - 1);
  };
  const density = (x: number, y: number) => {
    const b = baseAt(x);
    return 0.62 * smooth(b + 3, b - 3, y) + 0.4 * billow(x, y) - 0.12;
  };
  for (let r = 0; r < CH; r++)
    for (let x = 0; x < CW; x++) {
      const y = r + 0.5, i = r * CW + x;
      const d = density(x, y);
      const b = baseAt(x);
      cover[i] = smooth(0.42, 0.55, d);
      // each heap shaded round, lit a little on its lower side, creases dark;
      // the deck darkest overhead and brightest along its hanging belly
      const bl = billow(x, y);
      const shade = (bl - billow(x + 1, y - 2.5)) * 3 + (bl - 0.6) * 0.9;
      tone[i] = clamp(0.25 + shade + 0.3 * smooth(b - 8, b, y) - 0.15 * smooth(14, 0, y));
      // the undersides, which face the strikes below
      under[i] = clamp(0.25 + 0.75 * smooth(b - 9, b, y) + (bl - billow(x, y + 2.5)) * 2) * smooth(4, 14, y);
    }

  const fork = new Float32Array(N);

  return (t, { color } = {}) => {
    const lt = ((t % LOOP) + LOOP) % LOOP;
    const drift = t * 1.1;
    // which strikes are lit or still burning in the eye
    let fl = 0, af = 0, bi = 0;
    for (let s = 0; s < STRIKES.length; s++) {
      let a = lt - STRIKES[s][0];
      if (a < 0) a += LOOP;
      const f = flashAt(a), g = afterAt(a);
      if (f + g > fl + af) (fl = f), (af = g), (bi = s);
    }
    const B = bolts[bi];
    const fls = Math.min(1.4, fl);
    // the small bolts, flickering for a few frames each
    let forkLight = 0, fx = 0, fy = 0;
    fork.fill(0);
    for (let i = 0; i < FORKS.length; i++) {
      let a = lt - FORKS[i][0];
      if (a < 0) a += LOOP;
      if (a > 0.6) continue;
      const e = Math.exp(-a / 0.12) + 0.6 * Math.exp(-(((a - 0.22) / 0.05) ** 2));
      const F = forks[i];
      for (let j = 0; j < F.cells.length; j++) fork[F.cells[j]] += F.vals[j] * e;
      if (e > forkLight) (forkLight = e), (fx = F.x), (fy = F.y);
    }

    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 m = mat[k];
        let cr = 0, cg = 0, cbl = 0, floor = 0.22, fade = 1;
        // the strike's light here, falling off with distance from the bolt
        const od = B.outer[k];
        const near = Math.exp(-od / 26);
        // held back right beside the bolt, so its own edge stays the brightest
        const light = fls * (0.12 + 0.88 * near) * (0.2 + 0.8 * smooth(1, 10, od));
        const fd = forkLight ? Math.hypot(x - fx, (y - fy) * 1.4) : 1e3;
        const flight = forkLight * Math.exp(-fd / 20) * 0.6;

        if (m === AIR) {
          // sky behind the rain: near black up top, a dull grey at the horizon
          const v = y / HORIZON;
          // and a pale band low down, from the storm's far edge, for the hills to stand against
          const sky = 0.025 + 0.1 * v * v * v * v + 0.2 * Math.exp(-(HORIZON - y) / 6);
          cr = sky, cg = sky, cbl = sky;
          floor = 0.12;
          // the air itself flashes green near the strike
          const air = light * 0.16 + flight * 0.2;
          cr += air * EM[0], cg += air * EM[1], cbl += air * EM[2];
          if (r < CH) {
            const sx = x + drift, ix = Math.floor(sx), f = sx - ix;
            const i0 = r * CW + (ix % CW), i1 = r * CW + ((ix + 1) % CW);
            const c = cover[i0] + (cover[i1] - cover[i0]) * f;
            if (c > 0.01) {
              const tn = tone[i0] + (tone[i1] - tone[i0]) * f;
              const un = under[i0] + (under[i1] - under[i0]) * f;
              // storm grey, rolled, darker at the heart of the deck
              const g = 0.05 + 0.4 * Math.pow(tn, 1.4);
              // lit from below in the strike's green, whitening where it is hottest
              const lit = (light * (0.15 + 1.1 * un) + flight * (0.3 + un)) * 0.9;
              const hot = smooth(0.7, 1.6, lit) * 0.6;
              // and always a faint green on the bellies, from strikes far off
              const amb = 0.07 * un;
              const kr = g + lit * mix(EM[0], 1, hot) * 0.75 + amb * EM[0];
              const kg = g + lit * mix(EM[1], 1, hot) * 0.75 + amb * EM[1];
              const kb = g + lit * mix(EM[2], 1, hot) * 0.75 + amb * EM[2];
              cr = mix(cr, kr, c), cg = mix(cg, kg, c), cbl = mix(cbl, kb, c);
              floor = mix(0.12, 0.2, c);
            }
          }
        } else if (m === HILL) {
          // dark hills, their bay-facing slopes thrown into green relief
          const s = slope[k];
          const g = 0.008 + 0.015 * s + 0.12 * rim[k];
          const lx = Math.exp(-Math.abs(x - B.tip) / 55);
          const lit = fls * (0.15 + 0.85 * lx) * (0.3 + 0.9 * s) * 0.7 + flight * 0.3 * s;
          cr = g + lit * EM[0], cg = g + lit * EM[1], cbl = g + lit * EM[2];
          floor = 0.06;
        } else {
          // sea: black water, swells catching the green
          const v = (y - HORIZON) / (H - HORIZON);
          const w = 0.6 * noise(x * 0.08 + t * 0.15, y * 0.5 - t * 0.7, 0) + 0.4 * noise(x * 0.22 - t * 0.3, y * 1.0 - t * 1.2, 0);
          const sw = 0.5 + 0.9 * w;
          const g = (0.075 - 0.035 * v) * sw;
          cr = g, cg = g, cbl = g;
          // the bolt again, upside down in the water, broken by the waves
          const ry = Math.round(HORIZON - (y - HORIZON) * 2.1 - 1);
          const rx = Math.round(x + (w - 0.5) * 4);
          let refl = 0;
          if (ry >= 0 && rx >= 0 && rx < W) {
            const j = ry * W + rx;
            refl = (B.mask[j] * (fl * 0.8 + af * 0.5) + Math.exp(-B.outer[j] / 8) * fls * 0.2) * (0.1 + 0.9 * smooth(0.45, 0.8, w));
          }
          const sheen = (fls * Math.exp(-Math.abs(x - B.tip) / 40) * 0.4 + flight * 0.2) * smooth(0.45, 0.8, w);
          const hot = clamp(fl * 0.5 - 0.2) * smooth(0.6, 0.85, w);
          cr += (refl + sheen) * mix(EM[0], 1, hot);
          cg += (refl + sheen) * mix(EM[1], 1, hot);
          cbl += (refl + sheen) * mix(EM[2], 1, hot);
          floor = 0.14;
          fade = smooth(H, H - 22, y); // the bottom rows thin out into the ground
        }

        // the big bolt: emerald, white hot through its middle while it flashes,
        // a green ghost of itself after, haloed either way
        if (m !== SEA) {
          const bm = B.mask[k];
          // the halo starts a step off the edge and stays dim, so the outline
          // is never lost in its own glow
          const halo = smooth(0.5, 3.5, od) * Math.exp(-od / 7) * (fls * 0.2 + af * 0.1) + Math.exp(-od / 20) * fls * 0.05;
          cr += halo * EM[0], cg += halo * EM[1], cbl += halo * EM[2];
          if (bm > 0) {
            // white hot inside a thin emerald edge while it flashes, all emerald after
            const core = smooth(1.2, 2.8, B.inner[k]) * clamp(fl * 1.2);
            const lum = Math.min(1, fl * 1.3 + af * 0.9);
            const br = mix(EM[0], 1, core) * lum, bg = mix(EM[1], 1, core) * lum, bb = mix(EM[2], 1, core) * lum;
            cr = mix(cr, br, bm), cg = mix(cg, bg, bm), cbl = mix(cbl, bb, bm);
          }
          // where it strikes, a burst on the water line
          const dt = Math.hypot(x + 0.5 - B.tip, (y - HORIZON) * 1.6);
          const burst = Math.exp(-dt / 3) * fls * 0.9 + Math.exp(-dt / 10) * (fls * 0.3 + af * 0.15);
          cr += burst * mix(EM[0], 1, 0.4), cg += burst * mix(EM[1], 1, 0.4), cbl += burst * mix(EM[2], 1, 0.4);
        }

        // the small forks, green with a pale core
        const fv = fork[k] + B.leader[k] * Math.min(1.3, fl * 1.3 + af * 0.35);
        if (fv > 0) {
          const h = smooth(0.6, 1.2, fv);
          cr += fv * mix(EM[0], 1, h), cg += fv * mix(EM[1], 1, h), cbl += fv * mix(EM[2], 1, h);
        }

        // rain: thin slanted streaks falling fast, lit by whatever flashes
        const lane = Math.floor(x + y * 0.28);
        const hr = hash(lane, 17);
        if (hr > 0.5 && y > 12 && m !== HILL && B.mask[k] < 0.5) {
          const len = 10 + hash(lane, 18) * 12;
          const p = (y - t * (55 + hr * 25) + hash(lane, 19) * 400) / len;
          const seg = p - Math.floor(p);
          if (seg < 0.3) {
            const d = 0.13 * (1 - seg / 0.3) * smooth(12, 34, y);
            const gl = light * 0.6 + flight * 0.4;
            cr += d + d * gl * EM[0] * 3, cg += d + d * gl * EM[1] * 3, cbl += d + d * gl * EM[2] * 3;
          }
        }

        // Dot size from brightness, dithered. Then the colour makes up what the
        // dot size could not: a small dot is drawn brighter and a large one
        // dimmer, so a gradient stays smooth across the dither's steps.
        const peak = Math.max(cr, cg, cbl, 1e-4);
        const level = clamp(floor + (1 - floor) * Math.pow(peak, 0.85) * 0.95) * fade;
        const step = Math.max(0, Math.min(3, Math.round(level * 3 + BAYER[(r & 3) * 4 + (x & 3)])));
        out[k] = DOTS[step];
        if (color) {
          // Only bright light is pushed to full colour; the storm's greys keep
          // their own depth, so the night stays dark between strikes.
          const want = step ? Math.min(1, (level + 0.06) / COVER[step]) : 0;
          const s = (0.3 + 0.7 * want) / Math.max(peak, 0.8);
          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");
  };
}