Walldye

Coral loop

inspired by Anders Hoff (inconvergent), Differential Line1 and Nervous System, Floraform2

One closed line keeps splitting and pushing itself apart until it folds into a coral, ringed by two offset lines.

Made with Claude Opus 5.5

Technique
simulations, line art
Inspired by
creative coding
Shape
Any screen
Added
27 September 2026

Colours

  • #1C1B1Abackground
  • #DAD8CEforeground
  • #CF6A4Caccent

Export

Format
Shape
Size

Notes

The line starts as a circle of 30 points. At every step, springs keep neighbouring points evenly spaced, stretches of the line that come close push each other apart, and new points go in where the line is least crowded, until it has about a thousand. A lobed oval boundary stops the growth from rounding into a disc. The two outer lines are offsets of the finished shape, with the mouths of the channels rounded off.

Sources

  1. Anders Hoff (inconvergent), Differential Line, 2015. ↑
  2. Nervous System, Floraform, 2014. ↑

Source code

wallpapers/coral-loop/design.py, 102 lines

"""Differential growth: a closed line folds into a coral outline inside two smoothed offsets."""

import math

import numpy as np
from numpy.typing import NDArray
from scipy.ndimage import gaussian_filter1d
from scipy.spatial import cKDTree
from shapely import Polygon

from walldye import ACCENT, ACCENT_8, UI, UI_ALT, Canvas, NpRng, P, design
from walldye.geom import Affine

type Pts = NDArray[np.float64]

SEED, SCALE = 5, 1.7  # growth stream; canvas units per simulation unit
REPEL, REST, TARGET, RATE = 26, 7, 1050, 0.01
# A soft lobed ellipse keeps the coral from rounding into a disc: long axis toward the lower
# right, semi-axes in sim units, lobes as (harmonic, amplitude, phase).
HEADING, AXES = math.radians(24), (150, 105)
LOBES = ((2, 0.14, 0.6), (3, 0.16, 2.1), (5, 0.07, 4.0))
ECHOES = ((50, UI), (20, UI_ALT))  # outermost first: offset from the outline, stroke
TURN = 56  # degrees clockwise on portrait screens, standing the long axis nearly upright


def bound(p: Pts) -> Pts:
    """Containment radius along each point's direction from the origin."""
    ang = np.arctan2(p[:, 1], p[:, 0]) - HEADING
    a, b = AXES
    r = a * b / np.hypot(b * np.cos(ang), a * np.sin(ang))
    return r * (1 + sum(amp * np.cos(k * ang + ph) for k, amp, ph in LOBES))


def grow(rng: NpRng) -> Pts:
    """Closed differential-growth line around the origin, in simulation units."""
    a = np.linspace(0, 2 * np.pi, 30, endpoint=False)
    p = 20 * np.column_stack([np.cos(a), np.sin(a)])
    settle = 150
    while settle:
        n = len(p)
        prev, nxt = np.roll(p, 1, 0), np.roll(p, -1, 0)
        e = nxt - p
        el = np.linalg.norm(e, axis=1, keepdims=True) + 1e-6
        spring = e / el * (el - REST) * 0.25
        force = spring - np.roll(spring, 1, 0) + 0.3 * ((prev + nxt) / 2 - p)
        # near-neighbours (gap 3+) repel too: that chain stiffness is what stops node-scale zigzags
        i, j = cKDTree(p).query_pairs(REPEL, output_type="ndarray").T
        gap = np.abs(i - j)
        keep = np.minimum(gap, n - gap) > 2
        i, j = i[keep], j[keep]
        d = p[i] - p[j]
        dist = np.linalg.norm(d, axis=1, keepdims=True) + 1e-6
        push = d / dist * (REPEL - dist) / REPEL * 0.5
        np.add.at(force, i, push)
        np.add.at(force, j, -push)
        r = np.linalg.norm(p, axis=1)
        over = np.maximum(0, r - bound(p))[:, None]
        force -= p / (r[:, None] + 1e-6) * over * 0.1
        mag = np.linalg.norm(force, axis=1, keepdims=True)
        p = p + force * np.minimum(1, 1.5 / (mag + 1e-6)) + rng.normal(0, 0.03, p.shape)
        if n < TARGET:
            # split edges at random, favouring uncrowded ones so the tips grow and the core settles
            crowd = np.bincount(np.concatenate([i, j]), minlength=n)
            nxt = np.roll(p, -1, 0)
            w = np.linalg.norm(nxt - p, axis=1) / (1 + crowd)
            idx = np.sort(rng.choice(n, max(1, int(n * RATE)), replace=False, p=w / w.sum()))
            p = np.insert(p, idx + 1, (p[idx] + nxt[idx]) / 2, axis=0)
        else:
            settle -= 1
    return p


def chaikin(pts: Pts, n: int = 3) -> Pts:
    """Corner-cut a closed ring `n` times so offset rings lose their channel-mouth kinks."""
    for _ in range(n):
        q = np.roll(pts, -1, 0)
        pts = np.stack([0.75 * pts + 0.25 * q, 0.25 * pts + 0.75 * q], 1).reshape(-1, 2)
    return pts


@design(aspects="any")
def draw(s: Canvas) -> None:
    # left third on a landscape screen, leaving the right for windows; portrait: below the clock
    c = s.pick(landscape=(1 / 3, 14 / 27), portrait=(0.5, 0.56))
    p = gaussian_filter1d(grow(s.np_rng(SEED)), 2, axis=0, mode="wrap") * SCALE
    if not s.landscape:
        p = Affine.rotate(deg=TURN).apply(p)
    p = p - (p.min(0) + p.max(0)) / 2 + c  # centre the bounding box on c
    coral = Polygon(p)
    for dist, tone in ECHOES:
        # overshoot then shrink back so the ring rounds off channel mouths instead of forming cusps
        ring = coral.buffer(dist + 28, quad_segs=32).buffer(-28, quad_segs=32).simplify(0.8)
        assert isinstance(ring, Polygon)  # a net outward offset of one polygon stays one polygon
        pts = chaikin(np.asarray(ring.exterior.coords)[:-1])
        s.stroke(P().poly(pts, closed=True), tone, 1.3)
    s.path(
        P().spline(p, closed=True),
        fill=ACCENT_8,
        stroke=ACCENT,
        stroke_width=1.7,
        stroke_linejoin="round",
    )

Run it yourself

$ git clone https://github.com/nickolaj-jepsen/walldye && cd walldye$ uv run walldye render coral-loop --theme fireproof -o coral-loop-fireproof-16x9.svg