Sorted by Flight
2026 · gesture
A flick is one decision, argued afterwards by physics: the heavy ink falls short and stays, the fine ink carries and forgets, and the sorting is the drawing.
ink flicked off a brush from the lower-left corner, a cone of droplets sorted by size as they carry, two heavy blots where it landed first
painted by Kimi K3p5 2.3.2 · brush 2.2.2 · 2026-08-25
// composition: one cone of flicked ink opens from the lower-left corner toward the upper right,
// droplets sorted large-to-small along the throw; two heavy blots weight the lower-left thirds crossing.
// layering: dilute cone haze and the brush's own whip first, far mist and small droplets next,
// then the near heavy drops, and the two blots with their crowns and drips last.
// the paper above-left of the cone and the whole lower-right corner stay empty.
const PALETTE = [
"#ece5d7", // ground
"#3f4a52", // shadow, dilute slate haloes
"#1b1a1c", // mass, the ink itself
"#8a8272", // texture, dry grey mist
"#9c3f21", // accent, a second ink still on the brush
];
// the flick's origin sits just off the lower-left corner; the axis climbs to the upper right
const AX = 6;
const AY = 826;
const DX = 0.712;
const DY = -0.702;
// a point d along the throw, offset p across it
function coneX(d, p) {
return AX + d * DX - p * DY;
}
function coneY(d, p) {
return AY + d * DY + p * DX;
}
// droplets sort themselves by weight: heavy ones fall short, fine ones carry
function dropWeight(d) {
const t = constrain(1 - (d - 150) / 950, 0, 1);
return 1.1 + 18.5 * pow(t, 1.6);
}
// the second ink is rationed, and only ever rides a small drop
function inkColour(bias, small) {
const r = random();
if (small && r < 0.05) return "#9c3f21";
if (r < 0.05 + bias) return "#3f4a52";
return "#1b1a1c";
}
// the finest carry marks: a dot pressed by the nib, not a wash — they must stay crisp
function speck(x, y, r, col) {
brush.noFill();
brush.set("rotring", col, constrain(r * 1.5, 0.5, 2.6));
brush.line(x, y, x + 0.7, y + 0.5);
}
// a small wash goes darker than a large one, so every mass is built from overlapping lumps
function lump(x, y, r, col, op, spread) {
brush.fill(col, op);
brush.circle(x + randomGaussian(0, spread), y + randomGaussian(0, spread), r, true);
}
// a round droplet: damp rim for the heavy ones, then lumps piled into a body
function bead(x, y, r, col, op) {
brush.noStroke();
if (r > 9) {
brush.fillBleed(0.7, "out");
brush.fillTexture(0.32, 0.4);
brush.fill(col, constrain(op * 0.17, 10, 46));
brush.circle(x + random(-2, 2), y + random(-2, 2), r * 1.5, true);
}
brush.fillBleed(0.34, "out");
brush.fillTexture(0.48, 0.48);
if (r > 8) {
lump(x, y, r * 0.72, col, op, r * 0.26);
lump(x, y, r * 0.66, col, op, r * 0.3);
lump(x, y, r * 0.6, col, op, r * 0.18);
lump(x, y, r * 0.5, col, op, r * 0.12);
} else if (r > 4) {
lump(x, y, r * 0.8, col, op, r * 0.18);
lump(x, y, r * 0.7, col, op, r * 0.14);
lump(x, y, r * 0.56, col, op, r * 0.1);
} else {
brush.fill(col, op);
brush.circle(x, y, r, true);
brush.fill(col, op);
brush.circle(x + random(-0.5, 0.5), y + random(-0.5, 0.5), r * 0.8, true);
}
}
// a droplet still travelling: head forward, tail trailing back down the throw
function streak(x, y, r, ang, elong, col, op) {
const cx = Math.cos(ang);
const cy = Math.sin(ang);
const nx = -cy;
const ny = cx;
const tail = r * elong * 1.7;
brush.noStroke();
brush.fillBleed(0.34, "out");
brush.fillTexture(0.5, 0.5);
brush.fill(col, op);
brush.beginShape(0.72);
brush.vertex(x + cx * r * 0.95, y + cy * r * 0.95);
brush.vertex(x + nx * r * 0.82 + cx * r * 0.1, y + ny * r * 0.82 + cy * r * 0.1);
brush.vertex(x - cx * tail + nx * r * 0.2, y - cy * tail + ny * r * 0.2);
brush.vertex(x - cx * tail * 0.9 - nx * r * 0.17, y - cy * tail * 0.9 - ny * r * 0.17);
brush.vertex(x - nx * r * 0.82 + cx * r * 0.1, y - ny * r * 0.82 + cy * r * 0.1);
brush.endShape(true);
// the head carries the pigment; the tail is only what got away
brush.fillBleed(0.26, "out");
brush.fillTexture(0.5, 0.5);
brush.fill(col, constrain(op + 16, 90, 255));
brush.circle(x + cx * r * 0.16, y + cy * r * 0.16, r * 0.6, true);
}
// where the ink landed first: a damp bruise under an irregular body of piled lumps
function blot(x, y, r, skew) {
brush.noStroke();
brush.fillBleed(0.82, "out");
brush.fillTexture(0.26, 0.32);
brush.fill("#3f4a52", 30);
brush.circle(x + skew * 0.6, y + 7, r * 1.95, true);
// outer body: slate lumps crowding out to a torn rim
brush.fillBleed(0.44, "out");
brush.fillTexture(0.5, 0.5);
for (let i = 0; i < 7; i++) {
const a = random(0, 6.28);
const rr = constrain(randomGaussian(r * 0.5, r * 0.16), 0, r * 0.72);
lump(x + Math.cos(a) * rr + skew * 0.3, y + Math.sin(a) * rr,
constrain(randomGaussian(r * 0.5, r * 0.09), r * 0.28, r * 0.66),
"#3f4a52", constrain(randomGaussian(150, 20), 100, 210), r * 0.05);
}
// inner body: near-black, piled until the paper stops taking it
brush.fillBleed(0.3, "out");
brush.fillTexture(0.5, 0.5);
for (let i = 0; i < 13; i++) {
const a = random(0, 6.28);
const rr = constrain(randomGaussian(r * 0.26, r * 0.15), 0, r * 0.5);
lump(x + Math.cos(a) * rr, y + Math.sin(a) * rr - 1,
constrain(randomGaussian(r * 0.44, r * 0.08), r * 0.26, r * 0.56),
"#1b1a1c", constrain(randomGaussian(190, 18), 140, 240), r * 0.05);
}
// the heart, laid in crossed strokes: a wash of any opacity will not go this dark
brush.noStroke();
brush.fillBleed(0.2, "out");
brush.fill("#1b1a1c", 150);
brush.hatchStyle("pen", "#1b1a1c", 1.35);
brush.hatch(1.9, 36, { rand: 0.12, continuous: false });
brush.circle(x - skew * 0.2, y - 2, r * 0.58, true);
brush.hatch(2.2, -48, { rand: 0.16, continuous: false });
brush.circle(x + skew * 0.35, y + 1, r * 0.46, true);
brush.noHatch();
// a dry edge where the pool crept and stopped
brush.noFill();
brush.set("charcoal", "#1b1a1c", 1.2);
brush.line(x - r * 0.5, y + r * 0.34, x + r * 0.36, y + r * 0.64);
brush.line(x + r * 0.58, y - r * 0.2, x + r * 0.24, y + r * 0.52);
}
// the crown thrown off a landing blot
function crown(x, y, r, col, op) {
brush.noStroke();
brush.fillBleed(0.3, "out");
brush.fillTexture(0.52, 0.5);
brush.fill(col, op);
brush.circle(x, y, r, true);
if (r > 3) {
brush.fillBleed(0.22, "out");
brush.fill(col, constrain(op + 20, 90, 255));
brush.circle(x + random(-0.7, 0.7), y + random(-0.7, 0.7), r * 0.62, true);
}
}
function setup() {
createCanvas(800, 800, WEBGL);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background("#ece5d7");
// ---- dilute air, laid only inside the cone so the corners keep their paper
brush.noStroke();
brush.fillBleed(0.85, "out");
brush.fillTexture(0.26, 0.3);
brush.fill("#3f4a52", 20);
brush.circle(coneX(250, -20), coneY(250, -20), 172, true);
brush.fill("#3f4a52", 16);
brush.circle(coneX(430, 40), coneY(430, 40), 198, true);
brush.fill("#8a8272", 15);
brush.circle(coneX(650, -60), coneY(650, -60), 186, true);
brush.fill("#3f4a52", 11);
brush.circle(coneX(880, 70), coneY(880, 70), 210, true);
brush.fill("#8a8272", 13);
brush.circle(coneX(120, 30), coneY(120, 30), 128, true);
// ---- the whip: the brush's own arc leaving the corner, tapering as it lifts
brush.noFill();
brush.set("charcoal", "#3f4a52", 1.2);
brush.beginShape(0.5);
brush.vertex(-18, 810, 1.0);
brush.vertex(58, 754, 0.7);
brush.vertex(126, 716, 0.3);
brush.vertex(172, 700, 0.1);
brush.endShape(false);
brush.set("2H", "#8a8272", 0.8);
brush.beginShape(0.5);
brush.vertex(2, 830, 0.8);
brush.vertex(96, 792, 0.45);
brush.vertex(178, 766, 0.14);
brush.endShape(false);
// ---- dry mist, kept short and close to the axis so it reads as spray, not as foxing
brush.noFill();
brush.set("pen", "#8a8272", 0.7);
for (let i = 0; i < 46; i++) {
const d = constrain(randomGaussian(640, 210), 260, 1120);
const p = randomGaussian(0, 26 + 0.1 * d);
const x = coneX(d, p);
const y = coneY(d, p);
if (x < -20 || x > 820 || y < -20 || y > 820) continue;
brush.stroke(random() < 0.34 ? "#3f4a52" : "#8a8272");
brush.strokeWeight(0.45 + random(0, 0.5));
const len = constrain(randomGaussian(6, 3), 2, 13);
brush.line(x, y, x + DX * len, y + DY * len);
}
// ---- the sorted field: painted far-to-near, so the heavy drops land on top
const N = 84;
for (let i = 0; i < N; i++) {
const t = i / N;
const d = constrain(randomGaussian(200 + 810 * pow(1 - t, 1.7), 74), 130, 1200);
const p = randomGaussian(0, 30 + 0.13 * d);
const x = coneX(d, p);
const y = coneY(d, p);
if (x < -26 || x > 826 || y < -26 || y > 826) continue;
// drops near the axis carry more ink than the strays at the cone's lip
const off = constrain(1 - Math.abs(p) / (150 + 0.4 * d), 0.6, 1);
const r = constrain(randomGaussian(dropWeight(d) * off, dropWeight(d) * 0.22), 0.7, 22);
const col = inkColour(0.2 - 0.14 * (1 - t), r < 5.5);
const op = constrain(randomGaussian(238 - 40 * (1 - t), 22), 130, 255);
const ang = Math.atan2(coneY(d + 40, p * 1.04) - y, coneX(d + 40, p * 1.04) - x);
const elong = constrain(randomGaussian(0.9 + 1.1 * (1 - t), 0.5), 0.3, 3.1);
if (r < 1.9) speck(x, y, r, col);
else if (elong > 1.35 && r > 2.4) streak(x, y, r, ang, elong, col, op);
else bead(x, y, r, col, op);
}
// ---- five heavy drops hand-placed around the landing zone, each a different weight
bead(coneX(196, -58), coneY(196, -58), 15.6, "#1b1a1c", 248);
bead(coneX(268, 74), coneY(268, 74), 11.4, "#1b1a1c", 240);
bead(coneX(322, -96), coneY(322, -96), 8.6, "#3f4a52", 198);
bead(coneX(452, 116), coneY(452, 116), 7.8, "#1b1a1c", 244);
bead(coneX(150, 92), coneY(150, 92), 12.8, "#1b1a1c", 236);
streak(coneX(360, -34), coneY(360, -34), 6.8, -0.78, 2.2, "#1b1a1c", 250);
streak(coneX(560, 62), coneY(560, 62), 4.8, -0.72, 2.6, "#9c3f21", 224);
streak(coneX(700, -118), coneY(700, -118), 3.4, -0.86, 2.4, "#1b1a1c", 238);
// ---- the two blots, on the lower-left thirds crossing
blot(252, 556, 33, 6);
blot(316, 492, 21, -4);
// ---- their crowns: satellites thrown forward along the throw, hand-varied
crown(300, 522, 5.6, "#1b1a1c", 224);
crown(214, 604, 4.1, "#1b1a1c", 208);
crown(198, 512, 3.4, "#3f4a52", 182);
crown(292, 596, 2.6, "#1b1a1c", 216);
crown(340, 546, 3.9, "#1b1a1c", 198);
crown(178, 566, 2.2, "#9c3f21", 190);
crown(190, 492, 3.6, "#9c3f21", 210);
crown(370, 602, 2.5, "#9c3f21", 198);
crown(362, 466, 3.1, "#1b1a1c", 206);
crown(272, 452, 2.4, "#3f4a52", 176);
crown(356, 430, 1.8, "#1b1a1c", 212);
crown(150, 636, 1.9, "#1b1a1c", 194);
crown(388, 508, 1.5, "#1b1a1c", 200);
// ---- two drips, the only downward marks in the picture
brush.noFill();
brush.set("2B", "#1b1a1c", 1.0);
brush.beginShape(0.35);
brush.vertex(246, 588, 0.9);
brush.vertex(250, 626, 0.6);
brush.vertex(244, 652, 0.25);
brush.endShape(false);
brush.set("rotring", "#1b1a1c", 0.7);
brush.beginShape(0);
brush.vertex(318, 514, 0.8);
brush.vertex(322, 540, 0.4);
brush.endShape(false);
// ---- last darks: a hairline flick off each blot, then three fine far specks
brush.set("2B", "#1b1a1c", 0.8);
brush.line(280, 534, 336, 494);
brush.line(330, 478, 372, 452);
speck(coneX(940, 96), coneY(940, 96), 1.6, "#1b1a1c");
speck(coneX(1010, -104), coneY(1010, -104), 1.2, "#1b1a1c");
speck(coneX(830, -166), coneY(830, -166), 1.8, "#9c3f21");
speck(coneX(1080, 40), coneY(1080, 40), 1.0, "#1b1a1c");
}