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:
- Dragging values — the payload is a single cell value (a color, an emoji, a display function, a number); dropping is
fill. - Dragging pieces — the payload is a 3×3 quadrille; dropping is grid algebra (
andgates,orcommits). - 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 dimension0crops 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. TheisFilledguard doubles as the hit test: out-of-bounds reads are empty, so clicking anywhere off the palette leavesdraggedundefined.
ℹ️ Four types, one
fillThe drop line never inspects the payload’s type.
fill(row, col, value)stores whatever the cell held — anddrawQuadrilleroutes each value to its renderer: colors fill, strings center, numbers render as gray levels, and functions run, which is why a droppedpulsekeeps 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: itsordercounts overlapping filled cells, soorder > 0means 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, somerged.size !== board.sizeif and only if the piece exceeded the board on any side.
ℹ️
cloneis the whole concurrency storyThe sources regenerate every 45 frames, including mid-drag — yet the piece in hand never flickers, because
mousePressedgrabbed aclone, 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
drawQuadrilleroutes any object exposing adisplayfunction through the function renderer, with the transform already at the hosting cell’s corner andthisbound 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 twocellLengths at play: the board draws atCELLper call whileQuadrille.cellLengthstays at the source scale, sopulseand 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.singletonsemantics in the wild. Flood the spiral and watch every cell shuffle in lockstep each timerandomizefires: identical references, one shared state. Contrast withcloneinmousePressed, 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/orgate collapses to two predicates:isValidis the overflow test andisFilledthe 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:
| payload | plain drop | SHIFT drop | |
|---|---|---|---|
| Values | 1×1 crop (ring) | fill(row, col, ink) | fill(row, col, ink, 4) |
| Pieces | 3×3 clone | and gates, or commits | — |
| Boards | 3×3 clone | fill(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
- Rotation: Bind a key to
rotatethe piece mid-drag and turn the second sketch into a packing puzzle. - Snap preview: Replace the free-floating ghost with a grid-snapped one, tinting it by drop legality (color the
andresult red under the cursor). - Region stamp: Fill the second sketch’s table gap yourself — flood a throwaway mark, then a filtered
visitstampspiece.read(r % 3, c % 3)over it, wallpapering the chamber — and judge whether the result reads as pattern or noise. - Erasing: Add a drag-out gesture — grab from the board and
clearthe origin — to make placements reversible. - Undo timeline: Record board snapshots per drop for undo/redo.
- Goal condition: Score chamber coverage — e.g., fill every chamber with a distinct ink, or fill the board with the fewest drops.
- Maze generation: Swap the precomputed bitboard for a recursive-backtracker generator, then export it with
toBigInt— bitboards as a maze serialization format. - 8-directional flood: Compare
fill(row, col, value, 8)against4on a diagonal-walled labyrinth. - Deeper nesting: In the third sketch, make sources themselves quadrilles of quadrilles — the display contract recurses for free. How deep before frame rate objects?
- Two players: Alternate turns and inks; walls become contested territory boundaries.
- Touch support: Port the three handlers to
touchStarted/touchEndedfor tablets.
References
Quadrille API
- createQuadrille(width, height, bitboard, value)
- createQuadrille(width, height, order, value)
- drawQuadrille(quadrille, options)
- fill(row, col, value)
- fill(row, col, value, directions)
- Quadrille.and(q1, q2, row, col)
- Quadrille.or(q1, q2, row, col)
- ring(row, col, dimension)
- clone()
- randomize()
- visit(callback, filter)
- mouseRow / mouseCol
- order
- isValid / isFilled / isEmpty
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/orgate.