maze

[github]

a maze carved by backtracking, then solved, then remade

generative 61×21 20 fps 7.2 kB

use it

html

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

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

<Ascii piece={maze} />

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

astro

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

<Ascii piece="maze" />

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

const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, maze);

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.

maze.ts

[js] [github]
/*
 * maze: a recursive backtracker carving a maze out of a grid, its stack shown
 * as a dotted trail, then the way through drawn in, then the walls close up
 * and a new maze starts.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "maze",
  category: "generative",
  note: "a maze carved by backtracking, then solved, then remade",
  cols: 61,
  rows: 21,
  fps: 20,
} satisfies Meta;

const MW = 15, MH = 10; // cells across and down; four columns by two rows each
const CARVE = 40; // backtracker moves a second
const SOLVE = 2.4; // seconds to draw the way through
const HOLD = 2.2;
const CLOSE = 1.2; // seconds for the walls to sweep back
const START = 5.2; // where in the first cycle play time begins, in seconds

function mulberry32(a: number) {
  return () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

interface Maze {
  moves: number[];
  way: number[];
}

// The backtracker's moves for one maze: +n steps forward into cell n, -1 steps
// back. Then the way from the top left cell to the bottom right one.
function build(seed: number): Maze {
  const rand = mulberry32(seed);
  const N = MW * MH;
  const seen = new Uint8Array(N);
  const parent = new Int16Array(N).fill(-1);
  const moves: number[] = [];
  const stack = [0];
  seen[0] = 1;
  while (stack.length) {
    const c = stack[stack.length - 1];
    const x = c % MW, y = (c / MW) | 0;
    const next: number[] = [];
    if (x > 0 && !seen[c - 1]) next.push(c - 1);
    if (x < MW - 1 && !seen[c + 1]) next.push(c + 1);
    if (y > 0 && !seen[c - MW]) next.push(c - MW);
    if (y < MH - 1 && !seen[c + MW]) next.push(c + MW);
    if (!next.length) {
      stack.pop();
      if (stack.length) moves.push(-1);
      continue;
    }
    const n = next[Math.floor(rand() * next.length)];
    seen[n] = 1;
    parent[n] = c;
    stack.push(n);
    moves.push(n);
  }
  const way = [N - 1];
  while (way[way.length - 1] !== 0) way.push(parent[way[way.length - 1]]);
  return { moves, way: way.reverse() };
}

const L = 1, R = 2, U = 4, D = 8;
const JOIN = [" ", "╴", "╶", "─", "╵", "┘", "└", "┴", "╷", "┐", "┌", "┬", "│", "┤", "├", "┼"];
const PATH: Record<number, string> = { [L | R]: "━", [U | D]: "┃", [R | D]: "┏", [L | D]: "┓", [R | U]: "┗", [L | U]: "┛" };

export default function maze(): Frame {
  const { cols, rows } = meta;
  const mazes = new Map<number, Maze>();
  const get = (n: number): Maze => {
    if (!mazes.has(n)) {
      if (mazes.size > 2) mazes.delete(mazes.keys().next().value!);
      mazes.set(n, build(1009 + n * 7919));
    }
    return mazes.get(n)!;
  };
  // The backtracker steps into every cell but the first once and back once.
  const CARVED = (2 * (MW * MH - 1)) / CARVE;
  const PERIOD = CARVED + SOLVE + HOLD + CLOSE;

  const H = new Uint8Array((MH + 1) * MW); // the wall above each cell, and below the last row
  const V = new Uint8Array(MH * (MW + 1)); // the wall left of each cell, and right of the last column
  const state = new Uint8Array(MW * MH); // 0 untouched, 1 on the stack, 2 done
  const grid = Array.from({ length: rows }, () => new Array<string>(cols));
  const put = (r: number, c: number, ch: string) => (grid[r][c] = ch);
  const centre = (n: number): [number, number] => [2 * ((n / MW) | 0) + 1, 4 * (n % MW) + 2];

  const draw = (m: Maze, k: number, solved: number, out: string[]) => {
    H.fill(1);
    V.fill(1);
    V[0] = 0; // the way in, top left
    V[(MH - 1) * (MW + 1) + MW] = 0; // and out, bottom right
    state.fill(0);
    state[0] = 1;
    const stack = [0];
    for (let i = 0; i < k; i++) {
      const mv = m.moves[i];
      const c = stack[stack.length - 1];
      if (mv < 0) {
        state[stack.pop()!] = 2;
        continue;
      }
      const d = mv - c;
      if (d === 1) V[((c / MW) | 0) * (MW + 1) + (c % MW) + 1] = 0;
      else if (d === -1) V[((c / MW) | 0) * (MW + 1) + (c % MW)] = 0;
      else if (d === MW) H[mv] = 0;
      else H[c] = 0;
      state[mv] = 1;
      stack.push(mv);
    }
    if (k >= m.moves.length) state[0] = 2;

    for (let r = 0; r < rows; r++) grid[r].fill(" ");
    for (let j = 0; j <= MH; j++)
      for (let i = 0; i <= MW; i++) {
        const arms =
          (i > 0 && H[j * MW + i - 1] ? L : 0) |
          (i < MW && H[j * MW + i] ? R : 0) |
          (j > 0 && V[(j - 1) * (MW + 1) + i] ? U : 0) |
          (j < MH && V[j * (MW + 1) + i] ? D : 0);
        put(2 * j, 4 * i, JOIN[arms]);
        if (i < MW && H[j * MW + i]) for (let s = 1; s < 4; s++) put(2 * j, 4 * i + s, "─");
        if (j < MH && V[j * (MW + 1) + i]) put(2 * j + 1, 4 * i, "│");
      }
    for (let n = 0; n < MW * MH; n++) {
      const [r, c] = centre(n);
      if (state[n] === 0) for (let s = -1; s <= 1; s++) put(r, c + s, "░");
    }
    // The stack as a dotted trail, through each cell and each opening.
    if (k < m.moves.length) {
      for (let i = 0; i < stack.length; i++) {
        const [r, c] = centre(stack[i]);
        put(r, c, "·");
        if (i > 0) {
          const [r0, c0] = centre(stack[i - 1]);
          put((r + r0) / 2, (c + c0) / 2, "·");
        }
      }
      const [r, c] = centre(stack[stack.length - 1]);
      put(r, c - 1, "█");
      put(r, c, "█");
      put(r, c + 1, "█");
    }
    // The way through, drawn in from the entrance as far as `solved` (0 to 1).
    if (solved > 0) {
      const way = m.way;
      const pts = [[1, -1], ...way.map(centre), [2 * MH - 1, cols]];
      const cells: [number, number, number, number][] = [];
      for (let p = 1; p < pts.length; p++) {
        const [r0, c0] = pts[p - 1], [r1, c1] = pts[p];
        const dr = Math.sign(r1 - r0), dc = Math.sign(c1 - c0);
        for (let r = r0, c = c0; r !== r1 || c !== c1; ) {
          r += dr;
          c += dc;
          cells.push([r, c, dr, dc]);
        }
      }
      const shown = Math.round(solved * cells.length);
      for (let q = 0; q < shown && q < cells.length; q++) {
        const [r, c, dr, dc] = cells[q];
        if (c < 0 || c >= cols) continue;
        const into = dc > 0 ? L : dc < 0 ? R : dr > 0 ? U : D;
        const nx = cells[q + 1];
        const outOf = !nx || q + 1 >= shown ? (into & (L | R) ? L | R : U | D) & ~into : nx[3] > 0 ? R : nx[3] < 0 ? L : nx[2] > 0 ? D : U;
        put(r, c, PATH[into | outOf] || "━");
      }
    }
    for (let r = 0; r < rows; r++) out[r] = grid[r].join("");
  };

  const now: string[] = [], fresh: string[] = [];
  return (t) => {
    // Which maze is playing, and how far into it.
    const n = Math.floor((t + START) / PERIOD);
    const p = t + START - n * PERIOD;
    const m = get(n);
    const k = Math.min(m.moves.length, Math.floor(p * CARVE));
    const solved = Math.min(1, Math.max(0, (p - CARVED) / SOLVE));
    draw(m, k, solved, now);
    const close = (p - CARVED - SOLVE - HOLD) / CLOSE;
    if (close <= 0) return now.join("\n");
    // The walls sweep back in from the left a column of cells at a time,
    // ready for the next maze.
    draw(get(n + 1), 0, 0, fresh);
    const edge = 4 * Math.min(MW, Math.floor(close * (MW + 1))) + 1;
    return now.map((line, r) => fresh[r].slice(0, edge) + line.slice(edge)).join("\n");
  };
}