A maze looks like a special data structure, but it isn’t one: it is an ordinary quadrille whose filled cells happen to be walls. That single decision — walls are filled cells, corridors are empty cells — is what the new p5.quadrille.js 3.5.0-rc.1 pre-release builds on: maze(value) generates a perfect maze in place, reach(row, col) returns which cells a seed can reach (and in how many steps) as another quadrille, path(row1, col1, row2, col2) walks between two cells, and Quadrille.thinWall renders the walls thin — as a draw param, never touching storage. Because passability is just emptiness, the two queries work on any quadrille: mazes, game boards with pieces, hand-authored bitboards.

The demo below puts all four together. Click to drop the 🧀: a wavefront ripples out from the 🐭, and the instant it reaches the cheese, the shortest path lights up and the mouse runs it. Shift-click skips the ripple (same machine, wave fast-forwarded). Clicking a wall floods the whole maze and fizzles — failure made visible.

(click: drop the 🧀 · shift-click: run immediately · t: fat ⇄ thin · other keys: fresh level)

Code
'use strict';
Quadrille.cellLength = 30;
const COLS = 11, ROWS = 15;
const SPEED = 12; // rings (and steps) per second
let board, field, trail, path = [];
let mice, cheese;
let row = 0, col = 0, goal = null;
let front = -1, maxD = 0, acc = 0;
let thin = false;

// idle ──click──▶ ripple (front grows) ──wave reaches 🧀──▶ walk ──▶ idle
function setup() {
  createCanvas(COLS * Quadrille.cellLength, ROWS * Quadrille.cellLength);
  mice = createQuadrille(['🐭']);
  cheese = createQuadrille(['🧀']);
  level();
}

function draw() {
  background('#138a72');
  drawQuadrille(board, thin
    ? { colorDisplay: Quadrille.thinWall, outlineWeight: 0 }
    : { outlineWeight: 0.5 });
  front >= 0 && drawQuadrille(field, { // ONE field, revealed at draw time
    outlineWeight: 0,
    numberDisplay: ({ value: d, cellLength: l }) => {
      if (d > front) return;
      noStroke();
      fill(255, 209, 102, d === floor(front) ? 220 : 70); // crest glows
      rect(0, 0, l, l);
    }
  });
  drawingContext.globalAlpha = 0.5;
  drawQuadrille(trail, { outlineWeight: 0 });
  drawingContext.globalAlpha = 1;
  goal && drawQuadrille(cheese, { row: goal.row, col: goal.col, outlineWeight: 0 });
  drawQuadrille(mice, { row, col, outlineWeight: 0 });
  animate();
}

function animate() {
  const dt = deltaTime / 1000;
  if (front >= 0) {                            // ripple phase
    front += SPEED * dt;
    const d = goal ? field.read(goal.row, goal.col) : null;
    if (typeof d === 'number' && front >= d) go(); // the wave REACHED the 🧀
    else if (front > maxD + 1) { front = -1; goal = null; } // fizzled
  } else if (path.length) {                    // walk phase
    acc += SPEED * dt / 3;
    while (acc >= 1 && path.length) {
      acc--;
      ({ row, col } = path.shift());
      trail.clear(row, col);
    }
    path.length || (goal = null);              // 🧀 nom
  }
}

function go() { // reach event: wave arrived (or was fast-forwarded)
  path = board.path(row, col, goal.row, goal.col);
  path.forEach(({ row, col }) => trail.fill(row, col, color('#ffd166')));
  path.length || (goal = null);
  front = -1;
}

function mousePressed() {
  if (!board.isValid(board.mouseRow, board.mouseCol)) return;
  goal = { row: board.mouseRow, col: board.mouseCol };
  trail = createQuadrille(COLS, ROWS);
  [path, acc] = [[], 0];
  if (keyIsDown(SHIFT)) { go(); return; }      // impatient mode
  field = board.reach(row, col);               // seed at the agent
  maxD = Math.max(0, ...field.toArray().filter(v => v !== null));
  front = 0;                                   // the wave must reach the 🧀 first
}

function keyPressed() {
  key === 't' ? thin = !thin : key.length === 1 && level();
}

function level() {
  board = createQuadrille(COLS, ROWS).maze(color('#0b332b'));
  trail = createQuadrille(COLS, ROWS);
  [row, col, goal, path, front] = [0, 0, null, [], -1];
}

Everything above decomposes into five small steps. Each one is a minimal diff over the previous — climb the ladder and the demo assembles itself.

A maze in four lines

Declare → create → generate → render. maze(value) is a mutator like the parameterless chessboard fill(): it clears the quadrille, then carves a perfect maze at the current dimensions, storing value in every wall cell. Interaction is the same call again — a fresh level is one line inside an event handler.

(click: fresh level)

ℹ️ Perfect, borderless, seedable

Perfect means any two empty cells are joined by exactly one path — so pathfinding between corridors never fails, and no start/end cells are needed. Rooms sit at even (row, col) indices with (0, 0) always open, and the canvas edge plays the outer wall. For repeatable levels, call randomSeed(seed) before maze — the same convention as rand() and randomize(). Odd dimensions are canonical: even ones warn and seal the trailing strip.

Thin is a param

The walls are plain colors, so the thin look is not a second maze, a mode, or anything stored — it is a value for the existing colorDisplay draw param. Toggle it per draw site; the storage never moves:

drawQuadrille(board, { outlineWeight: 0.5 });                                   // fat
drawQuadrille(board, { colorDisplay: Quadrille.thinWall, outlineWeight: 0 });   // thin — same board

Press t in the demo to see it live, mid-animation. Each wall cell reads its orientation from its own index parity — odd row + even col draws a horizontal segment, even row + odd col a vertical one, odd-odd pillars draw nothing — and segments overshoot to the centers of their flanking pillars, so joints, corners, and tips form themselves.

ℹ️ Why a param and not a wall value

The design turn came from a simple observation: a skin driven by stroke and strokeWeight behaves like styling, so it belongs where styling lives — with the draw call, not inside the cells. That one move made the stored color the stroke (maze(color('crimson')) renders crimson thin walls, and replace recolors both looks at once), untangled the skin from any global scale, let the same board render fat and thin in one frame, and made it work in P2D and WEBGL alike.

The field draws itself

reach(row, col) answers “which cells can this seed reach, and in how many steps?” — and the answer is a quadrille: 0 at the seed, steps-to-reach elsewhere, empty where out of reach. Being a quadrille, it needs no visualization code: draw it.

(click: reseed the wavefront at the picked cell — a wall seed yields an empty field · any key: fresh maze)

ℹ️ One field, three roles

The same quadrille is a visited set (field.isEmpty(row, col) reads literally as out of reach), a distance map (field.read(row, col) is the steps to reach), and a drawable artifact. And because it works on any quadrille — filled cells are obstacles, whatever they are — the same call measures reach around game pieces, hand-authored bitboard walls, or nothing at all (plain grid distance on an empty board).

Reach and walk

path(row1, col1, row2, col2) is sugar over reach plus greedy descent: an array of {row, col} moves, excluding start, including end. Move validity needs no separate predicate — the emptiness of the answer is the answer:

(click a reachable cell: 🐭 teleports there · click a wall: nothing, by design · any key: fresh maze)

ℹ️ Failure is uniform

A wall endpoint (either one), an off-board endpoint, a sealed region, and start === end all return the same empty array — so game code never branches on error cases. steps.length is simultaneously the legality test and the travel cost, and steps.length === 1 is exactly “one move away”.

Time is a param too

The demo’s ripple stores no animation anywhere. It is one reach() field of plain numbers plus one scalar, front, gating a numberDisplay override:

front >= 0 && drawQuadrille(field, {
  outlineWeight: 0,
  numberDisplay: ({ value: d, cellLength: l }) => {
    if (d > front) return;                              // not yet reached by the wave
    noStroke();
    fill(255, 209, 102, d === floor(front) ? 220 : 70); // crest glows, wake stays faint
    rect(0, 0, l, l);
  }
});

Each frame, front += SPEED * deltaTime / 1000 advances the wave; when front reaches the field’s stamp at the 🧀, the walk begins — and shift-click is just that same event fast-forwarded. Data in cells, time in params: the same principle that keeps the thin skin out of storage keeps the clock out of it too. That is why the speed constant could be a live slider (it is, in the development version), acting retroactively on a field that was computed once.

Further Exploration

  1. Braid it: board.maze(wall).rand(4) clears four random filled cells — loops appear, and path starts finding alternatives.
  2. Ship a level: const LEVEL = board.maze(wall).toBigInt() authors once; createQuadrille(11, 15, LEVEL, wall) decodes it forever. Mind the dimensions: decode with the same ones.
  3. Diagonals: pass 8 as the last argument of reach/path for corner-cutting movement — watch path lengths drop from Manhattan to Chebyshev.
  4. Many agents, one field: compute reach from the goal and let every agent greedy-descend the same field — no recomputation per agent.
  5. Skin it yourself: thinWall is just a colorDisplay function receiving { graphics, value, row, col, cellLength } — write a rounded-caps or dashed variant and pass yours instead.
  6. Cross over: drop reach/path onto the Minesweeper layers — filled cells are obstacles, whatever the game.
  7. Controls: add a speed slider and a color picker deriving walls and accent from one base hue (the development demo does both).
  8. Break the convention on purpose: fill a wall on an even-even room slot, render thin, and watch the drawing show a corridor that path refuses — then explain why the fat rendering never lies.

References

This post runs on the 3.5.0-rc.1 pre-release, pinned explicitly (the sketches load p5.quadrille@3.5.0-rc.1 via CDN); npm i p5.quadrille and unpinned embeds still get 3.4.13 until 3.5.0 lands with full API docs. In your own sketches, pin the same URL and check Quadrille.VERSION in the console — include it in any bug report.

Quadrille API