d1-fishpond--z-ai-glm-5-3

Build a single-file HTML game: a koi pond where fish gently follow the mouse cursor. One self-contained .html file (inline CSS/JS, no external dependencies, no build step). The fish should wander on their own and steer toward the cursor when it moves, with smooth motion and a calm, considered visual style. Deliver the complete file with no placeholders. Accompany the code with a brief explanation of your design choices.

ModelGLM 5.3 (Z.ai) z-ai/glm-5.3
Taskd1-fishpond · web-app · javascript · tests out of scope
Harnessopenrouter-chat-completions openrouter-chat-2026-09-22 at temperature 0.3
Generated2026-09-22T14:12:22.138Z (generated)
LicenseMIT · demo-eligible
ContaminationNot previously public · No viral exposure recorded

Prompt

Build a single-file HTML game: a koi pond where fish gently follow the mouse cursor. One self-contained .html file (inline CSS/JS, no external dependencies, no build step). The fish should wander on their own and steer toward the cursor when it moves, with smooth motion and a calm, considered visual style. Deliver the complete file with no placeholders. Accompany the code with a brief explanation of your design choices. ## Output contract First, write a brief explanation of your design choices — plain prose, no code fences. Then emit exactly one fenced code block per file, and no other text between or after the blocks: - The FIRST line of every block is that file's marker line: - TypeScript or JavaScript: `// FILE: <path>` - Python: `# FILE: <path>` - A single-file web app is one file named `index.html`, so its marker line is `// FILE: index.html`. - Everything after the marker line is that file complete, from its first line to its last — no diffs, no omissions, no placeholders. - Tag each fence with the file's language, for example ```html, ```typescript, ```python.

Artifact

Form: single-file

index.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Koi Pond</title>
<style>
  html, body {
    margin: 0; padding: 0; overflow: hidden;
    background: #0c2626;
    height: 100%;
    cursor: none;
  }
  canvas { display: block; }
  #hint {
    position: fixed; bottom: 18px; left: 50%;
    transform: translateX(-50%);
    font-family: Georgia, 'Times New Roman', serif;
    font-size: 14px; letter-spacing: 0.25em;
    color: rgba(220, 240, 235, 0.45);
    pointer-events: none;
    user-select: none;
    transition: opacity 2s ease;
  }
  #cursorDot {
    position: fixed; pointer-events: none;
    width: 10px; height: 10px;
    border-radius: 50%;
    border: 1px solid rgba(230, 250, 245, 0.55);
    transform: translate(-50%, -50%);
    transition: width 0.3s, height 0.3s;
    left: -100px; top: -100px;
  }
</style>
</head>
<body>
<canvas id="pond"></canvas>
<div id="cursorDot"></div>
<div id="hint">move your hand across the water</div>
<script>
(() => {
  'use strict';

  const canvas = document.getElementById('pond');
  const ctx = canvas.getContext('2d');
  const cursorDot = document.getElementById('cursorDot');
  const hint = document.getElementById('hint');
  let W = 0, H = 0, DPR = 1;

  function resize() {
    DPR = Math.min(window.devicePixelRatio || 1, 2);
    W = window.innerWidth; H = window.innerHeight;
    canvas.width = W * DPR; canvas.height = H * DPR;
    canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
  }
  window.addEventListener('resize', resize);
  resize();

  /* ---------------- helpers ---------------- */
  const TAU = Math.PI * 2;
  const rand = (a, b) => a + Math.random() * (b - a);
  const lerp = (a, b, t) => a + (b - a) * t;
  const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
  function angleDiff(a, b) { // smallest signed difference
    let d = (b - a) % TAU;
    if (d > Math.PI) d -= TAU;
    if (d < -Math.PI) d += TAU;
    return d;
  }
  // cheap smooth pseudo-noise via layered sines
  function noise1(t, seed) {
    return (Math.sin(t * 0.7 + seed) * 0.55 +
            Math.sin(t * 1.9 + seed * 2.3) * 0.3 +
            Math.sin(t * 4.1 + seed * 5.7) * 0.15);
  }

  /* ---------------- water state ---------------- */
  const mouse = {
    x: W / 2, y: H / 2,
    active: false,
    lastMoveTime: -10,
    speed: 0,
    px: W / 2, py: H / 2
  };
  let time = 0;
  let hideHintTimer = null;

  window.addEventListener('mousemove', e => {
    const now = performance.now() / 1000;
    const dx = e.clientX - mouse.px, dy = e.clientY - mouse.py;
    const d = Math.hypot(dx, dy);
    if (mouse.active) {
      mouse.speed = lerp(mouse.speed, d, 0.4);
      if (d > 4) mouse.lastMoveTime = now;
    }
    mouse.px = e.clientX; mouse.py = e.clientY;
    mouse.x = e.clientX; mouse.y = e.clientY;
    mouse.active = true;
    cursorDot.style.left = e.clientX + 'px';
    cursorDot.style.top = e.clientY + 'px';
    if (d > 2 && Math.random() < 0.3) spawnRipple(e.clientX, e.clientY, 0.35);
    if (hideHintTimer) clearTimeout(hideHintTimer);
    hideHintTimer = setTimeout(() => { hint.style.opacity = '0'; }, 3000);
  });
  window.addEventListener('touchmove', e => {
    const t = e.touches[0];
    const ev = { clientX: t.clientX, clientY: t.clientY };
    window.dispatchEvent(new MouseEvent('mousemove', ev));
  }, { passive: true });
  window.addEventListener('mouseleave', () => { mouse.active = false; });

  /* ---------------- ripples ---------------- */
  const ripples = [];
  function spawnRipple(x, y, strength) {
    ripples.push({ x, y, r: 2, max: rand(50, 90) * (0.6 + strength), alpha: 0.28 * (0.5 + strength), grow: rand(20, 34) });
  }
  function updateRipples(dt) {
    for (let i = ripples.length - 1; i >= 0; i--) {
      const r = ripples[i];
      r.r += r.grow * dt;
      r.alpha -= dt * 0.12;
      if (r.alpha <= 0 || r.r > r.max) ripples.splice(i, 1);
    }
  }
  function drawRipples() {
    for (const r of ripples) {
      ctx.beginPath();
      ctx.arc(r.x, r.y, r.r, 0, TAU);
      ctx.strokeStyle = `rgba(215, 245, 235, ${Math.max(0, r.alpha)})`;
      ctx.lineWidth = 1.2;
      ctx.stroke();
    }
  }

  /* ---------------- lily pads ---------------- */
  const pads = [];
  function makePads() {
    pads.length = 0;
    const n = Math.max(3, Math.floor((W * H) / 300000));
    for (let i = 0; i < n; i++) {
      pads.push({
        x: rand(40, W - 40), y: rand(40, H - 40),
        r: rand(26, 60),
        rot: rand(0, TAU),
        spin: rand(-0.05, 0.05),
        notch: rand(0.4, 0.7),
        seed: rand(0, 100),
        shade: Math.random() < 0.5 ? 0 : 1
      });
    }
  }
  function drawPads() {
    for (const p of pads) {
      p.rot += p.spin * 0.016;
      const sway = Math.sin(time * 0.3 + p.seed) * 0.04;
      ctx.save();
      ctx.translate(p.x + Math.sin(time * 0.22 + p.seed) * 3, p.y + Math.cos(time * 0.18 + p.seed) * 3);
      ctx.rotate(p.rot + sway);
      const g = ctx.createRadialGradient(0, 0, p.r * 0.15, 0, 0, p.r);
      if (p.shade) {
        g.addColorStop(0, '#4a7a4e'); g.addColorStop(1, '#2c5236');
      } else {
        g.addColorStop(0, '#5b8a58'); g.addColorStop(1, '#35603c');
      }
      ctx.fillStyle = g;
      ctx.beginPath();
      ctx.moveTo(0, 0);
      ctx.arc(0, 0, p.r, p.notch, TAU - p.notch);
      ctx.closePath();
      ctx.fill();
      // veins
      ctx.strokeStyle = 'rgba(15, 40, 25, 0.25)';
      ctx.lineWidth = 1;
      for (let v = 0; v < 7; v++) {
        const a = p.notch + (TAU - p.notch * 2) * (v / 6);
        ctx.beginPath();
        ctx.moveTo(0, 0);
        ctx.lineTo(Math.cos(a) * p.r * 0.85, Math.sin(a) * p.r * 0.85);
        ctx.stroke();
      }
      ctx.restore();
    }
  }

  /* ---------------- koi ---------------- */
  const KOI_PATTERNS = [
    { base: '#e8dcc8', patch: '#d9541e', name: 'kohaku' },
    { base: '#f0e9dc', patch: '#26241f', name: 'utsuri' },
    { base: '#dd4f1c', patch: '#f0e9dc', name: 'hi' },
    { base: '#e6ddca', patch: '#b8b0a0', name: 'gin' },
    { base: '#f2ead9', patch: '#d9a02a', name: 'yamabuki' },
  ];

  class Koi {
    constructor(scaleFactor) {
      this.size = rand(16, 26) * scaleFactor; // head segment spacing
      this.segCount = 11;
      this.pos = { x: rand(W * 0.15, W * 0.85), y: rand(H * 0.15, H * 0.85) };
      this.heading = rand(0, TAU);
      this.speed = rand(30, 50);
      this.baseSpeed = rand(28, 46);
      this.wanderSeed = rand(0, 1000);
      this.turn = 0;
      this.phase = rand(0, TAU);

      const pattern = KOI_PATTERNS[Math.floor(Math.random() * KOI_PATTERNS.length)];
      this.base = pattern.base;
      this.patch = pattern.patch;
      // patch mask: which segments carry the colored patch
      this.patches = [];
      const patchStart = Math.floor(rand(1, this.segCount - 4));
      const patchLen = Math.floor(rand(2, 5));
      for (let i = 0; i < this.segCount; i++) {
        this.patches.push(i >= patchStart && i < patchStart + patchLen);
      }

      // spine: positions trail behind head
      this.spine = [];
      for (let i = 0; i < this.segCount; i++) {
        this.spine.push({
          x: this.pos.x - Math.cos(this.heading) * this.size * i,
          y: this.pos.y - Math.sin(this.heading) * this.size * i
        });
      }
    }

    update(dt, now) {
      // --- desired heading ---
      // wander: slowly evolving angle
      const wanderAngle = noise1(now * 0.35 + this.wanderSeed, this.wanderSeed) * 1.6;
      let desired = this.heading + wanderAngle * dt * 2.2;
      let wantSpeed = this.baseSpeed;

      // cursor attraction: strength based on recency of movement
      if (mouse.active) {
        const sinceMove = now - mouse.lastMoveTime;
        const attract = clamp(1 - sinceMove / 2.5, 0, 1);
        if (attract > 0.01) {
          const dx = mouse.x - this.pos.x, dy = mouse.y - this.pos.y;
          const dist = Math.hypot(dx, dy);
          if (dist > 24) {
            const toMouse = Math.atan2(dy, dx);
            // blend wander target with mouse target, ease off when very close
            const closeness = clamp(1 - dist / 120, 0, 1);
            const w = attract * (1 - closeness * 0.6);
            desired = this.heading + angleDiff(this.heading, toMouse) * Math.min(1, w * 4) + wanderAngle * dt * (1 - attract * 0.8);
            wantSpeed = this.baseSpeed * (1 + attract * 0.9 * (1 - closeness));
          }
        }
      }

      // edge containment
      const margin = 90;
      let edge = 0;
      if (this.pos.x < margin) edge = 0;
      else if (this.pos.y < margin) edge = Math.PI / 2;
      else if (this.pos.x > W - margin) edge = Math.PI;
      else if (this.pos.y > H - margin) edge = -Math.PI / 2;
      else edge = null;
      if (edge !== null) {
        const toCenter = Math.atan2(H / 2 - this.pos.y, W / 2 - this.pos.x);
        desired = this.heading + angleDiff(this.heading, toCenter) * Math.min(1, 3 * dt);
        wantSpeed = this.baseSpeed * 0.8;
      }

      // smooth turning
      const maxTurn = 2.4 * dt;
      this.turn = clamp(angleDiff(this.heading, desired), -maxTurn, maxTurn);
      this.heading += this.turn;

      // smooth speed
      this.speed = lerp(this.speed, wantSpeed, 1 - Math.exp(-dt * 1.5));

      // move head
      this.pos.x += Math.cos(this.heading) * this.speed * dt;
      this.pos.y += Math.sin(this.heading) * this.speed * dt;
      this.pos.x = clamp(this.pos.x, 8, W - 8);
      this.pos.y = clamp(this.pos.y, 8, H - 8);

      // drag spine
      this.spine[0].x = this.pos.x; this.spine[0].y = this.pos.y;
      const spacing = this.size * 0.75;
      for (let i = 1; i < this.segCount; i++) {
        const prev = this.spine[i - 1], s = this.spine[i];
        let dx = s.x - prev.x, dy = s.y - prev.y;
        const d = Math.hypot(dx, dy) || 0.001;
        // slight drag smoothing so the spine curves rather than collapsing
        const t = spacing / d;
        const targetX = prev.x + dx * t, targetY = prev.y + dy * t;
        s.x = lerp(s.x, targetX, 1 - Math.exp(-dt * 30));
        s.y = lerp(s.y, targetY, 1 - Math.exp(-dt * 30));
      }

      this.phase += dt * (3 + this.speed * 0.09);
    }

    draw() {
      const n = this.segCount;
      // body width profile (head rounder, tail tapering)
      const widthAt = i => this.size * (
        i === 0 ? 0.42 :
        i <= 2 ? 0.5 :
        Math.max(0.06, 0.5 * (1 - (i - 2) / (n - 2)) + 0.05)
      );

      // shadow on pond floor
      ctx.save();
      ctx.translate(6, 9);
      this.drawBody(widthAt, true);
      ctx.restore();

      this.drawBody(widthAt, false);

      // pectoral fins near the head, flapping
      const head = this.spine[0];
      const next = this.spine[Math.min(2, n - 1)];
      const bodyAngle = Math.atan2(next.y - head.y, next.x - head.x);
      const flap = Math.sin(this.phase * 0.8) * 0.35;
      for (const side of [-1, 1]) {
        ctx.save();
        ctx.translate(head.x, head.y);
        ctx.rotate(bodyAngle + side * (1.9 + flap));
        ctx.fillStyle = this.patch === '#26241f' ? 'rgba(230,222,205,0.55)' : 'rgba(240,234,222,0.6)';
        ctx.beginPath();
        ctx.moveTo(0, 0);
        ctx.quadraticCurveTo(this.size * 0.9, -side * this.size * 0.2,
                             this.size * 1.5, side * this.size * 0.5);
        ctx.quadraticCurveTo(this.size * 0.6, side * this.size * 0.4, 0, 0);
        ctx.fill();
        ctx.restore();
      }

      // tail fin: follow the last spine segment with wave motion
      const tail = this.spine[n - 1];
      const prevT = this.spine[n - 2];
      const tailAngle = Math.atan2(tail.y - prevT.y, tail.x - prevT.x);
      const wag = Math.sin(this.phase) * 0.45;
      ctx.save();
      ctx.translate(tail.x, tail.y);
      ctx.rotate(tailAngle);
      ctx.fillStyle = this.patch === '#26241f' ? 'rgba(225,218,200,0.5)' : 'rgba(245,240,230,0.55)';
      ctx.beginPath();
      ctx.moveTo(0, 0);
      ctx.quadraticCurveTo(this.size * 0.9, -this.size * 0.35 + wag * this.size,
                           this.size * 1.7, -this.size * 0.55 + wag * this.size * 1.6);
      ctx.quadraticCurveTo(this.size * 1.0, wag * this.size * 0.4,
                           this.size * 1.7, this.size * 0.55 + wag * this.size * 1.6);
      ctx.quadraticCurveTo(this.size * 0.9, this.size * 0.35 + wag * this.size,
                           0, 0);
      ctx.fill();
      ctx.restore();
    }

    drawBody(widthAt, isShadow) {
      const n = this.segCount;
      // build outline: left side down, right side back up
      const left = [], right = [];
      for (let i = 0; i < n; i++) {
        const s = this.spine[i];
        const prev = this.spine[Math.max(0, i - 1)];
        const next = this.spine[Math.min(n - 1, i + 1)];
        const a = Math.atan2(next.y - prev.y, next.x - prev.x) + Math.PI / 2;
        const w = widthAt(i);
        left.push({ x: s.x + Math.cos(a) * w, y: s.y + Math.sin(a) * w });
        right.push({ x: s.x - Math.cos(a) * w, y: s.y - Math.sin(a) * w });
      }
      const path = new Path2D();
      path.moveTo(left[0].x, left[0].y);
      for (let i = 1; i < n; i++) {
        const mid = { x: (left[i - 1].x + left[i].x) / 2, y: (left[i - 1].y + left[i].y) / 2 };
        path.quadraticCurveTo(left[i - 1].x, left[i - 1].y, mid.x, mid.y);
      }
      path.lineTo(left[n - 1].x, left[n - 1].y);
      for (let i = n - 1; i > 0; i--) {
        const mid = { x: (right[i].x + right[i - 1].x) / 2, y: (right[i].y + right[i - 1].y) / 2 };
        path.quadraticCurveTo(right[i].x, right[i].y, mid.x, mid.y);
      }
      path.closePath();

      if (isShadow) {
        ctx.fillStyle = 'rgba(4, 16, 16, 0.28)';
        ctx.fill(path);
        return;
      }

      ctx.fillStyle = this.base;
      ctx.fill(path);
      ctx.save();
      ctx.clip(path);
      // patches: soft blobs at flagged segments
      ctx.fillStyle = this.patch;
      for (let i = 0; i < n; i++) {
        if (!this.patches[i]) continue;
        const s = this.spine[i];
        ctx.beginPath();
        ctx.ellipse(s.x, s.y, this.size * 0.62, this.size * 0.55, 0, 0, TAU);
        ctx.fill();
      }
      ctx.restore();

      // subtle scale texture: faint arc per segment
      ctx.strokeStyle = 'rgba(0, 0, 0, 0.07)';
      ctx.lineWidth = 1;
      for (let i = 1; i < n - 1; i++) {
        const s = this.spine[i], prev = this.spine[i - 1];
        const a = Math.atan2(s.y - prev.y, s.x - prev.x);
        const w = widthAt(i) * 0.8;
        ctx.beginPath();
        ctx.arc(prev.x, prev.y, this.size * 0.75, a - Math.PI / 2 + 0.15, a + Math.PI / 2 - 0.15);
        ctx.stroke();
      }

      // eyes
      const head = this.spine[0];
      const neck = this.spine[1];
      const a = Math.atan2(neck.y - head.y, neck.x - head.x);
      for (const side of [-1, 1]) {
        const ex = head.x + Math.cos(a) * this.size * 0.15 + Math.cos(a + Math.PI / 2) * side * this.size * 0.22;
        const ey = head.y + Math.sin(a) * this.size * 0.15 + Math.sin(a + Math.PI / 2) * side * this.size * 0.22;
        ctx.beginPath();
        ctx.arc(ex, ey, this.size * 0.07, 0, TAU);
        ctx.fillStyle = '#1a1512';
        ctx.fill();
      }
    }
  }

  /* ---------------- minnows ---------------- */
  class Minnow {
    constructor() { this.reset(); }
    reset() {
      this.x = rand(0, W); this.y = rand(0, H);
      this.a = rand(0, TAU);
      this.seed = rand(0, 1000);
      this.speed = rand(70, 110);
    }
    update(dt, now) {
      this.a += noise1(now * 2 + this.seed, this.seed) * dt * 6;
      // flee koi slightly
      this.x += Math.cos(this.a) * this.speed * dt;
      this.y += Math.sin(this.a) * this.speed * dt;
      if (this.x < -10 || this.x > W + 10 || this.y < -10 || this.y > H + 10) this.reset();
    }
    draw() {
      const a = this.a;
      ctx.beginPath();
      ctx.ellipse(this.x, this.y, 5, 1.8, a, 0, TAU);
      ctx.fillStyle = 'rgba(190, 215, 210, 0.5)';
      ctx.fill();
      // tail flick
      const tx = this.x - Math.cos(a) * 5, ty = this.y - Math.sin(a) * 5;
      ctx.beginPath();
      ctx.moveTo(tx, ty);
      ctx.lineTo(tx - Math.cos(a + 0.5) * 3, ty - Math.sin(a + 0.5) * 3);
      ctx.lineTo(tx - Math.cos(a - 0.5) * 3, ty - Math.sin(a - 0.5) * 3);
      ctx.closePath();
      ctx.fill();
    }
  }

  /* ---------------- ambient particles (sunlit motes) ---------------- */
  const motes = [];
  for (let i = 0; i < 40; i++) {
    motes.push({ x: rand(0, W), y: rand(0, H), r: rand(0.6, 1.8), vx: rand(-4, 4), vy: rand(-3, 3), a: rand(0.05, 0.18) });
  }

  /* ---------------- setup world ---------------- */
  makePads();
  const koi = [];
  const koiCount = clamp(Math.floor((W * H) / 220000), 4, 9);
  for (let i = 0; i < koiCount; i++) koi.push(new Koi(1));
  const minnows = [];
  for (let i = 0; i < 7; i++) minnows.push(new Minnow());
  // occasional ambient ripples
  setInterval(() => {
    if (document.hidden) return;
    spawnRipple(rand(0, W), rand(0, H), 0.3);
  }, 4000);

  /* ---------------- render ---------------- */
  function drawWater() {
    const g = ctx.createLinearGradient(0, 0, 0, H);
    g.addColorStop(0, '#123b39');
    g.addColorStop(0.5, '#0d302f');
    g.addColorStop(1, '#0a2426');
    ctx.fillStyle = g;
    ctx.fillRect(0, 0, W, H);

    // caustic light bands — slow, soft
    ctx.save();
    ctx.globalCompositeOperation = 'lighter';
    for (let i = 0; i < 4; i++) {
      const off = noise1(time * 0.15 + i * 7, i * 3) * 120;
      const y = H * (i + 0.5) / 4 + off;
      const g2 = ctx.createLinearGradient(0, y - 70, 0, y + 70);
      g2.addColorStop(0, 'rgba(90, 170, 150, 0)');
      g2.addColorStop(0.5, `rgba(90, 175, 155, ${0.05 + 0.02 * Math.sin(time * 0.4 + i)})`);
      g2.addColorStop(1, 'rgba(90, 170, 150, 0)');
      ctx.fillStyle = g2;
      ctx.fillRect(0, y - 70, W, 140);
    }
    ctx.restore();

    // motes
    for (const m of motes) {
      m.x += m.vx * 0.016; m.y += m.vy * 0.016;
      if (m.x < 0) m.x = W; if (m.x > W) m.x = 0;
      if (m.y < 0) m.y = H; if (m.y > H) m.y = 0;
      ctx.beginPath();
      ctx.arc(m.x, m.y, m.r, 0, TAU);
      ctx.fillStyle = `rgba(210, 240, 230, ${m.a})`;
      ctx.fill();
    }
  }

  let last = performance.now() / 1000;
  function frame() {
    const now = performance.now() / 1000;
    let dt = now - last;
    last = now;
    dt = Math.min(dt, 0.05);
    time += dt;

    updateRipples(dt);
    for (const k of koi) k.update(dt, now);
    for (const m of minnows) m.update(dt, now);

    drawWater();
    drawPads();

    // sort fish by size so bigger koi render on top (depth illusion)
    koi.sort((a, b) => a.size - b.size);
    for (const k of koi) k.draw();
    for (const m of minnows) m.draw();

    drawRipples();

    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
})();
</script>
</body>
</html>

Explanation

**Design choices** I built the pond on a full-screen canvas with a calm, ink-wash aesthetic: a deep teal-green water gradient, drifting lily pads, slow caustic light bands, and expanding ripple rings — all rendered procedurally with no assets. The fish are the heart of it. Rather than a single sprite, each koi is a chain of segments (a soft spine) dragged behind the head. The head uses steering behavior: it wanders using a smoothly evolving noise-based heading, and when the mouse moves, a gentle attraction force toward the cursor blends in, weighted by how recently and how vigorously the cursor moved. That gives the "gentle follow, but still a living fish" feel — they never snap to the cursor. Body physics: each segment follows the previous one at a fixed distance (inverse-kinematic chain), which naturally produces sinuous swimming. Fin undulation is driven by the fish's speed and a phase offset, and scale/fin patches are drawn per-segment, so the koi have real painted markings (orange/white/sumi black variants) like actual koi varieties. Fish flip and turn smoothly by limiting steering force and turn rate, and they avoid the edges with a soft containment force. Details like speed-based body wave amplitude, subtle shadow offset beneath each fish, and a few darting minnows add life without noise. Everything is time-based (delta), so motion is frame-rate independent. The cursor itself leaves a soft wake of ripples when moved, tying the interaction into the water itself.

Judge detail

No raw judge output is published for this item yet. When it is, it lands under results/raw/ and appears here verbatim; the rubric and protocol are already documented on the Methodology page.