Models 44

Kimi K3.1 Pro

by MoonshotAIreasoning defaultClean
Open in new window

Running live in a sandboxed frame. Click inside it before using the keyboard.

How it went

Total time
3 min 54 s
Cost
$0.355
Output tokens
35,376
Spent thinking
19,201 tokens
Speed
151.4 tok/s
Console errors
0
First token after
589 ms
First visible text after
2 min 55 s
Prompt tokens
508
Code size
51.0 KB
Finish reason
stop
Served by
Modal
Model id
moonshotai/kimi-k3.1-pro
Reasoning sent
model default
Max output
943,718 tokens
Generated
Nov 12, 2026

What it was asked

The prompt
“a 3D aquarium with Three.js where the fish swim away when I move the mouse near them (Load Three.js only through this import map: <script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}</script>)”
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task.

Output rules:
- Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block.
- Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task.
- Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw.
- The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar.
- Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction.
- Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session.
- Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing.
- The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message.
- The code must run with zero errors in the console of the latest Chrome.

Code run-1.html · 1707 lines

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
  <title>Aquaria — The Drift Tank</title>
  <style>
    :root {
      color-scheme: dark;
      --ink: #ebffff;
      --muted: rgba(218, 249, 247, 0.66);
      --line: rgba(190, 245, 240, 0.18);
      --glass: rgba(5, 36, 45, 0.38);
      --aqua: #82f2d8;
    }

    * {
      box-sizing: border-box;
    }

    html,
    body {
      width: 100%;
      height: 100%;
      margin: 0;
      overflow: hidden;
      background: #031922;
    }

    body {
      font-family: "Avenir Next", "Segoe UI", Helvetica, Arial, sans-serif;
      color: var(--ink);
      -webkit-font-smoothing: antialiased;
      user-select: none;
    }

    #app,
    #scene {
      position: fixed;
      inset: 0;
    }

    #scene {
      background:
        radial-gradient(circle at 50% 0%, #0c6470 0%, transparent 55%),
        linear-gradient(180deg, #073a49 0%, #031923 100%);
    }

    #scene canvas {
      display: block;
      width: 100%;
      height: 100%;
      cursor: none;
      touch-action: none;
    }

    .vignette,
    .tank-frame {
      position: absolute;
      inset: 0;
      pointer-events: none;
    }

    .vignette {
      z-index: 2;
      background:
        linear-gradient(180deg, rgba(0, 12, 20, 0.26), transparent 19%, transparent 73%, rgba(0, 12, 18, 0.48)),
        radial-gradient(ellipse at center, transparent 43%, rgba(0, 10, 17, 0.38) 110%);
    }

    .tank-frame {
      z-index: 3;
      inset: 14px;
      border: 1px solid rgba(193, 248, 244, 0.2);
      border-radius: 24px;
      box-shadow:
        inset 0 0 0 1px rgba(255, 255, 255, 0.025),
        inset 0 0 90px rgba(1, 21, 29, 0.2),
        0 0 50px rgba(0, 0, 0, 0.2);
    }

    .tank-frame::before,
    .tank-frame::after {
      content: "";
      position: absolute;
      left: 24px;
      right: 24px;
      height: 1px;
      background: linear-gradient(90deg, transparent, rgba(211, 255, 251, 0.3), transparent);
    }

    .tank-frame::before {
      top: 5px;
    }

    .tank-frame::after {
      bottom: 5px;
      opacity: 0.4;
    }

    .topbar {
      position: absolute;
      z-index: 5;
      top: 36px;
      left: 40px;
      right: 40px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      pointer-events: none;
    }

    .brand {
      display: flex;
      align-items: center;
      gap: 12px;
    }

    .brand-mark {
      width: 43px;
      height: 43px;
      display: grid;
      place-items: center;
      border: 1px solid rgba(192, 249, 240, 0.25);
      border-radius: 50%;
      background: rgba(4, 39, 47, 0.3);
      box-shadow: inset 0 0 20px rgba(105, 239, 217, 0.07);
      backdrop-filter: blur(8px);
    }

    .brand-mark svg {
      width: 25px;
      height: 25px;
      fill: none;
      stroke: #b9fff0;
      stroke-width: 1.25;
      stroke-linejoin: round;
    }

    .brand-copy {
      display: flex;
      flex-direction: column;
      gap: 3px;
    }

    .brand-copy strong {
      font-size: 15px;
      line-height: 1;
      font-weight: 650;
      letter-spacing: 0.28em;
    }

    .brand-copy small {
      color: var(--muted);
      font-size: 8px;
      font-weight: 600;
      letter-spacing: 0.3em;
    }

    .status {
      display: flex;
      align-items: center;
      gap: 9px;
      padding: 10px 14px;
      border: 1px solid rgba(178, 242, 229, 0.18);
      border-radius: 999px;
      background: rgba(4, 37, 43, 0.35);
      backdrop-filter: blur(12px);
      color: rgba(226, 255, 249, 0.78);
      font-size: 9px;
      font-weight: 650;
      letter-spacing: 0.18em;
    }

    .status-dot {
      width: 6px;
      height: 6px;
      flex: 0 0 auto;
      border-radius: 50%;
      background: #82f2bb;
      box-shadow: 0 0 10px rgba(130, 242, 187, 0.75);
      animation: statusPulse 2.8s ease-in-out infinite;
    }

    .status-divider {
      width: 1px;
      height: 10px;
      background: rgba(210, 255, 247, 0.2);
      margin: 0 2px;
    }

    @keyframes statusPulse {
      50% { opacity: 0.45; box-shadow: 0 0 4px rgba(130, 242, 187, 0.4); }
    }

    .intro {
      position: absolute;
      z-index: 5;
      left: 42px;
      bottom: 43px;
      width: min(390px, calc(100vw - 84px));
      pointer-events: none;
      text-shadow: 0 2px 24px rgba(0, 15, 22, 0.5);
    }

    .eyebrow {
      display: flex;
      align-items: center;
      gap: 10px;
      margin-bottom: 12px;
      color: rgba(172, 241, 228, 0.7);
      font-size: 9px;
      font-weight: 650;
      letter-spacing: 0.25em;
    }

    .eyebrow::before {
      content: "";
      width: 23px;
      height: 1px;
      background: #80dbc9;
    }

    .intro h1 {
      margin: 0;
      font-size: clamp(34px, 3.2vw, 48px);
      line-height: 1.03;
      font-weight: 350;
      letter-spacing: -0.045em;
    }

    .intro h1 span {
      color: #a9f2e2;
      font-style: italic;
      font-weight: 300;
    }

    .intro p {
      max-width: 340px;
      margin: 13px 0 0;
      color: rgba(219, 246, 244, 0.65);
      font-size: 12px;
      line-height: 1.65;
      letter-spacing: 0.025em;
    }

    .hint {
      position: absolute;
      z-index: 6;
      left: 50%;
      bottom: 42px;
      transform: translateX(-50%);
      display: flex;
      align-items: center;
      gap: 11px;
      min-height: 45px;
      padding: 10px 16px;
      border: 1px solid rgba(190, 245, 235, 0.2);
      border-radius: 999px;
      background: rgba(3, 30, 38, 0.48);
      box-shadow: 0 8px 30px rgba(0, 15, 20, 0.14), inset 0 1px rgba(255, 255, 255, 0.035);
      backdrop-filter: blur(14px);
      pointer-events: none;
      white-space: nowrap;
      color: rgba(219, 250, 244, 0.64);
      font-size: 10px;
      letter-spacing: 0.025em;
    }

    .hint svg {
      width: 18px;
      height: 18px;
      fill: none;
      stroke: #a4f5e3;
      stroke-width: 1.35;
      stroke-linecap: round;
    }

    .hint b {
      color: #e6fff9;
      font-weight: 550;
    }

    .hint-separator {
      width: 1px;
      height: 15px;
      background: rgba(208, 255, 245, 0.17);
    }

    .activity {
      position: absolute;
      z-index: 5;
      right: 42px;
      bottom: 42px;
      width: 175px;
      padding: 15px 16px 13px;
      border: 1px solid rgba(188, 243, 235, 0.16);
      border-radius: 15px;
      background: rgba(3, 31, 39, 0.35);
      backdrop-filter: blur(12px);
      pointer-events: none;
    }

    .activity-head,
    .activity-foot {
      display: flex;
      align-items: center;
      justify-content: space-between;
    }

    .activity-head {
      color: rgba(213, 248, 241, 0.55);
      font-size: 7px;
      font-weight: 650;
      letter-spacing: 0.17em;
    }

    #activityLabel {
      color: #9af0d8;
    }

    .bars {
      height: 30px;
      margin: 10px 0 9px;
      display: flex;
      align-items: center;
      gap: 4px;
    }

    .bars i {
      display: block;
      width: 3px;
      height: 8px;
      border-radius: 3px;
      background: linear-gradient(180deg, #b9ffe9, #43bda8);
      opacity: 0.68;
      box-shadow: 0 0 8px rgba(99, 229, 199, 0.14);
      transition: height 120ms linear, opacity 150ms ease;
    }

    .activity-foot {
      padding-top: 9px;
      border-top: 1px solid rgba(194, 243, 235, 0.1);
      color: rgba(213, 247, 241, 0.45);
      font-size: 7px;
      letter-spacing: 0.13em;
    }

    #reactionCount {
      color: rgba(228, 255, 249, 0.85);
      font-size: 10px;
      letter-spacing: 0.08em;
      margin-right: 4px;
    }

    #cursor-ring {
      position: fixed;
      z-index: 20;
      left: 0;
      top: 0;
      width: 38px;
      height: 38px;
      border: 1px solid rgba(191, 255, 242, 0.5);
      border-radius: 50%;
      box-shadow: 0 0 18px rgba(106, 237, 214, 0.08), inset 0 0 12px rgba(106, 237, 214, 0.05);
      transform: translate(-50%, -50%);
      opacity: 0;
      pointer-events: none;
      transition: width 160ms ease, height 160ms ease, border-color 160ms ease, opacity 180ms ease;
    }

    #cursor-ring::before {
      content: "";
      position: absolute;
      left: 50%;
      top: 50%;
      width: 3px;
      height: 3px;
      border-radius: 50%;
      background: #d5fff5;
      box-shadow: 0 0 8px #91efd8;
      transform: translate(-50%, -50%);
    }

    #cursor-ring.is-startling {
      width: 55px;
      height: 55px;
      border-color: rgba(151, 255, 224, 0.82);
      box-shadow: 0 0 24px rgba(106, 237, 214, 0.18), inset 0 0 18px rgba(106, 237, 214, 0.08);
    }

    .click-ripple {
      position: fixed;
      z-index: 19;
      width: 30px;
      height: 30px;
      margin: -15px 0 0 -15px;
      border: 1px solid rgba(184, 255, 239, 0.75);
      border-radius: 50%;
      pointer-events: none;
      animation: ripple 850ms cubic-bezier(0.15, 0.7, 0.2, 1) forwards;
    }

    @keyframes ripple {
      to {
        width: 250px;
        height: 250px;
        margin: -125px 0 0 -125px;
        opacity: 0;
        border-width: 0.5px;
      }
    }

    .touch-copy {
      display: none;
    }

    @media (max-width: 900px) {
      .intro {
        width: 285px;
      }

      .intro p {
        max-width: 270px;
      }

      .hint {
        bottom: 112px;
      }
    }

    @media (max-width: 600px) {
      .tank-frame {
        inset: 8px;
        border-radius: 17px;
      }

      .topbar {
        top: 26px;
        left: 25px;
        right: 25px;
      }

      .brand-mark {
        width: 37px;
        height: 37px;
      }

      .brand-copy strong {
        font-size: 13px;
      }

      .brand-copy small,
      .status-divider,
      .status .wide-status {
        display: none;
      }

      .status {
        padding: 9px 11px;
        font-size: 8px;
      }

      .intro {
        left: 26px;
        bottom: 99px;
        width: calc(100vw - 52px);
      }

      .intro h1 {
        font-size: 34px;
      }

      .intro p {
        max-width: 285px;
        margin-top: 10px;
        font-size: 11px;
      }

      .eyebrow {
        margin-bottom: 9px;
        font-size: 8px;
      }

      .hint {
        left: 50%;
        bottom: 29px;
        min-height: 43px;
        padding: 9px 14px;
        font-size: 9px;
      }

      .hint .secondary,
      .hint-separator {
        display: none;
      }

      .activity {
        display: none;
      }

      .desktop-copy {
        display: none;
      }

      .touch-copy {
        display: inline;
      }
    }

    @media (max-height: 620px) and (min-width: 601px) {
      .intro {
        bottom: 30px;
      }

      .intro p {
        display: none;
      }

      .intro h1 {
        font-size: 30px;
      }

      .hint {
        left: auto;
        right: 30px;
        bottom: 30px;
        transform: none;
      }

      .activity {
        display: none;
      }
    }

    @media (pointer: coarse) {
      #cursor-ring {
        display: none;
      }

      #scene canvas {
        cursor: auto;
      }
    }
  </style>

  <script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}</script>
</head>
<body>
  <main id="app" aria-label="Interactive 3D aquarium">
    <div id="scene"></div>
    <div class="vignette"></div>
    <div class="tank-frame"></div>

    <header class="topbar">
      <div class="brand">
        <div class="brand-mark" aria-hidden="true">
          <svg viewBox="0 0 24 24">
            <path d="M3 12c3.1-4.7 8-6.2 12.8-3.2l4.2-3v12.4l-4.2-3C11 18.2 6.1 16.7 3 12Z"/>
            <circle cx="12.8" cy="10.5" r="0.8"/>
            <path d="M7.2 8.6c1.2 2.1 1.2 4.7 0 6.8"/>
          </svg>
        </div>
        <div class="brand-copy">
          <strong>AQUARIA</strong>
          <small>INTERACTIVE REEF</small>
        </div>
      </div>

      <div class="status">
        <span class="status-dot"></span>
        <span>LIVE HABITAT</span>
        <span class="status-divider"></span>
        <span class="wide-status"><span id="fishCount">22</span> FISH · 24°C</span>
      </div>
    </header>

    <section class="intro">
      <div class="eyebrow">INTERACTIVE HABITAT — 001</div>
      <h1>The <span>Drift</span> Tank</h1>
      <p>A tiny reef with a mind of its own. Glide through the water, but don’t get too close—the locals value their personal space.</p>
    </section>

    <div class="hint">
      <svg viewBox="0 0 20 20" aria-hidden="true">
        <rect x="5.2" y="1.8" width="9.6" height="16.4" rx="4.8"/>
        <path d="M10 2v5.2M5.4 7h9.2"/>
      </svg>
      <span class="desktop-copy"><b>Move near the fish</b> · they’ll dart away</span>
      <span class="touch-copy"><b>Drag near the fish</b> · they’ll dart away</span>
      <span class="hint-separator"></span>
      <span class="secondary">Click to scatter</span>
    </div>

    <aside class="activity">
      <div class="activity-head">
        <span>SCHOOL ACTIVITY</span>
        <span id="activityLabel">CRUISING</span>
      </div>
      <div class="bars" aria-hidden="true">
        <i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i>
      </div>
      <div class="activity-foot">
        <span><span id="reactionCount">00</span> STARTLES</span>
        <span>REEF 01</span>
      </div>
    </aside>

    <div id="cursor-ring"></div>
  </main>

  <script type="module">
    import * as THREE from 'three';

    const container = document.getElementById('scene');
    const cursorRing = document.getElementById('cursor-ring');
    const activityLabel = document.getElementById('activityLabel');
    const reactionCount = document.getElementById('reactionCount');
    const activityBars = [...document.querySelectorAll('.bars i')];

    let seed = 481516;
    function random() {
      seed |= 0;
      seed = seed + 0x6D2B79F5 | 0;
      let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
      t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
      return ((t ^ t >>> 14) >>> 0) / 4294967296;
    }
    const rand = (min, max) => min + (max - min) * random();

    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x062d39);
    scene.fog = new THREE.Fog(0x073844, 27, 64);

    const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 120);
    camera.position.set(0, 0.7, 31.5);
    camera.lookAt(0, 0, 0);

    const renderer = new THREE.WebGLRenderer({
      antialias: true,
      alpha: false,
      powerPreference: 'high-performance'
    });
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.8));
    renderer.outputColorSpace = THREE.SRGBColorSpace;
    renderer.toneMapping = THREE.ACESFilmicToneMapping;
    renderer.toneMappingExposure = 1.08;
    renderer.shadowMap.enabled = true;
    renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    renderer.domElement.setAttribute('aria-label', 'A living aquarium. Move your pointer near fish to make them swim away.');
    container.appendChild(renderer.domElement);

    let width = window.innerWidth;
    let height = window.innerHeight;
    let baseCameraZ = 31.5;
    const swimBounds = { halfWidth: 13.1 };

    function resize() {
      width = window.innerWidth;
      height = window.innerHeight;
      const aspect = width / height;
      baseCameraZ = aspect < 0.8 ? 30.5 : 31.5;

      renderer.setSize(width, height);
      renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.8));
      camera.aspect = aspect;
      camera.position.z = baseCameraZ;
      camera.updateProjectionMatrix();

      const visibleWidth = 2 * baseCameraZ *
        Math.tan(THREE.MathUtils.degToRad(camera.fov * 0.5)) * aspect;
      swimBounds.halfWidth = Math.max(3.4, Math.min(13.1, visibleWidth * 0.5 - 1.45));
    }
    resize();

    const hemi = new THREE.HemisphereLight(0xa7fff1, 0x284536, 1.65);
    scene.add(hemi);

    const sun = new THREE.DirectionalLight(0xd9fff3, 2.35);
    sun.position.set(-8, 15, 11);
    sun.castShadow = true;
    sun.shadow.mapSize.set(1024, 1024);
    sun.shadow.camera.left = -20;
    sun.shadow.camera.right = 20;
    sun.shadow.camera.top = 18;
    sun.shadow.camera.bottom = -18;
    sun.shadow.camera.near = 0.5;
    sun.shadow.camera.far = 60;
    sun.shadow.bias = -0.0006;
    scene.add(sun);

    const waterLight = new THREE.PointLight(0x52e5d3, 38, 34, 2);
    waterLight.position.set(2, 4, 5);
    scene.add(waterLight);

    const reefLight = new THREE.PointLight(0xffc58e, 17, 22, 2);
    reefLight.position.set(-8, -3, 2);
    scene.add(reefLight);

    function makeBackdropTexture() {
      const canvas = document.createElement('canvas');
      canvas.width = 512;
      canvas.height = 512;
      const ctx = canvas.getContext('2d');

      const gradient = ctx.createLinearGradient(0, 0, 0, 512);
      gradient.addColorStop(0, '#0c6972');
      gradient.addColorStop(0.42, '#07505e');
      gradient.addColorStop(1, '#062c3b');
      ctx.fillStyle = gradient;
      ctx.fillRect(0, 0, 512, 512);

      const glow = ctx.createRadialGradient(250, -30, 10, 250, 20, 390);
      glow.addColorStop(0, 'rgba(150,255,232,0.32)');
      glow.addColorStop(0.45, 'rgba(55,187,180,0.08)');
      glow.addColorStop(1, 'rgba(0,40,55,0)');
      ctx.fillStyle = glow;
      ctx.fillRect(0, 0, 512, 512);

      const texture = new THREE.CanvasTexture(canvas);
      texture.colorSpace = THREE.SRGBColorSpace;
      return texture;
    }

    const backdrop = new THREE.Mesh(
      new THREE.PlaneGeometry(30, 15),
      new THREE.MeshBasicMaterial({ map: makeBackdropTexture(), fog: false })
    );
    backdrop.position.set(0, 0, -6.05);
    scene.add(backdrop);

    const sandGeometry = new THREE.PlaneGeometry(30, 12, 70, 30);
    const sandPositions = sandGeometry.attributes.position;
    for (let i = 0; i < sandPositions.count; i++) {
      const x = sandPositions.getX(i);
      const y = sandPositions.getY(i);
      const dune = Math.sin(x * 0.48) * 0.075 +
        Math.cos(y * 0.75 + x * 0.13) * 0.065 +
        Math.sin(x * 1.2 + y * 0.6) * 0.025;
      sandPositions.setZ(i, dune);
    }
    sandGeometry.computeVertexNormals();

    const sand = new THREE.Mesh(
      sandGeometry,
      new THREE.MeshStandardMaterial({
        color: 0xb4aa7e,
        roughness: 0.94,
        metalness: 0
      })
    );
    sand.rotation.x = -Math.PI / 2;
    sand.position.y = -7.18;
    sand.receiveShadow = true;
    scene.add(sand);

    const causticMaterial = new THREE.ShaderMaterial({
      uniforms: { uTime: { value: 0 } },
      vertexShader: `
        varying vec2 vUv;
        void main() {
          vUv = uv;
          gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        }
      `,
      fragmentShader: `
        uniform float uTime;
        varying vec2 vUv;
        void main() {
          vec2 p = vUv * vec2(25.0, 10.0);
          float t = uTime * 0.42;
          float a = sin(p.x * 1.75 + sin(p.y * 1.35 + t) * 1.35 + t);
          float b = sin(p.y * 2.15 + sin(p.x * 1.12 - t) * 1.2 - t * 0.8);
          float field = abs(a + b) * 0.5;
          float lines = pow(max(0.0, 1.0 - field), 7.0);
          float edge = smoothstep(0.0, 0.05, vUv.x) *
                       smoothstep(0.0, 0.05, 1.0 - vUv.x) *
                       smoothstep(0.0, 0.07, vUv.y) *
                       smoothstep(0.0, 0.07, 1.0 - vUv.y);
          gl_FragColor = vec4(0.5, 0.95, 0.82, lines * edge * 0.19);
          #include <tonemapping_fragment>
          #include <colorspace_fragment>
        }
      `,
      transparent: true,
      depthWrite: false,
      blending: THREE.AdditiveBlending,
      side: THREE.DoubleSide
    });

    const caustics = new THREE.Mesh(new THREE.PlaneGeometry(30, 12), causticMaterial);
    caustics.rotation.x = -Math.PI / 2;
    caustics.position.y = -6.94;
    scene.add(caustics);

    const waterSurface = new THREE.Mesh(
      new THREE.PlaneGeometry(30, 12),
      new THREE.MeshPhongMaterial({
        color: 0x75e6df,
        transparent: true,
        opacity: 0.09,
        shininess: 120,
        side: THREE.DoubleSide,
        depthWrite: false
      })
    );
    waterSurface.rotation.x = -Math.PI / 2;
    waterSurface.position.y = 7.43;
    scene.add(waterSurface);

    const shaftMaterials = [];
    function addLightShaft(x, w, rotation, opacity, phase) {
      const material = new THREE.ShaderMaterial({
        uniforms: {
          uTime: { value: 0 },
          uOpacity: { value: opacity },
          uPhase: { value: phase }
        },
        vertexShader: `
          varying vec2 vUv;
          void main() {
            vUv = uv;
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
          }
        `,
        fragmentShader: `
          uniform float uTime;
          uniform float uOpacity;
          uniform float uPhase;
          varying vec2 vUv;
          void main() {
            float edge = pow(max(0.0, sin(vUv.x * 3.14159265)), 1.7);
            float fade = pow(vUv.y, 1.35);
            float shimmer = 0.83 + 0.17 * sin(vUv.y * 10.0 - uTime * 0.3 + uPhase);
            gl_FragColor = vec4(0.43, 0.92, 0.85, edge * fade * shimmer * uOpacity);
            #include <tonemapping_fragment>
            #include <colorspace_fragment>
          }
        `,
        transparent: true,
        depthWrite: false,
        blending: THREE.AdditiveBlending,
        side: THREE.DoubleSide
      });
      const shaft = new THREE.Mesh(new THREE.PlaneGeometry(w, 15), material);
      shaft.position.set(x, 0, -5.75);
      shaft.rotation.z = rotation;
      scene.add(shaft);
      shaftMaterials.push(material);
    }

    addLightShaft(-8.7, 3.7, -0.17, 0.105, 0.2);
    addLightShaft(-3.2, 2.2, -0.09, 0.075, 2.4);
    addLightShaft(4.7, 4.3, 0.14, 0.09, 4.7);
    addLightShaft(10.2, 2.4, 0.18, 0.06, 1.5);

    const rockGeometry = new THREE.DodecahedronGeometry(1, 0);
    const rockMaterials = [
      new THREE.MeshStandardMaterial({ color: 0x526f69, roughness: 0.95, flatShading: true }),
      new THREE.MeshStandardMaterial({ color: 0x65766a, roughness: 0.95, flatShading: true }),
      new THREE.MeshStandardMaterial({ color: 0x746f60, roughness: 0.95, flatShading: true })
    ];

    for (let i = 0; i < 24; i++) {
      const rock = new THREE.Mesh(rockGeometry, rockMaterials[i % rockMaterials.length]);
      const edgeBias = random() < 0.7;
      const x = edgeBias
        ? (random() < 0.5 ? -1 : 1) * rand(8.5, 13.7)
        : rand(-12.5, 12.5);
      const z = edgeBias ? rand(-4.8, 2.8) : rand(-5.1, -2.3);
      const s = edgeBias ? rand(0.45, 1.45) : rand(0.25, 0.85);
      rock.position.set(x, -7 + s * 0.32, z);
      rock.scale.set(s * rand(0.9, 1.45), s * rand(0.55, 0.9), s * rand(0.8, 1.3));
      rock.rotation.set(rand(0, 2), rand(0, 3), rand(0, 2));
      rock.castShadow = true;
      rock.receiveShadow = true;
      scene.add(rock);
    }

    const pebbleCount = 75;
    const pebbles = new THREE.InstancedMesh(
      new THREE.DodecahedronGeometry(1, 0),
      new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.92, flatShading: true }),
      pebbleCount
    );
    const pebbleMatrix = new THREE.Matrix4();
    const pebbleQuaternion = new THREE.Quaternion();
    const pebbleScale = new THREE.Vector3();
    const pebblePosition = new THREE.Vector3();
    const pebbleColors = [0x9a9174, 0x6e8073, 0xb0a481, 0x68766c];
    for (let i = 0; i < pebbleCount; i++) {
      const s = rand(0.055, 0.2);
      pebblePosition.set(rand(-14.3, 14.3), -7.02 + s * 0.15, rand(-5.6, 5.2));
      pebbleQuaternion.setFromEuler(new THREE.Euler(rand(0, 3), rand(0, 3), rand(0, 3)));
      pebbleScale.set(s * rand(0.8, 1.7), s * rand(0.45, 0.8), s);
      pebbleMatrix.compose(pebblePosition, pebbleQuaternion, pebbleScale);
      pebbles.setMatrixAt(i, pebbleMatrix);
      pebbles.setColorAt(i, new THREE.Color(pebbleColors[i % pebbleColors.length]));
    }
    pebbles.instanceMatrix.needsUpdate = true;
    if (pebbles.instanceColor) pebbles.instanceColor.needsUpdate = true;
    pebbles.receiveShadow = true;
    scene.add(pebbles);

    function makeBladeGeometry(heightValue, widthValue, bend) {
      const segments = 11;
      const positions = [];
      const uvs = [];
      const indices = [];

      for (let i = 0; i <= segments; i++) {
        const t = i / segments;
        const centerX = Math.sin(t * 2.2) * bend * t;
        const halfWidth = widthValue * (1 - t * 0.88) * (0.85 + Math.sin(t * 5) * 0.08);
        positions.push(centerX - halfWidth, t * heightValue, 0);
        positions.push(centerX + halfWidth, t * heightValue, 0);
        uvs.push(0, t, 1, t);
        if (i < segments) {
          const a = i * 2;
          indices.push(a, a + 1, a + 2, a + 1, a + 3, a + 2);
        }
      }

      const geometry = new THREE.BufferGeometry();
      geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
      geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
      geometry.setIndex(indices);
      geometry.computeVertexNormals();
      return geometry;
    }

    const plantMaterials = [
      new THREE.MeshStandardMaterial({ color: 0x2d9679, emissive: 0x0b493d, emissiveIntensity: 0.18, roughness: 0.72, side: THREE.DoubleSide }),
      new THREE.MeshStandardMaterial({ color: 0x55aa75, emissive: 0x123f31, emissiveIntensity: 0.16, roughness: 0.75, side: THREE.DoubleSide }),
      new THREE.MeshStandardMaterial({ color: 0x277b70, emissive: 0x0b3c35, emissiveIntensity: 0.2, roughness: 0.72, side: THREE.DoubleSide })
    ];
    const plants = [];

    const plantLocations = [
      [-12.5, -3.6], [-10.8, -4.2], [-8.8, -4.7], [-6.7, -5.0],
      [12.1, -3.8], [10.2, -4.5], [7.8, -4.9], [5.8, -5.1],
      [-13.6, 1.3], [13.2, 0.8], [-2.5, -5.1], [2.8, -5.25]
    ];

    plantLocations.forEach((location, clusterIndex) => {
      const bladeCount = 3 + Math.floor(random() * 3);
      for (let j = 0; j < bladeCount; j++) {
        const h = rand(2.1, 5.6);
        const blade = new THREE.Mesh(
          makeBladeGeometry(h, rand(0.13, 0.3), rand(-0.65, 0.65)),
          plantMaterials[(clusterIndex + j) % plantMaterials.length]
        );
        blade.position.set(location[0] + rand(-0.6, 0.6), -7.02, location[1] + rand(-0.35, 0.35));
        blade.rotation.y = rand(-0.85, 0.85);
        blade.userData.baseZ = rand(-0.13, 0.13);
        blade.userData.phase = rand(0, Math.PI * 2);
        blade.userData.speed = rand(0.45, 0.8);
        blade.userData.sway = rand(0.035, 0.09);
        scene.add(blade);
        plants.push(blade);
      }
    });

    const unitCylinder = new THREE.CylinderGeometry(1, 1, 1, 7);
    const coralTipGeometry = new THREE.SphereGeometry(1, 7, 5);
    const upVector = new THREE.Vector3(0, 1, 0);

    function cylinderBetween(a, b, radius, material, parent) {
      const direction = new THREE.Vector3().subVectors(b, a);
      const length = direction.length();
      const branch = new THREE.Mesh(unitCylinder, material);
      branch.position.copy(a).add(b).multiplyScalar(0.5);
      branch.scale.set(radius, length, radius);
      branch.quaternion.setFromUnitVectors(upVector, direction.normalize());
      branch.castShadow = true;
      parent.add(branch);
    }

    function createCoral(position, color, scaleValue) {
      const group = new THREE.Group();
      group.position.copy(position);
      group.scale.setScalar(scaleValue);
      scene.add(group);

      const material = new THREE.MeshStandardMaterial({
        color,
        emissive: color,
        emissiveIntensity: 0.075,
        roughness: 0.72,
        flatShading: true
      });

      function grow(start, direction, length, radius, depth) {
        const end = start.clone().addScaledVector(direction, length);
        cylinderBetween(start, end, radius, material, group);

        if (depth === 0) {
          const tip = new THREE.Mesh(coralTipGeometry, material);
          tip.position.copy(end);
          tip.scale.setScalar(radius * 1.35);
          group.add(tip);
          return;
        }

        const branches = depth === 2 ? 3 : 2;
        for (let i = 0; i < branches; i++) {
          const newDirection = direction.clone();
          newDirection.x += rand(-0.7, 0.7);
          newDirection.z += rand(-0.45, 0.45);
          newDirection.y = Math.abs(newDirection.y) + rand(0.15, 0.55);
          newDirection.normalize();
          grow(end, newDirection, length * rand(0.57, 0.73), radius * 0.68, depth - 1);
        }
      }

      grow(new THREE.Vector3(), new THREE.Vector3(rand(-0.1, 0.1), 1, 0).normalize(), 1.55, 0.16, 2);
    }

    createCoral(new THREE.Vector3(-9.7, -6.9, -0.5), 0xf0767f, 1.12);
    createCoral(new THREE.Vector3(9.3, -6.95, -1.5), 0xf39a67, 1.2);
    createCoral(new THREE.Vector3(5.8, -6.95, -4.2), 0xa477c9, 0.82);
    createCoral(new THREE.Vector3(-4.8, -6.98, -4.7), 0xe68eaa, 0.64);

    function createStarfish(x, z, color, sizeValue, rotation) {
      const shape = new THREE.Shape();
      for (let i = 0; i < 10; i++) {
        const angle = i / 10 * Math.PI * 2 - Math.PI / 2;
        const radius = i % 2 === 0 ? 0.78 : 0.28;
        const px = Math.cos(angle) * radius;
        const py = Math.sin(angle) * radius;
        if (i === 0) shape.moveTo(px, py);
        else shape.lineTo(px, py);
      }
      shape.closePath();
      const geometry = new THREE.ExtrudeGeometry(shape, {
        depth: 0.1,
        bevelEnabled: true,
        bevelSegments: 2,
        bevelSize: 0.055,
        bevelThickness: 0.04
      });
      const star = new THREE.Mesh(
        geometry,
        new THREE.MeshStandardMaterial({ color, roughness: 0.82 })
      );
      star.rotation.set(-Math.PI / 2, 0, rotation);
      star.position.set(x, -6.93, z);
      star.scale.setScalar(sizeValue);
      star.castShadow = true;
      scene.add(star);
    }

    createStarfish(-3.6, 2.1, 0xe98255, 0.75, 0.5);
    createStarfish(7.1, 2.8, 0xdab16c, 0.5, -0.7);

    function makeBubbleTexture() {
      const canvas = document.createElement('canvas');
      canvas.width = 64;
      canvas.height = 64;
      const ctx = canvas.getContext('2d');
      const gradient = ctx.createRadialGradient(25, 21, 2, 32, 32, 29);
      gradient.addColorStop(0, 'rgba(255,255,255,0.65)');
      gradient.addColorStop(0.18, 'rgba(190,250,255,0.12)');
      gradient.addColorStop(0.7, 'rgba(115,220,235,0.04)');
      gradient.addColorStop(0.84, 'rgba(190,250,255,0.55)');
      gradient.addColorStop(1, 'rgba(160,240,250,0)');
      ctx.fillStyle = gradient;
      ctx.fillRect(0, 0, 64, 64);
      const texture = new THREE.CanvasTexture(canvas);
      texture.colorSpace = THREE.SRGBColorSpace;
      return texture;
    }

    const bubbleCount = 72;
    const bubblePositions = new Float32Array(bubbleCount * 3);
    const bubbleData = [];
    const bubbleSources = [-11.6, 10.8, -1.8];

    for (let i = 0; i < bubbleCount; i++) {
      const source = bubbleSources[i % bubbleSources.length];
      const data = {
        source,
        x: source + rand(-0.45, 0.45),
        y: rand(-6.7, 7.2),
        z: rand(-4.8, 3.8),
        speed: rand(0.32, 0.88),
        phase: rand(0, Math.PI * 2),
        drift: rand(0.06, 0.2)
      };
      bubbleData.push(data);
      bubblePositions[i * 3] = data.x;
      bubblePositions[i * 3 + 1] = data.y;
      bubblePositions[i * 3 + 2] = data.z;
    }

    const bubbleGeometry = new THREE.BufferGeometry();
    bubbleGeometry.setAttribute('position', new THREE.BufferAttribute(bubblePositions, 3));
    const bubbles = new THREE.Points(
      bubbleGeometry,
      new THREE.PointsMaterial({
        color: 0xc6ffff,
        map: makeBubbleTexture(),
        size: 0.25,
        transparent: true,
        opacity: 0.68,
        depthWrite: false,
        blending: THREE.AdditiveBlending,
        sizeAttenuation: true
      })
    );
    scene.add(bubbles);

    const moteCount = 145;
    const motePositions = new Float32Array(moteCount * 3);
    for (let i = 0; i < moteCount; i++) {
      motePositions[i * 3] = rand(-14.5, 14.5);
      motePositions[i * 3 + 1] = rand(-6.7, 7.1);
      motePositions[i * 3 + 2] = rand(-5.6, 5.3);
    }
    const moteGeometry = new THREE.BufferGeometry();
    moteGeometry.setAttribute('position', new THREE.BufferAttribute(motePositions, 3));
    const motes = new THREE.Points(
      moteGeometry,
      new THREE.PointsMaterial({
        color: 0xc8fff1,
        size: 0.035,
        transparent: true,
        opacity: 0.34,
        depthWrite: false,
        blending: THREE.AdditiveBlending
      })
    );
    scene.add(motes);

    const tankEdges = new THREE.LineSegments(
      new THREE.EdgesGeometry(new THREE.BoxGeometry(30, 15, 12)),
      new THREE.LineBasicMaterial({
        color: 0xb5f4ed,
        transparent: true,
        opacity: 0.16
      })
    );
    scene.add(tankEdges);

    const glassFront = new THREE.Mesh(
      new THREE.PlaneGeometry(30, 15),
      new THREE.MeshBasicMaterial({
        color: 0xa1f2ec,
        transparent: true,
        opacity: 0.024,
        depthWrite: false,
        side: THREE.DoubleSide
      })
    );
    glassFront.position.z = 6.015;
    glassFront.renderOrder = 10;
    scene.add(glassFront);

    const rimMaterial = new THREE.MeshBasicMaterial({
      color: 0xd4fffa,
      transparent: true,
      opacity: 0.17,
      depthWrite: false
    });
    [-14.96, 14.96].forEach(x => {
      const rim = new THREE.Mesh(new THREE.PlaneGeometry(0.055, 14.8), rimMaterial);
      rim.position.set(x, 0, 6.03);
      rim.renderOrder = 11;
      scene.add(rim);
    });

    function makeFishTexture(species) {
      const canvas = document.createElement('canvas');
      canvas.width = 256;
      canvas.height = 128;
      const ctx = canvas.getContext('2d');

      const gradient = ctx.createLinearGradient(0, 0, 0, 128);
      gradient.addColorStop(0, species.light);
      gradient.addColorStop(0.45, species.base);
      gradient.addColorStop(1, species.dark);
      ctx.fillStyle = gradient;
      ctx.fillRect(0, 0, 256, 128);

      const sheen = ctx.createLinearGradient(0, 0, 256, 0);
      sheen.addColorStop(0, 'rgba(255,255,255,0)');
      sheen.addColorStop(0.45, 'rgba(255,255,255,0.16)');
      sheen.addColorStop(0.7, 'rgba(255,255,255,0)');
      ctx.fillStyle = sheen;
      ctx.fillRect(0, 0, 256, 128);

      if (species.pattern === 'clown') {
        [[20, 27], [70, 29], [105, 18]].forEach(([y, h]) => {
          ctx.fillStyle = 'rgba(36,35,31,0.8)';
          ctx.fillRect(0, y - 3, 256, h + 6);
          ctx.fillStyle = 'rgba(255,250,218,0.96)';
          ctx.fillRect(0, y, 256, h);
        });
      } else if (species.pattern === 'blue') {
        ctx.strokeStyle = 'rgba(5,39,100,0.65)';
        ctx.lineWidth = 10;
        ctx.beginPath();
        ctx.moveTo(0, 24);
        ctx.bezierCurveTo(80, 8, 148, 66, 256, 35);
        ctx.stroke();
        ctx.lineWidth = 5;
        ctx.beginPath();
        ctx.moveTo(0, 101);
        ctx.bezierCurveTo(80, 77, 162, 117, 256, 80);
        ctx.stroke();
      } else if (species.pattern === 'gold') {
        ctx.strokeStyle = 'rgba(255,255,220,0.18)';
        ctx.lineWidth = 2;
        for (let y = 14; y < 128; y += 16) {
          ctx.beginPath();
          ctx.moveTo(0, y);
          ctx.lineTo(256, y + 8);
          ctx.stroke();
        }
      } else {
        ctx.fillStyle = 'rgba(255,205,240,0.22)';
        for (let i = 0; i < 45; i++) {
          ctx.beginPath();
          ctx.arc(rand(0, 256), rand(0, 128), rand(1, 3.5), 0, Math.PI * 2);
          ctx.fill();
        }
      }

      const texture = new THREE.CanvasTexture(canvas);
      texture.colorSpace = THREE.SRGBColorSpace;
      texture.anisotropy = Math.min(4, renderer.capabilities.getMaxAnisotropy());
      return texture;
    }

    const species = [
      {
        name: 'Blue tang',
        base: '#168dc8', light: '#5bd6e0', dark: '#15549d',
        fin: 0xf3cd46, pattern: 'blue', cruise: 1.5, minSize: 0.74, maxSize: 0.98
      },
      {
        name: 'Clownfish',
        base: '#f47735', light: '#ffc066', dark: '#d84b24',
        fin: 0xff8c42, pattern: 'clown', cruise: 1.35, minSize: 0.66, maxSize: 0.88
      },
      {
        name: 'Golden chromis',
        base: '#edc64b', light: '#fff09a', dark: '#c8962e',
        fin: 0xffdb62, pattern: 'gold', cruise: 1.75, minSize: 0.46, maxSize: 0.64
      },
      {
        name: 'Orchid dottyback',
        base: '#a55bc6', light: '#ed9bd4', dark: '#643a9b',
        fin: 0xd98ade, pattern: 'orchid', cruise: 1.42, minSize: 0.72, maxSize: 0.96
      }
    ];

    const bodyGeometry = new THREE.SphereGeometry(1, 24, 16);
    bodyGeometry.rotateX(Math.PI / 2);

    const tailGeometry = new THREE.BufferGeometry();
    tailGeometry.setAttribute('position', new THREE.Float32BufferAttribute([
      0, 0, 0,   0, 0.76, -0.88,   0, 0, -0.5,
      0, 0, 0,   0, 0, -0.5,       0, -0.76, -0.88
    ], 3));
    tailGeometry.computeVertexNormals();

    const dorsalGeometry = new THREE.BufferGeometry();
    dorsalGeometry.setAttribute('position', new THREE.Float32BufferAttribute([
      0, 0.42, 0.48,
      0, 0.92, -0.2,
      0, 0.4, -0.72
    ], 3));
    dorsalGeometry.computeVertexNormals();

    const pectoralGeometry = new THREE.BufferGeometry();
    pectoralGeometry.setAttribute('position', new THREE.Float32BufferAttribute([
      0, 0, 0.3,
      0.68, -0.16, -0.28,
      0.12, 0, -0.46
    ], 3));
    pectoralGeometry.computeVertexNormals();

    const eyeGeometry = new THREE.SphereGeometry(0.115, 10, 8);
    const pupilGeometry = new THREE.SphereGeometry(0.064, 9, 7);
    const eyeMaterial = new THREE.MeshStandardMaterial({ color: 0xf4fff3, roughness: 0.25 });
    const pupilMaterial = new THREE.MeshStandardMaterial({ color: 0x071a22, roughness: 0.16 });

    species.forEach(s => {
      s.bodyMaterial = new THREE.MeshStandardMaterial({
        map: makeFishTexture(s),
        color: 0xffffff,
        roughness: 0.34,
        metalness: 0.035,
        emissive: new THREE.Color(s.base),
        emissiveIntensity: 0.055
      });
      s.finMaterial = new THREE.MeshStandardMaterial({
        color: s.fin,
        emissive: s.fin,
        emissiveIntensity: 0.045,
        roughness: 0.5,
        transparent: true,
        opacity: 0.92,
        side: THREE.DoubleSide
      });
    });

    const fish = [];
    const mobileCount = window.innerWidth < 600;
    const counts = mobileCount ? [4, 3, 4, 2] : [6, 5, 7, 4];

    function createFish(speciesIndex) {
      const s = species[speciesIndex];
      const sizeValue = rand(s.minSize, s.maxSize);
      const group = new THREE.Group();
      group.scale.setScalar(sizeValue);

      const body = new THREE.Mesh(bodyGeometry, s.bodyMaterial);
      body.scale.set(0.56, 0.62, 1.05);
      body.castShadow = true;
      group.add(body);

      const tail = new THREE.Group();
      tail.position.z = -0.88;
      const tailMesh = new THREE.Mesh(tailGeometry, s.finMaterial);
      tail.add(tailMesh);
      group.add(tail);

      const dorsal = new THREE.Mesh(dorsalGeometry, s.finMaterial);
      group.add(dorsal);

      const finLeft = new THREE.Mesh(pectoralGeometry, s.finMaterial);
      finLeft.position.set(0.4, -0.08, 0.08);
      group.add(finLeft);

      const finRight = new THREE.Mesh(pectoralGeometry, s.finMaterial);
      finRight.position.set(-0.4, -0.08, 0.08);
      finRight.scale.x = -1;
      group.add(finRight);

      [-1, 1].forEach(side => {
        const eye = new THREE.Mesh(eyeGeometry, eyeMaterial);
        eye.position.set(side * 0.435, 0.205, 0.64);
        group.add(eye);

        const pupil = new THREE.Mesh(pupilGeometry, pupilMaterial);
        pupil.position.set(side * 0.51, 0.205, 0.665);
        group.add(pupil);
      });

      let position;
      let attempts = 0;
      do {
        position = new THREE.Vector3(
          rand(-swimBounds.halfWidth * 0.88, swimBounds.halfWidth * 0.88),
          rand(-4.5, 4.8),
          rand(-3.9, 3.8)
        );
        attempts++;
      } while (
        attempts < 18 &&
        fish.some(f => f.group.position.distanceToSquared(position) < 1.4)
      );

      group.position.copy(position);
      scene.add(group);

      const velocity = new THREE.Vector3(
        rand(-1, 1),
        rand(-0.2, 0.2),
        rand(-0.3, 0.3)
      ).normalize().multiplyScalar(s.cruise);

      fish.push({
        group,
        body,
        tail,
        finLeft,
        finRight,
        vel: velocity,
        acc: new THREE.Vector3(),
        cruise: s.cruise * rand(0.88, 1.12),
        school: speciesIndex,
        size: sizeValue,
        phase: rand(0, Math.PI * 2),
        panic: 0,
        cooldown: 0
      });
    }

    counts.forEach((count, speciesIndex) => {
      for (let i = 0; i < count; i++) createFish(speciesIndex);
    });
    document.getElementById('fishCount').textContent = fish.length;

    const schools = species.map(() => ({
      center: new THREE.Vector3(),
      velocity: new THREE.Vector3(),
      count: 0
    }));

    const pointer = {
      active: false,
      x: width * 0.5,
      y: height * 0.5,
      ndc: new THREE.Vector2()
    };
    const pulse = { x: 0, y: 0, until: -1 };
    let elapsed = 0;
    let reactions = 0;
    let cursorStartling = false;

    function updatePointer(event) {
      pointer.x = event.clientX;
      pointer.y = event.clientY;
      pointer.ndc.set(
        event.clientX / width * 2 - 1,
        -(event.clientY / height) * 2 + 1
      );
      pointer.active = true;

      if (event.pointerType !== 'touch') {
        cursorRing.style.left = `${event.clientX}px`;
        cursorRing.style.top = `${event.clientY}px`;
        cursorRing.style.opacity = '1';
      }
    }

    window.addEventListener('pointermove', updatePointer, { passive: true });

    renderer.domElement.addEventListener('pointerdown', event => {
      updatePointer(event);
      pulse.x = event.clientX;
      pulse.y = event.clientY;
      pulse.until = elapsed + 0.36;

      const ripple = document.createElement('div');
      ripple.className = 'click-ripple';
      ripple.style.left = `${event.clientX}px`;
      ripple.style.top = `${event.clientY}px`;
      document.body.appendChild(ripple);
      window.setTimeout(() => ripple.remove(), 900);
    });

    window.addEventListener('pointerup', event => {
      if (event.pointerType === 'touch') pointer.active = false;
    });

    document.documentElement.addEventListener('mouseleave', () => {
      pointer.active = false;
      cursorRing.style.opacity = '0';
    });

    window.addEventListener('blur', () => {
      pointer.active = false;
      cursorRing.style.opacity = '0';
    });

    window.addEventListener('resize', resize);

    const projected = new THREE.Vector3();
    const cameraRight = new THREE.Vector3();
    const cameraUp = new THREE.Vector3();
    const away = new THREE.Vector3();
    const temp = new THREE.Vector3();
    const temp2 = new THREE.Vector3();
    const clock = new THREE.Clock();
    let frame = 0;

    function animate() {
      requestAnimationFrame(animate);
      const dt = Math.min(clock.getDelta(), 0.034);
      elapsed += dt;
      frame++;

      const targetCameraX = pointer.active
        ? pointer.ndc.x * 0.48
        : Math.sin(elapsed * 0.11) * 0.25;
      const targetCameraY = 0.7 + (pointer.active
        ? pointer.ndc.y * 0.24
        : Math.cos(elapsed * 0.13) * 0.12);

      camera.position.x += (targetCameraX - camera.position.x) * Math.min(1, dt * 1.25);
      camera.position.y += (targetCameraY - camera.position.y) * Math.min(1, dt * 1.25);
      camera.position.z += (baseCameraZ - camera.position.z) * Math.min(1, dt * 2);
      camera.lookAt(0, 0, 0);
      camera.updateMatrixWorld();

      cameraRight.set(1, 0, 0).applyQuaternion(camera.quaternion);
      cameraUp.set(0, 1, 0).applyQuaternion(camera.quaternion);

      causticMaterial.uniforms.uTime.value = elapsed;
      shaftMaterials.forEach(material => material.uniforms.uTime.value = elapsed);
      waterSurface.material.opacity = 0.085 + Math.sin(elapsed * 0.7) * 0.015;
      motes.rotation.y = Math.sin(elapsed * 0.055) * 0.018;
      motes.position.y = Math.sin(elapsed * 0.15) * 0.09;

      plants.forEach(plant => {
        const data = plant.userData;
        plant.rotation.z = data.baseZ + Math.sin(elapsed * data.speed + data.phase) * data.sway;
        plant.rotation.x = Math.cos(elapsed * data.speed * 0.73 + data.phase) * data.sway * 0.45;
      });

      const bubbleAttribute = bubbleGeometry.attributes.position;
      for (let i = 0; i < bubbleCount; i++) {
        const b = bubbleData[i];
        b.y += b.speed * dt;
        if (b.y > 7.25) {
          b.y = -6.8;
          b.x = b.source + rand(-0.4, 0.4);
          b.z = rand(-4.8, 3.8);
        }
        bubblePositions[i * 3] = b.x + Math.sin(elapsed * 1.2 + b.phase) * b.drift;
        bubblePositions[i * 3 + 1] = b.y;
        bubblePositions[i * 3 + 2] = b.z + Math.cos(elapsed * 0.8 + b.phase) * 0.045;
      }
      bubbleAttribute.needsUpdate = true;

      schools.forEach(school => {
        school.center.set(0, 0, 0);
        school.velocity.set(0, 0, 0);
        school.count = 0;
      });

      fish.forEach(f => {
        const school = schools[f.school];
        school.center.add(f.group.position);
        school.velocity.add(f.vel);
        school.count++;
      });

      schools.forEach(school => {
        if (school.count) {
          school.center.multiplyScalar(1 / school.count);
          school.velocity.multiplyScalar(1 / school.count);
        }
      });

      const pulseActive = elapsed < pulse.until;
      const interactionActive = pointer.active || pulseActive;
      const interactionX = pulseActive ? pulse.x : pointer.x;
      const interactionY = pulseActive ? pulse.y : pointer.y;
      let startledNow = 0;
      let panickedCount = 0;

      for (let i = 0; i < fish.length; i++) {
        const f = fish[i];
        const position = f.group.position;
        const acc = f.acc.set(0, 0, 0);

        f.panic = Math.max(0, f.panic - dt);
        f.cooldown = Math.max(0, f.cooldown - dt);

        acc.set(
          Math.sin(elapsed * 0.38 + f.phase) * 0.18,
          Math.sin(elapsed * 0.72 + f.phase * 1.7) * 0.12,
          Math.cos(elapsed * 0.43 + f.phase * 0.8) * 0.14
        );

        const school = schools[f.school];
        if (school.count > 1 && f.panic <= 0) {
          temp.copy(school.center).sub(position);
          acc.addScaledVector(temp, 0.025);
          temp.copy(school.velocity).sub(f.vel);
          acc.addScaledVector(temp, 0.045);
        }

        for (let j = 0; j < fish.length; j++) {
          if (i === j) continue;
          const other = fish[j];
          const minDistance = 0.85 + (f.size + other.size) * 0.65;
          const distanceSq = position.distanceToSquared(other.group.position);
          if (distanceSq > 0.0001 && distanceSq < minDistance * minDistance) {
            const distance = Math.sqrt(distanceSq);
            temp.copy(position).sub(other.group.position).multiplyScalar(1 / distance);
            acc.addScaledVector(temp, (1 - distance / minDistance) * 1.55);
          }
        }

        const softX = swimBounds.halfWidth - 1.25;
        if (position.x > softX) acc.x -= (position.x - softX) * 2.2 + 0.25;
        if (position.x < -softX) acc.x += (-softX - position.x) * 2.2 + 0.25;
        if (position.y > 4.35) acc.y -= (position.y - 4.35) * 2 + 0.2;
        if (position.y < -4.65) acc.y += (-4.65 - position.y) * 2 + 0.2;
        if (position.z > 3.25) acc.z -= (position.z - 3.25) * 2 + 0.15;
        if (position.z < -3.35) acc.z += (-3.35 - position.z) * 2 + 0.15;

        if (interactionActive) {
          projected.copy(position).project(camera);
          const screenX = (projected.x * 0.5 + 0.5) * width;
          const screenY = (-projected.y * 0.5 + 0.5) * height;
          const dx = screenX - interactionX;
          const dy = screenY - interactionY;
          const distance = Math.sqrt(dx * dx + dy * dy);
          const radius = (pulseActive ? 245 : 122) + f.size * 22;

          if (distance < radius && projected.z < 1) {
            const safeDistance = Math.max(distance, 0.001);
            const strength = Math.max(0, 1 - distance / radius);

            away.set(0, 0, 0)
              .addScaledVector(cameraRight, dx / safeDistance)
              .addScaledVector(cameraUp, -dy / safeDistance);

            if (away.lengthSq() < 0.001) {
              away.set(Math.sin(f.phase), Math.cos(f.phase), 0.2);
            }
            away.normalize();

            acc.addScaledVector(away, (pulseActive ? 32 : 24) * strength + 1.5);
            acc.z += Math.sin(f.phase * 2.3) * strength * 4;

            if (f.panic <= 0.06 && f.cooldown <= 0) {
              reactions++;
              f.cooldown = 0.8;
              reactionCount.textContent = String(reactions).padStart(2, '0');
            }

            f.panic = Math.max(f.panic, pulseActive ? 1.15 : 0.85);
            startledNow++;
          }
        }

        if (f.panic > 0) panickedCount++;

        const speed = f.vel.length();
        if (speed > 0.001 && f.panic <= 0) {
          temp.copy(f.vel).multiplyScalar(1 / speed);
          acc.addScaledVector(temp, (f.cruise - speed) * 1.35);
        }

        f.vel.addScaledVector(acc, dt);

        const maxSpeed = f.cruise + Math.min(1, f.panic * 2.2) * (6.4 - f.cruise);
        const newSpeed = f.vel.length();
        if (newSpeed > maxSpeed) f.vel.multiplyScalar(maxSpeed / newSpeed);
        else if (newSpeed < 0.52) f.vel.multiplyScalar(0.52 / Math.max(newSpeed, 0.001));

        position.addScaledVector(f.vel, dt);

        if (position.x > swimBounds.halfWidth) {
          position.x = swimBounds.halfWidth;
          f.vel.x = -Math.abs(f.vel.x) * 0.82;
        } else if (position.x < -swimBounds.halfWidth) {
          position.x = -swimBounds.halfWidth;
          f.vel.x = Math.abs(f.vel.x) * 0.82;
        }

        if (position.y > 5.35) {
          position.y = 5.35;
          f.vel.y = -Math.abs(f.vel.y) * 0.8;
        } else if (position.y < -5.55) {
          position.y = -5.55;
          f.vel.y = Math.abs(f.vel.y) * 0.8;
        }

        if (position.z > 4.45) {
          position.z = 4.45;
          f.vel.z = -Math.abs(f.vel.z) * 0.8;
        } else if (position.z < -4.45) {
          position.z = -4.45;
          f.vel.z = Math.abs(f.vel.z) * 0.8;
        }

        temp2.copy(position).add(f.vel);
        f.group.lookAt(temp2);
        f.group.rotateZ(Math.sin(elapsed * 0.85 + f.phase) * 0.035 - f.vel.y * 0.025);

        const wagSpeed = f.panic > 0 ? 16 : 7.2;
        const wagAmount = f.panic > 0 ? 0.54 : 0.28;
        f.tail.rotation.y = Math.sin(elapsed * wagSpeed + f.phase) * wagAmount;

        const finMotion = Math.sin(elapsed * (f.panic > 0 ? 13 : 5.2) + f.phase);
        f.finLeft.rotation.z = 0.1 + finMotion * 0.2;
        f.finRight.rotation.z = -0.1 - finMotion * 0.2;
      }

      if (frame % 5 === 0) {
        const energy = panickedCount / Math.max(1, fish.length);
        activityBars.forEach((bar, i) => {
          const wave = Math.sin(elapsed * (2.1 + energy * 5) + i * 0.83) * 0.5 + 0.5;
          const barHeight = 6 + wave * (7 + energy * 20) + energy * 5;
          bar.style.height = `${barHeight}px`;
          bar.style.opacity = `${0.46 + wave * 0.38 + energy * 0.15}`;
        });

        activityLabel.textContent = panickedCount > fish.length * 0.42
          ? 'STARTLED'
          : panickedCount > 0
            ? 'STIRRING'
            : 'CRUISING';
      }

      const shouldHighlightCursor = startledNow > 0;
      if (shouldHighlightCursor !== cursorStartling) {
        cursorStartling = shouldHighlightCursor;
        cursorRing.classList.toggle('is-startling', cursorStartling);
      }

      renderer.render(scene, camera);
    }

    animate();
  </script>
</body>
</html>
Reasoning trace (19,201 tokens)
I need build single HTML Three.js aquarium 3D fish flee mouse. Should have exactly code block html complete inline. Should thoughtful polished visual interactive demo start? This isn't game necessarily, no requirement game self playing. Something meaningful first paint. I should create aquarium with animated fish, bubbles, caustics perhaps plants, coral, rocks, glass, nice UI controls possibly no external fonts assets. Three import map only CDN allowed. Should avoid errors. Should have robust resizing 1280x800 and 375. Mouse near fish ray-projected into 3D plane; fish swim away. Could have interactive "Drag to orbit · Move near fish to startle them" and counter. Should use Three.js and OrbitControls addons perhaps okay via import map. But user specifically mouse move near fish should flee. Orbit controls may conflict with pointer movement but fine. Could instead camera fixed parallax and projected ray. Better minimal stable. Should have visual stunning aquarium. Use Three.js, custom geometries, lighting, shadows? Sandbox 3 sec. Should have no external images fonts. Could generate canvas textures? Inline allowed. Three.js import asynchronous but screenshot ~3 sec enough. Should have no errors.

I need think design composition. Full-screen with gradient navy background, central aquarium container with tank? Could use entire viewport with controls overlay. Should have "3D aquarium" appears full screen perhaps central large rounded panel with HUD. Could render watery scene in canvas full viewport with fish etc and glass, visible in screenshot. HTML overlay title "LITTLE BLUE / interactive aquarium", pill "LIVE ECOSYSTEM" etc. Should fish geometry identifiable and colorful. A few varieties. Background blue gradient, seabed sand, rocks, kelp swaying, bubbles, light shafts, caustic projected patterns. Fish swim procedural paths with velocity and steering, flee mouse in projected screen proximity and/or 3D ray. Avoid fish get stuck or clump. Start animate immediately. Cursor small glow bubble. On pointer move fish within ~140px run away away from projected pointer with world direction. Also perhaps raycaster near in 3D. Should intuitive robust.

Could build world dimensions tank x=30, y=16, z=12. Camera at (0,2,30), look at (0,0,0), perspective 42 deg. Fish positions. Fish moving through water with boids wander. Mouse ray intersect z=0 plane to world pointer; compute screen distance using Vector3.project(camera). Influence pointer near fish based on pixel dist. Should flee direction in world: use fish screen pos vs mouse screen => projected direction x,y then convert world direction scaled camera orientation. Since camera fixed slightly looks at origin and perhaps parallax, could use camera right/up. If use OrbitControls dynamic camera, right/up works but ray computations. Simpler fixed camera with subtle mouse parallax using camera target offset, fish response still via screen positions. Should not move camera too much when pointer moving; else affects projection. Use camera at (0,2,31) target 0. scene rotate? No.

Tank glass panels and frame: use BoxGeometry transparent material for front? If transparent full cube may render weird; use edges, glass side planes with MeshPhysicalMaterial, transmission perhaps expensive and tricky; use simple translucent front PlaneGeometry with opacity 0.04 and border lines. Floor at y=-7.2, x width 30, z depth 12, sand with slight dunes? Plane rotation -pi/2. Background back plane at z=-6 colored deep turquoise with subtle shader caustics. Side glass panels. Use FogExp2 or Fog linear to deep. Renderer toneMapping ACES, antialias true, output SRGB. Shadows perhaps renderer.shadowMap enabled PCFSoft, lights. Use ambient/hemi, directional, point. Performance target ~30 fish each grouped mesh with ~10 meshes = 300 draw calls could be heavy but okay. 16-22 fish. Use geometries shared and materials. Fish body using SphereGeometry scaled, tail custom triangle BufferGeometry or ConeGeometry flattened. Fins triangles. Eyes black/white spheres. Each fish group oriented along local +X? Build body sphere scale (1.1,0.65,0.45), head at +X. Tail at x=-1.2 with triangles in XY plane perhaps tail vertical fan. Fish direction along +X. Orient to velocity using quaternion setFromUnitVectors(new Vector3(1,0,0), vel normalized) but this causes roll arbitrary; better group.lookAt(pos+vel) makes local -Z points, not my +X. Could create fish model along -Z and lookAt works. Common use body along Z, head -Z; tail +Z. Or use quaternion with matrix lookAt then rotation Y. Use group.lookAt(position+vel) and rotateY(Math.PI/2)? Three lookAt for Object3D points +Z? Actually Object3D.lookAt rotates local +Z toward target for objects except cameras/lights -Z. In Three Object3D lookAt orients positive z to target. Yes docs: rotates to face in world space; camera -Z. Meshes +Z. Build fish forward +Z. Body SphereGeometry scaled (0.55,0.5,1.1), head at z=+0.75, tail z=-1.1. group.lookAt(pos+vel). Then tail fin in plane YZ at back x=0, triangles across y and z. Fish tail should vertical with lobes y ±0.75 at z=-1.65, base z=-1.0. Geometry vertices [0,0,-0.95, 0,0.75,-1.7, 0,-0.75,-1.7], double side. For 3D visible side at camera frontal z positive? Fish horizontal across x means side visible. Fine. Pectoral fins at sides etc. Eye positions x ±0.38, y0.2, z0.72, tiny white/black spheres. Body perhaps custom shader gradient stripes? Could use vertex colors via geometry? Each fish group has body mat MeshStandard color, roughness .35 metalness .05. Add stripes as torus? For each fish body create 3 thin bands torus scaled? Could use CanvasTexture per type with gradient/stripes generated. Fish sphere UV texture with canvas 256x128, colorful gradient and stripes. Create 4 textures. Eye etc. Clownfish orange with white stripes black edges; blue tang blue/yellow; yellow fish. I can generate canvas texture allowed. Use texture on SphereGeometry, set colorSpace. Body material map + color white. Sphere local z length. Pattern UV not exact but looks nice. Add tail same color or fin color. Perhaps body with emissive low. Add dorsal fin triangle.

Fish schooling: 24 fish in 4 species, each params scale speed phase. Movement use velocity and desired wander plus bounds. Each fish has base target generated and schooling? Steering:
- individual vel with sinusoidal vertical motion and z drift.
- flow = sin/cos position/time mild
- bound forces if abs x > 13 etc.
- neighbor separation among fish if distance <1.3 perhaps O(n²) fine.
- cursor repulsion strong with radius in pixels 135, using screen positions each frame. Determine ndc distance converted to viewport. If pointerActive and dist <radius, derive away screen vector. world direction = camera right*dx + camera up*dy (where dx from mouse to fish in NDC adjusted perhaps pixel). Add normalized world dir * strength (1-dist/radius)* something plus forward jitter. vel += repulsion * dt * ~15. Set scared timer. Fish acceleration and max speed 2-4 vs flee 7.
- Also clicking creates pulse? Could add "Tap to scatter" perhaps nice. But request move mouse near. On click could feed? Avoid complicating.
- fish body wiggle tail with tail.rotation.y? Since tail geometry in yz plane at x0, tail wag should around y? Tail fin extends backwards local -Z, rotates around Y changes x z yes. Assign pivot group at z=-0.85, geometry coords relative pivot. Tail.rotation.y = sin(time*speed+phase)*0.35 * speed factor. Pectoral fins flap rotation z.
- whole fish bank slight rotation z? group.lookAt then rotateZ? model rotation z changes local. Good.

Should initialize within tank and not initial overlapping. Colorful fish visible. Large fish perhaps 1 scale, tiny school 0.5. 22 fish at x spread y etc z. Camera FOV and responsive adapt to tank. For aspect narrower, to fit width camera z should increase perhaps camera at z = aspect<1 ? 42 : 32 and FOV 46. If 375x800 portrait tank need fit width 30 at aspect 0.469 horizontal FOV ~22deg; distance for 30 = 77? too far vertical showing huge space. Better on mobile adjust world tank scale? Could fit x perhaps show cropping? Requirement adapt gracefully no horizontal scrollbar, not necessarily whole tank visible. But can dynamically camera z based aspect: visible height 2*dist*tan(fov/2), width=height*aspect. Should width≥33 => dist≥33/(2*tan(fov/2)*aspect). Desktop aspect 1.6 dist >=24, okay 32. Mobile aspect .47 dist >=83; fish tiny. Better design at mobile camera FOV 60, tank width perhaps 22 and crop edges? Mobile full canvas with fish visible and UI compact. Can choose camera z=aspect<0.8 ? 45 : 31 and tank width mobile visible width=2*45*tan23*0.47=17.9 so edges crop but fish constrained to central perhaps responsive bounds? At resize use world bounds x=aspect<.8?8:13.5. But tank extends beyond view with okay sides cropped. Could reposition fish if too outside? wrap. Better to design dynamic world halfWidth = aspect<0.75 ? 8.5 : 13.5 and x bounds steer. On initial mobile random x within 8. Fish larger enough. Camera z = aspect<0.75? 32? visible width 2*32*tan23*.47=12.8; halfWidth 6.4. Could set z=38 gives 15.1. Use visible framing formula based aspect with max dist. For mobile set fov 55 and z=27 => visible width=13.2, use bounds x 5.5. Vertical visible 28 high > tank 16 means more background. Could adjust camera fov 42 z=34 => vertical26; still tank 16. Want tank fills height perhaps camera 22 => height 16.9, width7.9, very narrow. Could reduce tank scale x by camera? Hmm portrait will inevitably only see middle vertical slice if fill height; acceptable adaptive cropping. UI title on top. Fish remain in center. I can adapt bound x to camera visible halfwidth at z0, cap 13.2. At mobile dist 30, fov 45, visible height24.8 (tank vertical16 leaving margin), width11.6, bounds5.2. Could set camera y=0 z=28 fov 42 visible height21.5; tank y ±7.2, so 3.5 margin top/bottom. desktop width 34 at aspect 1.6 and height21.5, good. Mobile width10.1; fish constrained ±4.3. So mobile camera 28 desktop28? visible width desktop34.4, tank 30, good. Great. Use camera (0,0.6,28), lookAt(0,0,0), fov42. Mobile horizontal crop and fish bounds 4.2; tank x edges not visible but fine. Floor plane width 30, height visual. Perhaps display fewer fish on mobile? 22 all within smaller width => crowded but okay use 18 if width<600. On resize cannot recreate easily; initialize count based width. 20 desktop 16 mobile.

Glass front may be affected by pointer; HTML canvas full. CSS overlays pointer-events none except buttons. Perhaps control sound? no audio. Buttons for "Scatter fish", "Feed" perhaps. User only asks mouse. Add a small interactive toggle "Calm / curious"? No need. I can include button "Scatter school" with pointer events auto and click applies all scare. On screenshot a button might look polished. But no need to distract.

Visual design: background outside tank deep dark (#031a25). Canvas fills. Overlay gradient vignette and noise CSS via radial gradients, no SVG external. Top nav with brand fish logo made CSS inline svg? Everything inline okay inline svg icons allowed. Use custom CSS. Header at top left:
small icon fish line and "AQUARIA" uppercase
right glass status pill with green dot "LIVE HABITAT" and perhaps fish count.
Left bottom info card "A little ocean,\nright at your cursor." text and instruction mouse icon "Move close to a fish — they'll dart away". Should avoid obscuring. Perhaps top left title "DRIFT" and subtitle "INTERACTIVE AQUARIUM". Bottom left glass panel with "Go on, say hello." and instruction. Bottom right "24 fish · 4 species" / "Water 24°C" etc. These panels can be polished. Cursor hint central bottom? "MOVE YOUR CURSOR THROUGH THE WATER" pill. On start no pointer still fish swimming.

Should game instructions "Click or press key to start" only games. Not game no need. Could include "Move near fish to make them dart" visible.

Lighting shafts: translucent cones or PlaneGeometry with additive material, rotated slightly, at z=-4, from top to bottom. Use custom BufferGeometry trapezoid on vertical plane with gradient texture generated canvas. Could create 4 shafts as PlaneGeometry widths etc with canvas gradient alpha. Generate linear gradient white turquoise transparent; mesh basic material transparent additive depthWrite false. Plane at (x,3,-4.5), rotation z slight; scale. But rectangle will have hard edges horizontally unless texture has horizontal alpha fade. Generate 64x256 with x sin and y gradient. Use canvas texture. Or custom ShaderMaterial on plane with varying UV, fragment alpha=pow(sin(uv.x*pi),2)*smoothstep... vertical. Use single material clones with opacity. MeshBasic with canvas works.
Caustics on sand: generate canvas 512x512 pattern of wavy lines using random arcs? Should seamless-ish. Use ShaderMaterial on Plane with world UV moving to create caustics, combined sand color. Plane geometry floor receive shadows? Shader no lights but can simulate. Better floor MeshStandardMaterial color sand with onBeforeCompile injecting caustics? Complex. Use separate PlaneGeometry with ShaderMaterial transparent additive just above floor; fragment voronoi-ish caustic pattern using sin waves. Could use 3 sin fields, alpha threshold. Should avoid heavy. E.g. float c=abs(sin((vUv.x*25 + sin(vUv.y*18+uTime)) ) * sin(vUv.y*22 + cos(vUv.x*16-uTime*.8))); caustic=pow(1.0-c, 8) ... transparent. This gives grid perhaps. Voronoi shader expensive but floor only. Use standard sand plus caustic Plane with custom shader additive, transparent. uv x aspect adjust. Fragment:
vec2 p=vUv*vec2(18.,8.);
float a=sin(p.x*2.1 + sin(p.y*1.7+t)) + sin(p.y*2.4 + sin(p.x*1.3-t*.8));
float lines=pow(max(0.,1.-abs(a)*.55),6.);
alpha=lines*.16* smoothstep edge...
color cyan white.
Should floor rotation x -pi/2 with plane orientation and UV.

Backdrop vertical plane with ShaderMaterial gradient and subtle rays and suspended specks? Use MeshBasic color + scene background gradient CSS won't show due renderer alpha? Renderer alpha true and scene.background null, CSS gradient behind, but tank back plane opaque dark teal. Use backPlane shader with gradient uv.y, subtle noise/time caustic. Could create ShaderMaterial non-lit. background plane 30x16 at z=-6, y=0. Fragment mix deep #063445 bottom to #0b6672 top and light top center radial. Add sinusoidal caustic faint. Should correct colors in shader output; renderer tone mapping applies? ShaderMaterial includes tone mapping? include colorspace fragments perhaps if no include output linear gets conversion? Custom Shader in modern Three adds? Must include `#include <tonemapping_fragment>` and `<colorspace_fragment>` at end to properly output, or just colors still. Simpler MeshBasicMaterial color background with scene fog; a canvas gradient texture gives nice. Create canvas 512x512 gradient; add radial glows. CanvasTexture on plane. Light shafts overlay.

Sand floor: geometry Plane 30x12 with 60x30 segments, displace vertices y? Plane local x,y before rotate. Dunes need vertex z displacement before rotation: for PlaneGeometry width,height segments, position z=some sin; then rotate mesh x -pi/2 so local z becomes world y? rotation x -90: local z -> world y yes. Use colors? MeshStandard sand roughness .8. Add small pebbles InstancedMesh spheres/dodecahedron random 60, colors using instanceColor. Rocks groups 15 dodecahedron scaled.

Plants: 18 kelp clumps at back and sides. Should animate swaying. Use custom geometry ribbons with segments and ShaderMaterial using uTime to bend vertices according uv.y and instance? Easier each plant group with 4 blades, geometry PlaneGeometry(width,height,1,10) translated so base at 0; MeshStandardMaterial green side double; userData phase, animate rotation.z small + rotation.x. But whole plant rotates rigidly; can bend vertices per frame by storing base positions for ~20*5*22 vertices=2200 manageable. Build Blade custom BufferGeometry with positions segments. Update mesh geometry positions each frame based baseY and sin. Could use ShaderMaterial with vertex displacement and lights? MeshStandardMaterial onBeforeCompile to add sway based position.y and uTime; shared material per plant color with uniforms globally. Should modify `#include <begin_vertex>` to:
vec3 transformed=vec3(position);
float h=position.y/??;
transformed.x += sin(uTime*0.8 + position.y*0.8 + position.x)*0.25*h*h;
But each mesh position phase different via world position unavailable in vertex? position only. Use per-geometry phase attribute? Can have material per plant with uniforms phase. 15 materials okay. Or update mesh.rotation.z and rotation.x gently enough. Better plant group each blade rotation.z = baseAngle + sin(time+phase)*0.06; since base pivot, natural sway. Tall kelp perhaps uses TubeGeometry along curve? Update expensive. Plane blade with emissive and double side looks seaweed.

Coral: branching corals using cylinders between points, spheres/tips perhaps. 3 coral groups in pink/orange/purple. Procedural branch recursively 2 levels. MeshStandard materials. Add sea grass with cones. Anemone with tentacles capsule? Could use small cones in cluster.

Bubbles: Points with custom CanvasTexture circle, 70 points positions random; animate rising with per bubble speed; PointsMaterial size .12 transparent opacity .55 depthWrite false; world points all same size perspective. Could also create bubble spheres few glass. Points circle radial canvas. Bubble positions update attribute each frame. Bubble columns from floor aerator left/right. Add small white center? Canvas radial ring texture.

Particles marine snow: Points 140, tiny, additive. Should visually clear.

Fish variations: I'll design geometry carefully. Fish local +Z forward. Body sphere geometry radius1, 20x12, scale x=0.55*size, y=0.62*size,z=1.05*size. Tail group at z=-0.85; geometry coordinates relative. Tail geometry: vertices base top (0,0.12,0), top tip(0,0.72,-0.85), notch(0,0,-0.55), bottom tip(0,-0.72,-0.85), base bottom(0,-0.12,0). Indices triangles [0,1,2], [0,2,3?], Need shape fan. Could simple two triangles upper [0,0,0, 0,0.75,-0.9, 0,0,-0.55], lower [0,0,0, 0,0,-0.55, 0,-0.75,-0.9]. Material DoubleSide. Tail narrow x thickness zero but double side fine. Add second crossed fin perhaps tail vertical plane, side view obvious. Dorsal fin top body triangle in YZ at x0: [0,0.48,0.45, 0,0.95,-0.15, 0,0.42,-0.65]. Pectoral fins on sides x±.45,y0,z0; geometry small triangles in XZ tilted; animate. Eyes at x±0.42, y0.22,z0.55; white sphere .11 and black .06 placed outward x ±.5. Since camera in +Z and fish broadside when swims x, visible side x? For fish moving +X, group rotates y π/2, local x becomes world? local forward +Z rotated y π/2 -> +X; local x rotated -> world -Z, so near-side eye local x=-? camera at +Z, local x=-1 maps world +Z. Both eyes exist. Eye positions ±x. Great.
Mouth small torus? Add sphere tiny at z=.98? Body front point. Could add mouth using TorusGeometry(.09,.015,6,12,Math.PI) oriented perhaps; unnecessary.
Fish stripes texture canvas. UV on sphere orientation seam can be weird but enough. Body color plain with colored fins works. Add body scale elongated.

For clownfish, canvas texture base orange, vertical bands at u=.2,.55,.82? Sphere UV wraps along longitude around y, body elongated z. Stripes around body should be rings perpendicular to z, but SphereGeometry poles along y default, UV u corresponds longitude around y. Fish elongated along z, texture stripes along u become bands around y axis not forward. Could rotate geometry or just use procedural body pattern not crucial. Could construct fish body from SphereGeometry then rotateX(Math.PI/2)? SphereGeometry pole y; rotating geometry x π/2 maps pole y to z, then scale z; UV bands by v around pole? latitude v corresponds along forward z, so stripes using v horizontal canvas lines would ring forward. Yes SphereGeometry(1,...), rotateX(Math.PI/2) makes y-axis to z-axis? rotation X +90 maps y->z, z->-y. Then local sphere poles ±Z. Body geometry can be shared rotated. Scale (x .55,y .62,z1.05). Texture stripes vertical/horizontal based v: Canvas y corresponds v perhaps v inverted. Draw horizontal stripes at certain y across full width, giving bands perpendicular z. Create base gradient and stripes y=35-50, 85-100 etc. For blue tang simpler base blue with dark curved brush shapes and yellow tail.

Fish material map generated 256x128 per type:
clown orange gradient, horizontal stripe y 24-39 and 80-94 white with dark border; since sphere front/back texture all around.
blue: base #168bd1, dark navy sweeping lines, yellow at near y>100 perhaps tail separate yellow.
yellow: base yellow gradient with subtle light horizontal.
purple: gradient magenta violet with thin teal stripes.
Perhaps emerald silver.
Use CanvasTexture anisotropy 4.

Schooling perhaps small school of 8 yellow/blue fish coherent. Each fish has species and school id. Steering toward school center and align slight. 4 groups: 6 clown? Real clown not school but aquarium fantasy. 7 small chromis blue, 5 yellow, 4 clown, 3 purple, 2 big. 22 total. Distinct.

Implement Fish class closure object:
const fish=[];
function createFish(species, size, position, school) {
 group = new THREE.Group(); group.position.copy(position); group.scale.setScalar(size);
 scene.add(group)
 body etc (scaled body mesh).
 vel initial normalized random mostly x; speed random.
 state = {mesh, tail, fins, vel, desired..., phase, size, species, school, panic:0, turn:...}
}
When scaling group, movement speed world unaffected, good. For pointer proximity adjust radius based size.

Animation:
const dt=Math.min(clock.getDelta(),0.033)
time
for fish:
  let acc = new Vector3(
    Math.sin(time*.35+phase)*.12,
    Math.sin(time*.7+phase*2)*.09,
    Math.cos(time*.4+phase)*.1
  )
  // target cruise maintain direction, vel approaches preferredSpeed? 
  acc.addScaledVector(f.vel, -0.08) drag
  if no panic: desired direction based current + wander; acc perhaps.
  Bounds soft: if x>bound-2 acc.x -= (x-(bound-2))*1.2; if x< -bound+2 ...
 y top 5.5 bottom -4.8 (floor -7), z ±4.5.
 school: compute centers beforehand perhaps. Add center-pos *.08 and avg vel *.03.
 separation all neighbors O400: if dist<1.2*(sizes) acc += diff/d² *.4.
 cursor: use projected. pointer.active if last move <2.5 sec? Should still fish flee when pointer stationary nearby? Requirement when move mouse near; if fish swim into cursor perhaps also flee. Keep active until pointer leaves. projection after camera update before fish loop. px=(proj.x*.5+.5)*w; py=(-proj.y*.5+.5)*h. pointer client. d= sqrt. if d<radius+size*20:
   awayPixel=(px-pointer.x, py-pointer.y)/d. World away = cameraRight*awayPixel.x + cameraUp*(-awayPixel.y? Should have py down. world up positive corresponds py negative. To replicate pixel vector (dx,dy down), world = right*dx + up*(-dy). yes.
   worldAway.normalize; strength=(1-d/radius); acc.addScaledVector(worldAway, 18*strength+4); f.panic=1.2; f.lastScare=time; startled count? Could increment on transition panic<=0 only, displayed counter "0 startled" perhaps stats changes. Fun.
   Add ray world pointer repulsion too? Screen enough. Fish in front near cursor.
  if f.panic>0 decrement dt, maxSpeed = 6.5 else 2.0.
  f.vel.addScaledVector(acc,dt)
  enforce speed min/max: if len>max then set; if len<0.7 set to .7.
  pos += vel*dt
  constrain hard with reflect:
    if x beyond bound, pos.x clamp, vel.x*=-0.8, etc. This avoids escape during panic.
  group.lookAt(pos+vel) BUT mesh group scale nonzero and lookAt doesn't support non-uniform? uniform yes.
  Add banking after lookAt: group.rotateZ(Math.sin...*0.05 + -f.vel.y*.04)
  tail.rotation.y=sin(time*(f.panic>0?16:7)+phase)* (f.panic>0?.55:.28)
  fins rotations.
  perhaps body bob?
 
Because group.lookAt each frame with pos+vel where vel is world vector; Object3D lookAt handles parent none. fine.

Camera subtle parallax:
targetCamX = pointer.active ? pointerNdc.x*0.8 : sin(t*.1)*.3; camera.position.x += (target-camera.x)*dt*1.5; camera.position.y=0.6+pointerNdc.y*.4; lookAt(0,0,0). But this modifies fish screen position when pointer near, still okay. On mobile no pointer, gentle drift. Could skip to ensure easy alignment. Perhaps camera responds very subtly 0.35. Fish movement is 3D enough. Use pointerNDC computed. When cursor moves, camera shifts making immersive. But immediate flee perhaps lag no.

Glass distortion cursor ring: DOM `#cursorGlow` fixed pointer-events none circle 110px, border 1 rgba, backdrop-filter? Mouse move transform; shows faint circular field and dot. This can communicate influence. On mouse leave hide. Touch on pointer move works, on touch start scatter around. CSS custom cursor? Don't hide system cursor perhaps no. Ring a small 26px bubble dot, influence not giant. Could show `#cursor` 46px ring, mix-blend-mode screen. On mouse move near fish, ring turns cyan when any panic? Set class active if panic. Keep native cursor perhaps default. Better cursor:none on canvas and custom ring? Accessibility not critical. If overlay pointer events none. Custom cursor circle 30 px and center dot. Hide when mouse leaves. On UI buttons native cursor needed. Use canvas cursor:none only; DOM custom follows everywhere. Fine.

Click pulse: if pointerdown on canvas, apply radial impulse to all fish within larger 250 px and create bubble burst at world pointer. But requirement move mouse. Adds nice feedback and UI "Click to make a splash" perhaps unnecessary. Could implement one click "scatter" button. Simple pointerdown sets scareRadius 260 temporarily (pulse until time+.25) and creates ring DOM? Web visual ring in 3D: `THREE.Mesh(RingGeometry...)` at fish z? screen DOM pulse simpler: spawn div .ripple at pointer, animation. Should avoid UI handling. On pointerdown if e.target canvas, `pulse = {x,y,until:time+.35}`; in loop radius = pulse active? 260:130; strength. Create DOM ring. This means click to scatter. Hint says "Move to startle · Click to scatter". Great.

Fish should flee even on first interaction. Make sure pointer events canvas receive despite overlays. overlay panels pointer-events none. Top buttons perhaps button sound? Avoid.

Stats UI:
- header left brand with small line icon.
- top right "ECOSYSTEM STABLE" green pill.
- left middle perhaps depth labels vertical? too much.
- bottom center hint pill with mouse icon and text.
- bottom left card: small label "INTERACTIVE HABITAT 01", title "The Drift Tank", description "A tiny reef with a mind of its own."
- bottom right metrics "24°C / 22 fish / 4 species". Responsive hide card desc etc.
Should not overwhelm aquarium. More minimal perhaps title top left "AQUA / DRIFT" and large serif? No external fonts; use system sans. Use font-family Inter fallback but not loaded. Use `font-family: "Avenir Next", "Segoe UI", sans-serif`. Title 22 px letter spaced. Main headline bottom left 32. At 375 title etc adapt.

Visual tank frame: since full canvas, add CSS border overlay inset 16px, rounded 22px with box-shadow inset. `tank-frame` absolute inset 14px border 1px solid rgba, border-radius 22; pointer none; ::before top glare. Top/bottom metal bars? Aquarium should look like window into water, no literal box necessary. Add actual 3D glass panel edges with line geometry. Perhaps tank world back, floor, sides; frame edges in 3D front plane at z=6 with EdgesGeometry BoxGeometry(30,15,12), line material white transparent .12. Camera outside looking +? Camera z28, tank front z6, back z-6. Box edges includes front verticals at x±15,y±7.5 z6; top/bottom bars. Because near front plane at z6 projection larger, some edges offscreen at 1280? camera visible width at z6 distance22 => 27, so x±15 clipped. Back edges visible at x±? at z-6 dist34 width41.7, so back frame visible. Front offscreen gives immersive. Perhaps tank width24 instead to show glass box? Scene dimensions: camera z31, fov45, at z0 width 41 at aspect1.6, front at z6 distance25 width33, tank width30 fits. vertical at front20.7, tank16 fits. Great camera31 fov42? visible height at z0 23.8, width38; front distance25 height19.2,width30.7, x±15 just fits at 1280. Perfect. Set camera31.5, fov42, front edges visible. At aspect1280/800=1.6 width front30.9; exactly. On 16:9 1280x720 aspect1.78 width34.2. At mobile front width9; edges clipped. okay. Tank x±15,y±7.5,z±6. Floor y=-7.0. Camera lookAt y=0 perhaps.
But fish at z between -4 and 4, pointer projection correct.
Tank frame edges 3D with `LineSegments(new EdgesGeometry(BoxGeometry(30,15,12)),...)`. Add thick front glass rim using cylinders? CSS border enough.
Back plane 30x15 at z=-6.05.
Floor plane 30x12 centered y=-7.2,z=0 rotate x -π/2.
Left/right wall planes perhaps transparent teal MeshPhysical? Side planes at x±15, y0, z0, rotateY ±90, opacity .05. Top water surface plane y=7.4 rotate x π/2 with ShaderMaterial slight wave, opacity .15. From camera slightly above at y1.2 sees top perhaps top face edge. Light from top.
Glass front plane z6 with MeshPhysicalMaterial transparent opacity .035 roughness .05 metalness0 side DoubleSide depthWrite false. Can add two diagonal glare polygons using PlaneGeometry with gradient texture at z6.02, but would appear as overlays, nice. Use `createGlare`: PlaneGeometry 30,15 material with canvas gradient angled? Canvas linear gradient from transparent to white to transparent, but rectangle full. Could use CSS frame glare instead.

Postprocessing bloom? Could import EffectComposer UnrealBloomPass but extra complexity/performance. Not needed. Use emissive fish low. Output beautiful with tone mapping, fog and lights.

Scene background #052c38, fog `new THREE.FogExp2(0x073845,0.027)` fog attenuates back. Renderer clear color #061f2a.

Shadows: DirectionalLight at (-6,14,10) castShadow, shadow map 1024, camera bounds ±20, floor receiveShadow. Fish castShadow perhaps body only. Transparent tail no. Rocks cast. Use physically correct? renderer.useLegacyLights removed by r170? ignore. Directional intensity 2.2; HemisphereLight 1.5. Ambient 0.5. Add point turquoise at (0,4,3) intensity40 distance35 decay2. ACESFilmic exposure1.1.

Ground rock dodecahedron geometry with flat shading true. Sand geometry displace local z:
for each vertex let x=pos.getX, y=pos.getY; pos.setZ(i, 0.1*sin(x*.55)+.08*cos(y*.7)+.03 random). Compute normals.
Then mesh rotation x -π/2. Position y=-7.2.
Pebbles InstancedMesh new DodecahedronGeometry(1,0), MeshStandardMaterial vertexColors? setColorAt supported material automatically uses instanceColor. Matrix random. 70 rocks on floor at y -7+ sizes .1. Should floor dunes variation small.
Large rocks at x etc can use 18 meshes same geometry with material variations. Random deterministic seeded function to ensure consistent composition. Implement mulberry32 seed. Positions in x ±13,z -5..4. y floor. Larger at back edges, avoid center. Corals at (-9,-6.8,-1), (9,-6.8,-2), (-4,-6.8,-4). Seaweed positions edges.

Kelp geometry: use PlaneGeometry(.35,height,1,10), translate(0,height/2,0). rotate y random to face. Material side DoubleSide transparent perhaps alpha shape rectangular ugly. Better custom blade tapered geometry:
segments 10, each y = h*i/seg; center x = sin(i*.45+phase)*.12*i/seg perhaps initial curved; halfwidth = width*(1-i/seg)*(.75+sin...); vertices left/right; uv; indices. local base at y0. This gives tapered ribbon. MeshStandard double side, roughness .6, color green, emissive dark. Group rotation y random. Animate group rotation.z base + sin*0.08, rotation.x. Entire blade sways from base. Good.
Create 25 plants with 3-6 blades, total ~110 meshes, perhaps draw calls ~110 + fish 200 = okay. Can merge plant geometries per cluster? Not needed but performance perhaps 60fps with 400 draw calls desktop. I can reduce 14 clusters*4=56. Corals 40 meshes. Total <350. 22 fish each body, tail, dorsal, 2 eyes white+black=6, 2 fins=9 =198. Total350. Fine on latest Chrome screenshot.

Could combine eye white/black geometry shared. Each eye a black sphere only plus small white glint perhaps two meshes per eye. 4 eye meshes. Fish count 22 =88. okay.
Use shared geometry and materials per species. Body material shared. Tail fin material shared. Each mesh no unique material. Eye mats shared.
Per fish all castShadow only body. Set render order perhaps transparency tail double side.
Some fish have stripes mesh? texture enough.

Fish positions initial make pleasing. Random x ±11,y -3..5,z -3.5..3.5. For school groups perhaps around center. Species list:
0 blue tang count6 size .72-.95 speed1.4
1 clown count5 size .65-.9
2 yellow count7 size .45-.65
3 purple count4 size .8-1.05
Perhaps 22.
For more fish colorful visible at first frame. Use one larger turtle? User fish. Could add ray? unnecessary. Add starfish on sand with 5 arms (geometry shape) nice visual. Could add starfish 2 using Shape with 10 points extrude? ExtrudeGeometry allowed Three core. Starfish lying floor; shape in XY then rotate x -90. Orange. Nice.
Add seashells torus? no.

Bubbles two streams, points positions with data arrays; `bubbleData` {speed, drift, phase}; positions random y -6..7. Update y += speed dt; x=baseX+sin(t*...)*.1; if y>7 reset -6.5. Keep baseX etc.
Points material `map:bubbleTexture`, alphaTest .02, transparent true, opacity .65, size .22, sizeAttenuation true, depthWrite false, color 0xb9ffff`.
Marine particles 160 positions, PointsMaterial size .035. Animate whole points rotation y small? or positions drift slightly. Could update y very slow with array. Not essential.
Light shafts shader material:
vertex pass uv; fragment:
float edge=pow(sin(vUv.x*3.14159),1.5);
float vertical=smoothstep(0.,.2,vUv.y)*(1.-smoothstep(.65,1.,vUv.y))? Plane uv y0 bottom y1 top. Light strongest top, fades bottom: alpha=edge*pow(vUv.y, .8)*.09; also animated streak. Color. Use blending Additive, depthWrite false, side DoubleSide.
Create plane width2-4,height15, position x, y0,z-5.5, rotation z .15. Because rectangular plane width 3, edge fade. Good.

Back canvas gradient:
canvas width512,height512; linear gradient top #0a6670 to bottom #052c3b. radial at 50% 0 rgba teal. perhaps draw faint light rays. Set texture colorSpace SRGB.
Sand material color #b8a878 with roughness .9; fog.
Caustic shader additive floor with transparent true, depthWrite false. UV repeat? set fragment based world? vUv pattern. p = vUv*vec2(28.,12.) etc. animate.
Water surface shader perhaps use same caustics at y7.45 plane rotate x π/2, opacity .12. MeshPhysical material transparent color #76e7e5 opacity .12 roughness .15 metalness .1 side DoubleSide depthWrite false. animate texture? no. Caustic floor enough.

Mouse intersection world for bubble bursts on click: raycaster.setFromCamera(ndc,camera); ray.intersectPlane(new Plane(new Vector3(0,0,1),0), point) plane z0. Spawn 12 small meshes? Bubbles global can reset some existing bubbles at point with base? Could add burstPoints array of bubble sprite objects, but Points single stream enough. On click DOM ripple + scatter. No need 3D burst.

Fish startle counter update DOM no heavy every frame only when changed. `startled` count increments new scare when panic <=0.05 perhaps initial `panic=0`, cooldown. Add status pill text `22 FISH · <span id=startles>0</span> STARTLED`. But if count persists perhaps fun. Should not handle. Bottom right "CURSOR CURRENT / 0 REACTIONS". Could update. UI top right shows `22 FISH · REEF 01`.
A tiny "Startle-o-meter" gauge fills when fish panic. bottom right meter perhaps dynamic bars. Nice. Panel:
`ACTIVITY`
div bars `<i>` ... updated transform scaleY based number panicked.
`CALM` text changes to `PLAYFUL` / `EXCITED`. This gives feedback.
Bottom right glass card 170x80 with "SCHOOL ACTIVITY" and 12 vertical bars. CSS. update style height using activity random. But no need complexity. I can include meter with 9 bars, set class based active count. It will animate even calm based swimming. Visual polish.

Should avoid `window.open` etc. no storage. No network except importmap CDN. No external fonts. HTML includes importmap exactly as user. module script import * as THREE and perhaps no OrbitControls to reduce dependencies. Only Three from CDN allowed. Good.

Potential issue user may move mouse near fish and fish avoid based screen. Should pointer initial active? Set false; on pointermove set true and coords. On pointerleave false. Listener window? `renderer.domElement.addEventListener('pointermove'...)`. Since canvas covers entire, overlay pointer-events none, okay. For mouse movement into iframe at top overlays still pointer events none. Pointer leave on document. custom cursor global window event ensures. Use `window.addEventListener('pointermove', onPointerMove, {passive:true})`; this catches over buttons too. e.clientX available. Set pointer.seen true, active true. `pointer.ndc`.
On `pointerout` if !e.relatedTarget active=false? iframe doc. `document.addEventListener('mouseleave',...)` perhaps.
On touch pointermove pointer active but screen scrolling? body overflow hidden and touch-action:none on canvas. Add pointerdown to canvas sets coords/pulse. `e.preventDefault` perhaps with passive false not necessary touch action none.
Custom cursor style transform translate3d(x-...); initial opacity0; on pointermove style opacity1 for mouse only; for touch perhaps hide after. Use `requestAnimationFrame` update? directly style okay.
Could use CSS `body { cursor:none; }` hides cursor over everything; custom ring works. Better `canvas {cursor:none}` and ring visible, on UI no pointer interactive anyway. Body cursor default means custom ring plus cursor when overlays? overlay pointer none means target canvas and cursor none. good.
Custom ring 42x42, transition width/height? CSS `.alert { border-color...; box-shadow ...; }`. Update class based panic. transform includes translate(-50%,-50%) impossible combine with translate3d? style left/top x,y and CSS transform translate(-50%,-50%). Set left=e.clientX+'px',top. Fine.
Click ripple create div class ripple at x,y; append body; `setTimeout(()=>remove,850)` okay. DOM animation.

Scare algorithm direct uses `pointer.inside` indefinitely. If mouse stopped near fish, they should avoid region indefinitely and swim away. Good. Panic resets after1s but continuous acc keeps. If pointer remains and fish out radius, calm. `activity` counts f.panic>0.

Initial automated behavior fish always swimming. Good.

Camera update before projection. On pointermove target x from NDC; camera x lerp. Should camera view fit dynamic; on resize set base camera z depending aspect? Could set baseZ = aspect<0.8? 30.5:31.5. At mobile world x bound 5? calculate visible width at z0: 2*z*tan(fov/2)*aspect = 2*30.5*.3839*.469=10.98. half 5.49; fish max dimension up to1.2, bound4.1. At desktop width =37.8, tank width30 visible all with margin. at front z6 width30.6, exact. Great. On mobile height =23.4, tank15 so vertical margin4.2; frame top and bottom visible, left right off. Fine. Could increase camera fov on mobile to 48 height27, worse; keep42.
For very narrow 375x800, fish appear width scale based vertical: world-to-pixel h/(2*z*tan)=800/23.4=34.2px per unit; fish length 2.9*size .8=79px? Actually fish forward side length 2.9*.8=2.3 world=79px, good. 16 fish in 11 world width ~376px, crowded but okay; count 14 on mobile. Desktop world-to-pixel 800/(24.17)=33 px same! Same fish size ~75 px, nice. Width 1280 maps 38.8 world. Good. Bound mobile ±4.2; 14 fish size ~70px can crowd. Use 12 on mobile. But counts fixed create arrays based narrow: counts [4,3,4,2]=13. Desktop [6,5,7,4]=22. Good. UI says dynamic count.

For intermediate 768 width aspect .96 (if height800) visible width22.5, half11.2, bounds9.5, full 22 perhaps okay. Determine counts if width<600 only.
Responsive bounds in animate: `swimHalfWidth = Math.min(13.2, visibleWidthAtZero*.5-1.4)` with camera z and aspect; on desktop 18.9-1.4=17.5 cap13.2; mobile5.5-1.4=4.1. Good.
Camera baseZ on mobile 30.5 and desktop31.5. On resize, x positions outside bounds get soft steer/hard clamps. Fine.
Y bounds min=-5.4,max5.4, floor -7.2; fish size up to1 so enough.
Z bounds -4.2..4.3 (front6).
Fish avoid floor as bound.
Directional shadows floor at y -7.1.

Fish lookAt and z orientation: body geometry rotatedX before scaling. Should check eyes positions relative after geometry rotate doesn't affect group. Body geometry has poles z, eyes at z=.62. body scaled mesh (.55,.62,1.05), but eye positions specified in group units perhaps x±.39 y.2 z.65. Eye white spheres .12 unscaled? group scale uniform. Good. Body is mesh scaled, eyes not scaled by body. At side x±.48, body cross-section at z.55 has radius x .55*sqrt(1-(.55/1.05)^2)=.468, eye x±.44 intersects surface; white sphere radius.11, good. Pupil at x±.515 perhaps on outer side. But pupil sphere should positioned slightly forward? x sign* .51, y.22,z.68. White eye at sign*.43,.2,.63, pupil at sign*.50,.2,.66. Eye orientation no matter spheres. Near camera side whichever. White eye will partly embedded. Add glint at sign*.54,y.24,z.70 .022 white. More meshes 6 per fish, perhaps okay. Could use just black eyes with white base and no glint. 4 eye meshes.
Tail group pos z=-.88; body extends to z=-1.05. Tail base at local0 so overlap. Tail triangle tip z=-.85 => world -1.73. good.
Dorsal geometry local coords x0 y.42 z.3 etc. Material side double. Pectoral geometry create left and right separate? same geometry mirrored scale.x. Geometry vertices [0,0,0.25, .65,-.12,-.25, .15,0,-.45], in local around side. Mesh pos x=.4,y=-.05,z=.1; other scale.x=-1,posx=-.4. Flap rotation.z perhaps. Should fins visibly move.
Tail material colored and transparent opacity .9, roughness. `side:THREE.DoubleSide`.
Body `flatShading:false`.
Fish perhaps wide texture on sphere. Fine.

Create coral using `cylinderBetween(a,b,r,mat,parent)` CylinderGeometry unit shared? Should have varying length/orient. Shared CylinderGeometry(1,1,1,7), mesh scale(r,len,r), position midpoint, quaternion.setFromUnitVectors(Y, dir). 3 groups * ~15 branches =45. branch recursion:
function branch(start, dir,length,radius,depth) {
 end=start+dir*length;
 cylinder
 if depth>0:
  for i2: newDir=dir.clone().add(random horizontal...).normalize(); branch(end,newDir,length*.65,radius*.65,depth-1)
 else sphere tip perhaps shared sphere.
}
Initial start floor. Deterministic random.
Color corals #ff6f7d, #ff9f5a, #aa75df. Emissive matching low .08. Add sea fans perhaps.
Starfish Shape 10 points outer .7 inner .28, ExtrudeGeometry depth .12 bevel .04 etc; rotate x -π/2? Shape lies XY extrudes +Z; to lay flat on XZ plane normal+Y, rotateX(-π/2) maps normalZ-> +Y? rotation X -90 z->y yes. position y=-6.95. Good.
Shell use TorusGeometry scaled perhaps.

Seabed initial view bottom covered by UI but enough.

Glass glint front: create a few line segments? `BufferGeometry` vertices for vertical border and diagonal streak at front; LineBasicMaterial opacity .12. Actual box edges.
`const tankBox = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(30,15,12)), lineMat); tankBox.position.y=0;` front lower etc. Fog affects lines perhaps.
Add front highlights two planes using canvas linear gradient. Could generate PlaneGeometry(.12,12) at x=-14.9,z6,y0 MeshBasic white .2. corners.
CSS frame border does most.

Accessibility: `<main aria-label...>`, canvas created. Overlay hint. Use reduced motion? Could respect `prefers-reduced-motion` by slower camera not fish (fish must move), no need.
Fallback WebGL? If Three loads fails due network nothing; allowed assume. Could add noscript message.

Screenshot taken 3sec after load likely module loaded. Animation first frames. To ensure static before module, CSS background and UI visible. Good.

No long play needed all fish visible.

Potential memory console issues: Three r170 `outputColorSpace = THREE.SRGBColorSpace`, valid. `texture.colorSpace`. `renderer.shadowMap`. `renderer.setAnimationLoop` perhaps use requestAnimationFrame. Use `requestAnimationFrame(animate)` conventional.
`THREE.MathUtils.damp` exists but use simple lerp.
`new THREE.Plane` intersects etc.
`window.devicePixelRatio` cap 1.8 for performance.
`color` options in MeshStandard plus map: if map texture with color and color white, good. `emissive` color perhaps dark.
`new THREE.CanvasTexture` works generated. Anisotropy after renderer created.
`floor caustic` ShaderMaterial needs UV and uniforms; no lights. To ensure opacity blending color managed, set `transparent:true`, blending Additive. Use gl_FragColor and include `<colorspace_fragment>`; additive linear output perhaps okay. Could include tonemapping too. Should shader compile syntax valid WebGL:
vertex:
varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}
fragment precision default supplied; uniforms. `float wave = ...;`.
Back texture Canvas no issue.
Light shaft shader uses `gl_FragColor=vec4(uColor,alpha);` includes colorspace perhaps uColor linear? `new THREE.Color(0x7fe7e2)` internal linear, good. include.
`MeshBasicMaterial` toneMapped false perhaps textures etc. fine.

Fish materials emissive = species emissive, emissiveIntensity .12, map. If color textures generated in CSS hex canvas then texture colorSpace SRGB handles.
I can add gill line using TorusGeometry? Torus on body oriented? Half torus line perhaps unnecessary. Fish eyes enough.

Startle ring class update each frame may cause DOM class toggling; only when state changed. Meter update every 6 frames.
UI count. `reactionCountEl.textContent = reactions.toString().padStart(2,'0')`; if >99 no pad issue. activity label.
Custom pointer ring `--x` etc. Use left top.

Could add `cursor: crosshair`? custom.

Should header semantic. CSS full page.
Design perhaps:
body background radial-gradient at 50% 0 #0a4e5d, #031821.
#app position fixed inset0.
canvas display block width100% height100%.
.vignette absolute inset0 background radial gradient transparent 40%, rgba dark .45 100%; pointer none.
.frame absolute inset14 border ... border-radius24; box-shadow inset0 0 80px rgba etc.
.topbar position absolute top34 left38 right38 display flex.
.brand logo CSS circle with inline SVG fish. Use inline `<svg viewBox...>` no external. `<svg ...><path d=...>` icon.
.brand-text strong AQUARIA and small INTERACTIVE REEF.
.status capsule dot.
.bottom overlay bottom 38 left38 etc.
.info .eyebrow `VOLUME 01 — LIVING WATER`
h1 "A little ocean.<br><em>Full of life.</em>" perhaps font normal. User wanted demo not marketing. Better "The Drift Tank" big.
p "Move your cursor near a fish.<br>They remember personal space."
.control hint centered bottom 38; might overlap info at mobile. Place hint bottom 40 right? Layout bottom: info left, activity right, hint absolute center. At desktop enough 1280. At mobile info bottom 95, hint bottom32 full width, activity hidden, top status compact. Perhaps info h1 30 at bottom 96. Fine.
Info glass background? Could avoid big card obscuring fish. Text with shadows and small backdrop gradient. `max-width 340`.
Hint pill glass background blur border with mouse SVG and `Move near fish` strong, separator, `Click to scatter`.
Activity card glass with metrics.
Top right `LIVE` status.
Small depth ruler right center? no.
`corner-label` top perhaps x.

At 375:
frame inset8 border radius16
topbar top24 left24 right24
brand strong 15
status `.status-copy` hide leaving LIVE? Keep dot and "LIVE".
bottom info left24 bottom92; h1 font30; p font13 max250.
activity display none.
hint bottom24 left16 right16 transform none justify-center font11; optional second label hide `Click to scatter`? keep.
No horizontal scrollbar body overflow hidden. UI boxes sizing border-box.
Pointer ring hide @media pointer coarse.

Perhaps initial pointer central? No custom ring hidden. Fish naturally swim. All good.

I'll think code details and optimize.
Set scene = new THREE.Scene; scene.background new Color(0x062c38); fog.
Camera at (0,.8,31.5). `camera.lookAt(0,0,0)`.
Renderer DOM prepend to #scene.
Set pixel ratio before size.
Use clock.
Initialize `world={halfWidth:13.2}` update on resize.
`let W=innerWidth,H=innerHeight,aspect`.
resize:
renderer.setSize(W,H)
camera.aspect=aspect
baseZ = aspect<.8 ?30.5:31.5 perhaps if aspect<1.1 use31.5
camera.updateProjection...
`visibleWidth=2*baseZ*Math.tan(THREE.MathUtils.degToRad(camera.fov*.5))*aspect`
world.halfWidth=Math.max(3.4,Math.min(13.1,visibleWidth*.5-1.5))
This at 375=5.49-1.5=3.99. 13 fish fit.
No need change count on resize.

Animation camera:
let camTargetX = pointer.active ? pointer.ndc.x*.45 : Math.sin(t*.11)*.25;
let camTargetY = .7 + (pointer.active ? pointer.ndc.y*.25 : Math.cos(t*.13)*.15);
camera.position.x += (camTargetX-camera.position.x)*Math.min(1,dt*1.2);
camera.position.y ...
camera.position.z += (baseZ-camera.position.z)*...
camera.lookAt(camera.position.x*.08,0,0); perhaps lookAt x small. If lookAt(0,0,0), camera parallax changes orientation, okay.
For screen repulsion world basis after lookAt:
`const camRight = new THREE.Vector3(1,0,0).applyQuaternion(camera.quaternion);`
`const camUp = ...`.
`const projected = new THREE.Vector3()` reused per fish but group position project mutates copy. Use `proj.copy(f.group.position).project(camera)`. Pixel. If proj.z >1? fish all visible.
Pointer active perhaps UI overlays not matter.
radius = pulseActive?230:120; on high DPI use CSS pixels since W innerWidth. good.
size effect radius + f.scale*18.
Away d if d<.001 random.
`const strength = Math.pow(1-d/rad,1.4)` perhaps near center strong; at edge zero.
`acc.addScaledVector(away, 9 + strength*22?)` if at edge 9 causes abrupt. use `acc.addScaledVector(away, 28*strength + 3*(1-d/rad))`. At d0 31, dt => vel increment .5 per frame, good. max panic7.5. At d100 of120 strength .000? power1.4 .03 =>.8, mild. Better linear strength =1-d/rad; acc 22*strength. near d60 11, dt .016 =>.176 per frame, responds quickly. Set panic=.8 if d<rad.
If pulse active, radius260 and force35.
Only increment reaction if f.cooldown<=0 and f.panic<=0.05? Each f update:
`f.cooldown=Math.max(0,f.cooldown-dt);`
on scare:
`if(f.panic<=0.05 && f.cooldown<=0){reactions++; f.cooldown=.8; update...}`
`f.panic=Math.max(f.panic, .9)`
then at beginning `f.panic=Math.max(0,f.panic-dt)`.
Could retrigger every .9 while in cursor; okay. Reaction counter increments multiple, not critical. Perhaps count unique fish startled using Set, display reactions count new unique; f.counted bool. Top panel `STARTLES` increments events.
I don't need reaction count in screenshot initial 0. bottom activity text `CALM` changes.
Set `cursorEl.classList.toggle('is-startling', startledThisFrame)`.

School:
Before loop compute school centers arrays:
for f sums positions and vel; divide. Use 4 centers.
Then in loop if !panic:
`acc.addScaledVector(tmp.copy(centers[school]).sub(pos),.025)` very weak; if far helps.
`acc.addScaledVector(tmp.copy(avgVel).sub(f.vel),.035)`.
But species each assigned school perhaps each species school all positions spread; center around0 causing all gather eventually. Fine.
Cruise horizontal: If no strong bounds, wandering. Velocity drag `acc.addScaledVector(f.vel,-0.12)`. Max normal f.cruise ~1.5; with min speed .65 and no target force speeds variable. To maintain speed:
`if f.panic<=0: f.vel.addScaledVector(f.vel.normalize?, (f.cruise - speed)*dt*.8)` perhaps use acc.addScaledVector(dir,(f.cruise-speed)*1.5). This drives speed. Direction wander via sin.
Use acc plus wander and bounds. Then clamp max normal cruise*1.3 perhaps too constrains acceleration; normal max=f.cruise*1.45.
For panic max=6.8. As panic decays and max abruptly reduces to normal when zero; can lerp max=f.cruise + Math.min(1,f.panic*2)* (6.5-f.cruise). Better.
`const maxSpeed = f.cruise + Math.min(1,f.panic*2)*(6.2-f.cruise)`.
Fish always moves min .5.

Boundary soft weight: 
const bx=world.halfWidth-1.4;
if pos.x>bx acc.x -= (pos.x-bx)*2.4 + .3; if pos.x<-bx...
Y top=5.3 bottom=-5.3.
Hard bounds reflect at world.halfWidth etc.
Z.

Separation: nested loop each f with all g. Could compute in same loop using positions from start; okay. Reuse tmp but multiple. Avoid acc references shared? Each f.acc new Vector3 once at creation, set(0...). Use temps global but nested.
`const d2 = pos.distanceToSquared(g.group.position)`; if d2<minDist² and >.0001 acc.addScaledVector(tmp.copy(pos).sub(gpos).normalize(), (1-d/minDist)*1.8)`. Distance sqrt. 22*22 fine.
School center calculations use arrays created species max4.

Group orientation smooth? `lookAt` instantaneous vel changes may jitter, but velocity smooth. Banking using rotateZ.
Could store quaternion target and slerp:
`f.group.lookAt(tmp.copy(pos).add(f.vel));`
then rotateZ. Fine.
Object lookAt positive Z confirmed. Three docs: Object3D.lookAt: rotates object to face point; for camera and light -Z, all other objects +Z. yes.
Tail animation rotation.y works.
Pectoral `f.finL.rotation.z = .15 + sin*.25`; but initial geometry orientation and mesh rotation perhaps okay. `finR` opposite.

Fish slight body motion? `body.scale.y = .62*(1+sin*0.02)` not necessary. Tail wag enough.

Perhaps add fish trail when panic? small particles not needed.

Plants animate loop `plants.forEach(p => {p.rotation.z = p.baseR + Math.sin(t*p.speed+p.phase)*p.amount; p.rotation.x = ...})`. Each blade direct mesh, base at group position; rotation.z around own origin base because geometry vertices base0. yes. Different rotation.y set and preserved. rotation order XYZ, changing z and x won't affect y.
Corals static.
Bubbles updates position attr.
Marine snow rotates y tiny; points global rotation would rotate through tank, okay tiny.
Caustics uniforms time.
Light shafts material uniforms time shared? create each clone with phase uniforms perhaps. store shaft materials.
Water surface perhaps animate `water.material.opacity=.09+sin*.02`.
Background gradient texture static.

Renderer first frame animate called.
Could use `renderer.compile(scene,camera)` not necessary and may stall. No.

UI updating initial fish count `fishCount.textContent=fish.length`.
Dynamic activity meter bars initial CSS varying heights. In animation every6 frames:
`const panicCount=...; const energy = panicCount/fish.length; bars.forEach((bar,i)=>{ const h=8+ (Math.sin(t*3+i)*.5+.5)* (panicCount?...); bar.style.height=... })`; initial at t0 gets update after first frame. No high activity if calm small. `activityText.textContent = panicCount ? panicCount>6 ? 'STARTLED' : 'STIRRING' : 'CRUISING';`.
Reactions `String(reactions).padStart(2,'0')`.
Temperature static.

Add small indicator near pointer if fish panic custom ring.
Button? No actual buttons. Entire canvas pointer interactions enough. UI `hint` pointer-events none.

Touch hint should say "Drag near fish" via CSS spans desktop/mobile:
`<span class=desktop-copy>Move near fish</span><span class=touch-copy>Drag near fish</span>`. On coarse show. But screenshot desktop. Fine.
No keyboard requirement.

Potential user expectation aquarium perhaps more glass realistic. I have underwater full view. Great.

Could add resize orientation and no scrollbar.

Make sure module import script appears after importmap. Yes.

I'll draft code fully around 800 lines? Desired response one block only, can be 500 lines. Should manage output length perhaps ~25k tokens but okay. Aim ~650 lines 25k chars. I have huge budget. I should produce clean robust complete.

I'll outline HTML:
<!doctype html><html><head> meta... style ... importmap perhaps in head. </head><body>
<div id=app><div id=scene></div><div class=vignette></div><div class=frame></div>
<header ...>
...
</header>
<section class=intro>...
<div class=hint> svg mouse...
<aside activity>...
<div id=cursor>
<div id=ripple-container perhaps body dynamic>
</div>
<script type=importmap> exact.
<script type=module> import ...
...
</script></body></html>
Importmap in head required before module; user exact string perhaps formatting exactly content okay.
No external fonts.

CSS icons strokes currentColor.

Intro h1 perhaps `Where the<br><span>current follows you.</span>` inaccurate fish flee. `A little ocean.<br><span>A lot of personality.</span>` polished. But should task direct. "The Drift Tank" straightforward. Use eyebrow `INTERACTIVE HABITAT — 001`; h1 `The Drift Tank`; p `Glide through the water. Get too close, and the locals will make a quick exit.` Nice.
At top brand `AQUA / 3D` etc.

Bottom hint icon plus `<b>Move near the fish</b><span>they'll dart away</span><i></i><span>Click to scatter</span>`. Clear.

Activity right: top `SCHOOL ACTIVITY` and `span id activityLabel>CRUISING`; bars; bottom `<span id=reactionCount>00</span> STARTLES <span>24°C</span>`.

Perhaps UI hint on initial screenshot at bottom center overlays fish, okay.

World counts dynamic.

Detailed geometries:
```
const bodyGeo = new THREE.SphereGeometry(1,24,16);
bodyGeo.rotateX(Math.PI/2);
```
After rotate normals compute automatically apply matrix normal. Scale mesh.
TailGeo create BufferGeometry with position float array and index? upper/lower triangles no index simplest 6 vertices? positions:
[0,0,0, 0,.78,-.88, 0,0,-.5, 0,0,0, 0,0,-.5, 0,-.78,-.88]
computeVertexNormals; with x=0 all normals perhaps direction x; DoubleSide lighting okay normals -x, camera side catches via double side.
Dorsal 3 vertices similar.
Pectoral 3.
Eye geos shared.
Fish material maps:
Function texture draws base linear gradient y with color stops. Use helper hex colors; create pattern with `ctx`.
For stripes white horizontal y:
```
ctx.fillStyle='rgba(20,30,35,.75)'; fillRect(0,20,256,30); ...; fill white inner.
```
Since texture wraps horizontal all around at fixed v giving bands at certain z. Great.
For blue draw horizontal? Dark near back etc. Add lines.
Texture repeat etc.
Perhaps fin material color and map? no.
Body roughness .35, metalness .04. `emissive: new Color(spec.color).multiplyScalar(.08)` but passing Color okay; emissiveIntensity .25. If emissive color already dark. use emissive: species.color, emissiveIntensity:.06.
Point light bright.

Species data:
```
{name:'Clownfish', base:'#ff7b35', light:'#ffc063', fin:0xff8a3d, pattern:'clown', count... cruise1.35}
{name:'Blue tang', base:'#188fd0',light:'#55d7e4',fin:0xffd84d, pattern:'blue', cruise1.55}
{name:'Golden chromis', base:'#f2c94c',light:'#fff3a0',fin:0xffdf62, pattern:'gold', cruise1.8}
{name:'Orchid dottyback',base:'#aa5ddb',light:'#f19bd6',fin:0xd889ed,pattern:'orchid'}
```
Order for school. For blue tail yellow while body blue; for others fin same. dorsal yellow for blue might look tang.
Eye `whiteMat MeshStandardMaterial({color:0xf8ffef,roughness:.25})`, black with roughness .15.
Body texture with pattern. `texture.anisotropy=Math.min(4,renderer.capabilities.getMaxAnisotropy())`.
Fish body castShadow true perhaps each species same mat.
Scale random .72-1.0 except gold .48-.68.
Fish total initial if narrow: use scale counts by `(innerWidth<600 ? [3,3,4,2]:[5,5,7,4])` total21. Perhaps 22 use [6,5,7,4]. Mobile [4,3,4,2]=13.
Set initial positions school around assigned center:
For each species s, center predetermined [(-7,2,-1),(5,1,0),(-1,-1,2),(4,-2,-2)] desktop but mobile use within world halfwidth*0.5. I can simply random positions with group tendency.
`schoolHome = Vector3((s%2?1:-1)*world.halfWidth*.45, 2.5-s*1.5, -1+s%...)`.
For each fish position home + random ±halfWidth*.4 x etc clamp. At desktop home ±5.8, y2,-.5,-2 etc. Fish visible spread. Gold 7 at school perhaps clump. Better initial random globally then school centers weak.
`position.set(rand(-world.halfWidth*.85,world.halfWidth*.85),rand(-4,4.8),rand(-3.8,3.8))`; ensure no close perhaps rejection 20 attempts dist>1.2.
Fish initial vel `new Vector3(rand(-1,1),rand(-.25,.25),rand(-.35,.35)).normalize()` if random near0. multiply cruise.
School index perhaps s but no visual formations until later. Fine.
Large orchid etc.

Bounds y max5.2, floor min -5.5. Fish size .5-1 ensures no intersect.

Create static decor before fish so fog and occlusion.
Rocks random using seeded. Should floor `sandMesh.receiveShadow`.
For rock instances colors use linear? setColorAt with new Color hex. MeshStandard material color white. instanceMatrix usage default; after set needsUpdate.
Pebbles y = -7.0 + scale y? Dodeca centered, position y=-7.02+r*.25 perhaps half embedded.
Big rocks at y=-6.7.
Plants at y=-7.05.
Corals y=-6.9.
Light shafts behind all fish z=-5.4, depthWrite false.
Bubbles z random -4..5.
Marine snow.
Tank edges renderOrder perhaps 20.

Water surface plane: `new THREE.Mesh(new PlaneGeometry(30,12,20,8), MeshPhysicalMaterial...)` perhaps MeshPhysical transparent with transmission0 no environment not realistic. Use MeshPhongMaterial color #5de4e7 transparent opacity .1 shininess120 side DoubleSide depthWrite false. Plane rotateX(-π/2) (normal up? plane normal +Z rotateX(-90) => +Y), at y7.45. Directional highlights perhaps.
Floor MeshStandard.

Background plane canvas gradient should be lit? MeshBasic map so exact.
Set background plane `fog:false` perhaps material fog false to preserve gradient; scene fog will fog to similar color. Fine.

Add ambient sound? no.

Caustic shader using uv:
```
vec2 p=vUv*vec2(22.0,9.0);
float t=uTime*.35;
float a=sin(p.x*1.7+sin(p.y*1.3+t)*1.4+t);
float b=sin(p.y*2.1+sin(p.x*1.1-t)*1.2-t*.8);
float c=abs(a+b)*.5;
float lines=pow(max(0.0,1.0-c),7.0);
float edge=smoothstep(0.,.08,vUv.x)*...
gl_FragColor=vec4(vec3(.45,.95,.88),lines*.18*edge);
```
Color in linear manually .45 etc perhaps bright. Add colorspace fragment could convert to >1 clipped but okay. Set opacity .12.
Should plane slight above sand with dunes varying up to.2; caustic plane flat y=-7.02 could z-fight with dunes and clip. Put y=-6.88 above all dunes; visibly floating but projection same. Sand base -7.15+disp .15 max-7.0, caustic y=-6.96 safe. Could produce additive flat over fish near floor but only plane. depth test true, plane above floor, fish above.
Water floor plane size 30,12 exactly. `caustics.rotation.x=-π/2; position.y=-6.94`. receive no shadows.
Fog on ShaderMaterial not included, floor distance okay.

Light shafts use ShaderMaterial `depthTest:true`, back plane behind at -6, shafts z-5.8. Since transparent order, perhaps draw after fish despite behind, depthTest with fish depth blocks appropriately, depthWrite false. renderOrder -1? If renderOrder -1 but transparent rendering after opaque always. depth test works fish wrote depth. Fine.
Bubbles Points transparent depthTest true.

Glass front plane transparent after all can tint fish slightly. `renderOrder=10` opacity .035 depthWrite false. It can occlude bubbles? minimal.
Line edges.

Pointer ring not shown screenshot; okay.

3 sec auto screenshot initial fish after ~3 seconds may schools/clamp but natural. Avoid all fish near edges initial. wander low. Good.

Potential `directionalLight.shadow.camera` set and light target default origin; floor huge; shadows. Renderer physically in r170 no useLegacy. fine.
`renderer.setClearColor` scene background.
No console logs.

One concern generated body textures using `ctx.roundRect` unsupported? don't use. standard.
Noise in canvas perhaps no need.
Use seeded rng all random deterministic, initial angle.

Pointer click scatter if e target not canvas? window pointerdown will create when UI clicked but UI pointer none. add renderer.domElement listener. onPointerMove window for custom ring; pointer active even if e mouse outside? no.
`pointerdown` call updatePointer(event) then pulse. `pulse.until = elapsed+.32` but elapsed variable clock time set in animate. Define `let elapsed=0`, pointer event uses elapsed. Good.
`createRipple` uses DOM element and e coordinates; setTimeout safe. CSS `@keyframes ripple`.
Custom cursor coordinates initialized. `pointer.active = true` mouse remains. `window.addEventListener('blur',()=> pointer.active=false)`; no errors.
`document.addEventListener('mouseleave'...` MouseEvent on document works.
Pointer NDC y.
On mobile touch pointer active after pointerup should deactivate to avoid invisible cursor force persistent. Add pointerup if pointerType!=='mouse' set active false after .2? If deactivate immediately after touch, scatter pulse still works .35 sec. Move during drag. pointer.active true on down/move; pointerup false for touch. Custom cursor only mouse.
On pointerdown mouse active and pulse.
Mouse pointerup not deactivate.
Pointer move uses e.pointerType perhaps undefined for synthesized? treat mouse.
`canvas.addEventListener('pointerdown', e=>{updatePointer(e); pulse...})`.
Window pointermove updates.
Frame pulse uses elapsed < pulseUntil. `pulseX/Y` pointer coords captured? if moving while pulse active should ring center original click. store pulse.x,y. Repulsion center = pulseActive? pulse : pointer. But pointer should still flee while pulse active? choose click center for 0.35, then pointer. okay.
On click ripple and set cursor class burst perhaps.
If pointer inactive but pulse active possible touch after up, use pulse.
`const interactionActive = pointer.active || pulseActive; const ix=...`.
No reaction count if no.

World away cameraRight/up vectors reused. For each fish need fresh away global temp set:
```
away.set(0,0,0).addScaledVector(camRight, dx/(d||1)).addScaledVector(camUp,-dy/(d||1)).normalize()
f.acc.addScaledVector(away,...)
```
Depth fish away from cursor should include z jitter to make 3D dart:
`away.z += Math.sin(f.phase)*.25` but cameraRight/up may include z. Add `f.acc.z += Math.sin(f.phase)*strength*5` to scatter depth.
`f.panic = 1`.
Cursor away only world direction screen, good.

Camera projected position vector uses group.position center. Fish length might be near cursor tail but only center; radius includes size*24 ~20 pixels, body 70px so okay.

`f.group.rotateZ` after lookAt bank modifies forward orientation slightly but group.lookAt next resets. Good.
Fins rotation baseline perhaps left pectoral at x positive etc.
Tail rotation about localY.

I can also animate slight eye? no.

Initial school orientation velocity random; screenshot fish facing varied, natural.

No network besides Three. Import map uses exact URL r170.0.

I'll consider if using module fails in sandbox due external network not loaded screenshot; requirement allowed. Fine.

Perhaps add non-module loading indicator that hides on first frame? Could show "Filling the tank…" initially, but screenshot after3 secs might still show if network slow. Better no loading overlay, only CSS backdrop UI, then canvas appears. No error fallback. Could set #scene background gradient CSS so visually pleasing even pre-load. Good.

Zero console errors: `new THREE.Color(species[s].color)` passing CSS string? I use hex numeric for fin, string for emissive okay. `emissive` accepts ColorRepresentation string.
InstancedMesh setColorAt instanceColor automatically; `instanceColor.needsUpdate` only if exists after set. yes.
Sand geometry position `pos.getX(i)` etc valid.
Starfish ExtrudeGeometry bevel options. MeshStandard.
`bubbles.geometry.attributes.position.needsUpdate`.
`THREE.AdditiveBlending`.
No deprecated calls.
`renderer.outputColorSpace = THREE.SRGBColorSpace;` correct.
`renderer.toneMapping = THREE.ACESFilmicToneMapping`.
Directional shadow map size `shadow.mapSize.set(1024,1024)` Vector2 set yes.
`new THREE.FogExp2` accepted.
`texture.colorSpace`.
`LineBasicMaterial transparent opacity`.
`renderer.setSize` CSS.
`renderer.domElement.setAttribute('aria-label'...)`.
`renderer.domElement.style.touchAction='none'` or CSS #scene canvas.
`window.innerWidth` etc.

Use ResizeObserver? window resize enough. Mobile viewport height using innerHeight updates. body 100%.
CSS `100dvh` perhaps. `#app{position:fixed;inset:0}`.

I should perhaps include a button "Reset view" no.

What does user mean fish swim away when move mouse near them perhaps expect individual fish reactive. My force works. I can additionally turn fish acceleration away if pointer within world radial via screen. yes.

I'll ensure UI clear without click focus. No keyboard used.

I think response code ~700 lines. Should have exactly one fenced code block no explanation. I can write detailed but not insane.

I'll simulate fish geometry scale: group scale size; body mesh scale set .58,.62,1.05; eyes group positions not scaled by body but group. Dorsal etc fit. For small fish 0.5 length (body2.1+tail.9)*.5=1.5 world ~50px; large1 length3 world100px. Great.
Fish count22 desktop, average 60px. tank 1280x800, nice.
Set orchid max.95. Blue .75-.95. Clown .68-.88. Gold .46-.62.

Fish body texture drawing:
```
const g=ctx.createLinearGradient(0,0,0,128)
g.addColorStop(0,spec.light); ...
```
UV v top/bottom after texture flipY default true; canvas top is v1 perhaps; fine.
For horizontal stripes drawn y 18-34 and 72-88. On fish forward z poles, both near ends perhaps. Should center bands across body. okay.
Blue pattern use ctx.strokeStyle dark line bezier; tail yellow.
Orchid horizontal thin lines perhaps along length? Texture horizontal means bands, use speckles instead.

Sand back at z? floor spans z±6, camera view. back wall meets floor at y-7.5, z-6. floor y-7.15 slight gap. Add back bottom rocks conceal.
Tank Box height15 center0 edges ±7.5. floor at-7.2.
Fish max5.3, top enough.

Plant colors material variants 3 shared? Each plant blade material from three shared greens; no emissive custom per plant. group rotation. Create mats global.
When same material MeshStandard with double side and transparent false, narrow triangles. fine.
Plant blades receive shadow perhaps castShadow false.

Kelp orientation ribbon initially in XY plane normal+Z, viewed from camera front good; random rotateY ±.7 ensures visibility. For blade local x curve baked; group base pos.

Corals recursion cylinder materials shared per coral group? create mat per group. Each branch using unitCylinder shared; tips spheres shared. `quaternion.setFromUnitVectors(up, dir)` up global constant but clone? `setFromUnitVectors` doesn't mutate inputs. good.
Random dir branch with z small. all branch within.
Star geometry random.

Bubbles texture:
canvas 64, radial gradient at center? create ring:
```
ctx.strokeStyle rgba; lineWidth3; arc32,32,23
radial gradient fill with center transparent? 
const gr=createRadialGradient(24,22,2,32,32,29) ...
```
Use fill plus arc. CanvasTexture. Points map alpha.
PointsMaterial transparent no alphaTest perhaps quad dark additive? Blending Additive avoids black. use AdditiveBlending.
Marine snow points material opacity .24.

Light shafts uPhase per mesh. fragment uses `sin(vUv.y*8.-uTime*.15+uPhase)` slight but not necessary. `alpha=edge*pow(vUv.y,1.5)*(.035+.02*sin...)`. At top uv.y1 brightest. vertical top may clip.
Plane positions y0 height15, top7.5.
Shaft rotation z changes positions.
Could add `mesh.renderOrder=1`.

CSS vignette opacity but not block. Frame.
`#scene canvas { display:block;width:100%;height:100%;cursor:none; }`
On coarse `cursor:auto`, cursorRing display none.
UI `user-select:none`.

Status and intro pointer-events none. custom cursor.
Color white #e9ffff.

HTML SVG logo path simple fish:
`<path d="M3 12c3.2-4.8 8.1-6.3 13-3.2l4-3v12l-4-3C11 18.3 6.2 16.8 3 12Z"/><circle ...>`.
Viewbox24.

Hint mouse SVG rect etc.

Activity bars 9 i with style heights via JS. Initial CSS nth child heights? JS first frame updates. Add default `height:12px`.
No need aria live every frame? reaction count perhaps aria hidden.

Add top center location small `REEF 01 / 24°C` perhaps status.

Responsive:
```
@media(max-width:700px){
.topbar{...}
.brand small{display:none}
.status .stable{display:none}
.intro{bottom:96px;...}
.intro h1{font-size:30px}
.intro p{font-size:12px;max-width:265px}
.activity{display:none}
.hint{left:50%;bottom:25px;white-space:nowrap;padding...}
.hint .secondary{display:none}
}
@media(max-height:620px){.intro p{display:none}.intro{bottom:28px}.hint{left:auto;right:28px;transform:none;...}}
```
At 1280x800 fine. At mobile height800 intro bottom100 and hint bottom24 no overlap. h1 34.
At small landscape height<620 intro bottom24, hint bottom24 right24 could overlap; info left24 and hint right24 perhaps if width small landscape 600 then overlap. Hide hint secondary and intro p. okay.
No horizontal scrollbar fixed.

Could hide intro p mobile? Keep.
Status `padding 10px`.

`z-index`: scene0, vignette2, frame3, UI5, cursor10.
Canvas scene.

Animation meter layout initial.
`ui.bars = [...document.querySelectorAll...]`.
Reaction update no layout expensive.

One potential bug `f.group.scale.setScalar(size)` and `lookAt` docs doesn't support non-uniform parent scale but no parent, uniform object scale okay.
`group.position` assigned vector from pos. fish object store pos? use group.position directly.
School center compute `schools[f.school].center.add(f.group.position)` initializes zero each frame.
Store avg count.

Frame elapsed `clock.getDelta`, if tab hidden huge clamp .033. t=clock.elapsedTime may jump but fine. Use elapsed += dt for continuity. Uniform time uses elapsed. pulse uses elapsed. On first frame clock delta0, fine.
`requestAnimationFrame(animate)`.
Call resize before create fish? Should world halfwidth computed before fish. Setup camera renderer, call resize then build scene. resize uses renderer etc and camera; no fish needed. UI fish count after creation.
Camera initial z updated via resize.

Build order: scene/camera/renderer; `resize()`; create textures materials environment; fish; event listeners; animate. window resize listener set before? can after.
BaseZ global31.5.
During resize camera.aspect etc, world half width uses baseZ and camera.fov. camera.position.z perhaps baseZ at init. Set immediately on resize if !hasSized? `camera.position.z=baseZ` in resize always resets on resize okay.
On resize user pointer camera x unaffected.
worldHalf.
Fish existing hard bounds next frame.

Create school arrays static 4. Counts dynamic but schools all nonempty.

Shadow camera near .5 far60; left right ±20 top20 bottom-20. Light pos (-8,15,10), target origin. floor. Fine.

Environment gradient point light `new THREE.PointLight(0x56e5d5,38,32,2)` r170 intensity units perhaps 38 moderate. Directional 2.4, hemisphere1.5; aquarium bright.
Add warm light `PointLight(0xffd59a,18,20,2)` near coral perhaps.
FogExp2 density.022 fish at camera distance30? Fog uses depth from camera; with density .022 and distance30 => fog factor 1-exp(-(0.66)^2)=.35, fish washed 35%. Scene fog underwater nice but all fish perhaps muted. Use Fog linear near25 far65 to background; camera distance to fish ~30, mild 12%. `new THREE.Fog(0x073845,25,62)`. Back wall distance37 =>30% fog. Good. Fish front distance27 =>5%. Use linear. Sand near front 30 etc. Great.
Back plane material fog:false perhaps color stable. `MeshBasicMaterial({map, fog:false})`.
Frame line fog true okay.

Fish emissive .06.
Ambient enough.

Glass front plane with material `MeshBasicMaterial({color:0x8de8ea,transparent:true,opacity:.025,side:DoubleSide,depthWrite:false})`; no physical shader cost.
Side walls perhaps same opacity .04 but transparent; omit side planes to avoid draw.
Glare line segments custom:
```
const glintGeo = BufferGeometry.setFromPoints([...])` line segments disconnected at front plane, opacity .15.
```
Box edges enough.

Could add front vertical thin highlights at x±14.95,y0,z6.01 as Plane .06x14.5 MeshBasic white opacity.18, not on mobile. Fine.

Plant colors.

Should have all code inline. I'll proceed composing response carefully.