Drag and drop looks like a physics problem — hit testing, ghosting, snapping, collision — but on a grid it reduces to data. This post distills a mechanic that keeps resurfacing across object-oriented programming projects — and, most recently, in a student serious-game prototype — into a three-sketch progression using p5-v2 and the Quadrille API, where the same gesture acquires increasingly rich drop semantics:

  1. Dragging values — the payload is a single cell value (a color, an emoji, a display function, a number); dropping is fill.
  2. Dragging pieces — the payload is a 3×3 quadrille; dropping is grid algebra (and gates, or commits).
  3. Dragging boards — the payload is again a 3×3 quadrille, but it nests whole into a single cell of the board, rendering itself through the display contract.

All three sketches share one skeleton: mousePressed grabs a quadrille (a crop or a clone — never the source), draw ghosts it under the mouse with drawingContext.globalAlpha, and mouseReleased commits it — or doesn’t. State mutation happens at exactly one point, which keeps each sketch easy to reason about. In the first and last sketches, holding SHIFT while dropping switches the commit from a point operation to a region operation: flood fill.

The Labyrinth Board

Flood fill only makes sense when there is something to bound it, so the boards are labyrinths, precomputed as bitboards and decoded with createQuadrille(width, height, bitboard, value) — each 1 bit becomes a wall. Walls partition the empty cells into disjoint chambers, so a SHIFT-drop paints exactly one chamber and stops at its boundary. The 12×15 labyrinth shared by the first two sketches — an open hall wrapping two walled keeps, # marks a wall bit:

............        MAZE = 0x400001F81081085081F810A1081081F8000020000n
.....#......
............        outer hall  → one chamber
...######...        upper keep  → one chamber
...#....#...        lower keep  → one chamber
...#....#...
.#.#....#...
...######...
...#....#.#.
...#....#...
...#....#...
...######...
............
......#.....
............

Dragging Values

The palette on the right is a single 4×1 quadrille holding one value per type — a color, a string (emoji), a display function, and a number. Grab a cell, drop it into the labyrinth: a plain drop inks one cell; a SHIFT-drop floods its chamber.

(drag a value from the right → drop fills a cell · SHIFT+drop floods the chamber · any key resets)

Code
Quadrille.cellLength = 30;
Quadrille.outline = 'magenta';
const COLS = 12, ROWS = 15;
// 12×15 labyrinth bitboard: 1 = wall (row-major, big-endian, MSB = top-left)
const MAZE = 0x400001F81081085081F810A1081081F8000020000n;
let board, palette, dragged;

// cell effect (display contract): self-animated pulsing dot
function pulse() {
  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((COLS + 2) * Quadrille.cellLength, ROWS * Quadrille.cellLength);
  board = createQuadrille(COLS, ROWS, MAZE, color('#0b332b'));
  palette = createQuadrille(1, [color('#8c1f2f'), '🎃', pulse, 200]);
}

function draw() {
  background('#138a72');
  drawQuadrille(board, { outlineWeight: 0.5 });
  drawQuadrille(palette, { col: COLS + 1 });
  if (dragged) {
    drawingContext.globalAlpha = 0.5; // tint transparency while dragging
    drawQuadrille(dragged, {
      x: mouseX - dragged.width * Quadrille.cellLength / 2,
      y: mouseY - dragged.height * Quadrille.cellLength / 2
    });
    drawingContext.globalAlpha = 1;
  }
}

function mousePressed() {
  const row = palette.mouseRow, col = palette.mouseCol;
  dragged = palette.isFilled(row, col) ? palette.ring(row, col, 0) : undefined;
}

function mouseReleased() {
  if (!dragged) return;
  const ink = dragged.read(0, 0);
  dragged = undefined;
  const row = board.mouseRow, col = board.mouseCol;
  if (!board.isValid(row, col) || board.isFilled(row, col)) return;
  keyIsDown(SHIFT) ? board.fill(row, col, ink, 4) : board.fill(row, col, ink);
}

function keyPressed() { // any key but SHIFT resets the board
  key !== 'Shift' && (board = createQuadrille(COLS, ROWS, MAZE, color('#0b332b')));
}

ℹ️ Grabbing with ring

ring(row, col, 0) with dimension 0 crops a 1×1 quadrille around the clicked cell — the dragged payload is a quadrille from the very first sketch, which is what lets all three sketches share the same ghost-drawing code verbatim. The isFilled guard doubles as the hit test: out-of-bounds reads are empty, so clicking anywhere off the palette leaves dragged undefined.

ℹ️ Four types, one fill

The drop line never inspects the payload’s type. fill(row, col, value) stores whatever the cell held — and drawQuadrille routes each value to its renderer: colors fill, strings center, numbers render as gray levels, and functions run, which is why a dropped pulse keeps beating inside the labyrinth. Flood-fill a chamber with it and the whole region animates in sync.

ℹ️ Flood fill as region drop

fill(row, col, value, 4) replaces the connected region matching the start cell’s value — here the start cell is empty, so the flood expands across connected empty cells and stops at the walls. Since the labyrinth has three disjoint chambers, three SHIFT-drops paint the whole board in three inks.

Dragging Pieces

Now the payload grows to a 3×3 quadrille with 5 randomly placed cells, one source per value type — colors, emojis, cell effects, numbers — regenerated every 45 frames via Quadrille.factory, which evaluates its function once per filled cell. A plain drop sticks the piece onto the board — but only if the algebra allows it. This is the one sketch without a SHIFT gesture; the closing table explains why.

(drag a piece → drop sticks it, walls permitting · any key resets)

Code
Quadrille.cellLength = 30;
Quadrille.outline = 'magenta';
const COLS = 12, ROWS = 15; // 15 = 4 sources × 3 rows + 3 gap rows
const MAZE = 0x400001F81081085081F810A1081081F8000020000n;
const EMOJIS = ['🎃', '🔥', '🧙', '🧛', '🍄', '👻'];
let board, sources, dragged;

// cell effect (display contract): self-animated pulsing dot
function pulse() {
  const cl = Quadrille.cellLength;
  push();
  noStroke();
  fill('#c77dff');
  circle(cl / 2, cl / 2, cl * (0.4 + 0.25 * sin(frameCount * 0.1)));
  pop();
}

// one maker per source; factories run once per filled cell
const makers = [
  () => createQuadrille(3, 3, 5, Quadrille.factory(() => color(random(155, 255), random(100), random(100)))),
  () => createQuadrille(3, 3, 5, Quadrille.factory(() => random(EMOJIS))),
  () => createQuadrille(3, 3, 5, pulse),
  () => createQuadrille(3, 3, 5, Quadrille.factory(() => int(random(50, 255))))
];

function setup() {
  createCanvas((COLS + 4) * Quadrille.cellLength, ROWS * Quadrille.cellLength);
  board = createQuadrille(COLS, ROWS, MAZE, color('#0b332b'));
  sources = makers.map(make => make());
}

function draw() {
  background('#138a72');
  frameCount % 45 === 0 && (sources = makers.map(make => make())); // animate sources
  drawQuadrille(board, { outlineWeight: 0.5 });
  sources.forEach((q, i) => drawQuadrille(q, { row: 4 * i, col: COLS + 1 }));
  if (dragged) {
    drawingContext.globalAlpha = 0.5; // tint transparency while dragging
    drawQuadrille(dragged, {
      x: mouseX - dragged.width * Quadrille.cellLength / 2,
      y: mouseY - dragged.height * Quadrille.cellLength / 2
    });
    drawingContext.globalAlpha = 1;
  }
}

function mousePressed() {
  dragged = sources.find(q => q.isValid(q.mouseRow, q.mouseCol))?.clone();
}

function mouseReleased() {
  if (!dragged) return;
  const piece = dragged;
  dragged = undefined;
  const row = board.mouseRow, col = board.mouseCol;
  if (!board.isValid(row, col)) return; // dropped off-board
  const offRow = row - int(piece.height / 2), offCol = col - int(piece.width / 2);
  const overlap = Quadrille.and(board, piece, offRow, offCol).order > 0; // cell overlap
  const merged = Quadrille.or(board, piece, offRow, offCol);             // union, board wins ties
  if (!overlap && merged.size === board.size) board = merged;            // reject overlap & overflow
}

function keyPressed() { // any key but SHIFT resets the board
  key !== 'Shift' && (board = createQuadrille(COLS, ROWS, MAZE, color('#0b332b')));
}

ℹ️ The algebra gate

Drop legality is two set operations, no coordinate arithmetic. Quadrille.and(board, piece, offRow, offCol) is the intersection: its order counts overlapping filled cells, so order > 0 means collision — with the labyrinth walls or with previously dropped pieces alike. Quadrille.or(...) is the commit: the union of board and piece, with board values winning ties (which is what keeps the walls intact). The overflow test falls out of the merge sizing rule for free — the result spans the bounding rectangle of both operands, so merged.size !== board.size if and only if the piece exceeded the board on any side.

ℹ️ clone is the whole concurrency story

The sources regenerate every 45 frames, including mid-drag — yet the piece in hand never flickers, because mousePressed grabbed a clone, not a reference. One method call replaces what would otherwise be freeze flags and copy-on-write bookkeeping.

Notice how the labyrinth changes the game: the open hall accepts pieces almost anywhere, while the two keeps admit only a well-aimed drop — the walls turned a sandbox into a placement puzzle without a single line of rule code.

Dragging Boards

The final variant drops the entire 3×3 piece into a single cell of a 5×5 labyrinth — a quadrille of quadrilles. Enforcing the visual constraint that a whole piece equals one board cell takes no scaling code: the board renders at CELL = 3 × Quadrille.cellLength, and each nested piece renders itself at CELL / 3 through a one-line display contract.

The 5×5 labyrinth — a spiral chamber plus a sealed side pocket:

.....        MAZE = 0xF2948n
####.
.#.#.
.#.#.
.#...

(drag a piece → drop nests it in one cell · SHIFT+drop floods with the same piece · any key resets)

Code
Quadrille.cellLength = 30;              // source / nested cell length
Quadrille.outline = 'magenta';
const CELL = 3 * Quadrille.cellLength;  // board cell length: one whole 3×3 piece
const COLS = 5, ROWS = 5;               // 5 board rows @90 = 15 source rows @30
const MAZE = 0xF2948n;                  // 5×5 labyrinth bitboard: 1 = wall
const EMOJIS = ['🎃', '🔥', '🧙', '🧛', '🍄', '👻'];
let board, sources, dragged;

// display contract: a quadrille stored in a cell draws itself, scaled to fit
Quadrille.prototype.display = function () {
  drawQuadrille(this, { cellLength: CELL / this.width });
};

// cell effect (display contract): self-animated pulsing dot
function pulse() {
  const cl = Quadrille.cellLength;
  push();
  noStroke();
  fill('#c77dff');
  circle(cl / 2, cl / 2, cl * (0.4 + 0.25 * sin(frameCount * 0.1)));
  pop();
}

// one maker per source; factories run once per filled cell
const makers = [
  () => createQuadrille(3, 3, 5, Quadrille.factory(() => color(random(155, 255), random(100), random(100)))),
  () => createQuadrille(3, 3, 5, Quadrille.factory(() => random(EMOJIS))),
  () => createQuadrille(3, 3, 5, pulse),
  () => createQuadrille(3, 3, 5, Quadrille.factory(() => int(random(50, 255))))
];

function setup() {
  createCanvas(COLS * CELL + 4 * Quadrille.cellLength, ROWS * CELL);
  board = createQuadrille(COLS, ROWS, MAZE, color('#0b332b'));
  sources = makers.map(make => make());
}

function draw() {
  background('#138a72');
  if (frameCount % 45 === 0) {
    sources = makers.map(make => make());          // animate sources
    board.visit(({ value }) => value.randomize(),  // animate dropped pieces too
                ({ value }) => value instanceof Quadrille);
  }
  drawQuadrille(board, { cellLength: CELL, outlineWeight: 0.5 });
  sources.forEach((q, i) => drawQuadrille(q, { row: 4 * i, col: 3 * COLS + 1 }));
  if (dragged) {
    drawingContext.globalAlpha = 0.5; // tint transparency while dragging
    drawQuadrille(dragged, {
      x: mouseX - dragged.width * Quadrille.cellLength / 2,
      y: mouseY - dragged.height * Quadrille.cellLength / 2
    });
    drawingContext.globalAlpha = 1;
  }
}

function mousePressed() {
  dragged = sources.find(q => q.isValid(q.mouseRow, q.mouseCol))?.clone();
}

function mouseReleased() {
  if (!dragged) return;
  const piece = dragged;
  dragged = undefined;
  const row = board.mouseRow, col = board.mouseCol;
  if (!board.isValid(row, col) || board.isFilled(row, col)) return;
  keyIsDown(SHIFT) ? board.fill(row, col, piece, 4) : board.fill(row, col, piece);
}

function keyPressed() { // any key but SHIFT resets the board
  key !== 'Shift' && (board = createQuadrille(COLS, ROWS, MAZE, color('#0b332b')));
}

ℹ️ The display contract does the nesting

drawQuadrille routes any object exposing a display function through the function renderer, with the transform already at the hosting cell’s corner and this bound to the object. The one-line prototype method — drawQuadrille(this, { cellLength: CELL / this.width }) — is therefore the entire rendering story: value and renderer travel together, so nesting a fully live, self-animating board inside a grid cell costs one method. Note the two cellLengths at play: the board draws at CELL per call while Quadrille.cellLength stays at the source scale, so pulse and the nested renders read the right size everywhere with no scaling code.

ℹ️ SHIFT-drop is an aliasing lesson

fill(row, col, piece, 4) stores the same instance in every cell the flood reaches — Quadrille.singleton semantics in the wild. Flood the spiral and watch every cell shuffle in lockstep each time randomize fires: identical references, one shared state. Contrast with clone in mousePressed, which is why dragging never mutates a source — reference versus copy, demonstrated in one gesture.

ℹ️ Algebra traded for containment

Since a drop targets exactly one cell, the previous sketch’s and/or gate collapses to two predicates: isValid is the overflow test and isFilled the overlap test. The geometry enforces the rest.

One Gesture, Three Semantics

Placing the three mouseReleased bodies side by side is the point of the progression. The grab and the ghost never change; only the commit does:

payloadplain dropSHIFT drop
Values1×1 crop (ring)fill(row, col, ink)fill(row, col, ink, 4)
Pieces3×3 cloneand gates, or commits
Boards3×3 clonefill(row, col, piece)fill(row, col, piece, 4)

The empty cell is deliberate. Flood is coherent when the payload reduces to one thing — one ink in the first sketch, one instance in the third. A piece is a shape, and a flood is shapeless: every reduction — extract a single ink, tile the pattern across the chamber — reads as noise on an irregular region, so the second sketch keeps only the algebraic commit. Flood fill is the constructive dual of sticking: or composes outward from the piece’s shape, while the flood composes inward from the board’s own connectivity, with the boundary encoded as data — the labyrinth — rather than as collision geometry. When a payload supports both semantics, hanging them on the same gesture is the design decision worth stealing; when it doesn’t, the honest move is the dash in the table.

Further Exploration

  1. Rotation: Bind a key to rotate the piece mid-drag and turn the second sketch into a packing puzzle.
  2. Snap preview: Replace the free-floating ghost with a grid-snapped one, tinting it by drop legality (color the and result red under the cursor).
  3. Region stamp: Fill the second sketch’s table gap yourself — flood a throwaway mark, then a filtered visit stamps piece.read(r % 3, c % 3) over it, wallpapering the chamber — and judge whether the result reads as pattern or noise.
  4. Erasing: Add a drag-out gesture — grab from the board and clear the origin — to make placements reversible.
  5. Undo timeline: Record board snapshots per drop for undo/redo.
  6. Goal condition: Score chamber coverage — e.g., fill every chamber with a distinct ink, or fill the board with the fewest drops.
  7. Maze generation: Swap the precomputed bitboard for a recursive-backtracker generator, then export it with toBigInt — bitboards as a maze serialization format.
  8. 8-directional flood: Compare fill(row, col, value, 8) against 4 on a diagonal-walled labyrinth.
  9. Deeper nesting: In the third sketch, make sources themselves quadrilles of quadrilles — the display contract recurses for free. How deep before frame rate objects?
  10. Two players: Alternate turns and inks; walls become contested territory boundaries.
  11. Touch support: Port the three handlers to touchStarted / touchEnded for tablets.

References

Quadrille API

Further Reading

  • Bitboards — the chess-engine representation behind the labyrinth constant.
  • Flood fill — the classic algorithm, here repurposed as a drop semantic.
  • Constructive solid geometry — the boolean-composition mindset behind the and/or gate.