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) 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 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
strokeandstrokeWeightbehaves 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 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. 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
- 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 acolorDisplayfunction receiving{ graphics, value, row, col, cellLength }— write a rounded-caps or dashed variant and pass yours 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.
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
- 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.0release- replace(value), rand(times), toBigInt()
- mouseRow, mouseCol, isValid, isEmpty