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.4 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 draw params, 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, driven by two gestures on one machine. Click seeks: 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 walks: no ripple — the path is laid and spent immediately, the same machine entered at its walking state. Clicking a wall floods the whole maze and fizzles — failure made visible.

(click: seek the 🧀 — ripple, then run · shift-click: walk — no ripple · t: fat ⇄ thin · other keys: fresh level)

Code
'use strict';
Quadrille.cellLength = 30;
const COLS = 11, ROWS = 15;
const SPEED = 24; // rings (and steps) per second
const THIN = { // one display function, every channel, no tile — the thin look, storage untouched
  colorDisplay: Quadrille.thinWall, imageDisplay: Quadrille.thinWall,
  stringDisplay: Quadrille.thinWall, numberDisplay: Quadrille.thinWall,
  textZoom: 1, tileDisplay: null, outlineWeight: 7
};
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;

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

function draw() {
  background('#138a72');
  drawQuadrille(board, thin ? { ...THIN, outline: '#0b332b' } : { 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;
  const zoom = thin ? 1.2 : Quadrille.textZoom; // thin frees the cell — let the emoji grow
  goal && drawQuadrille(cheese, { row: goal.row, col: goal.col, outlineWeight: 0, textZoom: zoom });
  drawQuadrille(mice, { row, col, outlineWeight: 0, textZoom: zoom });
  animate();
}

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

function walk() { // seek arrives here via the reach event; shift-click jumps straight in
  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;                                  // ripple off — spending the plan now
}

function mousePressed() {
  if (!board.isValid(board.mouseRow, board.mouseCol)) return;
  goal = { row: board.mouseRow, col: board.mouseCol };
  trail = createQuadrille(COLS, ROWS);
  [path, acc] = [[], 0];
  keyIsDown(SHIFT) ? walk() : seek();          // two gestures, one machine
}

function seek() { // the patient gesture: the wave must reach the 🧀 before the walk
  field = board.reach(row, col);               // seed at the agent
  maxD = Math.max(0, ...field.toArray().filter(v => v !== null));
  front = 0;                                   // ripple on
}

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

One Frame, Five Quadrilles

Nothing in the demo is a special object — not the maze, not the ripple, not the breadcrumbs, not even the mouse. The frame is five ordinary quadrilles drawn in order:

layerquadrilledrawn as
levelboardwalls — fat or thin, a param
fieldfieldthe ripple, revealed by front
trailtrailbreadcrumbs at half alpha
goalcheesea 1×1 placed via { row, col }
agentmicea 1×1 placed via { row, col }

Draw order is z-order, and that is the whole compositor. The layers never write into each other: they interact only through queriesreach and path read the level and answer with fresh quadrilles (the field, the plan) — so each layer stays a single-concern board that can be cleared, swapped, or skinned alone. Even the agent is a board: a 1×1 quadrille whose position lives in the draw params — the same place the thin skin and the clock live.

This is the multi-layer strategy at the core of the game-board-games approach — the organizing idea of a chapter in progress: model each concern as its own quadrille and composite by draw order; when layers only ever read each other, queries are the entire inter-layer protocol. Its companion is the algebra: the moment layers must merge — pieces committing onto a board, programs stamped into levels — and gates and or commits become the inter-layer calculus, as in Dragging Quadrilles. Two strategies, one rule of thumb: layers for concerns, algebra for commits.

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 data, so the thin look is not a second maze, a mode, or anything stored — it is a display function passed to the draw call, plus the draw call’s own style params. thinWall is value-agnostic: it ignores the cell content entirely, reading the stroke from outline and the weight from outlineWeight, so the same trick works on walls stored as colors, images, emojis, whatever. Install it as every display and silence the tile; toggle per draw site — the storage never moves:

const THIN = {
  colorDisplay: Quadrille.thinWall, imageDisplay: Quadrille.thinWall,
  stringDisplay: Quadrille.thinWall, numberDisplay: Quadrille.thinWall,
  tileDisplay: null, outlineWeight: 7
};
drawQuadrille(board, { outlineWeight: 0.5 });                 // fat
drawQuadrille(board, { ...THIN, outline: '#0b332b' });      // thin — same board

Press t in the demo to see it live, mid-animation — the 🐭 and 🧀 grow with the toggle too: textZoom is just another draw param, and thin walls free the cell it fills. 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, terminating flush at the board’s perimeter, where no pillar flanks.

ℹ️ Why params and not a wall value

The design turn came from a simple observation: a skin driven by outline and outlineWeight behaves like styling, so it belongs where styling lives — with the draw call, not inside the cells. That one move untangled the skin from the stored data entirely: recolor by passing another outline (no replace, no mutation), let the same board render fat and thin in one frame, keep walls of any type thin-renderable, and make 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: one reach() field of plain numbers, one scalar front advancing per frame, and a numberDisplay override hiding cells the wave has not reached yet. Data in cells, time in params — the same principle that keeps the thin skin out of storage keeps the clock out of it too, which is why a speed slider (the development version has one) acts retroactively on a field computed once. The machinery — phase machine, crest, trail — lives in the appendix.

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 display function receiving { graphics, row, col, cellLength, outline, outlineWeight } and ignoring the cell value — write a rounded-caps or dashed variant and install yours as the displays 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.

Appendix: inside the demo

The engine behind the 🐭🧀 demo, in three pieces — the most advanced code in this lesson. Skip on first read; nothing else depends on it.

The phase machine. One scalar owns the animation: front >= 0 means seek’s wave is traveling; otherwise path.length means the mouse is walking. Seek enters walk() through exactly one transition — the reach event, front catching the field’s stamp at the 🧀 — while shift-click calls walk() directly and never starts a ripple. Two gestures, one target state.

if (front >= 0) {                                  // seek: the ripple travels
  front += SPEED * deltaTime / 1000;
  const d = goal ? field.read(goal.row, goal.col) : null;
  if (typeof d === 'number' && front >= d) walk(); // reach event → walk
  else if (front > maxD + 1) { front = -1; goal = null; } // fizzle: wall target
} else if (path.length) { /* walk: spend the plan */ }

The crest. numberDisplay runs per filled cell of the field with that cell’s stamp in hand: hide what the wave has not reached, glow the ring it rides now, fade the wake. One override, zero per-cell state.

numberDisplay: ({ value: d, cellLength: l }) => {
  if (d > front) return;                              // not yet reached
  noStroke();
  fill(255, 209, 102, d === floor(front) ? 220 : 70); // crest vs wake
  rect(0, 0, l, l);
}

The trail stack. path is a plan spent one move at a time; trail is its visible shadow — laid down all at once, eaten cell by cell as the plan executes:

path.forEach(({ row, col }) => trail.fill(row, col, color('#ffd166'))); // lay the breadcrumbs
({ row, col } = path.shift());                                          // take one step
trail.clear(row, col);                                                  // eat one breadcrumb

Plan-now, spend-later generalizes far beyond mazes: fuses, snake bodies, patrol routes, footprints that fade — each is a quadrille being emptied on schedule.

References

This post runs on 3.5.0-rc.4, pinned explicitly (the sketches load p5.quadrille@3.5.0-rc.4 via CDN); npm i p5.quadrille and unpinned embeds track the latest release. In your own sketches, pin the same URL and check Quadrille.VERSION in the console — include it in any bug report.

Quadrille API