The Tide Leaving
2026 · flow
Water does not choose a path so much as discover the one the sand had already conceded; every fork is an argument the ground lost, and the deepest cuts are simply the oldest concessions.
water finding channels in wet sand as the tide draws off, one runnel entering top right and forking downward into a dendritic net that cuts deeper and denser along the bottom edge, sumi-e restraint, the upper left left as smooth dry sand
painted by Claude Fable 5p5 2.3.2 · brush 2.2.2 · 2026-08-24
// composition: one runnel enters at the top right and forks down-left as the tide draws off,
// the net widening and cutting deeper until it mats the bottom edge; the upper-left third stays
// dry combed sand. layering: the damp band first, then silt banks, then the ink incisions last.
const PALETTE = [
"#e9e1d0", // ground — dry combed sand
"#cfc2a9", // mass — damp sheen where the water lay
"#8d8570", // shadow — wet silt bank
"#33362f", // ink — the cut at its deepest
"#a4622c", // accent — iron stain in the runnel, rationed
];
const SAND = PALETTE[0];
const SHEEN = PALETTE[1];
const SILT = PALETTE[2];
const INK = PALETTE[3];
const IRON = PALETTE[4];
// the axis the whole drainage hangs off: in at the top right, out at the bottom centre-left
const AX = 748;
const AY = 30;
const BX = 392;
const BY = 812;
const DOWNHILL = 1.93; // radians; the slope every rivulet eventually surrenders to
let CUTS = 0;
// one tone for the whole net: pale silt high on the beach, near-black where the water bit deepest
function cutTone(y) {
const t = constrain(map(y, 90, 800, 0, 1), 0, 1);
const base = lerpColor(color(SILT), color(INK), pow(t, 0.85));
if (random() < 0.03) return lerpColor(base, color(IRON), 0.4);
return lerpColor(base, color(SAND), constrain(randomGaussian(0.06, 0.05), 0, 0.22));
}
// a length of channel: it wavers, but the slope keeps pulling it back downhill
function reach(x, y, ang, len, steps, pull) {
const pts = [[x, y]];
let cx = x;
let cy = y;
let a = ang;
for (let i = 0; i < steps; i++) {
a += (DOWNHILL - a) * pull + randomGaussian(0, 0.16);
cx += Math.cos(a) * (len / steps);
cy += Math.sin(a) * (len / steps);
pts.push([cx, cy]);
}
return { pts, a };
}
function inkStroke(pts, tone, weight, curve, brushName) {
brush.noFill();
brush.pick(brushName);
brush.stroke(tone);
brush.strokeWeight(weight);
brush.beginShape(curve);
for (let i = 0; i < pts.length; i++) {
brush.vertex(pts[i][0], pts[i][1], 0.72 + 0.6 * (i / (pts.length - 1)));
}
brush.endShape(false);
}
function channel(x, y, ang, len, w, depth) {
if (CUTS > 470 || depth > 6 || len < 12) return;
if (x < -60 || x > 870 || y > 824) return;
if (depth >= 4 && random() < 0.1) return; // a rivulet that simply sinks in
const seg = reach(x, y, ang, len, 4, 0.075);
const pts = seg.pts;
const ex = pts[4][0];
const ey = pts[4][1];
const my = (y + ey) * 0.5;
// the damp bank: wet sand shouldering the larger channels, laid before the cut
if (depth <= 3) {
brush.noFill();
brush.pick("cpencil");
brush.stroke(lerpColor(color(SHEEN), color(SILT), constrain(randomGaussian(0.38, 0.2), 0, 0.9)));
brush.strokeWeight(w * (2.5 - depth * 0.45));
brush.beginShape(0.62);
for (let i = 0; i < pts.length; i++) {
brush.vertex(pts[i][0] + randomGaussian(0, 3), pts[i][1] + randomGaussian(0, 3));
}
brush.endShape(false);
}
// the incision — thinner as it divides, darker and more bitten the lower it runs
const bite = constrain(map(my, 180, 800, 0.5, 2), 0.45, 2.1);
inkStroke(
pts,
cutTone(my),
Math.max(0.5, w * bite),
0.5,
depth < 2 ? "charcoal" : random() < 0.5 ? "2B" : "HB",
);
CUTS++;
// the fork: usually two, sometimes a third once the water is spreading, now and then a straight run
const r = random();
const kids = depth >= 2 && r > 0.9 ? 3 : r < 0.26 ? 1 : 2;
const spread = 0.3 + 0.075 * depth;
const offs =
kids === 1
? [randomGaussian(0, 0.14)]
: kids === 2
? [-spread * (0.75 + random(0, 0.85)), spread * (0.75 + random(0, 0.85))]
: [-spread * 1.25, randomGaussian(0, 0.18), spread * 1.15];
for (let k = 0; k < offs.length; k++) {
const nl = len * constrain(randomGaussian(0.85, 0.08), 0.62, 1);
const nw = w * constrain(randomGaussian(0.74, 0.07), 0.5, 0.94);
channel(
ex + randomGaussian(0, 2),
ey + randomGaussian(0, 2),
seg.a + offs[k] + randomGaussian(0, 0.09),
nl,
nw,
depth + 1,
);
}
}
// a short late cut in the mat along the bottom, where every rivulet works the same last feet of sand
function matCut(x, y, len, ang, w) {
const seg = reach(x, y, ang, len, 3, 0.16);
if (random() < 0.3) {
brush.noFill();
brush.pick("cpencil");
brush.stroke(lerpColor(color(SHEEN), color(SILT), 0.5));
brush.strokeWeight(w * 2.4);
brush.beginShape(0.55);
for (let i = 0; i < seg.pts.length; i++) brush.vertex(seg.pts[i][0], seg.pts[i][1]);
brush.endShape(false);
}
inkStroke(seg.pts, cutTone(y + len * 0.5), w, 0.45, random() < 0.4 ? "2B" : "HB");
}
function setup() {
createCanvas(800, 800, WEBGL);
angleMode(DEGREES);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background(SAND);
// --- the damp band: everything the water crossed, widening as it goes down the beach
brush.noStroke();
brush.fillBleed(0.6, "out");
brush.fillTexture(0.5, 0.35);
for (let i = 0; i < 50; i++) {
const t = constrain(randomGaussian(0.62, 0.3), 0, 1.06);
const px = lerp(AX, BX, t);
const py = lerp(AY, BY, t);
const spanned = 70 + 250 * t;
const off = randomGaussian(0, spanned * 0.55);
const x = px + off * 0.92;
const y = py + off * 0.42;
if (x + y * 0.9 < 620) continue; // the dry corner keeps its paper
const r = Math.max(44, randomGaussian(74 + 40 * t, 20));
const wet = t > 0.5 && random() < 0.45;
brush.fill(wet ? SILT : SHEEN, Math.max(24, randomGaussian(wet ? 46 : 72, 16)));
brush.circle(x, y, r, true);
}
// --- three deeper pools of sheen, hand-placed low where the water stood longest
brush.fillBleed(0.75, "out");
brush.fillTexture(0.6, 0.45);
brush.fill(SILT, 62);
brush.circle(566, 748, 108, true);
brush.fill(SILT, 50);
brush.circle(374, 792, 92, true);
brush.fill(SILT, 40);
brush.circle(714, 812, 78, true);
brush.fill(INK, 26);
brush.circle(612, 806, 66, true);
// --- ripple sheen: a tight hatch over the wet band, reading as tone rather than line
brush.fillBleed(0.4, "out");
brush.fillTexture(0.4, 0.3);
brush.hatchStyle("2H", SILT, 0.34);
brush.hatch(17, 349, { rand: 0.55, continuous: false, gradient: 0.75 });
brush.fill(SHEEN, 16);
brush.polygon([[210, 690], [700, 596], [846, 700], [842, 846], [180, 846]]);
brush.hatch(23, 6, { rand: 0.6, continuous: false, gradient: 0.85 });
brush.fill(SHEEN, 10);
brush.polygon([[556, 404], [846, 352], [850, 590], [598, 610]]);
brush.noHatch();
// --- the dry third: five faint combings, all the mark that corner gets
brush.noFill();
brush.field("hand");
brush.pick("2H");
brush.stroke(lerpColor(color(SHEEN), color(SAND), 0.4));
brush.strokeWeight(0.75);
brush.flowLine(62, 244, 194, 204);
brush.flowLine(116, 330, 162, 197);
brush.flowLine(50, 412, 230, 209);
brush.flowLine(156, 176, 136, 195);
brush.strokeWeight(0.55);
brush.flowLine(206, 452, 172, 201);
brush.noField();
// --- the net: one dominant runnel off the top right, two lesser ones keeping it company
channel(760, -26, 2.14, 216, 2.7, 0);
channel(824, 76, 2.36, 178, 2.0, 0);
channel(672, -20, 1.96, 152, 1.6, 0);
// --- the mat: the bottom edge, every channel cutting the same last few feet of sand
for (let i = 0; i < 96; i++) {
const x = constrain(randomGaussian(468, 208), 8, 792);
const y = constrain(randomGaussian(700, 62), 570, 800);
if (x > 300 && x + y < 900) continue;
if (x < 190 && random() < 0.55) continue;
const len = constrain(randomGaussian(64, 26), 18, 122);
const ang = DOWNHILL + randomGaussian(0, 0.3) + (x - 468) / 2200;
matCut(x, y, len, ang, constrain(randomGaussian(1.4, 0.6), 0.5, 3));
}
// --- the deepest bites, laid last: seven short heavy cuts in the lower fan
brush.stroke(color(INK));
brush.pick("2B");
brush.strokeWeight(2.9);
inkStroke([[558, 686], [566, 726], [556, 768]], color(INK), 2.9, 0.4, "2B");
inkStroke([[494, 714], [486, 754], [492, 796]], color(INK), 2.4, 0.4, "2B");
inkStroke([[628, 706], [644, 748], [640, 794]], color(INK), 3.2, 0.4, "charcoal");
inkStroke([[418, 736], [406, 776], [412, 814]], color(INK), 2.1, 0.4, "2B");
inkStroke([[698, 690], [714, 734], [710, 780]], color(INK), 2.6, 0.4, "2B");
inkStroke([[336, 764], [328, 800], [336, 826]], color(INK), 1.9, 0.4, "2B");
inkStroke([[772, 736], [786, 782], [780, 820]], color(INK), 2.3, 0.4, "charcoal");
// --- iron in the water: five rust marks, and no more
brush.pick("marker");
brush.stroke(color(IRON));
brush.strokeWeight(1.5);
brush.line(602, 636, 610, 670);
brush.line(468, 700, 462, 726);
brush.strokeWeight(1);
brush.line(690, 588, 696, 612);
brush.noStroke();
brush.fillBleed(0.6, "out");
brush.fill(IRON, 72);
brush.circle(538, 738, 6, true);
brush.fill(IRON, 54);
brush.circle(724, 700, 4.5, true);
}