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

isFilled is value != null — never false, 0, or ''. A bool[][] renders as a wall of ✅/❎ and zeros paint dark; those aren’t bugs, they’re the convention that later powers and/or, flood, and order, surfacing early. The escape hatches already exist: filter: ({ value }) => value === true at 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, …), no arr.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) or createQuadrille(arr) (copies, normalizes, owns). The numbered contract lives in the drawQuadrille docs — 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)
memorycopiesaliases
holes & ragged rowsnormalized — repaired, padded rectangulartolerated — render as empty, never repaired
your later mutationsinvisiblenext frame
ownershiptransfers to the quadrillestays 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

  1. The bool trap, on purpose: generate a bool[][], draw it naively, and meet the ✅/❎ wall — then fix it with filter: ({ value }) => value === true and decide which representation you actually wanted.
  2. Your maze, its clothes: write your own maze generator emitting null corridors, then skin it with stringDisplay: Quadrille.thinWall, tileDisplay: null — your algorithm wearing Of Mice and Mazes’ wardrobe.
  3. 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.
  4. Detach on purpose: call view.rotate(), keep mutating the array, and explain what froze — then reconcile with the second info box.
  5. Two views, one array: draw the same array twice at different cellLengths and edit through either — a zoom lens with no zoom code.
  6. Through the door: hand the view to reach/path from Of Mice and Mazes — filled cells are obstacles, and your raw array just became a pathfinding level.
  7. 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