snow train

[docs] [github]

a lit train crossing a snowy stone viaduct at night as snow falls

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

html

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

<ascii-art piece="snow-train"></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 { snowTrain } from "ascii.rest/pieces";

<Ascii piece={snowTrain} />

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

astro

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

<Ascii piece="snow-train" />

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

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

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 snow-train

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("snow-train", { seconds: 2 }) from ascii.rest/terminal shows it as a splash screen.

snow-train.ts

[js] [github]
/*
 * snow train: a winter night in the mountains. A long stone viaduct spans a
 * deep valley, and a red train with warm windows comes out of a tunnel and
 * crosses it slowly to the left, its headlight cutting a cone through snow
 * that falls at three depths. Pines stand in the snow, a frozen river below
 * takes the lights, a full moon sits behind thin cloud, and the chimneys of a
 * small village smoke into the cold.
 *
 * 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: "snow train",
  category: "scenes",
  note: "a lit train crossing a snowy stone viaduct at night as snow falls",
  cols: 200,
  rows: 100,
  cell: 1,
  fps: 15,
  ground: "#070b16",
  palette: [
    "#0c1428", "#121d38", "#19284a", "#22345e", "#2d4374", "#3b548a", "#4d68a0", "#6480b4",
    "#7f99c6", "#9db3d6", "#bccde4", "#d9e4f1", "#f1f5fb",
    "#ffe7b0", "#ffc56e", "#f29a3c", "#b8642a", "#6e3a22",
    "#3e1420", "#6a1e26", "#9a2c2e", "#ff4a3d",
    "#0b1a1e", "#11262a", "#1a3436", "#26474a",
    "#1e2230", "#2c3242", "#3e4658", "#586276", "#7c8698",
  ],
} satisfies Meta;

const W = 200, H = 100;
const MOON = [152, 15];
const MOON_R = 7;
const ROOF = 42; // the train's roof row; the parapet hides it below row 48
const LAMP_Y = 47;
const SPEED = 7; // cells a second
const LEN = 156; // engine and five coaches, nose to tail
const MOUTH = 185; // the train is inside the hill past this column
const PERIOD = MOUTH + LEN + 40; // one crossing and a pause on the empty bridge
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);

// What a cell is, for the parts of the frame that change with time.
const SKY = 0, FAR = 1, STONE = 2, CAP = 3, FRONT = 4, RIVER = 5, PANE = 6, MOUTH_K = 7;

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

// The near ground: a bank on the left, the valley floor, and the hillside on
// the right that rises past the track into the mountain with the tunnel.
const ground = (x: number) =>
  Math.min(80 + 8 * smooth(0, 48, x), 88 - 39 * smooth(104, 172, x) - 30 * smooth(170, 200, x)) +
  1.6 * (fbm(x * 0.09, 2.3, 3, 0) - 0.5);
// The far range, a big peak on the left and a lower one under the moon.
const ridge = (x: number) =>
  34 - 18 * Math.exp(-(((x - 52) / 30) ** 2)) - 9 * Math.exp(-(((x - 118) / 16) ** 2)) - 5 * Math.exp(-(((x - 178) / 22) ** 2)) +
  7 * (fbm(x * 0.06, 0.5, 4, 0) - 0.5) + 2.5 * (fbm(x * 0.3, 4.5, 2, 0) - 0.5);
// The wooded slopes behind the viaduct, seen through its arches.
const woods = (x: number) => 76 + 6 * (fbm(x * 0.04, 7.1, 3, 0) - 0.5) - 6 * smooth(40, 0, x) - 6 * smooth(110, 160, x);
const riverY = (x: number) => 93.5 + 1.8 * Math.sin(x * 0.045 + 0.6) + 0.8 * Math.sin(x * 0.13);
const riverW = (x: number) => 2.4 + 0.8 * Math.sin(x * 0.07 + 2);

// A pine at (cx, base), h tall, w half-wide at the foot: tiers of boughs with
// snow along their tops and on the moon side. 0 outside, 1 needles, 2 snow.
function pine(x: number, y: number, cx: number, base: number, h: number, w: number): number {
  const dy = y - (base - h);
  if (dy < 0 || dy > h) return 0;
  const dx = x + 0.5 - cx;
  if (dy > h - 1.5) return Math.abs(dx) < 0.6 ? 1 : 0; // the trunk
  const tiers = Math.max(2, Math.round(h / 4));
  const tier = (dy / (h - 1.5)) * tiers;
  const local = tier - Math.floor(tier);
  const hw = w * ((Math.floor(tier) + 0.35 + 0.65 * local) / tiers) + 0.35;
  if (Math.abs(dx) > hw) return 0;
  // snow lies on each tier's upper edge and toward the moon (the right)
  const top = local < 0.3 && Math.abs(dx) > hw * 0.35;
  const lee = dx > hw * 0.45 && hash(x * 3 + 1, y * 7 | 0) > 0.45;
  return top || lee || (dy < 1.2) ? 2 : 1;
}

export default function snowTrain(): 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 still picture, built once ---------------------------------------
  const kind = new Uint8Array(N);
  const br = new Float32Array(N), bg = new Float32Array(N), bb = new Float32Array(N);
  const flo = new Float32Array(N);
  const set = (k: number, kd: number, r: number, g: number, b: number, f: number) => {
    (kind[k] = kd), (br[k] = r), (bg[k] = g), (bb[k] = b), (flo[k] = f);
  };
  const G = new Float32Array(W + 1), R = new Float32Array(W + 1), B = new Float32Array(W + 1);
  for (let x = 0; x <= W; x++) (G[x] = ground(x + 0.5)), (R[x] = ridge(x + 0.5)), (B[x] = woods(x + 0.5));

  // relief for the far snowfields: a ridged height, lit from the moon's side
  const relief = (x: number, y: number) => {
    const n = fbm(x * 0.05 + y * 0.03, y * 0.11, 4, 0);
    return 1 - Math.abs(2 * n - 1);
  };

  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 = y / 50;
      // sky: deep navy overhead to a paler band behind the peaks, a halo round the moon
      const dmx = x + 0.5 - MOON[0], dmy = y - MOON[1];
      const dm = Math.sqrt(dmx * dmx + dmy * dmy);
      const halo = Math.exp(-dm / 26) * 0.3 + Math.exp(-dm / 10) * 0.4;
      const veil = 0.88 + 0.24 * fbm(x * 0.05, y * 0.08, 3, 0);
      set(k, SKY, (0.015 + 0.06 * v * v) * veil + 0.26 * halo, (0.025 + 0.08 * v * v) * veil + 0.31 * halo, (0.06 + 0.15 * v * v) * veil + 0.38 * halo, 0.04);

      if (y >= R[x]) {
        // snowfields and rock, lit up-right, sinking into the valley mist
        // faces turned to the moon (the ridge falling away to the right) are lit,
        // couloirs streak down them, and the lower slopes go dark with forest
        const depth = y - R[x];
        const fall = (R[Math.min(W, x + 3)] - R[Math.max(0, x - 3)]) / 6;
        const h0 = relief(x, y);
        const face = clamp(0.45 + fall * 1.6 + 5 * (h0 - relief(x + 1.3, y - 0.9)));
        const streak = noise(x * 0.3 + y * 0.12, y * 0.05, 0);
        const rim = smooth(2, 0, depth) * 0.45;
        const s = ((0.16 + 0.95 * face * face) * (0.7 + 0.45 * streak) + rim) * (1 - 0.75 * smooth(7, 22, depth));
        let cr = 0.52 * s, cg = 0.62 * s, cb = 0.88 * s;
        // the valley mist, pale and luminous, lying behind the bridge
        const mist = smooth(36, 52, y);
        cr = mix(cr, 0.3, mist * 0.92), cg = mix(cg, 0.38, mist * 0.92), cb = mix(cb, 0.57, mist * 0.92);
        set(k, FAR, cr, cg, cb, 0.12);
      }
      if (y >= B[x]) {
        // the woods behind: dark boughs with snow caught in them, paler in the mist
        const fleck = hash(x * 5 + 3, r * 9 + 1) > 0.8 ? 1 : 0;
        const deep = smooth(B[x], B[x] + 10, y);
        const n = fbm(x * 0.2, y * 0.3, 2, 0);
        const m = 0.5 * (1 - deep);
        set(k, FAR, mix(0.03 + 0.2 * fleck * n, 0.2, m), mix(0.06 + 0.25 * fleck * n, 0.26, m), mix(0.09 + 0.36 * fleck * n, 0.4, m), 0.05);
      }
    }
  }

  // the treeline of the back woods, small pines along the top edge
  for (let x = -2; x < W + 2; x += 1.6 + hash(x * 13 | 0, 21) * 1.8) {
    const cx = x + hash(x * 7 | 0, 22);
    const base = woods(cx) + 3;
    const h = 4 + hash(cx * 11 | 0, 23) * 5;
    const w = 1.3 + hash(cx * 5 | 0, 24) * 1;
    for (let r = Math.max(0, Math.floor(base - h)); r < Math.min(H, base + 1); r++) {
      for (let xx = Math.max(0, Math.floor(cx - w - 1)); xx < Math.min(W, cx + w + 1); xx++) {
        const p = pine(xx, r + 0.5, cx, base, h, w);
        if (!p) continue;
        const m = 0.45;
        if (p === 2) set(r * W + xx, FAR, mix(0.32, 0.22, m), mix(0.4, 0.28, m), mix(0.58, 0.42, m), 0.1);
        else set(r * W + xx, FAR, mix(0.05, 0.2, m * 0.6), mix(0.09, 0.26, m * 0.6), mix(0.11, 0.4, m * 0.6), 0.08);
      }
    }
  }

  // the viaduct, the near ground and the river
  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 g = G[x];
      if (x < 176 && y >= 49 && y < g + 0.5) {
        const i = Math.round((x + 0.5 - 8) / 20);
        const dx = x + 0.5 - (8 + 20 * i);
        const hw = 7 - Math.max(0, y - 66) * 0.05;
        const open = y > 59 && (y < 66 ? dx * dx + (y - 66) ** 2 < 49 : Math.abs(dx) < hw);
        if (!open) {
          // dressed stone in courses, each block a little different
          const course = Math.floor((r - 49) / 3);
          const bx = Math.floor((x + (course & 1) * 3) / 6);
          const mortar = (r - 49) % 3 === 0 || (x + (course & 1) * 3) % 6 === 0;
          let s = 0.55 + 0.4 * hash(bx, course) + 0.25 * fbm(x * 0.3, y * 0.3, 2, 0);
          if (mortar) s *= 0.6;
          // the arch rings, a step lighter, and the pier edge facing the moon
          const ring = y > 57 && y < 67 && Math.sqrt(dx * dx + (y - 66) ** 2) < 9.2 && Math.abs(dx) < 9;
          if (ring) s *= 1.3;
          if (y >= 59 && Math.abs(dx) > (y < 66 ? Math.sqrt(Math.max(0, 49 - (y - 66) ** 2)) : hw) - 0.1 && dx < 0 && dx > -hw - 1.5) s *= 1.6;
          // lower down the piers stand in the valley mist
          const mist = smooth(62, 92, y) * 0.2;
          let cr = mix(0.07 * s, 0.16, mist), cg = mix(0.085 * s, 0.2, mist), cb = mix(0.13 * s, 0.3, mist);
          let kd = STONE;
          if (r === 49) {
            // snow along the parapet
            const n = 0.85 + 0.15 * hash(x, 77);
            (cr = 0.62 * n), (cg = 0.7 * n), (cb = 0.86 * n), (kd = CAP);
          } else if (r === 53 || r === 66) {
            // snow lying on the cornice and the impost ledges
            const n = hash(x * 3, r) > 0.3 ? 1 : 0.5;
            (cr = 0.5 * n), (cg = 0.58 * n), (cb = 0.76 * n);
          } else if (r === 54) {
            (cr *= 0.45), (cg *= 0.45), (cb *= 0.5); // shade under the cornice
          }
          set(k, kd, cr, cg, cb, kd === CAP ? 0.12 : 0.03);
        }
      }
      if (y >= g) {
        // the near snow, lit where it turns toward the moon, rippled by wind
        const slope = G[Math.min(W, x + 1)] - g;
        const face = clamp(0.5 + slope * 1.4);
        const crest = Math.exp(-(y - g) / 2.5) * 0.18;
        const ripple = 0.06 * Math.sin(x * 0.35 + y * 1.1 + fbm(x * 0.05, y * 0.05, 2, 0) * 9);
        const s = 0.12 + 0.24 * fbm(x * 0.07, y * 0.12, 4, 0) + 0.34 * face + 1.8 * crest + ripple;
        set(k, FRONT, 0.42 * s, 0.53 * s, 0.82 * s, 0.1);
        const ry = riverY(x + 0.5), rw = riverW(x + 0.5);
        if (Math.abs(y - ry) < rw && y > g + 2) {
          // ice: slate blue, smoothed by wind, with pale cracks across it
          const n = fbm(x * 0.12, y * 0.7, 3, 0);
          const crack = Math.abs(noise(x * 0.22, y * 0.9, 0) - 0.5) < 0.035 ? 0.18 : 0;
          const edge = smooth(rw, rw - 1.2, Math.abs(y - ry));
          set(k, RIVER, (0.2 + 0.16 * n + crack) * edge + 0.4 * (1 - edge), (0.28 + 0.2 * n + crack) * edge + 0.46 * (1 - edge), (0.44 + 0.24 * n + crack) * edge + 0.62 * (1 - edge), 0.16);
        }
        if (y > 98 + 1.5 * noise(x * 0.1, 3, 0)) {
          // the near bank, in shadow
          set(k, FRONT, 0.22, 0.27, 0.4, 0.12);
        }
      }
      // the tunnel portal: a stone face with a round-topped mouth
      if (x >= 174 && x < 189 && y >= 35 && y < 53) {
        const dx = x + 0.5 - 181.5;
        const open = (y >= 45 ? Math.abs(dx) < 4.5 : dx * dx + (y - 45) ** 2 < 20.25) && y < 52.5;
        if (open) set(k, MOUTH_K, 0.015, 0.015, 0.03, 0);
        else if (r === 35) set(k, CAP, 0.62, 0.7, 0.86, 0.12);
        else {
          const ring = Math.sqrt(dx * dx + (y - 45) ** 2) < 6.2 && y < 46;
          const mortar = (r - 35) % 3 === 0 || (x + ((r / 3) & 1) * 3) % 5 === 0;
          const s = (0.5 + 0.3 * hash(x >> 2, (r / 3) | 0)) * (mortar ? 0.75 : 1) * (ring ? 1.2 : 1);
          set(k, STONE, 0.21 * s, 0.24 * s, 0.32 * s, 0.12);
        }
      }
    }
  }

  // the village on the hillside: snowed-in houses with lit windows
  const chimneys: [number, number][] = [];
  const panes: [number, number][] = [];
  for (const [x0, w] of [[115, 10], [128, 9], [141, 10]]) {
    let base = 0;
    for (let x = x0; x <= x0 + w; x++) base = Math.max(base, G[x]);
    base = Math.round(base) + 1;
    for (let r = base - 9; r <= base; r++) {
      for (let x = x0 - 1; x <= x0 + w + 1; x++) {
        const k = r * W + x;
        const y = r + 0.5;
        if (r > base - 5 && x >= x0 && x <= x0 + w) {
          // log walls, two warm windows
          const s = 0.6 + 0.4 * hash(x, r * 3);
          set(k, FRONT, 0.06 * s, 0.065 * s, 0.09 * s, 0.02);
          if ((x === x0 + 2 || x === x0 + 3 || x === x0 + w - 3 || x === x0 + w - 2) && r >= base - 3 && r <= base - 2) {
            set(k, PANE, 1, 0.74, 0.34, 0.3);
            panes.push([x + 0.5, y]);
          }
          if (r === base && hash(x, 91) > 0.4) set(k, FRONT, 0.5, 0.58, 0.78, 0.2); // drift at the door
        } else if (r <= base - 5) {
          // a steep roof heavy with snow, a shadowed underside at the eaves
          const half = (w / 2 + 1.5) * ((y - (base - 9)) / 4.5);
          const cx = x0 + w / 2 + 0.5;
          if (Math.abs(x + 0.5 - cx) <= half) {
            const lit = x + 0.5 > cx ? 1 : 0.82;
            set(k, FRONT, 0.84 * lit, 0.89 * lit, 1 * lit, 0.3);
            if (r === base - 5) set(k, FRONT, 0.05, 0.05, 0.08, 0.02);
          }
        }
      }
    }
    const cx = x0 + w - 2;
    for (let r = base - 10; r < base - 7; r++) set(r * W + cx, FRONT, 0.14, 0.1, 0.1, 0.08);
    set((base - 10) * W + cx, FRONT, 0.6, 0.68, 0.86, 0.2);
    chimneys.push([cx + 0.5, base - 10]);
  }

  // pines on the near ground, back to front, the two big ones last
  const trees: [number, number, number, number][] = [];
  const plant = (from: number, to: number, gap: number, hMin: number, hMax: number, seed: number) => {
    for (let x = from; x < to; x += gap + hash(x * 7 | 0, seed) * gap) {
      if (x > 114 && x < 156) continue; // the village keeps its clearing
      const h = hMin + hash(x * 3 | 0, seed + 1) * (hMax - hMin);
      trees.push([x, G[Math.max(0, Math.min(W, Math.round(x)))] + 1.5, h, h * 0.36]);
    }
  };
  plant(26, 70, 3.5, 6, 11, 40);
  plant(102, 174, 3, 6, 12, 50);
  plant(189, 203, 3, 8, 14, 60);
  // and scattered down the slopes, the hillside dark with forest
  for (let i = 0; i < 150; i++) {
    const x = hash(i, 71) * 206 - 3;
    const g = G[Math.max(0, Math.min(W, Math.round(x)))];
    const base = g + 4 + hash(i, 72) * (93 - g);
    if (base > 91 || (x > 108 && x < 156 && base < g + 14) || (x < 96 && x > 40) || (x > 170 && x < 192 && base < 56)) continue;
    const h = 5 + hash(i, 73) * 5;
    trees.push([x, base, h, h * 0.38]);
  }
  trees.push([112, G[112] + 1.5, 12, 4.2], [158, G[158] + 1.5, 10, 3.6], [161, G[161] + 1.5, 13, 4.5]);
  trees.sort((a, b) => a[1] - b[1]);
  trees.push([8, 104, 40, 11], [25, 103, 28, 8], [-2, 102, 22, 7]);
  for (const [cx, base, h, w] of trees) {
    const big = h > 20;
    for (let r = Math.max(0, Math.floor(base - h)); r < Math.min(H, base + 1); r++) {
      for (let x = Math.max(0, Math.floor(cx - w - 1)); x < Math.min(W, cx + w + 1); x++) {
        const p = pine(x, r + 0.5, cx, base, h, w);
        if (!p) continue;
        const k = r * W + x;
        if (p === 2) {
          const s = big ? 0.75 : 0.9 + 0.2 * hash(x, r);
          set(k, FRONT, 0.66 * s, 0.74 * s, 0.94 * s, 0.2);
        } else {
          const s = big ? 0.5 : 0.7 + 0.5 * hash(x * 3, r);
          set(k, FRONT, 0.04 * s, 0.1 * s, 0.11 * s, 0.05);
        }
      }
    }
  }

  // lamplight from the village windows, warm on the snow round them
  for (let r = 0; r < H; r++) {
    for (let x = 100; x < 160; x++) {
      const k = r * W + x;
      if (kind[k] !== FRONT || bb[k] < 0.15) continue; // the snow, not the dark walls
      let a = 0;
      for (const [px, py] of panes) {
        const d = Math.sqrt((x + 0.5 - px) ** 2 + ((r + 0.5 - py) * 1.3) ** 2);
        a += Math.exp(-d / 1.5) * 0.2;
      }
      (br[k] += a), (bg[k] += a * 0.6), (bb[k] += a * 0.22);
    }
  }

  // --- thin cloud: long wrapping streaks that drift across the moon ---------
  const CW = 600, CH = 40;
  const cloud = new Float32Array(CW * CH);
  for (let r = 0; r < CH; r++) {
    for (let x = 0; x < CW; x++) {
      const y = r + 0.5;
      const q = fbm(x * 0.006, y * 0.04, 3, CW * 0.006);
      const d = fbm(x * 0.014 + q * 2, y * 0.09 + q, 5, CW * 0.014);
      cloud[r * CW + x] = smooth(0.5, 0.68, d + 0.06 * Math.sin(y * 0.35)) * smooth(2, 10, y) * smooth(CH, 24, y);
    }
  }

  // the snow, three depths: far flakes behind the bridge, the rest in front
  const LAYERS = [
    { n: 420, fall: 2.4, sway: 0.7, big: false, v: 0.24, front: false },
    { n: 200, fall: 5, sway: 1.3, big: false, v: 0.55, front: true },
    { n: 60, fall: 9.5, sway: 2.4, big: true, v: 0.95, front: true },
  ].map((L, li) => ({
    ...L,
    seeds: Array.from({ length: L.n }, (_, i) => [hash(i, li * 31 + 1) * W, hash(i, li * 31 + 2) * (H + 6), hash(i, li * 31 + 3) * 6.28, 0.6 + hash(i, li * 31 + 4) * 0.8]),
  }));
  const back = new Float32Array(N);
  const front = new Float32Array(N);
  const put = (buf: Float32Array, x: number, r: number, v: number) => {
    if (r < 0 || r >= H) return;
    const xi = ((x % W) + W) % W, k = r * W + xi;
    if (v > buf[k]) buf[k] = v;
  };

  // light from the coaches' windows along the train, by distance from the nose
  const windows = (u: number) => {
    if (u < 0 || u >= LEN) return 0;
    if (u < 26) return u > 8 && u < 12 ? 0.4 : 0;
    const c = (u - 28) % 26;
    return u >= 28 && c >= 2 && c < 22 ? 1 : 0;
  };

  return (t, { color } = {}) => {
    const F = MOUTH - ((SPEED * t + 135) % PERIOD); // the nose's column
    const drift = t * 1.1;
    back.fill(0);
    front.fill(0);
    for (const L of LAYERS) {
      const buf = L.front ? front : back;
      for (const [x0, y0, ph, sp] of L.seeds) {
        const y = ((y0 + L.fall * sp * t) % (H + 6)) - 3;
        const x = x0 - 0.4 * L.fall * sp * t + L.sway * Math.sin(t * 0.9 * sp + ph);
        const xi = Math.round(x), ri = Math.round(y);
        put(buf, xi, ri, L.v);
        if (L.big) {
          put(buf, xi - 1, ri, L.v * 0.45), put(buf, xi + 1, ri, L.v * 0.45);
          put(buf, xi, ri - 1, L.v * 0.45), put(buf, xi, ri + 1, L.v * 0.45);
        }
      }
    }

    for (let r = 0; r < H; r++) {
      const y = r + 0.5;
      for (let x = 0; x < W; x++) {
        const k = r * W + x;
        const m = kind[k];
        let cr = br[k], cg = bg[k], cbl = bb[k], floor = flo[k], fade = 1;

        if (m === SKY) {
          const dmx = x + 0.5 - MOON[0], dmy = y - MOON[1];
          const dm = Math.sqrt(dmx * dmx + dmy * dmy);
          let c = 0;
          if (r < CH) {
            const sx = x + drift, ix = Math.floor(sx), fx = sx - ix;
            const i0 = r * CW + (ix % CW), i1 = r * CW + ((ix + 1) % CW);
            c = cloud[i0] + (cloud[i1] - cloud[i0]) * fx;
          }
          if (dm < MOON_R) {
            // the moon, its seas faint, veiled where the cloud crosses it
            const face = 0.84 + 0.16 * fbm(x * 0.45, y * 0.45, 2, 0);
            const a = (1 - c * 0.55) * smooth(MOON_R, MOON_R - 1, dm);
            cr = mix(cr, 0.97 * face, a), cg = mix(cg, 0.97 * face, a), cbl = mix(cbl, 1.0 * face, a);
          } else if (c < 0.1 && hash(x, r * 3 + 11) > 0.984) {
            const tw = 0.6 + 0.4 * Math.sin(t * (1.5 + hash(x, r) * 3) + hash(r, x) * 6.28);
            const s = (0.45 + 0.5 * tw) * smooth(48, 18, y);
            cr = Math.max(cr, s * 0.9), cg = Math.max(cg, s * 0.93), cbl = Math.max(cbl, s);
          }
          if (c > 0.01) {
            // thin cloud, silver near the moon, slate away from it
            const near = Math.exp(-dm / 24);
            const b = 0.35 + 0.9 * near;
            const a = c * (0.6 + 0.35 * near);
            cr = mix(cr, 0.32 * b + 0.06, a), cg = mix(cg, 0.38 * b + 0.07, a), cbl = mix(cbl, 0.52 * b + 0.12, a);
          }
        } else if (m === RIVER) {
          // the moon's glitter on the ice, and the train's windows above it
          const road = Math.exp(-(((x + 0.5 - MOON[0]) / 5) ** 2));
          const spark = hash(x * 7 + ((t * 3) | 0), r * 5) > 0.93 ? 1 : 0;
          cr += road * (0.25 + 0.6 * spark), cg += road * (0.28 + 0.6 * spark), cbl += road * (0.32 + 0.6 * spark);
          const u = x + 0.5 - F;
          if (x < MOUTH) {
            // each lit pane a smeared streak on the ice
            const pane = (((u - 30) % 4) + 4) % 4 < 2.6 ? 1 : 0.25;
            const lit = windows(u) * pane * (0.35 + 0.65 * noise(x * 0.5, y * 1.8, 0)) * smooth(riverW(x + 0.5) + 0.3, 0.5, Math.abs(y - riverY(x + 0.5)));
            cr += 0.95 * lit, cg += 0.6 * lit, cbl += 0.22 * lit;
            const lamp = Math.exp(-(((u + 10) / 9) ** 2)) * 0.5 * (F < MOUTH ? 1 : 0);
            cr += lamp, cg += lamp * 0.9, cbl += lamp * 0.7;
          }
        } else if (m === PANE) {
          const g = 0.85 + 0.15 * Math.sin(t * 2.3 + x * 1.7);
          (cr = g), (cg = 0.72 * g), (cbl = 0.32 * g);
        }

        // the train, between the far hills and the parapet
        const u = x + 0.5 - F;
        if (r >= ROOF && r < 49 && u >= 0 && u < LEN && x < MOUTH && (m === SKY || m === FAR || m === MOUTH_K)) {
          let tr = -1, tg = 0, tb = 0;
          if (u < 26) {
            // the engine: a sloped nose, a dark windscreen, the lamp low down
            const nose = (48 - r) * 0.85;
            if (u >= nose) {
              const s = r === 43 ? 1.25 : 1;
              (tr = 0.46 * s), (tg = 0.09 * s), (tb = 0.1 * s);
              if (r === ROOF) (tr = 0.66), (tg = 0.73), (tb = 0.9);
              if (r >= 43 && r <= 44 && u < nose + 3.5) (tr = 0.12), (tg = 0.1), (tb = 0.14);
              if (r >= 43 && r <= 45 && u > 8.5 && u < 11.5) (tr = 0.8), (tg = 0.52), (tb = 0.22);
              if (r === 46) (tr = 0.62), (tg = 0.56), (tb = 0.5);
              if (r === 47 && u < 1.6) (tr = 1), (tg = 0.97), (tb = 0.85);
            }
          } else if (u >= 28) {
            const c = (u - 28) % 26;
            if (c < 24) {
              (tr = 0.42), (tg = 0.08), (tb = 0.09);
              if (r === 43) (tr = 0.54), (tg = 0.11), (tb = 0.12);
              if (r === ROOF) {
                if (c < 0.9 || c > 23.1) tr = -1;
                else (tr = 0.66), (tg = 0.73), (tb = 0.9);
              } else if ((r === 44 || r === 45) && c >= 2 && c < 22 && (c - 2) % 4 < 2.6) {
                // a lit pane; now and then a passenger in it
                const wi = Math.floor((u - 28) / 4);
                const dim = hash(wi, 5) < 0.12 ? 0.45 : 1;
                const who = hash(wi, 6) < 0.3 && r === 45 && (c - 2) % 4 < 1.3 ? 0.55 : 1;
                const g = (0.9 + 0.1 * Math.sin(t * 1.3 + wi)) * dim * who;
                (tr = g), (tg = 0.72 * g), (tb = 0.34 * g);
              } else if (r === 46) (tr = 0.62), (tg = 0.56), (tb = 0.5);
              else if (r >= 47) (tr = 0.24), (tg = 0.05), (tb = 0.07);
              if (u > LEN - 1.5 && r === 47) (tr = 1), (tg = 0.25), (tb = 0.2);
            } else if (r === 46 || r === 47) (tr = 0.04), (tg = 0.04), (tb = 0.06);
          }
          if (tr >= 0) {
            const dark = m === MOUTH_K || x > 176 ? 0.35 : 1; // still in the tunnel's shadow
            (cr = tr * dark), (cg = tg * dark), (cbl = tb * dark), (floor = 0.1);
          }
        }

        // the headlight: a hot point, a soft glow, and a cone of light ahead
        let beam = 0;
        if (F < MOUTH + 30 && r > 28 && r < 66 && m !== FRONT && !(m === STONE && x >= 174)) {
          const lx = Math.min(F, 176);
          const a = lx - (x + 0.5);
          if (a > 0) {
            const sp = 0.8 + a * 0.2;
            const py = y - LAMP_Y - 0.5 - a * 0.03;
            beam = Math.exp(-a / 60) * (Math.exp(-((py / sp) ** 2)) + 0.18 * Math.exp(-((py / (sp * 2.6)) ** 2))) * (1 - Math.exp(-a / 2));
          }
          const dx = x + 0.5 - F, dy = y - LAMP_Y - 0.5;
          const d = Math.sqrt(dx * dx + dy * dy);
          const glow = F < 177 ? Math.exp(-d / 1.6) * 0.8 + Math.exp(-d / 7) * 0.25 : 0;
          const on = beam * (m === CAP ? 1.2 : 0.6) + glow;
          cr += on, cg += on * 0.92, cbl += on * 0.74;
        }

        // warm spill from the windows onto the snowy parapet and the air above
        let spill = 0;
        if (r > 34 && r < 52 && x < MOUTH) {
          const wu = windows(u) + 0.5 * (windows(u - 2) + windows(u + 2));
          if (wu > 0) {
            spill = wu * (r === 49 ? 0.35 : r < ROOF ? Math.exp(-(ROOF - r) / 2.2) * 0.12 : 0);
            cr += spill, cg += spill * 0.62, cbl += spill * 0.26;
          }
        }

        // chimney smoke, leaning left on the wind and thinning as it climbs
        if (x > 70 && x < 152 && r > 24 && r < 80) {
          let s = 0;
          for (const [sx, sy] of chimneys) {
            const up = sy - y;
            if (up < 0 || up > 30) continue;
            const cx = sx - up * 0.3 - up * up * 0.012 + 2.4 * (noise(up * 0.15 - t * 0.6, sx, 0) - 0.5) * Math.min(1, up / 6);
            const wd = 0.9 + up * 0.2;
            const n = 0.45 + 0.55 * noise(x * 0.22 + t * 0.4, y * 0.3 + t * 0.9, 0);
            s += Math.exp(-(((x + 0.5 - cx) / wd) ** 2)) * Math.exp(-up / 13) * n;
          }
          if (s > 0.01) {
            const a = Math.min(0.8, s * 1.4);
            cr = mix(cr, 0.56, a), cg = mix(cg, 0.6, a), cbl = mix(cbl, 0.74, a);
          }
        }

        // falling snow: moonlit, warm near the windows, blazing in the beam
        let flake = m === SKY || m === FAR ? back[k] : 0;
        if (front[k] > flake) flake = front[k];
        if (flake > 0) {
          const hot = beam * 4 + spill * 3;
          cr += flake * (0.62 + hot), cg += flake * (0.68 + hot * 0.86), cbl += flake * (0.82 + hot * 0.6);
        }

        if (r > 92) fade = smooth(H + 2, H - 8, y);

        // 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) {
          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");
  };
}