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:
| layer | quadrille | drawn as |
|---|---|---|
| level | board | walls — fat or thin, a param |
| field | field | the ripple, revealed by front |
| trail | trail | breadcrumbs at half alpha |
| goal | cheese | a 1×1 placed via { row, col } |
| agent | mice | a 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 queries — reach 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) beforemaze— the same convention asrand()andrandomize(). 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
outlineandoutlineWeightbehaves 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 anotheroutline(noreplace, no mutation), let the same board render fat and thin in one frame, keep walls of any type thin-renderable, and make it work inP2DandWEBGLalike.
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 === endall return the same empty array — so game code never branches on error cases.steps.lengthis simultaneously the legality test and the travel cost, andsteps.length === 1is 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
- Braid it:
board.maze(wall).rand(4)clears four random filled cells — loops appear, andpathstarts finding alternatives. - 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. - Diagonals: pass
8as the last argument ofreach/pathfor corner-cutting movement — watch path lengths drop from Manhattan to Chebyshev. - Many agents, one field: compute
reachfrom the goal and let every agent greedy-descend the same field — no recomputation per agent. - Skin it yourself:
thinWallis 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. - Cross over: drop
reach/pathonto the Minesweeper layers — filled cells are obstacles, whatever the game. - Controls: add a speed slider and a color picker deriving walls and accent from one base hue (the development demo does both).
- Break the convention on purpose: fill a wall on an even-even room slot, render thin, and watch the drawing show a corridor that
pathrefuses — 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
- createQuadrille(width, height) and drawQuadrille(quadrille, options)
maze(value),reach(row, col, directions),path(row1, col1, row2, col2, directions),Quadrille.thinWall— API pages land with the3.5.0-rc.4release- replace(value), rand(times), toBigInt()
- mouseRow, mouseCol, isValid, isEmpty