Every course that teaches 2D arrays produces the same artifact: a char[][] or bool[][] with nested loops around it, rendered — if at all — through a hand-rolled rect loop. p5.quadrille.js 3.5.0-rc.3 meets that code where it lives: drawQuadrille now accepts a raw 1D/2D JS array and renders it through a live, zero-copy view — no conversion, no copy, and no createQuadrille anywhere in this post. Your array stays yours; rendering becomes a service. One contract powers everything: empty is null — the same convention behind the algebra, flood fill, and order, met here on day one.
Hello, Your Array
A hand-written array of mixed values, drawn in one call. The full display contract comes along for free — colors fill, strings center, numbers gray, functions run — and the call’s return value is the whole upgrade path: a real, live Quadrille aliasing your rows, with mouseRow/mouseCol set by the draw you just made.
(click any cell: a raw assignment into the array — the view shows it next frame)
Code
'use strict';
Quadrille.cellLength = 60;
let cells = [ // a plain 2D array — yours; null is empty
[null, '🎃', null, '🍄'],
['👻', null, 42, null],
[null, 128, null, '🧙']
];
let view;
function pulse() { // display contract: functions run in their cell
const cl = Quadrille.cellLength;
push(); noStroke(); fill('#c77dff');
circle(cl / 2, cl / 2, cl * (0.4 + 0.25 * sin(frameCount * 0.1)));
pop();
}
function setup() {
createCanvas(4 * Quadrille.cellLength, 3 * Quadrille.cellLength);
cells[0][0] = pulse; // still your array, your assignment
cells[1][1] = color('#e76f51');
}
function draw() {
background('#138a72');
view = drawQuadrille(cells); // the return: a live, zero-copy Quadrille view of the array
}
function mousePressed() { // borrow the view's picking, write your own memory
const r = view.mouseRow, c = view.mouseCol;
view.isValid(r, c) && (cells[r][c] = random(['🎃', '👻', '🧙', '🍄', null]));
}
ℹ️ Empty means null
isFilledisvalue != null— neverfalse,0, or''. Abool[][]renders as a wall of ✅/❎ and zeros paint dark; those aren’t bugs, they’re the convention that later powersand/or, flood, andorder, surfacing early. The escape hatches already exist:filter: ({ value }) => value === trueat the draw call, or normalize at generation.
ℹ️ The return is the door
Display is the only verb arrays get — there is deliberately no
Quadrille.and(arr, …), noarr.mouseRow, no flood on raw arrays. To ask questions you need the noun, and the draw call hands it to you: keep the returned view (aliases your memory) orcreateQuadrille(arr)(copies, normalizes, owns). The numbered contract lives in thedrawQuadrilledocs — your editor shows it on hover, at the call site.
Life, Aliased
The classic raw-array assignment, untouched: nested loops, neighbor counts, in-place update. The library never sees step; step never sees the library. The view is captured on the first frame and drawn ever after — every generation you see arrives through the alias, and inking through the view writes straight into cells.
(drag: ink live cells through the view — the writes land in your array · any key: reshuffle, in place)
Code
'use strict';
Quadrille.cellLength = 14;
const N = 36;
let INK, cells, view;
const roll = () => random() < 0.25 ? INK : null;
function setup() {
createCanvas(N * Quadrille.cellLength, N * Quadrille.cellLength);
INK = color('#ffd166');
cells = Array.from({ length: N }, () => Array.from({ length: N }, roll));
}
// in-place Life: rows are never replaced, so the view's alias never breaks
function step(a) {
const counts = a.map((row, r) => row.map((_, c) => {
let k = 0;
for (let dr = -1; dr <= 1; dr++) for (let dc = -1; dc <= 1; dc++)
(dr || dc) && a[r + dr]?.[c + dc] != null && k++;
return k;
}));
for (let r = 0; r < N; r++) for (let c = 0; c < N; c++)
a[r][c] = (a[r][c] != null ? counts[r][c] === 2 || counts[r][c] === 3 : counts[r][c] === 3)
? (a[r][c] ?? INK) : null;
}
function draw() {
background('#138a72');
frameCount % 6 === 0 && step(cells); // your Life, your loops, your memory
view = view ? drawQuadrille(view, { outlineWeight: 0.25 })
: drawQuadrille(cells, { outlineWeight: 0.25 }); // wrapped once — aliased forever
}
function mousePressed() { ink(); }
function mouseDragged() { ink(); }
function ink() { // write-through: view.fill lands in cells
const r = view.mouseRow, c = view.mouseCol;
view.isValid(r, c) && view.fill(r, c, INK);
}
function keyPressed() { // reset IN PLACE: swapping rows would orphan the alias
for (const row of cells) for (let c = 0; c < N; c++) row[c] = roll();
}
ℹ️ Aliasing is cell-deep, not shape-deep
Cell mutators on the view —
fill,clear,replace, flood — write through to your rows. Shape transforms —rotate,transpose,shift,sort, resizing — rebuild internal memory and silently detach the view: it keeps working as an ordinary quadrille, but it stops being a window onto your array. The same discipline binds your side: replace a row (or the whole array) and a held view keeps aliasing the orphan — which is why the sketch’s reset mutates in place. Holes and ragged rows render as empty through read semantics and are never repaired; the one thing the view validates is the skeleton — a non-array row throws a named error instead of a cryptic crash three frames later.
Copy or View
The library now has two doors with opposite contracts:
createQuadrille(arr) | drawQuadrille(arr) | |
|---|---|---|
| memory | copies | aliases |
| holes & ragged rows | normalized — repaired, padded rectangular | tolerated — render as empty, never repaired |
| your later mutations | invisible | next frame |
| ownership | transfers to the quadrille | stays with your array |
The view could have been a conversion — wrap by copying, done. That copy was measured, and at teaching scale it was too cheap to matter. It was rejected anyway, because cost was never the point: a per-frame copy renders a snapshot of your array; the view renders your array. Mutation transparency — your loops write, the next frame shows — is the contract. And the table’s diagonal is a single principle read twice: you cannot normalize someone else’s memory without taking ownership of it, which is why the view repairs nothing and createQuadrille repairs everything.
Further Exploration
- The bool trap, on purpose: generate a
bool[][], draw it naively, and meet the ✅/❎ wall — then fix it withfilter: ({ value }) => value === trueand decide which representation you actually wanted. - Your maze, its clothes: write your own maze generator emitting
nullcorridors, then skin it withstringDisplay: Quadrille.thinWall, tileDisplay: null— your algorithm wearing Of Mice and Mazes’ wardrobe. - Zero allocation: the sketches above already hold the returned view and draw it — remove that and wrap fresh each frame, and measure whether you can tell the difference.
- Detach on purpose: call
view.rotate(), keep mutating the array, and explain what froze — then reconcile with the second info box. - Two views, one array: draw the same array twice at different
cellLengths and edit through either — a zoom lens with no zoom code. - Through the door: hand the view to
reach/pathfrom Of Mice and Mazes — filled cells are obstacles, and your raw array just became a pathfinding level. - Other Lives: HighLife (B36/S23), Seeds (B2/S) — one line changes in your code; the rendering never changes at all.
References
This post runs on 3.5.0-rc.3, pinned explicitly (the sketches load p5.quadrille@3.5.0-rc.3 via CDN); npm i p5.quadrille and unpinned embeds track the latest release. The array-view contract — six numbered limitations vs a full Quadrille — ships in the drawQuadrille JSDoc and surfaces in editor hover at the call site.
Quadrille API
- drawQuadrille(quadrille, options) — now
drawQuadrille(arrOrQ, options); the view contract lands with the3.5.0-rc.3release - createQuadrille(jagged array) — the copying door
- fill(row, col, value) / clear(row, col)
- mouseRow / mouseCol / isValid / isFilled