Harbour, Almost Nothing
2026 · nocturne
I wanted to find out how little light a picture can be built from, and whether a row of lamps can hold a whole wall of dark in place. The mist is the bare ground; everything else is night pressed down over it.
a nocturne: one low band of harbour lamps smeared into black water, a few masts implied, mist eating the far shore, everything else graded dark
painted by Kimi K2.6p5 2.3.2 · brush 2.2.2 · 2026-08-25
// composition: one band of harbour lamps just below the middle, its weight on the right third; above it
// nothing but graded dark, a few masts dissolving into it, and mist standing where the far shore should be.
// layering: the ground is the mist itself — the picture is made by pressing night down over it, densest at
// the top edge and in the near water; then the shore smudge and the masts, the mist rolled back in over
// the left, and last the lamps and the gold they leave smeared on the water.
const PALETTE = [
"#1a272c", // the ground: misty night — the paper of this picture
"#3d545a", // pale slate, the mist and the glow behind the lamps
"#0d1519", // night, laid over the ground to push it back
"#080c0e", // ink, the last darks
"#d9a24e", // lamp gold — the only incident, rationed
"#7d5f31", // dim gold, the halos and the drowned reflections
];
const BAND_Y = 452; // the lamps
const SHORE_Y = 436; // the bank they stand on
function toneMix(a, b, t) {
return lerpColor(color(a), color(b), constrain(t, 0, 1));
}
// how much of a lamp survives: mist eats the left, the right third carries the weight
function lampForce(x) {
const clear = constrain((x - 150) / 320, 0, 1);
const d = (x - 552) / 195;
return clear * (0.24 + 0.76 / (1 + d * d));
}
// pigment goes on in passes; one pass is a breath, four is weather
function tonePool(x, y, r, col, n, bleed) {
brush.noStroke();
brush.fillBleed(bleed, "out");
brush.fillTexture(0.4, 0.5);
for (let k = 0; k < n; k++) {
brush.fill(col, 255);
brush.circle(x + randomGaussian(0, 4), y + randomGaussian(0, 3), r * (1 + randomGaussian(0, 0.06)), true);
}
}
function toneSlab(x, y, w, h, col, n, bleed) {
brush.noStroke();
brush.fillBleed(bleed, "out");
brush.fillTexture(0.38, 0.46);
for (let k = 0; k < n; k++) {
brush.fill(col, 255);
brush.rect(x + randomGaussian(0, 4), y + randomGaussian(0, 3), w, h);
}
}
// light does not pool in this medium, it lies down flat: mist and glow go on as long low strokes
function mistDrag(x0, x1, y, col, w) {
brush.set(random() < 0.5 ? "2B" : "marker", col, w);
brush.line(x0, y + randomGaussian(0, 1.6), x1, y + randomGaussian(0, 3.4));
}
// a lamp: reach, halo, core — the core built up until the gold arrives
function lampMark(x, y, s, force) {
brush.noStroke();
brush.fillTexture(0.34, 0.5);
brush.fillBleed(0.34, "out");
brush.fill(PALETTE[5], 200);
brush.circle(x + randomGaussian(0, 2), y + randomGaussian(0, 1.6), s * 2.1, true);
brush.fillBleed(0.24, "out");
for (let k = 0; k < (force > 0.7 ? 2 : 1); k++) {
brush.fill(PALETTE[5], 255);
brush.circle(x + randomGaussian(0, 1), y + randomGaussian(0, 0.8), s * 1.15, true);
}
brush.fillBleed(0.16, "out");
const core = 2 + Math.round(force * 4);
for (let k = 0; k < core; k++) {
brush.fill(PALETTE[4], 255);
brush.circle(x + randomGaussian(0, 0.6), y + randomGaussian(0, 0.5), s * (0.5 + random(0, 0.1)), true);
}
if (force > 0.78) {
brush.fillBleed(0.1, "out");
for (let k = 0; k < 2; k++) {
brush.fill(PALETTE[4], 255);
brush.circle(x + randomGaussian(0, 0.4), y, s * 0.26, true);
}
}
}
// the lamp coming apart on the water: a few broken dashes, wandering as they sink
function lampSmear(x, s, force) {
let y = BAND_Y + random(8, 16);
const reach = min(BAND_Y + 40 + 200 * force, 690);
let guard = 0;
while (y < reach && guard < 14) {
guard++;
const sink = (y - BAND_Y) / 250;
const len = max(7, random(16, 44) * (1 - sink * 0.4));
brush.set(
random() < 0.45 ? "marker" : "2B",
toneMix(PALETTE[4], PALETTE[2], 0.2 + sink * 1.1 + random(0, 0.14)),
max(0.7, (1.5 + s * 0.1) * (1 - sink * 0.45) + randomGaussian(0, 0.2))
);
brush.line(
x + randomGaussian(0, 1.4 + sink * 6),
y,
x + randomGaussian(0, 2 + sink * 8),
y + len
);
y += len + random(14, 42);
}
// where the light first touches the water
if (force > 0.6) {
brush.noStroke();
brush.fillBleed(0.2, "out");
brush.fill(PALETTE[5], 255);
brush.circle(x + randomGaussian(0, 1.6), BAND_Y + 13, s * 0.9, true);
}
}
// a mast: a thin dark line that gives up as it climbs
function mastStroke(x, top, lean, weight) {
const segs = 7;
for (let i = 0; i < segs * 2; i++) {
const j = i % segs;
const t0 = j / segs;
const t1 = (j + 1) / segs;
brush.set(
j < 2 ? "charcoal" : j < 4 ? "2B" : "2H",
toneMix(PALETTE[3], PALETTE[0], 0.1 + t1 * 0.9),
max(0.4, min(1.05, weight * (1 - t1 * 0.6)) + randomGaussian(0, 0.07))
);
brush.line(
x + lean * t0 * t0 + randomGaussian(0, 1),
lerp(SHORE_Y + 8, top, t0),
x + lean * t1 * t1 + randomGaussian(0, 1),
lerp(SHORE_Y + 8, top, t1)
);
}
}
function setup() {
createCanvas(800, 800, WEBGL);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background(PALETTE[0]);
// 1 — night pressed down over the mist: heaviest along the top edge, letting go near the shore
for (let row = 0; row < 9; row++) {
const t = row / 8;
const y = lerp(-40, 400, t);
const n = max(0, Math.round(lerp(4.2, 0.15, t) + randomGaussian(0, 0.4)));
if (n === 0) continue;
for (let i = 0; i < 5; i++) {
tonePool(
i * 192 - 42 + randomGaussian(0, 26),
y + randomGaussian(0, 10),
randomGaussian(122, 12),
row < 3 ? PALETTE[3] : PALETTE[2],
n,
0.3
);
}
}
toneSlab(-60, -90, 320, 150, PALETTE[3], 2, 0.3);
toneSlab(240, -90, 320, 150, PALETTE[3], 2, 0.3);
toneSlab(520, -90, 340, 150, PALETTE[3], 2, 0.3);
// the left half of the sky is left as it was mixed — one breath over it, no more
tonePool(150, 250, 130, PALETTE[2], 1, 0.28);
// 2 — the far shore: a smudge with a few blocks in it, gone entirely at the left
toneSlab(300, 412, 250, 24, PALETTE[2], 2, 0.3);
toneSlab(516, 408, 300, 28, PALETTE[2], 2, 0.3);
const blocks = [[452, 22], [498, 15], [566, 26], [612, 13], [676, 19], [742, 12]];
for (let i = 0; i < blocks.length; i++) {
toneSlab(blocks[i][0], SHORE_Y - blocks[i][1], 18 + random(0, 30), blocks[i][1], random() < 0.5 ? PALETTE[2] : PALETTE[3], 2, 0.24);
}
// the bank itself, a line that keeps losing its nerve
for (let i = 0; i < 12; i++) {
const sx = 292 + i * 45 + randomGaussian(0, 7);
brush.set(
random() < 0.4 ? "charcoal" : "2B",
toneMix(PALETTE[3], PALETTE[0], constrain(0.2 + (800 - sx) / 700, 0.1, 0.95)),
max(0.45, randomGaussian(0.8, 0.18))
);
brush.line(sx, SHORE_Y + randomGaussian(0, 1.6), sx + random(18, 46), SHORE_Y + randomGaussian(0, 1.6));
}
// 4 — the water: heavier than the air, and heavier still as it comes forward
for (let row = 0; row < 8; row++) {
const t = row / 7;
const y = lerp(BAND_Y + 34, 700, t);
const n = max(1, Math.round(lerp(0.8, 3.4, t) + randomGaussian(0, 0.4)));
for (let i = 0; i < 5; i++) {
tonePool(
i * 192 - 42 + randomGaussian(0, 26),
y + randomGaussian(0, 10),
randomGaussian(120, 12),
t > 0.55 ? PALETTE[3] : PALETTE[2],
n,
0.3
);
}
}
// a few flat strokes lying on the water, shorter and fainter toward the left
for (let i = 0; i < 22; i++) {
const wy = BAND_Y + 16 + Math.abs(randomGaussian(0, 104));
const wx = randomGaussian(420, 210);
const sink = constrain((wy - BAND_Y) / 300, 0, 1);
brush.set(
random() < 0.5 ? "2H" : "2B",
toneMix(PALETTE[1], PALETTE[2], 0.45 + sink * 0.5),
max(0.5, randomGaussian(1.1, 0.35))
);
brush.line(wx, wy, wx + random(50, 190), wy + randomGaussian(0, 1.8));
}
// 5 — mist rolled back in over the left, in front of everything but the lamps
for (let i = 0; i < 96; i++) {
const y = randomGaussian(448, 36);
const fade = constrain(1 - Math.abs(y - 448) / 82, 0, 1);
const x0 = random() < 0.55 ? random(-60, -20) : randomGaussian(110, 90);
const x1 = x0 + random(40, 300) * (0.35 + fade * 0.85);
mistDrag(
x0,
x1,
y,
toneMix(PALETTE[2], PALETTE[1], 0.16 + fade * 0.52 * (0.65 + random(0, 0.5))),
max(0.8, randomGaussian(1.7, 0.6) * (0.55 + fade * 0.6))
);
}
// a little of it drifting further out, thinner every time
for (let i = 0; i < 12; i++) {
const y = randomGaussian(452, 36);
const x0 = randomGaussian(310, 100);
mistDrag(x0, x0 + random(50, 180), y, toneMix(PALETTE[2], PALETTE[1], 0.14 + random(0, 0.34)), max(0.7, randomGaussian(1.3, 0.45)));
}
// and the glow the town leaves in the air behind the cluster
for (let i = 0; i < 16; i++) {
const y = randomGaussian(424, 20);
const x0 = randomGaussian(470, 70);
mistDrag(x0, x0 + random(90, 260), y, toneMix(PALETTE[2], PALETTE[1], 0.18 + random(0, 0.4)), max(0.8, randomGaussian(1.6, 0.55)));
}
for (let i = 0; i < 4; i++) {
const y = randomGaussian(436, 11);
const x0 = randomGaussian(506, 48);
mistDrag(x0, x0 + random(50, 120), y, toneMix(PALETTE[2], PALETTE[5], 0.28 + random(0, 0.26)), max(0.8, randomGaussian(1.3, 0.4)));
}
// the light the water keeps hold of, only under the cluster
for (let i = 0; i < 9; i++) {
const y = randomGaussian(BAND_Y + 34, 22);
const x0 = randomGaussian(520, 70);
mistDrag(x0, x0 + random(60, 190), y, toneMix(PALETTE[2], PALETTE[1], 0.16 + random(0, 0.34)), max(0.8, randomGaussian(1.4, 0.5)));
}
// the left water taken down again — the mist should sit on nothing
tonePool(60, 560, 122, PALETTE[2], 2, 0.3);
tonePool(200, 592, 112, PALETTE[2], 2, 0.3);
tonePool(330, 620, 104, PALETTE[2], 1, 0.3);
// 6 — masts, implied: five over the weight of the picture, one lost in the mist.
// they go on after the mist so the haze does not swallow them entirely.
mastStroke(512, 302, -7, 1.05);
mastStroke(548, 346, 4, 0.95);
mastStroke(586, 282, 9, 1.05);
mastStroke(624, 364, -5, 0.9);
mastStroke(702, 380, 6, 0.8);
mastStroke(238, 402, -4, 0.5);
brush.set("2B", toneMix(PALETTE[3], PALETTE[0], 0.3), 0.7);
brush.line(566, 336, 608, 329);
brush.set("2B", toneMix(PALETTE[3], PALETTE[0], 0.42), 0.6);
brush.line(586, 296, 648, 386);
brush.set("2H", toneMix(PALETTE[3], PALETTE[0], 0.5), 0.55);
brush.line(514, 312, 548, 358);
// two hulls, no more than a dark smudge each, sitting where the water starts
for (let i = 0; i < 5; i++) {
brush.set("2B", toneMix(PALETTE[3], PALETTE[2], random(0, 0.4)), max(0.8, randomGaussian(1.5, 0.4)));
brush.line(504 + randomGaussian(0, 6), BAND_Y + 11 + i * 2.4, 566 + randomGaussian(0, 8), BAND_Y + 10 + i * 2.4);
}
for (let i = 0; i < 3; i++) {
brush.set("2B", toneMix(PALETTE[3], PALETTE[2], 0.2 + random(0, 0.4)), max(0.7, randomGaussian(1.2, 0.3)));
brush.line(660 + randomGaussian(0, 6), BAND_Y + 12 + i * 2.6, 706 + randomGaussian(0, 8), BAND_Y + 11 + i * 2.6);
}
// 7 — the band of lamps, faintest first so the bright ones sit on top
const lamps = [];
let x = -20;
while (x < 842 && lamps.length < 32) {
const force = constrain(lampForce(x) * (0.7 + random(0, 0.5)), 0, 1);
lamps.push({
x: x,
y: BAND_Y + randomGaussian(0, 3.2),
s: max(2.0, randomGaussian(4.6, 1.2) * (0.52 + force * 0.62)),
f: force,
});
x += max(18, randomGaussian(46, 16));
}
// the cluster that holds the picture, on the right third
lamps.push({ x: 508, y: BAND_Y - 2, s: 5.4, f: 0.84 });
lamps.push({ x: 532, y: BAND_Y + 4, s: 4.0, f: 0.7 });
lamps.push({ x: 560, y: BAND_Y - 1, s: 7.4, f: 1.0 });
lamps.push({ x: 584, y: BAND_Y + 5, s: 4.6, f: 0.78 });
lamps.push({ x: 648, y: BAND_Y + 3, s: 3.6, f: 0.64 });
// one riding light out on the water, nearer than the rest
lamps.push({ x: 352, y: 520, s: 3.4, f: 0.56 });
lamps.sort((a, b) => a.f - b.f);
for (let i = 0; i < lamps.length; i++) {
lampMark(lamps[i].x, lamps[i].y, lamps[i].s, lamps[i].f);
}
// 8 — what the lamps leave on the water
for (let i = 0; i < lamps.length; i++) {
if (lamps[i].f > 0.45) lampSmear(lamps[i].x, lamps[i].s, lamps[i].f);
}
// a masthead light, the one mark above the band
brush.noStroke();
brush.fillBleed(0.2, "out");
brush.fill(PALETTE[5], 255);
brush.circle(586, 292, 3.6, true);
for (let k = 0; k < 3; k++) {
brush.fill(PALETTE[4], 255);
brush.circle(586, 292, 1.5, true);
}
// 9 — last darks: the near water taken down, and the corners with it
for (let i = 0; i < 5; i++) {
tonePool(i * 200 - 60 + randomGaussian(0, 22), randomGaussian(772, 14), randomGaussian(118, 10), PALETTE[3], 3, 0.3);
}
tonePool(52, 704, 112, PALETTE[3], 2, 0.3);
tonePool(180, 748, 98, PALETTE[3], 2, 0.3);
tonePool(760, 726, 104, PALETTE[3], 2, 0.3);
for (let i = 0; i < 12; i++) {
const dy = 616 + Math.abs(randomGaussian(0, 92));
const dx = randomGaussian(260, 230);
brush.set(random() < 0.4 ? "charcoal" : "2B", toneMix(PALETTE[3], PALETTE[2], random(0, 0.45)), max(0.6, randomGaussian(0.85, 0.18)));
brush.line(dx, dy, dx + random(110, 300), dy + randomGaussian(0, 2.2));
}
// the seam under the band, holding the lights up — three touches, then nothing
brush.set("2B", toneMix(PALETTE[3], PALETTE[0], 0.5), 1.6);
brush.line(268, BAND_Y + 24, 396, BAND_Y + 26);
brush.set("charcoal", toneMix(PALETTE[3], PALETTE[2], 0.35), 0.85);
brush.line(430, BAND_Y + 25, 596, BAND_Y + 22);
brush.set("2B", toneMix(PALETTE[3], PALETTE[0], 0.34), 1.8);
brush.line(648, BAND_Y + 23, 792, BAND_Y + 20);
}