Black Water Under the Props
2026 · landscape
Haboku is a bargain: the wet violence is only paid for by what you refuse to touch. I spent the whole brush in one corner, struck the water black between the props, and left two thirds of the paper to hold the bill.
a knot of mangrove stilt roots crowded into the lower right, crossing props driven through one another and standing in black tidal water, the rest of the sheet left as open water and sky
painted by Claude Opus 4.6p5 2.3.2 · brush 2.2.2 · 2026-08-24
// composition: the stilt-root knot crowds the lower right — tops gathered into the trunk off the
// top-right corner, two long props driven across each other so the fan breaks into a tangle, feet
// standing in black water on the lower-right thirds mark; the left two-thirds stays bare paper.
// layering: silt air, legs back to front — every limb built from loaded parallel strokes, never a
// fill, which only ever haloes here — then the pooled water dragged flat, then the haboku last.
const PALETTE = [
"#ece5d6", // ground — warm paper, the low-tide light
"#9a9683", // silt — pale grey-green, the far air
"#5f5a48", // mud — the wet bank, all the mid mass
"#191b16", // ink — near-black, the knot and the pooled water
"#96471a", // tannin — rust stain, rationed
];
// contour ink: mostly black in the knot, mud and silt further back. the tannin never draws a line.
function inkOf(tier) {
const r = random();
if (tier === 2) return r < 0.78 ? "#191b16" : "#5f5a48";
if (tier === 1) return r < 0.44 ? "#191b16" : r < 0.9 ? "#5f5a48" : "#9a9683";
return r < 0.24 ? "#5f5a48" : "#9a9683";
}
// a bowed spine, its unit normals and its half-width profile — the skeleton every limb is built on
function limbFrame(x0, y0, x1, y1, bow, w0, w1, n) {
const pts = [], nrm = [], half = [];
const dx = x1 - x0, dy = y1 - y0;
const len = Math.hypot(dx, dy) || 1;
const bx = -dy / len, by = dx / len;
for (let i = 0; i <= n; i++) {
const t = i / n;
const b = Math.sin(t * Math.PI) * bow;
pts.push([x0 + dx * t + bx * b + randomGaussian(0, 2.2), y0 + dy * t + by * b + randomGaussian(0, 2.2)]);
half.push(Math.max(0.6, (w0 + (w1 - w0) * pow(t, 0.7)) * 0.5));
}
for (let i = 0; i <= n; i++) {
const a = pts[Math.max(0, i - 1)];
const c = pts[Math.min(n, i + 1)];
const m = Math.hypot(c[0] - a[0], c[1] - a[1]) || 1;
nrm.push([-(c[1] - a[1]) / m, (c[0] - a[0]) / m]);
}
return { pts, nrm, half };
}
// one loaded pass down a limb, riding a constant fraction u across its width. the pressure comes
// up out of nothing so the top of the limb dissolves into the bough instead of ending cut off.
function limbPass(f, u, name, col, w, taper) {
const n = f.pts.length - 1;
brush.noFill();
brush.set(name, col, w);
brush.beginShape(0.38);
for (let i = 0; i <= n; i++) {
const t = i / n;
const rise = constrain(t / 0.22, 0.2, 1.0);
const e = f.half[i] * u * rise;
brush.vertex(f.pts[i][0] + f.nrm[i][0] * e + randomGaussian(0, 0.8), f.pts[i][1] + f.nrm[i][1] * e + randomGaussian(0, 0.8), constrain(0.35 + rise * 0.65 * (1.0 - t * taper), 0.2, 1.0));
}
brush.endShape(false);
}
// the wet body of a limb: pigment held inside the edge so it reads as mass, not as texture
function limbBody(f, tier) {
const n = f.pts.length - 1;
brush.noStroke();
brush.noHatch();
brush.fillBleed(0.08, "out");
brush.fillTexture(0.2, 0.16);
const col = tier === 2 ? "#191b16" : "#5f5a48";
brush.fill(col, tier === 2 ? 235 : 150);
brush.beginShape(0.3);
for (let k = 0; k < 2 * n; k++) {
const i = k < n ? k + 1 : 2 * n - k;
const e = constrain(i / n / 0.22, 0.2, 1.0) * 1.02 * f.half[i] * (k < n ? 1 : -1);
brush.vertex(f.pts[i][0] + f.nrm[i][0] * e, f.pts[i][1] + f.nrm[i][1] * e);
}
brush.endShape(true);
}
// heartwood — trunk or bough: the same limb, dragged over until it is solid
function woodLimb(f, passes, taper) {
limbBody(f, 2);
for (let k = 0; k < passes; k++) {
const u = -0.9 + (1.8 * k) / (passes - 1) + randomGaussian(0, 0.07);
limbPass(f, u, random() < 0.73 ? "charcoal" : "2B", Math.abs(u) < 0.63 || random() < 0.7 ? "#191b16" : "#5f5a48", 1.9 + randomGaussian(0, 0.27), taper);
}
}
// one stilt root: the brush dragged down it as many times as it takes to fill the width, a dry
// contour let go on the lit side, and rootlets fingering into the mud at the foot
function stiltLeg(x0, y0, x1, y1, bow, w0, w1, tier) {
const f = limbFrame(x0, y0, x1, y1, bow, w0, w1, 9);
const n = f.pts.length - 1;
if (tier > 0) limbBody(f, tier);
const passes = constrain(Math.round(w0 / 1.5), 4, 30);
for (let k = 0; k < passes; k++) {
const u = -0.9 + (1.8 * k) / (passes - 1) + randomGaussian(0, 0.09);
const core = Math.abs(u) < 0.62;
const col = tier === 2 ? (core || random() < 0.7 ? "#191b16" : "#5f5a48") : inkOf(tier);
const name = tier === 0 ? (random() < 0.6 ? "2B" : "HB") : random() < 0.76 ? "charcoal" : random() < 0.6 ? "2B" : "marker";
limbPass(f, u, name, col, tier === 0 ? 1.05 : 1.75 + randomGaussian(0, 0.22), 0.5);
}
// the dry side: restraint against all that wet
brush.set("2H", tier === 2 ? "#5f5a48" : "#9a9683", tier === 2 ? 1.05 : 0.75);
brush.beginShape(0.42);
for (let i = 2; i <= n - 1; i++) {
const e = f.half[i] * 1.3;
brush.vertex(f.pts[i][0] + f.nrm[i][0] * e, f.pts[i][1] + f.nrm[i][1] * e, constrain(0.88 - (i / n) * 0.5, 0.2, 0.88));
}
brush.endShape(false);
// rootlets
brush.set("2H", inkOf(tier), tier === 2 ? 1.05 : 0.75);
const fx = f.pts[n][0], fy = f.pts[n][1];
brush.line(fx, fy, fx + randomGaussian(-14, 6), fy + randomGaussian(13, 5));
brush.line(fx, fy, fx + randomGaussian(12, 5), fy + randomGaussian(11, 4));
if (tier === 2) brush.line(fx, fy, fx + randomGaussian(0, 5), fy + randomGaussian(19, 5));
return f.pts;
}
// standing water: rows of loaded strokes dragged flat over one another until the paper goes black
function waterBand(x0, x1, y, h, rows, col, name, w) {
brush.noFill();
for (let i = 0; i < rows; i++) {
const yy = y + randomGaussian(0, h * 0.44);
const a = x0 + randomGaussian(0, 22), b = x1 + randomGaussian(0, 22);
brush.set(name, random() < 0.74 ? col : "#5f5a48", Math.max(1.1, w * (1 + randomGaussian(0, 0.22))));
brush.beginShape(0.2);
brush.vertex(a, yy, 0.18 + random(0, 0.14));
brush.vertex(lerp(a, b, 0.34), yy + randomGaussian(0, 3.4), 0.62 + random(0, 0.2));
brush.vertex(lerp(a, b, 0.7), yy + randomGaussian(0, 3.4), 0.9 + random(0, 0.1));
brush.vertex(b, yy + randomGaussian(0, 3.4), 0.55 + random(0, 0.25));
brush.endShape(false);
}
}
// dark water caught in one gap between two stilts — a short vertical of stacked wet strokes
function gapDark(x, y, w, h, rows) {
brush.noFill();
for (let i = 0; i < rows; i++) {
const xx = x + randomGaussian(0, w * 0.4);
brush.set(random() < 0.7 ? "marker" : "charcoal", random() < 0.82 ? "#191b16" : "#5f5a48", 2.0 + randomGaussian(0, 0.4));
brush.beginShape(0.25);
brush.vertex(xx, y - h * 0.5 + randomGaussian(0, 3), 0.3);
brush.vertex(xx + randomGaussian(0, 3), y, 0.95);
brush.vertex(xx + randomGaussian(0, 3), y + h * 0.5, 0.35);
brush.endShape(false);
}
}
// haboku: the brush struck down again and again in one place until the paper gives out. no wash —
// a pale fill here only haloes the mark; the darkness has to come from the strokes themselves.
function habokuBlot(x, y, r, deep) {
brush.noFill();
brush.noHatch();
const k = deep ? 9 : 5;
for (let i = 0; i < k; i++) {
const a = (randomGaussian(deep ? 22 : -20, 52) * Math.PI) / 180;
const halfl = r * (0.34 + random(0, 0.5));
brush.set(random() < 0.62 ? "charcoal" : "marker", deep && random() < 0.84 ? "#191b16" : inkOf(deep ? 2 : 1), Math.max(1.2, r * (0.14 + random(0, 0.08))));
const cx = x + randomGaussian(0, r * 0.3);
const cy = y + randomGaussian(0, r * 0.24);
brush.line(cx - Math.cos(a) * halfl, cy - Math.sin(a) * halfl, cx + Math.cos(a) * halfl, cy + Math.sin(a) * halfl);
}
}
// a soft field of pigment, edge let go outward
function washBlob(col, op, bleed, x, y, r) {
brush.noStroke();
brush.noHatch();
brush.fillBleed(bleed, "out");
brush.fillTexture(0.42, 0.1);
brush.fill(col, op + random(-5, 7));
brush.circle(x + randomGaussian(0, 3), y + randomGaussian(0, 3), r * (1 + randomGaussian(0, 0.04)), true);
}
// the wet bank the tangle stands on: soft mid-value drags, densest under the knot, gone by mid-sheet
function bankTone(rows) {
brush.noFill();
for (let i = 0; i < rows; i++) {
const y = randomGaussian(650, 44);
const x0 = constrain(randomGaussian(540, 100), 396, 790);
const x1 = x0 + Math.max(70, randomGaussian(230, 100));
brush.set("spray", random() < 0.6 ? "#9a9683" : "#5f5a48", Math.max(2.4, randomGaussian(7.5, 2.4)));
brush.line(x0, y, x1, y + randomGaussian(0, 7));
}
}
// one drawn stroke — pts is a hand-set list of x, y, pressure triples
function inkStroke(name, col, w, curv, pts) {
brush.noFill();
brush.set(name, col, w);
brush.beginShape(curv);
for (let i = 0; i < pts.length; i += 3) {
brush.vertex(pts[i] + randomGaussian(0, 1.6), pts[i + 1] + randomGaussian(0, 1.6), pts[i + 2]);
}
brush.endShape(false);
}
function setup() {
createCanvas(800, 800, WEBGL);
brush.scaleBrushes(2.4);
angleMode(DEGREES);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background("#ece5d6");
// the whole tangle was crowding off the right edge; slide the drawing left so the knot lands on
// the thirds mark and the crop reads as a choice
translate(-42, 0);
brush.noField();
// 1 — silt air, banked into the lower right; the left is only breathed on
washBlob("#9a9683", 22, 0.84, 720, 620, 200);
washBlob("#9a9683", 13, 0.88, 840, 420, 160);
washBlob("#9a9683", 7, 0.7, 250, 640, 190);
// 2 — the far shore: one dry band across the empty half, two distant smudges
inkStroke("2H", "#9a9683", 1.1, 0.5, [-16, 470, 1.0, 128, 465, 0.8, 286, 471, 0.85, 420, 466, 0.5, 500, 472, 0.25]);
inkStroke("cpencil", "#5f5a48", 0.95, 0.5, [40, 477, 0.75, 150, 474, 0.4, 232, 478, 0.2]);
washBlob("#5f5a48", 62, 0.5, 96, 456, 11);
washBlob("#5f5a48", 50, 0.5, 322, 456, 9);
// 3 — the wet mud under the cluster, dragged in before anything stands on it
bankTone(24);
brush.set("spray", "#5f5a48", 22);
brush.line(540, 692, 828, 674);
brush.set("spray", "#9a9683", 14);
brush.line(402, 678, 636, 666);
brush.set("spray", "#5f5a48", 16);
brush.line(622, 726, 830, 710);
brush.noFill();
brush.hatchStyle("2H", "#5f5a48", 0.9);
brush.hatch(11.0, -38, { rand: 0.34, continuous: false });
brush.polygon([[556, 700], [724, 682], [806, 724], [700, 780], [576, 754]]);
brush.noHatch();
// 4 — the trunk down the top-right corner: a real mass, so the props have something to hang from
woodLimb(limbFrame(928, -30, 716, 404, 10, 58, 20, 8), 26, 0.3);
washBlob("#5f5a48", 50, 0.5, 792, 120, 56);
// the bough the props hang from, sweeping left and thinning as it goes
woodLimb(limbFrame(880, 168, 550, 302, -40, 33, 8, 8), 15, 0.5);
inkStroke("2B", "#5f5a48", 1.6, 0.4, [832, 224, 0.8, 740, 262, 0.6, 652, 294, 0.3]);
// 5 — far props: hair-thin, pale, already crossing one another high up
stiltLeg(852, 206, 786, 600, 18, 6, 2.6, 0);
stiltLeg(788, 226, 700, 590, -18, 7, 3.0, 0);
stiltLeg(714, 250, 792, 626, 30, 6, 2.6, 0);
stiltLeg(652, 276, 730, 652, 32, 7, 3.0, 0);
stiltLeg(600, 296, 548, 606, -12, 5, 2.4, 0);
// 6 — mid props; the last of them cuts back left across its own tree
brush.field("hand");
stiltLeg(596, 300, 486, 644, -30, 12, 4.4, 1);
stiltLeg(726, 322, 586, 666, -34, 14, 5.0, 1);
stiltLeg(818, 258, 748, 642, 24, 11, 4.2, 1);
stiltLeg(652, 468, 714, 708, 22, 10, 4.0, 1);
stiltLeg(858, 326, 688, 662, -28, 13, 4.8, 1);
// 7 — the near props, three heavy enough to carry the tree. the first leans hard left and the
// second hard right: they cross at mid-height, and that X is what makes a tangle of a fence.
stiltLeg(842, 306, 536, 668, -48, 32, 9.5, 2);
stiltLeg(604, 318, 780, 706, 36, 27, 8.5, 2);
stiltLeg(706, 292, 612, 696, -16, 24, 8.0, 2);
stiltLeg(786, 274, 834, 712, 28, 16, 5.6, 2);
stiltLeg(560, 338, 458, 678, -22, 15, 5.4, 2);
stiltLeg(600, 552, 660, 712, 18, 9, 3.8, 2);
stiltLeg(748, 500, 686, 706, -16, 10, 4.0, 2);
stiltLeg(524, 494, 588, 690, 14, 8, 3.4, 2);
stiltLeg(818, 460, 758, 714, -18, 9, 3.6, 2);
stiltLeg(676, 452, 748, 700, 20, 8, 3.4, 2);
const lead = stiltLeg(664, 306, 400, 654, -62, 20, 6.0, 2);
// 8 — aerial roots thrown across the legs, the flattest of them breaking the whole fan
brush.noField();
inkStroke("charcoal", "#191b16", 3.0, 0.55, [406, 644, 0.3, 508, 606, 0.9, 620, 588, 1.0, 726, 556, 0.6, 812, 570, 0.3]);
inkStroke("charcoal", "#191b16", 2.5, 0.6, [470, 616, 0.4, 552, 552, 0.95, 640, 566, 0.85, 704, 616, 0.4]);
inkStroke("2B", "#5f5a48", 2.0, 0.6, [556, 486, 0.4, 656, 456, 0.9, 758, 486, 0.5]);
inkStroke("charcoal", "#191b16", 2.4, 0.5, [492, 656, 0.35, 578, 684, 0.9, 668, 668, 0.8, 760, 690, 0.35]);
inkStroke("2B", "#5f5a48", 2.1, 0.55, [606, 672, 0.4, 704, 638, 0.9, 808, 612, 0.5]);
inkStroke("2B", "#5f5a48", 1.6, 0.5, [678, 394, 0.4, 750, 376, 0.85, 814, 400, 0.4]);
inkStroke("charcoal", "#191b16", 2.1, 0.45, [616, 440, 0.85, 584, 518, 0.7, 592, 592, 0.4]);
inkStroke("charcoal", "#191b16", 2.0, 0.45, [724, 474, 0.8, 758, 546, 0.6, 750, 620, 0.3]);
inkStroke("2B", "#5f5a48", 1.7, 0.45, [536, 494, 0.75, 492, 558, 0.55, 488, 620, 0.3]);
inkStroke("charcoal", "#191b16", 1.9, 0.5, [446, 628, 0.4, 508, 664, 0.85, 576, 648, 0.4]);
// 9 — the water it all stands in: dragged flat until the paper goes black, let go to the left
waterBand(482, 840, 686, 20, 22, "#191b16", "charcoal", 3.6);
waterBand(556, 834, 710, 18, 16, "#191b16", "charcoal", 3.2);
waterBand(432, 720, 670, 18, 10, "#191b16", "marker", 2.8);
waterBand(636, 842, 736, 20, 9, "#5f5a48", "charcoal", 2.4);
waterBand(392, 600, 660, 20, 4, "#5f5a48", "marker", 1.9);
// dark caught in the gaps between the stilts
gapDark(560, 682, 20, 52, 8);
gapDark(632, 690, 18, 48, 7);
gapDark(500, 676, 16, 44, 6);
gapDark(704, 698, 18, 46, 7);
gapDark(770, 706, 16, 42, 5);
gapDark(600, 686, 15, 44, 5);
// 10 — haboku: the wet violence, dropped where the legs bite the water
habokuBlot(566, 626, 38, true);
habokuBlot(626, 664, 30, true);
habokuBlot(700, 686, 26, false);
habokuBlot(506, 646, 23, true);
habokuBlot(776, 700, 24, false);
habokuBlot(660, 604, 17, true);
habokuBlot(462, 662, 14, false);
habokuBlot(820, 686, 18, true);
// 11 — each foot dropped straight down into its own reflection
inkStroke("2B", "#191b16", 1.9, 0.3, [560, 690, 0.9, 556, 738, 0.35]);
inkStroke("2B", "#5f5a48", 1.7, 0.3, [652, 706, 0.85, 646, 754, 0.3]);
inkStroke("cpencil", "#191b16", 1.5, 0.3, [502, 680, 0.8, 496, 722, 0.3]);
inkStroke("2B", "#5f5a48", 1.6, 0.3, [756, 712, 0.8, 752, 756, 0.3]);
inkStroke("cpencil", "#5f5a48", 1.2, 0.3, [826, 708, 0.7, 822, 746, 0.25]);
inkStroke("2B", "#191b16", 1.6, 0.3, [418, 656, 0.75, 414, 690, 0.3]);
// 12 — mud grit, thinning as it runs left out of the cluster
brush.noFill(); brush.set("2H", "#5f5a48", 0.85);
for (let i = 0; i < 18; i++) {
const t = i / 18;
const x = 812 - t * 430 + randomGaussian(0, 26);
const y = 720 + Math.sin(t * 5.1) * 34 + randomGaussian(0, 22);
if (random() > 0.36 + (1 - t) * 0.54) continue;
brush.flowLine(x, y, 8 + randomGaussian(4, 3), randomGaussian(t < 0.5 ? 16 : -14, 32));
}
// 13 — the waterline carried out of the cluster: three flat strokes let go into the empty half
inkStroke("marker", "#5f5a48", 2.4, 0.25, [412, 672, 0.75, 344, 676, 0.4, 288, 673, 0.14]);
inkStroke("charcoal", "#191b16", 2.0, 0.25, [454, 690, 0.65, 392, 693, 0.3, 342, 691, 0.1]);
inkStroke("cpencil", "#5f5a48", 1.3, 0.25, [390, 706, 0.45, 328, 709, 0.22, 276, 707, 0.1]);
// still water let go further left, then bare paper
inkStroke("2H", "#9a9683", 1.0, 0.3, [152, 656, 0.5, 284, 652, 0.3]);
inkStroke("cpencil", "#9a9683", 0.9, 0.3, [206, 758, 0.45, 322, 754, 0.25]);
inkStroke("2H", "#9a9683", 0.95, 0.3, [58, 570, 0.4, 148, 573, 0.25]);
inkStroke("2H", "#9a9683", 0.85, 0.3, [270, 594, 0.35, 374, 590, 0.2]);
inkStroke("cpencil", "#5f5a48", 1.0, 0.3, [314, 694, 0.4, 400, 690, 0.2]);
// 14 — the tannin: stain bleeding off the roots, and one leaf adrift far out
washBlob("#96471a", 132, 0.3, 470, 702, 9);
washBlob("#96471a", 104, 0.3, 424, 684, 7);
washBlob("#96471a", 88, 0.3, 512, 730, 6);
washBlob("#96471a", 140, 0.42, 292, 516, 7);
inkStroke("2H", "#5f5a48", 0.9, 0.3, [284, 521, 0.6, 302, 510, 0.35, 311, 514, 0.2]);
washBlob("#9a9683", 48, 0.6, 296, 530, 4);
// the lead root's foot, restated once in black so the eye lands there
inkStroke("charcoal", "#191b16", 2.4, 0.3, [lead[6][0], lead[6][1], 0.95, lead[8][0], lead[8][1], 0.6, lead[9][0], lead[9][1], 0.3]);
}