Models 44

MiniMax M3.1 Flash

by MiniMaxReasoningClean
Open in new window

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

How it went

Total time
11 min 48 s
Cost
$0.031
Output tokens
51,581
Spent thinking
31,414 tokens
Speed
72.9 tok/s
Console errors
0
First token after
729 ms
First visible text after
9 min 52 s
Prompt tokens
600
Code size
63.2 KB
Finish reason
stop
Served by
Together
Model id
minimax/minimax-m3.1-flash
Reasoning sent
{"effort":"max"}
Max output
64,000 tokens
Generated
Nov 11, 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 · 1909 lines

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="theme-color" content="#04141d">
  <title>Tideglass — A Reactive Aquarium</title>
  <style>
    :root {
      --ice: #d9f8ff;
      --muted: #83a9b3;
      --cyan: #72e3ec;
      --line: rgba(159, 231, 242, .15);
      --glass: rgba(5, 28, 38, .56);
    }

    * { box-sizing: border-box; }

    html, body {
      width: 100%;
      height: 100%;
      margin: 0;
      overflow: hidden;
      background:
        radial-gradient(circle at 50% 43%, rgba(24, 102, 116, .26), transparent 43%),
        radial-gradient(circle at 18% 75%, rgba(16, 91, 107, .13), transparent 35%),
        linear-gradient(180deg, #04131c 0%, #071f29 52%, #020b11 100%);
      color: var(--ice);
      font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      -webkit-font-smoothing: antialiased;
    }

    body::before {
      content: "";
      position: fixed;
      inset: 0;
      pointer-events: none;
      opacity: .16;
      background-image:
        repeating-linear-gradient(0deg, rgba(255,255,255,.018) 0, rgba(255,255,255,.018) 1px, transparent 1px, transparent 4px);
      z-index: 2;
    }

    #app, #scene {
      position: fixed;
      inset: 0;
      width: 100%;
      height: 100%;
    }

    #scene {
      display: block;
      touch-action: none;
      cursor: crosshair;
      z-index: 1;
    }

    .vignette {
      position: fixed;
      inset: 0;
      z-index: 2;
      pointer-events: none;
      background:
        linear-gradient(180deg, rgba(1, 8, 13, .32), transparent 18%, transparent 77%, rgba(1, 7, 11, .54)),
        radial-gradient(ellipse at center, transparent 43%, rgba(0, 7, 12, .42) 100%);
    }

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

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

    .brand-mark {
      width: 43px;
      height: 43px;
      display: grid;
      place-items: center;
      border: 1px solid var(--line);
      border-radius: 50%;
      background: rgba(9, 42, 53, .35);
      box-shadow: inset 0 0 20px rgba(75, 218, 232, .08);
    }

    .brand-mark svg {
      width: 25px;
      height: 25px;
      stroke: #9deef5;
      fill: none;
      stroke-width: 1.35;
      stroke-linecap: round;
    }

    .kicker {
      display: block;
      margin-bottom: 5px;
      color: #81aab5;
      font-size: 9px;
      line-height: 1;
      font-weight: 650;
      letter-spacing: .22em;
      text-transform: uppercase;
    }

    .brand-name {
      font-size: 18px;
      line-height: 1;
      font-weight: 620;
      letter-spacing: .2em;
    }

    .top-right {
      display: flex;
      align-items: center;
      gap: 11px;
    }

    .live-chip, .mode-chip {
      height: 36px;
      display: flex;
      align-items: center;
      gap: 9px;
      padding: 0 14px;
      border: 1px solid var(--line);
      border-radius: 999px;
      background: rgba(4, 24, 33, .46);
      backdrop-filter: blur(14px);
      -webkit-backdrop-filter: blur(14px);
      color: #b7dbe2;
      font-size: 9px;
      font-weight: 650;
      letter-spacing: .16em;
      text-transform: uppercase;
    }

    .live-dot {
      width: 6px;
      height: 6px;
      border-radius: 50%;
      background: #72efbd;
      box-shadow: 0 0 12px rgba(114, 239, 189, .85);
      animation: breathe 2.4s ease-in-out infinite;
    }

    .mode-chip {
      color: #7fd9e3;
    }

    .glass {
      border: 1px solid var(--line);
      background: linear-gradient(135deg, rgba(9, 40, 51, .65), rgba(3, 19, 27, .48));
      box-shadow: 0 18px 55px rgba(0, 5, 10, .22), inset 0 1px rgba(255, 255, 255, .025);
      backdrop-filter: blur(18px);
      -webkit-backdrop-filter: blur(18px);
    }

    .behavior-card {
      position: fixed;
      z-index: 4;
      left: 32px;
      bottom: 32px;
      width: 282px;
      padding: 20px 21px 18px;
      border-radius: 16px;
      pointer-events: none;
    }

    .behavior-top {
      display: flex;
      align-items: center;
      justify-content: space-between;
    }

    .signal {
      display: flex;
      align-items: flex-end;
      gap: 3px;
      height: 12px;
    }

    .signal i {
      display: block;
      width: 2px;
      border-radius: 2px;
      background: #5bd2dc;
      opacity: .75;
      animation: signal 1.4s ease-in-out infinite alternate;
    }

    .signal i:nth-child(1) { height: 4px; }
    .signal i:nth-child(2) { height: 7px; animation-delay: -.3s; }
    .signal i:nth-child(3) { height: 10px; animation-delay: -.6s; }
    .signal i:nth-child(4) { height: 12px; animation-delay: -.9s; }

    .behavior-card h3 {
      margin: 15px 0 8px;
      max-width: 230px;
      font-size: 19px;
      line-height: 1.25;
      font-weight: 520;
      letter-spacing: -.02em;
    }

    .behavior-card p {
      margin: 0;
      color: #89aab2;
      font-size: 11px;
      line-height: 1.65;
    }

    .tags {
      display: flex;
      gap: 7px;
      margin-top: 15px;
    }

    .tags span {
      padding: 5px 8px;
      border: 1px solid rgba(135, 226, 237, .12);
      border-radius: 999px;
      color: #80a9b3;
      font-size: 8px;
      letter-spacing: .12em;
      text-transform: uppercase;
    }

    .readouts {
      position: fixed;
      z-index: 4;
      right: 32px;
      bottom: 32px;
      width: 216px;
      padding: 16px 18px;
      border-radius: 15px;
      pointer-events: none;
    }

    .readout {
      display: flex;
      align-items: baseline;
      justify-content: space-between;
      padding: 7px 0;
      color: #7ea3ad;
      font-size: 9px;
      letter-spacing: .14em;
      text-transform: uppercase;
    }

    .readout + .readout { border-top: 1px solid rgba(156, 229, 239, .07); }
    .readout strong {
      color: #c8edf2;
      font-size: 11px;
      font-weight: 550;
      letter-spacing: .04em;
    }

    .start-card {
      position: fixed;
      z-index: 6;
      left: 50%;
      bottom: 43px;
      width: 462px;
      min-height: 104px;
      transform: translateX(-50%);
      display: grid;
      grid-template-columns: 50px 1fr auto;
      align-items: center;
      gap: 15px;
      padding: 17px 18px;
      border: 1px solid rgba(161, 236, 247, .18);
      border-radius: 19px;
      background: linear-gradient(135deg, rgba(7, 39, 50, .78), rgba(3, 20, 29, .69));
      box-shadow: 0 22px 80px rgba(0, 5, 10, .42), inset 0 1px rgba(255, 255, 255, .04);
      backdrop-filter: blur(22px);
      -webkit-backdrop-filter: blur(22px);
      pointer-events: none;
      transition: opacity .55s ease, transform .55s ease, visibility .55s;
    }

    .start-card.is-hidden {
      opacity: 0;
      visibility: hidden;
      transform: translate(-50%, 14px);
    }

    .start-icon {
      width: 46px;
      height: 46px;
      display: grid;
      place-items: center;
      border-radius: 50%;
      border: 1px solid rgba(114, 227, 236, .3);
      background: radial-gradient(circle, rgba(91, 220, 231, .13), transparent 70%);
      box-shadow: 0 0 0 7px rgba(87, 213, 226, .035);
      animation: orbit 3s ease-in-out infinite;
    }

    .start-icon svg {
      width: 19px;
      height: 19px;
      fill: none;
      stroke: #a4f0f6;
      stroke-width: 1.4;
      stroke-linecap: round;
      stroke-linejoin: round;
    }

    .start-copy .kicker { margin-bottom: 7px; }
    .start-copy h2 {
      margin: 0;
      font-size: 15px;
      line-height: 1.25;
      font-weight: 560;
      letter-spacing: -.01em;
    }

    .start-copy p {
      margin: 6px 0 0;
      color: #87a8b1;
      font-size: 10px;
      line-height: 1.45;
    }

    #start-button {
      pointer-events: auto;
      cursor: pointer;
      height: 38px;
      padding: 0 15px;
      border: 1px solid rgba(150, 237, 245, .24);
      border-radius: 999px;
      background: rgba(72, 195, 208, .12);
      color: #d3f7fb;
      font: inherit;
      font-size: 9px;
      font-weight: 650;
      letter-spacing: .09em;
      text-transform: uppercase;
      transition: background .2s, transform .2s;
      white-space: nowrap;
    }

    #start-button:hover {
      background: rgba(72, 195, 208, .24);
      transform: translateY(-1px);
    }

    #start-button span { margin-left: 7px; color: #78e8ef; }

    #demo-label {
      position: fixed;
      z-index: 3;
      left: 50%;
      top: 50%;
      transform: translate(-50%, -50%);
      display: flex;
      align-items: center;
      gap: 8px;
      pointer-events: none;
      opacity: .76;
      transition: opacity .35s;
      color: #b8f6fa;
      font-size: 8px;
      font-weight: 700;
      letter-spacing: .2em;
      text-shadow: 0 1px 8px rgba(0,0,0,.5);
    }

    #demo-label::before {
      content: "";
      width: 25px;
      height: 1px;
      background: linear-gradient(90deg, transparent, rgba(157, 240, 247, .7));
    }

    #demo-label.hidden { opacity: 0; }

    .influence {
      position: fixed;
      z-index: 4;
      left: 50%;
      top: 50%;
      width: 76px;
      height: 76px;
      transform: translate(-50%, -50%);
      border: 1px solid rgba(138, 235, 244, .17);
      border-radius: 50%;
      background: radial-gradient(circle, rgba(112, 228, 238, .055), transparent 67%);
      box-shadow: 0 0 30px rgba(83, 211, 225, .035);
      pointer-events: none;
      opacity: 0;
      transition: opacity .25s;
    }

    .influence.visible { opacity: 1; }
    .influence::after {
      content: "";
      position: absolute;
      inset: 33px;
      border: 1px solid rgba(166, 243, 249, .48);
      border-radius: 50%;
    }

    @keyframes breathe {
      0%, 100% { opacity: .55; transform: scale(.85); }
      50% { opacity: 1; transform: scale(1.12); }
    }

    @keyframes orbit {
      0%, 100% { transform: rotate(-8deg) scale(.96); }
      50% { transform: rotate(8deg) scale(1.04); }
    }

    @keyframes signal {
      to { opacity: .25; transform: scaleY(.55); }
    }

    @media (max-width: 1120px) {
      .readouts { display: none; }
    }

    @media (max-width: 760px) {
      .topbar { top: 17px; left: 17px; right: 17px; }
      .brand-mark { width: 37px; height: 37px; }
      .brand-mark svg { width: 22px; height: 22px; }
      .brand .kicker { display: none; }
      .brand-name { font-size: 14px; letter-spacing: .16em; }
      .live-chip { display: none; }
      .mode-chip { height: 31px; padding: 0 10px; font-size: 8px; }
      .behavior-card { display: none; }
      .start-card {
        left: 14px;
        right: 14px;
        bottom: 15px;
        width: auto;
        min-height: 0;
        grid-template-columns: 42px 1fr;
        gap: 12px;
        padding: 15px;
        border-radius: 17px;
        transform: none;
      }
      .start-card.is-hidden { transform: translateY(14px); }
      .start-icon { width: 40px; height: 40px; }
      .start-copy h2 { font-size: 14px; }
      .start-copy p { font-size: 9px; }
      #start-button { grid-column: 1 / -1; width: 100%; }
    }

    @media (prefers-reduced-motion: reduce) {
      .live-dot, .signal i, .start-icon { animation: none; }
    }
  </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">
    <canvas id="scene" aria-label="An interactive three-dimensional aquarium"></canvas>
    <div class="vignette"></div>

    <header class="topbar">
      <div class="brand">
        <div class="brand-mark" aria-hidden="true">
          <svg viewBox="0 0 32 32">
            <path d="M5 12.5c3.2-3.2 6.4-3.2 9.6 0s6.4 3.2 9.6 0"/>
            <path d="M5 18c3.2-3.2 6.4-3.2 9.6 0s6.4 3.2 9.6 0"/>
            <path d="M8 24c2.2-2.2 4.4-2.2 6.6 0s4.4 2.2 6.6 0"/>
          </svg>
        </div>
        <div>
          <span class="kicker">No. 07 / Living collection</span>
          <span class="brand-name">TIDEGLASS</span>
        </div>
      </div>
      <div class="top-right">
        <div class="mode-chip" aria-live="polite"><span id="mode-dot"></span><span id="mode-text">Auto-pilot</span></div>
        <div class="live-chip"><span class="live-dot"></span>Reef online</div>
      </div>
    </header>

    <aside class="behavior-card glass">
      <div class="behavior-top">
        <span class="kicker">Field note 07</span>
        <span class="signal" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
      </div>
      <h3>A little space goes a long way.</h3>
      <p>Your cursor becomes a gentle disturbance. Bring it close and the reef quietly makes room.</p>
      <div class="tags">
        <span>22 residents</span>
        <span>4 schools</span>
        <span>Reactive</span>
      </div>
    </aside>

    <div class="readouts glass">
      <div class="readout"><span>Water</span><strong>24.8°C</strong></div>
      <div class="readout"><span>Current</span><strong>0.4 kn</strong></div>
      <div class="readout"><span>Light</span><strong>72%</strong></div>
      <div class="readout"><span>Resident</span><strong>22 fish</strong></div>
    </div>

    <div id="demo-label">Auto pointer</div>
    <div id="influence" class="influence"></div>

    <section id="start-card" class="start-card" aria-label="Start the aquarium">
      <div class="start-icon" aria-hidden="true">
        <svg viewBox="0 0 24 24">
          <path d="m6 3 11 9-6 .8-2.8 5.4L6 3Z"/>
          <path d="M18.5 3.5c1.2 1.3 1.8 2.8 1.8 4.4"/>
        </svg>
      </div>
      <div class="start-copy">
        <span class="kicker">Sandbox / auto-pilot</span>
        <h2>Click or press a key to start</h2>
        <p>The reef is already alive. Move near a fish and watch the school make room.</p>
      </div>
      <button id="start-button" type="button">Take the wheel <span>→</span></button>
    </section>
  </main>

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

    const canvas = document.getElementById('scene');
    const startCard = document.getElementById('start-card');
    const startButton = document.getElementById('start-button');
    const influence = document.getElementById('influence');
    const demoLabel = document.getElementById('demo-label');
    const modeText = document.getElementById('mode-text');

    const renderer = new THREE.WebGLRenderer({
      canvas,
      antialias: true,
      alpha: true,
      powerPreference: 'high-performance'
    });
    renderer.setClearColor(0x000000, 0);
    renderer.outputColorSpace = THREE.SRGBColorSpace;
    renderer.toneMapping = THREE.ACESFilmicToneMapping;
    renderer.toneMappingExposure = 1.13;
    renderer.shadowMap.enabled = true;
    renderer.shadowMap.type = THREE.PCFSoftShadowMap;

    const scene = new THREE.Scene();
    scene.fog = new THREE.Fog(0x041923, 16, 35);

    const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 80);
    const cameraBase = { x: 0, y: 3.15, z: 12.7 };
    const cameraRight = new THREE.Vector3();
    const cameraUp = new THREE.Vector3();

    let width = window.innerWidth;
    let height = window.innerHeight;
    let elapsed = 0;
    let frame = 0;
    let started = false;

    function mulberry32(seed) {
      return function () {
        let t = seed += 0x6D2B79F5;
        t = Math.imul(t ^ t >>> 15, t | 1);
        t ^= t + Math.imul(t ^ t >>> 7, t | 61);
        return ((t ^ t >>> 14) >>> 0) / 4294967296;
      };
    }

    const random = mulberry32(7042026);
    const clamp = THREE.MathUtils.clamp;
    const damp = (a, b, lambda, dt) => THREE.MathUtils.lerp(a, b, 1 - Math.exp(-lambda * dt));

    function makeTexture(w, h, paint) {
      const c = document.createElement('canvas');
      c.width = w;
      c.height = h;
      paint(c.getContext('2d'), w, h);
      const texture = new THREE.CanvasTexture(c);
      texture.colorSpace = THREE.SRGBColorSpace;
      texture.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());
      return texture;
    }

    const radialTexture = makeTexture(256, 256, (ctx, w, h) => {
      const gradient = ctx.createRadialGradient(w / 2, h / 2, 0, w / 2, h / 2, w / 2);
      gradient.addColorStop(0, 'rgba(255,255,255,1)');
      gradient.addColorStop(0.18, 'rgba(255,255,255,.7)');
      gradient.addColorStop(0.55, 'rgba(255,255,255,.13)');
      gradient.addColorStop(1, 'rgba(255,255,255,0)');
      ctx.fillStyle = gradient;
      ctx.fillRect(0, 0, w, h);
    });

    const backTexture = makeTexture(256, 256, (ctx, w, h) => {
      const gradient = ctx.createLinearGradient(0, 0, 0, h);
      gradient.addColorStop(0, '#0b7180');
      gradient.addColorStop(0.42, '#07515e');
      gradient.addColorStop(1, '#022631');
      ctx.fillStyle = gradient;
      ctx.fillRect(0, 0, w, h);

      const glow = ctx.createRadialGradient(w * 0.48, h * 0.12, 0, w * 0.48, h * 0.12, w * 0.65);
      glow.addColorStop(0, 'rgba(110,240,240,.25)');
      glow.addColorStop(1, 'rgba(110,240,240,0)');
      ctx.fillStyle = glow;
      ctx.fillRect(0, 0, w, h);

      ctx.globalAlpha = 0.06;
      for (let i = 0; i < 100; i++) {
        ctx.fillStyle = i % 2 ? '#d8ffff' : '#001c28';
        ctx.fillRect(random() * w, random() * h, 1, 1 + random() * 5);
      }
    });

    const shaftTexture = makeTexture(128, 512, (ctx, w, h) => {
      const horizontal = ctx.createLinearGradient(0, 0, w, 0);
      horizontal.addColorStop(0, 'rgba(255,255,255,0)');
      horizontal.addColorStop(0.5, 'rgba(255,255,255,1)');
      horizontal.addColorStop(1, 'rgba(255,255,255,0)');
      ctx.fillStyle = horizontal;
      ctx.fillRect(0, 0, w, h);
      ctx.globalCompositeOperation = 'destination-in';
      const vertical = ctx.createLinearGradient(0, 0, 0, h);
      vertical.addColorStop(0, 'rgba(255,255,255,.8)');
      vertical.addColorStop(0.75, 'rgba(255,255,255,.16)');
      vertical.addColorStop(1, 'rgba(255,255,255,0)');
      ctx.fillStyle = vertical;
      ctx.fillRect(0, 0, w, h);
    });

    // Lighting
    scene.add(new THREE.HemisphereLight(0xb7f3ff, 0x06252d, 1.65));
    scene.add(new THREE.AmbientLight(0x8bcdd6, 0.18));

    const keyLight = new THREE.DirectionalLight(0xd8ffff, 2.65);
    keyLight.position.set(-3.5, 8, 6);
    keyLight.castShadow = true;
    keyLight.shadow.mapSize.set(1024, 1024);
    keyLight.shadow.camera.left = -7;
    keyLight.shadow.camera.right = 7;
    keyLight.shadow.camera.top = 7;
    keyLight.shadow.camera.bottom = -5;
    keyLight.shadow.camera.near = 0.5;
    keyLight.shadow.camera.far = 25;
    keyLight.shadow.bias = -0.0005;
    scene.add(keyLight);

    const waterLight = new THREE.PointLight(0x56e4f1, 24, 13, 2);
    waterLight.position.set(0, 4.8, 1.1);
    scene.add(waterLight);

    const warmLight = new THREE.PointLight(0xffb77d, 8, 7, 2);
    warmLight.position.set(-3.6, 2.1, 2.2);
    scene.add(warmLight);

    const coralLight = new THREE.PointLight(0xff708e, 5, 4, 2);
    coralLight.position.set(3.2, 1.1, -0.5);
    scene.add(coralLight);

    // The tank
    const W = 9.5;
    const D = 3.35;
    const FLOOR = 0.59;
    const TOP = 5.05;
    const H = TOP - FLOOR;
    const tank = new THREE.Group();
    scene.add(tank);

    const floor = new THREE.Mesh(
      new THREE.PlaneGeometry(60, 60),
      new THREE.MeshStandardMaterial({ color: 0x031018, roughness: 0.76, metalness: 0.18 })
    );
    floor.rotation.x = -Math.PI / 2;
    floor.position.y = -0.12;
    floor.receiveShadow = true;
    scene.add(floor);

    const floorGlow = new THREE.Mesh(
      new THREE.PlaneGeometry(13, 5),
      new THREE.MeshBasicMaterial({
        map: radialTexture,
        color: 0x1b9aac,
        transparent: true,
        opacity: 0.15,
        blending: THREE.AdditiveBlending,
        depthWrite: false
      })
    );
    floorGlow.rotation.x = -Math.PI / 2;
    floorGlow.position.set(0, -0.105, 0.4);
    scene.add(floorGlow);

    const backdrop = new THREE.Sprite(new THREE.SpriteMaterial({
      map: radialTexture,
      color: 0x33bccc,
      transparent: true,
      opacity: 0.21,
      blending: THREE.AdditiveBlending,
      depthWrite: false
    }));
    backdrop.position.set(0, 3.1, -4.5);
    backdrop.scale.set(14, 9, 1);
    scene.add(backdrop);

    const base = new THREE.Mesh(
      new THREE.BoxGeometry(W + 0.3, 0.38, D + 0.25),
      new THREE.MeshPhysicalMaterial({
        color: 0x08232c,
        roughness: 0.3,
        metalness: 0.64,
        clearcoat: 0.45
      })
    );
    base.position.y = 0.07;
    base.castShadow = true;
    base.receiveShadow = true;
    tank.add(base);

    const trim = new THREE.Mesh(
      new THREE.BoxGeometry(W + 0.14, 0.095, D + 0.12),
      new THREE.MeshStandardMaterial({ color: 0x2d7076, roughness: 0.24, metalness: 0.8 })
    );
    trim.position.y = 0.315;
    trim.castShadow = true;
    tank.add(trim);

    const accent = new THREE.Mesh(
      new THREE.BoxGeometry(W * 0.48, 0.022, 0.025),
      new THREE.MeshBasicMaterial({ color: 0x7eeaf0, transparent: true, opacity: 0.65 })
    );
    accent.position.set(0, 0.24, D / 2 + 0.14);
    tank.add(accent);

    const backPanel = new THREE.Mesh(
      new THREE.PlaneGeometry(W, H),
      new THREE.MeshStandardMaterial({ map: backTexture, roughness: 0.96, metalness: 0.02 })
    );
    backPanel.position.set(0, FLOOR + H / 2, -D / 2);
    backPanel.receiveShadow = true;
    tank.add(backPanel);

    const glassMaterial = new THREE.MeshPhysicalMaterial({
      color: 0xa6eff7,
      transparent: true,
      opacity: 0.065,
      roughness: 0.06,
      metalness: 0.02,
      clearcoat: 1,
      clearcoatRoughness: 0.08,
      side: THREE.DoubleSide,
      depthWrite: false
    });

    for (const side of [-1, 1]) {
      const sidePanel = new THREE.Mesh(new THREE.PlaneGeometry(D, H), glassMaterial);
      sidePanel.position.set(side * W / 2, FLOOR + H / 2, 0);
      sidePanel.rotation.y = side * Math.PI / 2;
      sidePanel.renderOrder = 6;
      tank.add(sidePanel);
    }

    const frontGlass = new THREE.Mesh(
      new THREE.PlaneGeometry(W, H),
      new THREE.MeshBasicMaterial({
        color: 0xa8eef5,
        transparent: true,
        opacity: 0.018,
        side: THREE.DoubleSide,
        depthWrite: false
      })
    );
    frontGlass.position.set(0, FLOOR + H / 2, D / 2 + 0.015);
    frontGlass.renderOrder = 7;
    tank.add(frontGlass);

    const edgeMaterial = new THREE.LineBasicMaterial({
      color: 0xa6eff8,
      transparent: true,
      opacity: 0.32
    });
    const tankEdges = new THREE.LineSegments(
      new THREE.EdgesGeometry(new THREE.BoxGeometry(W, H, D)),
      edgeMaterial
    );
    tankEdges.position.y = FLOOR + H / 2;
    tankEdges.renderOrder = 8;
    tank.add(tankEdges);

    const volume = new THREE.Mesh(
      new THREE.BoxGeometry(W - 0.08, H - 0.12, D - 0.07),
      new THREE.MeshBasicMaterial({
        color: 0x1ac9d3,
        transparent: true,
        opacity: 0.027,
        side: THREE.BackSide,
        depthWrite: false
      })
    );
    volume.position.y = FLOOR + H / 2;
    volume.renderOrder = 1;
    tank.add(volume);

    for (const streak of [
      { x: -3.25, w: 0.48, o: 0.045, r: -0.22 },
      { x: -2.85, w: 0.16, o: 0.035, r: -0.22 },
      { x: 2.88, w: 0.25, o: 0.028, r: -0.27 }
    ]) {
      const mesh = new THREE.Mesh(
        new THREE.PlaneGeometry(streak.w, H * 0.96),
        new THREE.MeshBasicMaterial({
          color: 0xd9fbff,
          transparent: true,
          opacity: streak.o,
          blending: THREE.AdditiveBlending,
          depthWrite: false
        })
      );
      mesh.position.set(streak.x, FLOOR + H * 0.54, D / 2 + 0.03);
      mesh.rotation.z = streak.r;
      mesh.renderOrder = 8;
      tank.add(mesh);
    }

    // Sand with low dunes
    const sandGeometry = new THREE.PlaneGeometry(W - 0.15, D - 0.15, 52, 24);
    sandGeometry.rotateX(-Math.PI / 2);
    const sandPosition = sandGeometry.attributes.position;
    const sandColors = [];
    const sandColor = new THREE.Color();

    for (let i = 0; i < sandPosition.count; i++) {
      const x = sandPosition.getX(i);
      const z = sandPosition.getZ(i);
      const y = Math.sin(x * 1.3 + z * 0.7) * 0.034
        + Math.sin(x * 3.2 - z * 1.6) * 0.017
        + Math.cos(z * 4.1) * 0.012;
      sandPosition.setY(i, y);
      const lightness = clamp(0.64 + Math.sin(x * 2.4 + z) * 0.055 + (random() - 0.5) * 0.035, 0.52, 0.73);
      sandColor.setHSL(0.105 + (random() - 0.5) * 0.015, 0.29, lightness);
      sandColors.push(sandColor.r, sandColor.g, sandColor.b);
    }
    sandGeometry.setAttribute('color', new THREE.Float32BufferAttribute(sandColors, 3));
    sandGeometry.computeVertexNormals();

    const sand = new THREE.Mesh(
      sandGeometry,
      new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.95, metalness: 0 })
    );
    sand.position.y = FLOOR;
    sand.receiveShadow = true;
    tank.add(sand);

    const causticMaterial = new THREE.ShaderMaterial({
      uniforms: { uTime: { value: 0 } },
      transparent: true,
      depthWrite: false,
      blending: THREE.AdditiveBlending,
      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(15.0, 8.0);
          float a = abs(sin(p.x + sin(p.y * 1.25 + uTime * .32)));
          float b = abs(sin(p.y * 1.5 + cos(p.x * 1.05 - uTime * .27)));
          float line = pow(1.0 - min(a, b), 7.0);
          float pulse = .72 + .28 * sin(uTime * 1.1 + p.x * .4);
          gl_FragColor = vec4(.38, .96, 1.0, line * .31 * pulse);
        }
      `
    });
    const caustics = new THREE.Mesh(new THREE.PlaneGeometry(W - 0.18, D - 0.18), causticMaterial);
    caustics.rotation.x = -Math.PI / 2;
    caustics.position.y = FLOOR + 0.085;
    caustics.renderOrder = 2;
    tank.add(caustics);

    // Animated water surface
    const surfaceGeometry = new THREE.PlaneGeometry(W - 0.05, D - 0.05, 36, 16);
    surfaceGeometry.rotateX(-Math.PI / 2);
    const surfacePosition = surfaceGeometry.attributes.position;
    const surfaceBase = new Float32Array(surfacePosition.count * 2);
    for (let i = 0; i < surfacePosition.count; i++) {
      surfaceBase[i * 2] = surfacePosition.getX(i);
      surfaceBase[i * 2 + 1] = surfacePosition.getZ(i);
    }
    const surface = new THREE.Mesh(
      surfaceGeometry,
      new THREE.MeshPhysicalMaterial({
        color: 0x45d7dc,
        transparent: true,
        opacity: 0.27,
        roughness: 0.15,
        metalness: 0.12,
        clearcoat: 1,
        clearcoatRoughness: 0.12,
        side: THREE.DoubleSide,
        depthWrite: false
      })
    );
    surface.position.y = TOP;
    surface.renderOrder = 5;
    tank.add(surface);

    const waterlinePoints = [
      new THREE.Vector3(-W / 2, TOP, D / 2),
      new THREE.Vector3(W / 2, TOP, D / 2),
      new THREE.Vector3(W / 2, TOP, -D / 2),
      new THREE.Vector3(-W / 2, TOP, -D / 2)
    ];
    const waterline = new THREE.LineLoop(
      new THREE.BufferGeometry().setFromPoints(waterlinePoints),
      new THREE.LineBasicMaterial({ color: 0xc2fbff, transparent: true, opacity: 0.66 })
    );
    waterline.renderOrder = 8;
    tank.add(waterline);

    // Subtle underwater light shafts
    const shaftMaterial = new THREE.MeshBasicMaterial({
      map: shaftTexture,
      color: 0x9ef8ff,
      transparent: true,
      opacity: 0.12,
      blending: THREE.AdditiveBlending,
      depthWrite: false,
      side: THREE.DoubleSide
    });
    for (const shaft of [
      { x: -2.65, z: -0.85, w: 0.95, r: -0.23 },
      { x: -0.2, z: -1.1, w: 0.7, r: -0.2 },
      { x: 2.6, z: -0.65, w: 1.0, r: -0.24 }
    ]) {
      const beam = new THREE.Mesh(new THREE.PlaneGeometry(shaft.w, H * 1.05), shaftMaterial);
      beam.position.set(shaft.x, FLOOR + H * 0.54, shaft.z);
      beam.rotation.z = shaft.r;
      beam.renderOrder = 2;
      tank.add(beam);
    }

    // Rocks
    function createRock(x, z, sx, sy, sz, color) {
      const geometry = new THREE.IcosahedronGeometry(1, 2);
      const p = geometry.attributes.position;
      for (let i = 0; i < p.count; i++) {
        const px = p.getX(i), py = p.getY(i), pz = p.getZ(i);
        const distortion = 1 + Math.sin(px * 8.3 + py * 5.2 + pz * 6.1) * 0.075;
        p.setXYZ(i, px * distortion, py * distortion, pz * distortion);
      }
      geometry.computeVertexNormals();
      const rock = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({
        color,
        roughness: 0.9,
        metalness: 0.03,
        flatShading: true
      }));
      rock.position.set(x, FLOOR + sy * 0.53, z);
      rock.scale.set(sx, sy, sz);
      rock.rotation.y = random() * Math.PI;
      rock.castShadow = true;
      rock.receiveShadow = true;
      tank.add(rock);
      return rock;
    }

    createRock(-3.1, -0.92, 0.86, 0.58, 0.62, 0x435b5d);
    createRock(-3.62, -0.35, 0.46, 0.36, 0.42, 0x60706a);
    createRock(-2.35, -1.04, 0.52, 0.31, 0.46, 0x526a68);
    createRock(2.86, -1.0, 0.92, 0.5, 0.63, 0x3c5355);
    createRock(3.5, -0.55, 0.43, 0.29, 0.39, 0x64736b);
    createRock(1.87, -1.13, 0.46, 0.26, 0.38, 0x526966);
    createRock(-4.1, 0.5, 0.3, 0.21, 0.31, 0x697870);
    createRock(3.92, 0.63, 0.27, 0.17, 0.28, 0x5d6d68);

    for (let i = 0; i < 26; i++) {
      const pebble = new THREE.Mesh(
        new THREE.DodecahedronGeometry(0.05 + random() * 0.08, 0),
        new THREE.MeshStandardMaterial({
          color: random() > 0.5 ? 0x95866c : 0x5d716f,
          roughness: 1
        })
      );
      pebble.position.set((random() - 0.5) * (W - 0.5), FLOOR + 0.035, (random() - 0.5) * (D - 0.4));
      pebble.scale.y = 0.55;
      pebble.rotation.set(random() * 2, random() * 2, random() * 2);
      pebble.castShadow = true;
      tank.add(pebble);
    }

    // Seaweed
    const plantGroups = [];
    const plantMaterialCache = new Map();

    function getPlantMaterial(color) {
      if (!plantMaterialCache.has(color)) {
        plantMaterialCache.set(color, new THREE.MeshStandardMaterial({
          color,
          roughness: 0.74,
          metalness: 0.01,
          emissive: new THREE.Color(color).multiplyScalar(0.025),
          side: THREE.DoubleSide
        }));
      }
      return plantMaterialCache.get(color);
    }

    function ribbonGeometry(height, width, bend, phase) {
      const segments = 12;
      const positions = [];
      const uvs = [];
      const indices = [];
      for (let i = 0; i <= segments; i++) {
        const t = i / segments;
        const centerX = Math.sin(t * 2.7 + phase) * bend * t;
        const centerY = t * height;
        const bladeWidth = width * Math.pow(1 - t, 0.62) + 0.009;
        const z = Math.sin(t * 5.2 + phase) * 0.026;
        positions.push(centerX - bladeWidth, centerY, z, centerX + bladeWidth, centerY, z);
        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;
    }

    function createSeaweed(x, z, height, color, count) {
      const group = new THREE.Group();
      group.position.set(x, FLOOR + 0.015, z);
      group.userData.phase = random() * Math.PI * 2;
      for (let i = 0; i < count; i++) {
        const h = height * (0.58 + random() * 0.62);
        const blade = new THREE.Mesh(
          ribbonGeometry(h, 0.075 + random() * 0.045, 0.16 + random() * 0.16, random() * 6),
          getPlantMaterial(color)
        );
        blade.position.set((random() - 0.5) * 0.28, 0, (random() - 0.5) * 0.2);
        blade.rotation.y = (random() - 0.5) * 0.45;
        blade.rotation.z = (random() - 0.5) * 0.16;
        blade.castShadow = true;
        group.add(blade);
      }
      plantGroups.push(group);
      tank.add(group);
    }

    createSeaweed(-4.08, -1.0, 1.7, 0x16866c, 8);
    createSeaweed(-3.46, -1.21, 1.25, 0x24a873, 6);
    createSeaweed(-2.0, -1.3, 0.88, 0x3ebc8c, 5);
    createSeaweed(3.95, -1.05, 1.72, 0x16856b, 8);
    createSeaweed(4.18, -0.54, 1.05, 0x37ba87, 5);
    createSeaweed(1.35, -1.31, 0.78, 0x238b70, 5);
    createSeaweed(-4.15, 0.56, 0.72, 0x3cb88a, 4);
    createSeaweed(3.92, 0.74, 0.64, 0x55c99a, 4);

    // Coral helpers
    const yAxis = new THREE.Vector3(0, 1, 0);

    function addSegment(parent, a, b, r1, r2, material) {
      const direction = b.clone().sub(a);
      const length = direction.length();
      direction.normalize();
      const mesh = new THREE.Mesh(
        new THREE.CylinderGeometry(r2, r1, length, 7, 1, false),
        material
      );
      mesh.position.copy(a).add(b).multiplyScalar(0.5);
      mesh.quaternion.setFromUnitVectors(yAxis, direction);
      mesh.castShadow = true;
      parent.add(mesh);
      return mesh;
    }

    function createBranchCoral(x, z, color, scale) {
      const group = new THREE.Group();
      group.position.set(x, FLOOR + 0.02, z);
      group.scale.setScalar(scale);
      const material = new THREE.MeshStandardMaterial({
        color,
        roughness: 0.57,
        metalness: 0.02,
        emissive: new THREE.Color(color).multiplyScalar(0.045)
      });
      const tipMaterial = new THREE.MeshStandardMaterial({
        color: 0xffd3c3,
        roughness: 0.4,
        emissive: new THREE.Color(color).multiplyScalar(0.18)
      });

      addSegment(group, new THREE.Vector3(0, 0, 0), new THREE.Vector3(0.02, 0.72, 0), 0.15, 0.105, material);
      const branches = [
        [new THREE.Vector3(0, 0.22, 0), new THREE.Vector3(-0.43, 0.61, 0.04)],
        [new THREE.Vector3(0, 0.31, 0), new THREE.Vector3(0.42, 0.76, -0.02)],
        [new THREE.Vector3(0, 0.46, 0), new THREE.Vector3(-0.3, 0.94, -0.05)],
        [new THREE.Vector3(0, 0.53, 0), new THREE.Vector3(0.2, 1.0, 0.08)]
      ];
      for (const [a, b] of branches) {
        addSegment(group, a, b, 0.09, 0.047, material);
        const tip = new THREE.Mesh(new THREE.SphereGeometry(0.075, 9, 7), tipMaterial);
        tip.position.copy(b);
        tip.scale.y = 1.35;
        group.add(tip);
        addSegment(group, b.clone().add(new THREE.Vector3(-0.03, 0, 0)), b.clone().add(new THREE.Vector3(-0.13, 0.25, 0.04)), 0.05, 0.025, material);
        addSegment(group, b.clone().add(new THREE.Vector3(0.03, 0, 0)), b.clone().add(new THREE.Vector3(0.12, 0.22, -0.03)), 0.045, 0.024, material);
      }
      tank.add(group);
      return group;
    }

    createBranchCoral(-2.6, -0.57, 0xff7f72, 1.0);
    createBranchCoral(2.25, -1.04, 0xc17cf1, 1.12);
    createBranchCoral(-1.04, -1.18, 0xffa15f, 0.75);

    const anemoneGroups = [];
    function createAnemone(x, z, color, scale) {
      const group = new THREE.Group();
      group.position.set(x, FLOOR + 0.02, z);
      group.scale.setScalar(scale);
      group.userData.baseScale = scale;
      group.userData.phase = random() * 6;
      const material = new THREE.MeshStandardMaterial({
        color,
        roughness: 0.48,
        emissive: new THREE.Color(color).multiplyScalar(0.12)
      });
      const tipMaterial = new THREE.MeshStandardMaterial({
        color: 0xffd8dc,
        roughness: 0.35,
        emissive: 0xff718b,
        emissiveIntensity: 0.45
      });
      for (let i = 0; i < 24; i++) {
        const angle = (i / 24) * Math.PI * 2 + random() * 0.16;
        const reach = 0.38 + random() * 0.18;
        const height = 0.5 + random() * 0.26;
        const curve = new THREE.CatmullRomCurve3([
          new THREE.Vector3(0, 0.02, 0),
          new THREE.Vector3(Math.cos(angle) * 0.12, 0.2, Math.sin(angle) * 0.12),
          new THREE.Vector3(Math.cos(angle) * reach * 0.68, height * 0.72, Math.sin(angle) * reach * 0.68),
          new THREE.Vector3(Math.cos(angle) * reach, height, Math.sin(angle) * reach)
        ]);
        const tentacle = new THREE.Mesh(new THREE.TubeGeometry(curve, 12, 0.027, 6, false), material);
        tentacle.castShadow = true;
        group.add(tentacle);
        const tip = new THREE.Mesh(new THREE.SphereGeometry(0.043, 8, 6), tipMaterial);
        tip.position.copy(curve.getPoint(1));
        group.add(tip);
      }
      anemoneGroups.push(group);
      tank.add(group);
    }
    createAnemone(3.36, -0.67, 0xf46f93, 1.02);
    createAnemone(-3.75, -0.02, 0xa476ed, 0.67);

    // Cup coral
    const cupMaterial = new THREE.MeshStandardMaterial({
      color: 0xf5a56a,
      roughness: 0.62,
      side: THREE.DoubleSide
    });
    for (const cup of [
      { x: 0.72, z: -1.05, s: 0.75 },
      { x: 1.0, z: -1.2, s: 0.53 },
      { x: 0.47, z: -1.24, s: 0.42 }
    ]) {
      const mesh = new THREE.Mesh(new THREE.CylinderGeometry(0.2 * cup.s, 0.12 * cup.s, 0.31 * cup.s, 18, 1, true), cupMaterial);
      mesh.position.set(cup.x, FLOOR + 0.15 * cup.s, cup.z);
      mesh.castShadow = true;
      tank.add(mesh);
    }

    // Fan coral
    const fan = new THREE.Group();
    fan.position.set(0.45, FLOOR + 0.02, -1.32);
    fan.rotation.y = -0.12;
    const fanMaterial = new THREE.MeshStandardMaterial({ color: 0xf5a267, roughness: 0.6 });
    for (let i = 0; i < 9; i++) {
      const angle = -1.2 + (i / 8) * 2.4;
      const reach = 0.55 + Math.sin(i / 8 * Math.PI) * 0.25;
      const b = new THREE.Vector3(Math.cos(angle) * reach, Math.sin(angle) * reach, 0);
      addSegment(fan, new THREE.Vector3(0, 0.06, 0), b, 0.038, 0.015, fanMaterial);
      if (i > 0 && i < 8) {
        const mid = b.clone().multiplyScalar(0.57);
        mid.y += 0.06;
        addSegment(fan, mid, b, 0.021, 0.012, fanMaterial);
      }
    }
    tank.add(fan);

    // Starfish
    for (const star of [
      { x: -2.0, z: 0.64, r: 0.25, color: 0xf19368 },
      { x: 2.05, z: 0.72, r: 0.2, color: 0xe98269 }
    ]) {
      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 ? star.r * 0.43 : star.r;
        const x = Math.cos(angle) * radius;
        const y = Math.sin(angle) * radius;
        if (i === 0) shape.moveTo(x, y);
        else shape.lineTo(x, y);
      }
      shape.closePath();
      const mesh = new THREE.Mesh(
        new THREE.ShapeGeometry(shape),
        new THREE.MeshStandardMaterial({ color: star.color, roughness: 0.72, side: THREE.DoubleSide })
      );
      mesh.rotation.x = -Math.PI / 2;
      mesh.rotation.z = random() * Math.PI;
      mesh.position.set(star.x, FLOOR + 0.04, star.z);
      mesh.castShadow = true;
      tank.add(mesh);
    }

    // Fish geometry
    const fishBodyGeometry = new THREE.SphereGeometry(1, 20, 14);
    const fishTailGeometry = new THREE.BufferGeometry();
    fishTailGeometry.setAttribute('position', new THREE.Float32BufferAttribute([
      0, 0, 0,
      -0.5, 0.38, 0,
      -0.38, 0, 0,
      -0.5, -0.38, 0
    ], 3));
    fishTailGeometry.setIndex([0, 1, 2, 0, 2, 3]);
    fishTailGeometry.computeVertexNormals();

    const dorsalGeometry = new THREE.BufferGeometry();
    dorsalGeometry.setAttribute('position', new THREE.Float32BufferAttribute([
      -0.3, 0, 0,
      0.23, 0, 0,
      -0.05, 0.38, 0
    ], 3));
    dorsalGeometry.computeVertexNormals();

    const stripeGeometry = new THREE.TorusGeometry(0.5, 0.055, 7, 30);
    stripeGeometry.rotateY(Math.PI / 2);
    const eyeGeometry = new THREE.SphereGeometry(1, 10, 8);
    const pupilMaterial = new THREE.MeshStandardMaterial({ color: 0x071015, roughness: 0.2 });
    const eyeMaterial = new THREE.MeshStandardMaterial({ color: 0xf0fbf7, roughness: 0.3 });
    const stripeMaterial = new THREE.MeshStandardMaterial({ color: 0xffecd0, roughness: 0.48 });

    function fishMaterial(color, metalness = 0.06) {
      const material = new THREE.MeshPhysicalMaterial({
        color,
        roughness: 0.34,
        metalness,
        clearcoat: 0.62,
        clearcoatRoughness: 0.24,
        side: THREE.DoubleSide
      });
      material.emissive.copy(new THREE.Color(color).multiplyScalar(0.045));
      material.emissiveIntensity = 0.42;
      return material;
    }

    const speciesDefinitions = [
      {
        id: 'sun', school: 'sun', count: 6,
        body: 0xff873b, fin: 0xffca52,
        size: [0.78, 1.04], height: [0.43, 0.57], width: [0.24, 0.31],
        pattern: 'bands'
      },
      {
        id: 'blue', school: 'blue', count: 5,
        body: 0x168df0, fin: 0x57e3f1,
        size: [0.67, 0.92], height: [0.43, 0.57], width: [0.23, 0.3],
        pattern: 'patch'
      },
      {
        id: 'violet', school: 'violet', count: 4,
        body: 0xb16af1, fin: 0xff87cb,
        size: [0.6, 0.81], height: [0.37, 0.49], width: [0.2, 0.27],
        pattern: 'plain'
      },
      {
        id: 'silver', school: 'silver', count: 7,
        body: 0xb8e9e6, fin: 0xeaffff,
        size: [0.4, 0.59], height: [0.24, 0.34], width: [0.14, 0.2],
        pattern: 'plain'
      }
    ];

    const schoolAnchors = {
      sun: new THREE.Vector3(-1.65, 2.6, 0.28),
      blue: new THREE.Vector3(1.62, 3.28, 0.03),
      violet: new THREE.Vector3(1.08, 1.77, 0.63),
      silver: new THREE.Vector3(-0.25, 3.86, -0.48)
    };

    const schools = {};
    for (const definition of speciesDefinitions) {
      schools[definition.school] = {
        members: [],
        center: new THREE.Vector3(),
        averageVelocity: new THREE.Vector3()
      };
    }

    const fishList = [];

    function createFish(definition, index) {
      const length = definition.size[0] + random() * (definition.size[1] - definition.size[0]);
      const height = definition.height[0] + random() * (definition.height[1] - definition.height[0]);
      const width = definition.width[0] + random() * (definition.width[1] - definition.width[0]);
      const root = new THREE.Group();
      const visual = new THREE.Group();
      root.add(visual);

      const bodyMaterial = fishMaterial(definition.body);
      const finMaterial = fishMaterial(definition.fin, 0.02);
      const body = new THREE.Mesh(fishBodyGeometry, bodyMaterial);
      body.scale.set(length * 0.55, height * 0.5, width * 0.5);
      body.castShadow = true;
      visual.add(body);

      const tailPivot = new THREE.Group();
      tailPivot.position.x = -length * 0.47;
      const tail = new THREE.Mesh(fishTailGeometry, finMaterial);
      tail.scale.set(length * 0.95, height * 0.77, 1);
      tail.castShadow = true;
      tailPivot.add(tail);
      visual.add(tailPivot);

      const dorsal = new THREE.Mesh(dorsalGeometry, finMaterial);
      dorsal.position.set(-length * 0.04, height * 0.38, 0);
      dorsal.scale.set(length * 0.78, height * 0.58, 1);
      dorsal.castShadow = true;
      visual.add(dorsal);

      const pectorals = [];
      for (const side of [-1, 1]) {
        const fin = new THREE.Mesh(eyeGeometry, finMaterial);
        fin.position.set(length * 0.02, -height * 0.03, side * width * 0.43);
        fin.scale.set(length * 0.17, height * 0.14, width * 0.065);
        fin.rotation.x = side * 0.45;
        visual.add(fin);
        pectorals.push(fin);

        const eye = new THREE.Mesh(eyeGeometry, eyeMaterial);
        eye.position.set(length * 0.31, height * 0.13, side * width * 0.39);
        eye.scale.setScalar(length * 0.047);
        visual.add(eye);

        const pupil = new THREE.Mesh(eyeGeometry, pupilMaterial);
        pupil.position.set(length * 0.325, height * 0.13, side * width * 0.425);
        pupil.scale.setScalar(length * 0.026);
        visual.add(pupil);
      }

      if (definition.pattern === 'bands') {
        for (const x of [-0.27, 0.02, 0.28]) {
          const band = new THREE.Mesh(stripeGeometry, stripeMaterial);
          band.position.x = length * x;
          band.scale.set(1, height, width);
          visual.add(band);
        }
      }

      if (definition.pattern === 'patch') {
        const patchMaterial = new THREE.MeshStandardMaterial({ color: 0x10294f, roughness: 0.46 });
        for (const side of [-1, 1]) {
          const patch = new THREE.Mesh(eyeGeometry, patchMaterial);
          patch.position.set(-length * 0.26, 0, side * width * 0.43);
          patch.scale.set(length * 0.22, height * 0.34, width * 0.055);
          visual.add(patch);
        }
      }

      const anchor = schoolAnchors[definition.school];
      root.position.set(
        anchor.x + (random() - 0.5) * 2.7,
        clamp(anchor.y + (random() - 0.5) * 1.4, FLOOR + 0.65, TOP - 0.45),
        clamp(anchor.z + (random() - 0.5) * 1.15, -1.15, 1.1)
      );

      const velocity = new THREE.Vector3(random() - 0.5, (random() - 0.5) * 0.2, random() - 0.5)
        .normalize().multiplyScalar(0.45 + random() * 0.25);

      const fish = {
        root,
        visual,
        tailPivot,
        pectorals,
        length,
        height,
        width,
        velocity,
        desired: new THREE.Vector3(),
        wanderTarget: new THREE.Vector3(),
        wanderTimer: random() * 2,
        phase: random() * Math.PI * 2,
        baseSpeed: 0.48 + random() * 0.24,
        school: definition.school,
        startle: 0,
        projected: new THREE.Vector3(),
        world: new THREE.Vector3()
      };

      fishList.push(fish);
      schools[definition.school].members.push(fish);
      tank.add(root);
      return fish;
    }

    for (const definition of speciesDefinitions) {
      for (let i = 0; i < definition.count; i++) createFish(definition, i);
    }

    // Bubbles
    const bubbleCount = 46;
    const bubbleGeometry = new THREE.SphereGeometry(0.052, 9, 7);
    const bubbleMaterial = new THREE.MeshPhysicalMaterial({
      color: 0xc8faff,
      transparent: true,
      opacity: 0.28,
      roughness: 0.04,
      metalness: 0.02,
      clearcoat: 1,
      depthWrite: false
    });
    const bubbleMesh = new THREE.InstancedMesh(bubbleGeometry, bubbleMaterial, bubbleCount);
    bubbleMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
    bubbleMesh.renderOrder = 4;
    tank.add(bubbleMesh);

    const bubbles = [];
    for (let i = 0; i < bubbleCount; i++) {
      const source = i % 3 === 0 ? -3.72 : 3.42;
      bubbles.push({
        source,
        x: source + (random() - 0.5) * 0.22,
        z: (source < 0 ? -0.05 : -0.68) + (random() - 0.5) * 0.3,
        y: FLOOR + random() * (TOP - FLOOR),
        speed: 0.28 + random() * 0.52,
        phase: random() * Math.PI * 2,
        size: 0.45 + random() * 1.25
      });
    }
    const bubbleDummy = new THREE.Object3D();

    // Plankton
    const planktonCount = 135;
    const planktonPositions = new Float32Array(planktonCount * 3);
    const planktonSpeeds = new Float32Array(planktonCount);
    for (let i = 0; i < planktonCount; i++) {
      planktonPositions[i * 3] = (random() - 0.5) * (W - 0.4);
      planktonPositions[i * 3 + 1] = FLOOR + random() * (H - 0.2);
      planktonPositions[i * 3 + 2] = (random() - 0.5) * (D - 0.35);
      planktonSpeeds[i] = 0.015 + random() * 0.04;
    }
    const planktonGeometry = new THREE.BufferGeometry();
    planktonGeometry.setAttribute('position', new THREE.BufferAttribute(planktonPositions, 3));
    const plankton = new THREE.Points(planktonGeometry, new THREE.PointsMaterial({
      color: 0xbffaff,
      map: radialTexture,
      size: 0.055,
      transparent: true,
      opacity: 0.46,
      depthWrite: false,
      blending: THREE.AdditiveBlending,
      sizeAttenuation: true
    }));
    plankton.frustumCulled = false;
    plankton.renderOrder = 4;
    tank.add(plankton);

    // Self-playing demo cursor
    const demoCursor = new THREE.Group();
    const demoRingMaterial = new THREE.MeshBasicMaterial({
      color: 0xc7fbff,
      transparent: true,
      opacity: 0.72,
      depthWrite: false,
      depthTest: false
    });
    const demoRing = new THREE.Mesh(new THREE.TorusGeometry(0.21, 0.014, 8, 40), demoRingMaterial);
    demoCursor.add(demoRing);
    const demoGlowMaterial = new THREE.SpriteMaterial({
      map: radialTexture,
      color: 0x8beef5,
      transparent: true,
      opacity: 0.33,
      blending: THREE.AdditiveBlending,
      depthWrite: false,
      depthTest: false
    });
    const demoGlow = new THREE.Sprite(demoGlowMaterial);
    demoGlow.scale.set(0.9, 0.9, 1);
    demoCursor.add(demoGlow);
    demoCursor.position.set(0, 2.7, 1.08);
    demoCursor.renderOrder = 12;
    tank.add(demoCursor);
    let demoMix = 1;

    // Pointer and interaction
    const pointer = {
      ndc: new THREE.Vector2(),
      smooth: new THREE.Vector2(),
      inside: false,
      moved: false,
      last: -100000
    };
    const raycaster = new THREE.Raycaster();
    const interactionPlane = new THREE.Plane();
    const planeNormal = new THREE.Vector3();
    const planePoint = new THREE.Vector3(0, 2.7, 1);
    const hitPoint = new THREE.Vector3();

    const sharedRippleGeometry = new THREE.RingGeometry(0.075, 0.104, 36);
    const ripples = [];

    function spawnRipple(position, color = 0xaaf7ff) {
      if (ripples.length > 18) {
        const oldest = ripples.shift();
        tank.remove(oldest.mesh);
        oldest.mesh.material.dispose();
      }
      const material = new THREE.MeshBasicMaterial({
        color,
        transparent: true,
        opacity: 0.72,
        side: THREE.DoubleSide,
        blending: THREE.AdditiveBlending,
        depthWrite: false
      });
      const mesh = new THREE.Mesh(sharedRippleGeometry, material);
      mesh.position.copy(position);
      mesh.quaternion.copy(camera.quaternion);
      mesh.scale.setScalar(0.5);
      mesh.renderOrder = 10;
      tank.add(mesh);
      ripples.push({ mesh, birth: elapsed, life: 0.8 });
    }

    function startExperience() {
      if (started) return;
      started = true;
      startCard.classList.add('is-hidden');
    }

    startButton.addEventListener('click', (event) => {
      event.stopPropagation();
      startExperience();
    });

    window.addEventListener('keydown', (event) => {
      if (event.key !== 'Tab') startExperience();
    });

    canvas.addEventListener('pointermove', (event) => {
      const rect = canvas.getBoundingClientRect();
      pointer.ndc.set(
        ((event.clientX - rect.left) / rect.width) * 2 - 1,
        -((event.clientY - rect.top) / rect.height) * 2 + 1
      );
      pointer.inside = true;
      pointer.moved = true;
      pointer.last = performance.now();
      influence.style.left = `${event.clientX}px`;
      influence.style.top = `${event.clientY}px`;
      influence.classList.add('visible');
      modeText.textContent = 'Manual control';
    });

    canvas.addEventListener('pointerleave', () => {
      pointer.inside = false;
      influence.classList.remove('visible');
    });

    canvas.addEventListener('pointerdown', (event) => {
      startExperience();
      pointer.last = performance.now();
      const rect = canvas.getBoundingClientRect();
      const ndc = new THREE.Vector2(
        ((event.clientX - rect.left) / rect.width) * 2 - 1,
        -((event.clientY - rect.top) / rect.height) * 2 + 1
      );
      raycaster.setFromCamera(ndc, camera);
      camera.getWorldDirection(planeNormal);
      interactionPlane.setFromNormalAndCoplanarPoint(planeNormal, planePoint);
      if (raycaster.ray.intersectPlane(interactionPlane, hitPoint)) {
        hitPoint.x = clamp(hitPoint.x, -W / 2 + 0.2, W / 2 - 0.2);
        hitPoint.y = clamp(hitPoint.y, FLOOR + 0.2, TOP - 0.2);
        const local = tank.worldToLocal(hitPoint.clone());
        spawnRipple(local, 0xd5fcff);
      }
    });

    // Steering helpers
    const separationDelta = new THREE.Vector3();
    const schoolDelta = new THREE.Vector3();
    const wanderDelta = new THREE.Vector3();
    const demoDelta = new THREE.Vector3();
    const projectedPosition = new THREE.Vector3();

    function updateSchools() {
      for (const key in schools) {
        const school = schools[key];
        school.center.set(0, 0, 0);
        school.averageVelocity.set(0, 0, 0);
        for (const fish of school.members) {
          school.center.add(fish.root.position);
          school.averageVelocity.add(fish.velocity);
        }
        school.center.multiplyScalar(1 / school.members.length);
        school.averageVelocity.multiplyScalar(1 / school.members.length);
      }
    }

    let lastFishRipple = -1;

    function updateFish(dt) {
      const pointerX = (pointer.smooth.x * 0.5 + 0.5) * width;
      const pointerY = (-pointer.smooth.y * 0.5 + 0.5) * height;
      const pointerFresh = pointer.inside && performance.now() - pointer.last < 4200;

      for (const fish of fishList) {
        const desired = fish.desired.set(0, 0, 0);
        fish.world.copy(fish.root.position);
        tank.localToWorld(fish.world);
        fish.projected.copy(fish.world).project(camera);
        const screenX = (fish.projected.x * 0.5 + 0.5) * width;
        const screenY = (-fish.projected.y * 0.5 + 0.5) * height;
        const dx = screenX - pointerX;
        const dy = screenY - pointerY;
        const distance = Math.hypot(dx, dy);
        const fearRadius = 145 + fish.length * 30;

        if (pointerFresh && distance < fearRadius && distance > 0.001) {
          const intensity = Math.pow(1 - distance / fearRadius, 2);
          desired.addScaledVector(cameraRight, dx / distance * intensity * 11);
          desired.addScaledVector(cameraUp, -dy / distance * intensity * 11);
          fish.startle = Math.min(1, fish.startle + intensity * dt * 5.5);
        }

        demoDelta.copy(fish.root.position).sub(demoCursor.position);
        const demoDistance = demoDelta.length();
        if (demoMix > 0.02 && demoDistance < 1.65 && demoDistance > 0.001) {
          const intensity = Math.pow(1 - demoDistance / 1.65, 2);
          desired.addScaledVector(demoDelta.normalize(), intensity * 8.5 * demoMix);
          fish.startle = Math.min(1, fish.startle + intensity * demoMix * dt * 3.8);
        }

        const school = schools[fish.school];
        schoolDelta.copy(school.center).sub(fish.root.position);
        desired.addScaledVector(schoolDelta, 0.095);
        desired.addScaledVector(school.averageVelocity.clone().sub(fish.velocity), 0.19);

        for (const other of fishList) {
          if (other === fish) continue;
          separationDelta.copy(fish.root.position).sub(other.root.position);
          const separationDistance = separationDelta.length();
          if (separationDistance > 0.001 && separationDistance < 0.78) {
            desired.addScaledVector(separationDelta.normalize(), (0.78 - separationDistance) * 2.05);
          }
        }

        fish.wanderTimer -= dt;
        if (fish.wanderTimer <= 0) {
          fish.wanderTimer = 2 + random() * 3;
          fish.wanderTarget.set(
            school.center.x + (random() - 0.5) * 4.2,
            clamp(school.center.y + (random() - 0.5) * 2.1, FLOOR + 0.55, TOP - 0.45),
            school.center.z + (random() - 0.5) * 2.1
          );
        }
        wanderDelta.copy(fish.wanderTarget).sub(fish.root.position);
        if (wanderDelta.lengthSq() > 0.001) {
          desired.addScaledVector(wanderDelta.normalize(), 0.22);
        }

        const marginX = W / 2 - 0.72;
        if (fish.root.position.x > marginX) desired.x -= (fish.root.position.x - marginX) * 2.4;
        if (fish.root.position.x < -marginX) desired.x += (-marginX - fish.root.position.x) * 2.4;
        if (fish.root.position.y < FLOOR + 0.48) desired.y += (FLOOR + 0.48 - fish.root.position.y) * 3.4;
        if (fish.root.position.y > TOP - 0.38) desired.y -= (fish.root.position.y - TOP + 0.38) * 3.4;
        if (fish.root.position.z > D / 2 - 0.42) desired.z -= (fish.root.position.z - D / 2 + 0.42) * 3.4;
        if (fish.root.position.z < -D / 2 + 0.38) desired.z += (-D / 2 + 0.38 - fish.root.position.z) * 3.4;

        if (desired.length() > 11) desired.setLength(11);
        fish.velocity.addScaledVector(desired, dt * (1.18 + fish.startle * 2.1));
        fish.velocity.multiplyScalar(Math.pow(0.997, dt * 60));

        const speed = fish.velocity.length();
        const maxSpeed = fish.baseSpeed * 1.42 + fish.startle * 1.85;
        const minSpeed = fish.baseSpeed * 0.66;
        if (speed > maxSpeed) fish.velocity.multiplyScalar(maxSpeed / speed);
        else if (speed < minSpeed && fish.startle < 0.16) fish.velocity.multiplyScalar(minSpeed / Math.max(speed, 0.001));

        fish.root.position.addScaledVector(fish.velocity, dt);
        fish.root.position.x = clamp(fish.root.position.x, -W / 2 + 0.3, W / 2 - 0.3);
        fish.root.position.y = clamp(fish.root.position.y, FLOOR + 0.34, TOP - 0.26);
        fish.root.position.z = clamp(fish.root.position.z, -D / 2 + 0.3, D / 2 - 0.3);

        const horizontalSpeed = Math.hypot(fish.velocity.x, fish.velocity.z);
        const targetYaw = Math.atan2(-fish.velocity.z, fish.velocity.x);
        const yawDelta = Math.atan2(
          Math.sin(targetYaw - fish.root.rotation.y),
          Math.cos(targetYaw - fish.root.rotation.y)
        );
        fish.root.rotation.y += yawDelta * Math.min(1, dt * 5.5);
        const targetPitch = clamp(Math.atan2(fish.velocity.y, Math.max(horizontalSpeed, 0.001)), -0.52, 0.52);
        fish.visual.rotation.z = damp(fish.visual.rotation.z, targetPitch, 6, dt);
        fish.visual.position.y = Math.sin(elapsed * 2.1 + fish.phase) * 0.012;

        const wagSpeed = 4.2 + fish.velocity.length() * 3.7 + fish.startle * 4;
        fish.tailPivot.rotation.y = Math.sin(elapsed * wagSpeed + fish.phase) * (0.31 + fish.startle * 0.2);
        fish.pectorals[0].rotation.x = -0.45 + Math.sin(elapsed * 4.4 + fish.phase) * 0.22;
        fish.pectorals[1].rotation.x = 0.45 - Math.sin(elapsed * 4.4 + fish.phase) * 0.22;

        fish.startle *= Math.exp(-dt * 1.55);

        if (pointerFresh && distance < 92 && fish.startle > 0.72 && elapsed - lastFishRipple > 0.38) {
          spawnRipple(fish.root.position);
          lastFishRipple = elapsed;
        }
      }
    }

    function updateSurface(time) {
      const position = surfacePosition;
      for (let i = 0; i < position.count; i++) {
        const x = surfaceBase[i * 2];
        const z = surfaceBase[i * 2 + 1];
        const wave = Math.sin(x * 1.55 + time * 1.4) * 0.024
          + Math.sin(z * 2.35 - time * 1.08 + x * 0.4) * 0.017;
        position.setY(i, wave);
      }
      position.needsUpdate = true;
      if (frame % 3 === 0) surfaceGeometry.computeVertexNormals();
    }

    function updateBubbles(dt, time) {
      for (let i = 0; i < bubbles.length; i++) {
        const bubble = bubbles[i];
        bubble.y += bubble.speed * dt;
        if (bubble.y > TOP - 0.05) bubble.y = FLOOR + 0.035;
        bubbleDummy.position.set(
          bubble.x + Math.sin(time * 1.3 + bubble.phase) * 0.055,
          bubble.y,
          bubble.z + Math.cos(time * 0.9 + bubble.phase) * 0.035
        );
        const pulse = 0.9 + Math.sin(time * 2 + bubble.phase) * 0.08;
        bubbleDummy.scale.setScalar(bubble.size * pulse);
        bubbleDummy.updateMatrix();
        bubbleMesh.setMatrixAt(i, bubbleDummy.matrix);
      }
      bubbleMesh.instanceMatrix.needsUpdate = true;
    }

    function updatePlankton(dt, time) {
      const position = planktonGeometry.attributes.position;
      for (let i = 0; i < planktonCount; i++) {
        let y = position.getY(i) + planktonSpeeds[i] * dt;
        if (y > TOP - 0.1) y = FLOOR + 0.05;
        position.setY(i, y);
        position.setX(i, position.getX(i) + Math.sin(time * 0.6 + i) * dt * 0.012);
      }
      position.needsUpdate = true;
    }

    function updateRipples() {
      for (let i = ripples.length - 1; i >= 0; i--) {
        const ripple = ripples[i];
        const t = (elapsed - ripple.birth) / ripple.life;
        if (t >= 1) {
          tank.remove(ripple.mesh);
          ripple.mesh.material.dispose();
          ripples.splice(i, 1);
          continue;
        }
        ripple.mesh.scale.setScalar(0.5 + t * 5.2);
        ripple.mesh.material.opacity = (1 - t) * 0.68;
        ripple.mesh.quaternion.copy(camera.quaternion);
      }
    }

    function updateDemo(dt, time) {
      const idle = pointer.moved && (!pointer.inside || performance.now() - pointer.last > 11000);
      const targetMix = idle ? 1 : 0;
      demoMix = damp(demoMix, targetMix, 2.7, dt);

      demoCursor.position.set(
        Math.sin(time * 0.27) * 2.75 + Math.sin(time * 0.59) * 0.42,
        2.66 + Math.sin(time * 0.41) * 1.02,
        1.08 + Math.sin(time * 0.33) * 0.16
      );
      demoRing.quaternion.copy(camera.quaternion);
      demoRingMaterial.opacity = demoMix * (0.62 + Math.sin(time * 2.1) * 0.12);
      demoGlowMaterial.opacity = demoMix * 0.29;
      demoCursor.visible = demoMix > 0.015;

      projectedPosition.copy(demoCursor.position);
      tank.localToWorld(projectedPosition);
      projectedPosition.project(camera);
      demoLabel.style.left = `${(projectedPosition.x * 0.5 + 0.5) * width}px`;
      demoLabel.style.top = `${(-projectedPosition.y * 0.5 + 0.5) * height}px`;
      demoLabel.classList.toggle('hidden', demoMix < 0.08);
      modeText.textContent = demoMix > 0.55 ? 'Auto-pilot' : 'Manual control';
    }

    function resize() {
      width = window.innerWidth;
      height = window.innerHeight;
      const aspect = width / height;
      renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, width < 760 ? 1.5 : 1.8));
      renderer.setSize(width, height, false);
      camera.aspect = aspect;

      if (aspect < 0.8) {
        camera.fov = 46;
        cameraBase.x = 0;
        cameraBase.y = 2.98;
        cameraBase.z = 21.2;
        tank.scale.setScalar(0.88);
      } else if (aspect < 1.25) {
        camera.fov = 43;
        cameraBase.x = 0;
        cameraBase.y = 3.02;
        cameraBase.z = 15.3;
        tank.scale.setScalar(0.96);
      } else {
        camera.fov = 40;
        cameraBase.x = 0;
        cameraBase.y = 3.15;
        cameraBase.z = 12.7;
        tank.scale.setScalar(1);
      }

      camera.position.set(cameraBase.x, cameraBase.y, cameraBase.z);
      camera.lookAt(0, 2.63, 0);
      camera.updateProjectionMatrix();
      camera.updateMatrixWorld();
    }

    window.addEventListener('resize', resize);
    resize();

    const clock = new THREE.Clock();

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

      pointer.smooth.lerp(pointer.ndc, 1 - Math.exp(-dt * 12));

      const manualParallax = pointer.moved && pointer.inside ? pointer.smooth : { x: 0, y: 0 };
      const targetX = cameraBase.x + manualParallax.x * 0.24 + Math.sin(elapsed * 0.17) * 0.045;
      const targetY = cameraBase.y + manualParallax.y * 0.09 + Math.sin(elapsed * 0.23) * 0.025;
      camera.position.x = damp(camera.position.x, targetX, 2.1, dt);
      camera.position.y = damp(camera.position.y, targetY, 2.1, dt);
      camera.lookAt(0, 2.63, 0);
      camera.updateMatrixWorld();
      cameraRight.setFromMatrixColumn(camera.matrixWorld, 0).normalize();
      cameraUp.setFromMatrixColumn(camera.matrixWorld, 1).normalize();

      tank.updateMatrixWorld(true);
      updateSchools();
      updateFish(dt);
      updateSurface(elapsed);
      updateBubbles(dt, elapsed);
      updatePlankton(dt, elapsed);
      updateRipples();
      updateDemo(dt, elapsed);

      causticMaterial.uniforms.uTime.value = elapsed;
      coralLight.intensity = 5 + Math.sin(elapsed * 1.3) * 0.8;

      for (const plant of plantGroups) {
        const phase = plant.userData.phase;
        plant.rotation.z = Math.sin(elapsed * 0.68 + phase) * 0.065;
        plant.rotation.x = Math.cos(elapsed * 0.51 + phase) * 0.035;
      }
      for (const anemone of anemoneGroups) {
        anemone.rotation.y = Math.sin(elapsed * 0.33 + anemone.userData.phase) * 0.08;
        const pulse = 1 + Math.sin(elapsed * 1.4 + anemone.userData.phase) * 0.012;
        anemone.scale.setScalar(anemone.userData.baseScale * pulse);
      }

      renderer.render(scene, camera);
      requestAnimationFrame(animate);
    }

    animate();
  </script>
</body>
</html>
Reasoning trace (31,414 tokens)
We need generate self-contained HTML 3D aquarium Three.js fish swim away when mouse near them. Must use exact import map given. Need code only one fenced code block. Need craft visually compelling aquarium 1280x800 responsive 375. Need meaningful first paint, self-playing demo mode behind "Click or press a key to start" if keyboard uses. We can avoid needing keyboard and mouse; message "Click or press a key to start" per requirement games. Aquarium interactive experience maybe we should show a clear overlay initial "Click or press a key to start" and AI fish swimming. Need Three.js via import map. All inline CSS JS, external CDN allowed (Three.js). No images. Use procedural geometry textures shaders. Need implement nice 3D aquarium with glass tank, sand, plants, rocks, coral, bubbles, fish that flee from pointer ray proximity in 3D, shoaling AI, nice water caustics lighting and UI. Need ensure first paint looks good without interaction and demo AI. Provide overlay start hint, on click hide, mouse movement influences avoidance. Could also continue ambient on pointer hover? Requirement fish swim away when move mouse near them. Need implement real raycast against tank plane at water surface? We can map mouse to fish avoidance: compute world point by raycasting onto tank interior box or plane at various depth. Fish flee if distance mouse world projection. Better approach: raycaster.setFromCamera pointer; intersect a vertical plane at z=0 (tank depth) maybe fish at varying z. Or intersect invisible tank wall? We can use screen-space distance to fish projected; fish flee from pointer if within radius (e.g. 160px) weighted by world depth. Also fish use pointer world location computed by intersect plane at z = fish z? Easiest and robust: for each fish compute vector to pointer in screen space and steer away if distance < threshold. Compute projection of fish's world position each frame, then compute away vector. This works reliably. Add underwater ripple where pointer moves, plus gentle push particles. Could use pointer world in tank with raycaster plane perpendicular camera at z=0 and use distance world. Screen-space best for "near them".

Design:
- Full viewport gradient deep navy.
- Canvas aquarium fills main region maybe full screen with tank centered, large margins? Need nice. UI overlay with title "TIDEGLASS / LIVING REEF", subtitle. Bottom controls with hint, legend.
- Tank is 3D open-front rectangular aquarium on dark pedestal/table, softly lit backdrop. Glass sides subtle transparent blue, edges highlight. Camera at (0, 2.8, 11.5) looking at (0,1.8,0). Tank dims width 9.4, height 4.5, depth 3.2. Water volume inside. Sand bottom, rocks, corals, seaweed, anemone. Fish colors vivid.
- Water surface at y=4.3 maybe tank base y=0.5, top 5.0. Sand y=0.65. Waterline at 4.8. camera at (0,2.8,11). Need fish not hidden.

Build:
Scene background maybe dark gradient via CSS behind transparent canvas? Use renderer alpha true scene bg null. But fog? Could use scene.fog = FogExp2 maybe opaque background. Use CSS gradient and renderer alpha. Water volume use transparent boxes.
- renderer antialias true alpha true; pixel ratio min(devicePixelRatio,2); shadowMap enabled PCFSoftShadowMap; toneMapping ACESFilmic, exposure 1.2; outputColorSpace SRGB.
- Camera Perspective 42, position (0,2.9,12.8), lookAt(0,2.4,0). Tank bounds x [-4.7,4.7], y [0.35,5.05], z [-1.6,1.6].
- floor plane under tank at y=-0.12 maybe large, dark blue reflective-ish.
- Pedestal/console: rounded box? Use BoxGeometry. Tank bottom frame from y=0.15 to .55; dark frame side columns? A clean frameless glass box with thin edges. Tank top open with water surface; visible back wall tinted teal gradient maybe. Glass side panels MeshPhysicalMaterial transparent opacity .1 roughness .08 transmission? Transmission requires render cost but okay. To avoid performance use MeshPhysical with transmission 0.15, transparent etc. Glass front large plane slightly transparent with blue, but it can obscure. Use side/back only plus front edges as line segments. Add subtle front glass plane opacity .035.
- sand: PlaneGeometry rotated horizontal at y=.58, material color #e0c89a roughness. Could deform with noise geometry to add dunes. Use PlaneGeometry(9,3,60,30), set y based on noise and vertex colors. create vertex color warm sand gradient. Use MeshStandard material vertexColors. Position y .6. More organic.
- background inside tank: back panel at z=-1.6 plane vertical with gradient shader? Create a CanvasTexture gradient for wall; allowed inline no images. Use canvas 256x256 gradient turquoise/dark. Or shader material. Use a large plane with custom ShaderMaterial gradient plus caustic pattern. Water volume box transparent.
- caustics: use animated shader on floor/sand overlay and back? Implement ShaderMaterial transparent plane just above sand, fragment with layered sinusoidal noise, additive blending, opacity .12, update time. Could be visually nice. Need avoid too much complexity. Use canvas generated caustic texture? Could animate via shader.
- Water surface plane at y=4.9, material MeshPhysical with transparent opacity .28, color cyan, roughness .2, metalness .15. Animate vertices sin wave and normals maybe. It will show from camera slightly above. Add white edge line and subtle animated ripple rings where pointer moves.
- Light: Hemisphere light sky #a4ecff ground #062b32 intensity 1.5; Directional light #cfffff intensity 2.5 position(-3,8,5), castShadow; Point lights cyan inside top, warm left; maybe spot lights.
- Bubbles: ~60 small spheres or sprites rising, with ring outlines? Use InstancedMesh sphere geometry, material MeshPhysical? Can animate each. Use sphere geo low segments, transparent material. At edges from coral.
- Particles plankton ~120 Points with soft circles shader/canvas texture; use PointsMaterial with map canvas circle, transparent, depthWrite false.
- Fish:
 multiple species groups: 14-20 fish in tank. Build fish mesh with body ellipsoid (SphereGeometry scaled), tail geometry (custom triangle shape or cone flattened), top/bottom fins, side pectoral fins, eye. Body geometry via SphereGeometry(16,12) scaled. Tail via ConeGeometry or custom BufferGeometry triangle, in local x direction. Fish faces +X? Choose +X forward. Body scale (.55,.27,.18). Tail at x=-.5, triangle plane oriented vertical (y dimension) maybe custom vertices. Dorsal fin triangle along top. Eye small spheres black with white sparkle, positioned at x=.28, y=.06, z=.16. Both sides eyes maybe. Some fish have stripes as torus? body materials with two colors, pattern using shader? Could use MeshStandard on body, a stripe as thin torus around x-axis? Torus geometry default ring in XY around Z axis; scale rotate to around x, position; or create small scaled sphere bands? Torus at x=0 maybe band in yz plane. TorusGeometry(.24,.045,6,16), rotateY(Math.PI/2) so ring axis X. Fish body is elongated along X, circle radius .25 fits. Stripe band color contrasting. Another spot as small sphere? Nice.
 Fish variety colors orange/yellow, teal, blue, pink. Sizes. Use groups.
 Movement: each fish has position, velocity, wander target, phase. Update boids-ish:
  cohesion to center of school, alignment, separation from neighbors, wander, bounds steering; pointer avoidance screen-space. School species cohesion. Each fish projected to screen, avoid within radius 130 px scaled by fish size. Away vector x,y screen => transform to camera right/up world? We can compute in world using camera basis: world direction away from pointer approximate vector projected fish to camera? Better direct: project fish pos to screen, compute away 2D; unproject direction through camera basis: right = camera.matrixWorld column0; up = column1. vel += right*away.x + up*away.y. This gives screen plane direction. Also if pointer close in depth no matter. Strong impulse ~ 6.
 We want fish swim away continuously when mouse near. We'll maintain pointerActive and pointer NDC. Without pointer, AI schooling motion. Initially auto demo with occasional "ghost cursor" moving? Requirement games self-playing demo mode behind start message. This is an aquarium not exactly game, but include overlay "Click or press a key to start" and fish animate automatically. Could have an "AI guide" cursor? Maybe a small glowing pointer that drifts automatically and fish flee from it—self-playing demonstration. But user asked when move mouse near them. We can implement demo pointer that wandles like an AI predator across tank, causing fish reactions, until user moves pointer/click. Nice: demo cursor (small glowing ring) autonomously sweeps around, fish flee. The overlay says "Click or press a key to start" and "The reef is already alive · try moving your cursor". Actually iframe only receives keyboard after click; mouse events may still get pointermove? likely click to focus. We handle pointermove on canvas window anyway. Show overlay clickable "Enter the reef" button. But first paint message. The "demo" auto pointer can be visible with label "DEMO CURSOR". Maybe after 3s? visible in first seconds. Keep overlay small center top or bottom, semi translucent.

Interaction:
 - pointermove on renderer canvas sets pointer NDC, lastMove, active true; pointer world not needed.
 - pointerdown starts/hides splash, ripple at projected tank plane.
 - click spawns burst of bubbles? Nice but not needed.
 - when pointer near fish, fish speed up, tail faster; maybe body turns away.
 - A "calm" toggle? Could have UI button "Calm waters" resets schooling. Buttons maybe "Bubbles" toggle.
 Keep UI polished. Top-left brand, top-right status "LIVE · 18 residents", bottom card instructions. Maybe clear hint in a pill at bottom center: "MOVE TO STARTLE THE SCHOOL" with animated dot. But requirement says behind a "Click or press a key to start" message. Need exact phrase visible. Overlay center: "Click or press a key to start" + "Move your cursor through the water to make room." The scene continues behind. Add button "Enter the tank" but no need; message should be enough.
 We must handle keydown to start (Enter, Space etc) and hide overlay. If user just moves pointer, maybe overlay remains? Good: overlay says click or key to start; fish AI demo behind. On click anywhere overlay hide; after pointer movement over canvas maybe hide too? Requirement the iframe only receives keyboard after click, message clear. We'll implement keydown hides. pointerdown hides. Mouse moving should maybe hide overlay as well (after 300ms) to let interaction. But initial overlay can obscure scene; make modest centered bottom. We'll make compact glass card centered near bottom (at 68% viewport), not blocking fish too much. It says CLICK OR PRESS A KEY TO START; "Move near a fish to watch it dart away." Could include button. It can fade after pointer activity.

 Need responsive. At width 375, camera aspect adjusts and tank fills width. UI stacked, small. Canvas height 100vh. Could have an outer stage padding; but easiest full viewport. 3D camera resize adjusts. On mobile, camera position maybe farther to fit tank; adjust based on aspect. At aspect &lt;1, set camera.position.z = 16.5 and look target, FOV 48 maybe to fit width. Or compute framing: tank width 9.6; with FOV 42 vertical, at distance d horizontal visible = 2*d*tan(vFOV/2)*aspect. At d=13, vertical 10, horizontal at .47 =4.7 too narrow. Need d ~ 25 for mobile. So adjust z based on aspect: distance = baseZ * (targetWidth/(aspect*...)). We can set camera.fov = 40 and distance = 12.5 for wide; at aspect .47 needs 25. We can compute fit using vertical FOV and aspect: visibleWidth=2*z*tan(fov/2)*aspect. To fit tank width 9.6 at aspect .47 requires z= 9.6/(2*0.364*.47)=28. That's far, tank appears small vertical. Could instead tank only 6.8 width mobile? Could scale tank group to .75 on narrow. Or camera fov wider ~60 mobile gives visible width 2*z*.577*.47; z=13 =>7.05, still slight crop. Set z=17 =>9.2 and fov 56 gives 8.6. Tank fits. Vertical visible at z 17, fov 56 =18, tank height appears small. On portrait full-screen aquarium inevitably wide object. Could scale entire tank group to 0.8 and camera z 18; vertical tank ~4 so lots space. UI fills. Accept graceful. Perhaps design tank as full-bleed rectangle with width nearly 100vw and height 55vh on mobile via camera z=20 and scale. Need no horizontal overflow.

Alternative renderer stage occupies full width/height but aquarium composition could crop sides on mobile gracefully; fish still visible. "adapt gracefully down to 375" no scrollbar. Fine if some side cropping, but ideally full tank visible. We'll implement responsive camera using fit:
 const aspect=w/h;
 camera.fov = aspect<0.8 ? 48 : 42;
 let z = aspect<0.8 ? 19 : 12.7;
 camera.position.set(0, 2.9, z); lookAt(0,2.3,0)
Maybe tank appears width ~? at z=19, fov48 vertical visible 16.9, horizontal=7.9 at .47 <9.6 crop 18%. Scale tank group? Use stage group scale .88 on mobile, width 8.45 fits. Set z=19.5, fov46 -> horiz 7.9 still. z=21 =>8.7. scale .9 =>8.64 fits. Good. But top/bottom big margins. Could shift tank y maybe 0.4 and UI. Fine.
Maybe camera look target y=2.35.

Visual details:
- Back wall gradient canvas texture; if using canvas texture with SRGB.
 create gradient 256x256: top #0a7184, bottom #022f3b, with subtle vertical streaks. CanvasTexture.
- Mesh plane back at z=-1.75, y=2.75, dimensions 9.5x4.6, material MeshBasic map transparent? color? MeshStandard with map, roughness 1.
- water volume: BoxGeometry(9.45,4.2,3.5) position y=2.65, material MeshPhysical transparent opacity .12, color #0acfe2, roughness .05, metalness .0, side BackSide maybe. But transparent volume can muddle. Use FrontSide opacity .055. Add edges.
- glass panels side: PlaneGeometry depth x height rotated etc; material transparent opacity .08. Front glass opacity .025 and perhaps subtle shader waves. Line edges via EdgesGeometry with LineBasicMaterial opacity .22 color #9deeff.
- tank frame: bottom base box 9.8x.3x3.8 at y .35 dark metal; top rim maybe thin box frame? Thin edge line only. Feet.
- light cone from top maybe transparent cone mesh additive.
- pebbles on sand, rocks: IcosahedronGeometry with vertex displacement, scaled, color slate; at bottom.
- plants: procedural seaweed groups with ribbons using PlaneGeometry or custom shape; sway. Use multiple tapered leaf meshes (ShapeGeometry) and animate rotation; materials MeshStandard side DoubleSide, roughness .6, colors #0a745e, #27b69b, etc. Build seaweed as curves? Could use TubeGeometry along CatmullRom curve, radius .04, green; multiple fronds. Animate by rotating group slight. More visually realistic: kelp blades as custom BufferGeometry strip with 7 segments, width varying, oriented in xy, wave in shader maybe onBeforeCompile. Simpler group of narrow tapered planes with green, rotate. Use MeshStandard. 9 plants at back corners.
- corals:
 branch coral using cylinders between points; pink/orange. Use TubeGeometry with curve or cylinders and spheres at tips. Position bottom. Anemone: many thin curved tubes (TubeGeometry) with glowing tips, radial.
- fan coral: flattened shape with segments.
- starfish? maybe.
- bubbles rising from coral at x=3.6 y=.8; instanced mesh spheres; bubbles circle ring can be sprite texture. Instanced Mesh sphere with transparent physical.
- Fish count 17. Need drawcalls manageable ~17*8=136 fine. But shadow many maybe no fish shadows or only directional. Keep total <300.
- Add school fish small (silver), tangs etc.
 fish mesh creation:
 Group root. body local +X forward:
 body = SphereGeometry(1,20,14); scale(s*0.55? Actually length values). Use base body radius .5 scaled(length*0.52, height*.5, width*.5).
 For clownfish pattern: 3 white vertical bands. Torus as above scaled to body profile. Could create rings using TorusGeometry(0.5,.05,8,24) rotated Y 90 and positioned x=-.2,0,.2; scale y maybe 1, z .8? Since body ellipse and torus circle in yz radius .5; body height .5 radius? If body base sphere radius1 scaled (.55,.26,.18); torus radius .24, tube .04, scale z? Torus geometry lies XY normal z; rotate Y 90 => normal x, ring in yz. radius .24. scale (1,1,0.7) after? Local scaling affects coordinate. Position.
 For blue tang, body vivid blue and yellow tail.
 Generic reef fish colored body, tail slightly transparent? material MeshStandard roughness .45 metalness .1.
 Eyes: small sphere black at x=length*.34, y=height*.12, z=width*.52 maybe body surface; for both z ±. Add tiny white dot sphere.
 Tail: custom BufferGeometry triangle at x=-length*.48 extending to -length*.95, y ±height*.45; z=0, double side. But shape 3D can bend. Make triangle vertices [-.05,.0,0],[-0.55,.32,0],[-0.55,-.32,0] relative. Use Shape? Buffer.
 Dorsal: triangle top.
 Pectoral fins small flattened spheres on sides, tail animate rotation z? Fish local forward X; tail wag rotate around Y axis. Tail group at back.
 Fish group rotation.y = atan2(-? Need orient +X toward velocity: group.rotation.y = -atan2(v.z, v.x)? In Three.js local +X transformed by y rotation gives (cos,0,-sin) because rotation Y matrix x'=cos x + sin z, z'=-sin x + cos z. For +X => (cos,0,-sin). To align to (vx,vy,vz), yaw = atan2(-vz,vx). Also pitch rotation.z = atan2(vy, horizontal). If set rotation order YXZ maybe use quaternion lookAt? We can use quaternion setFromUnitVectors(new Vector3(1,0,0), velocity normalized), but that allows roll odd. Use yaw and pitch: group.rotation.set(0, yaw, pitch, 'YXZ')? Euler order 'YXZ' where rotation.y yaw, x pitch (local X?), z roll. Rotation about local Z would pitch? Forward +X, pitch is rotate around Z (X toward Y). So group.rotation.z = atan2(vy, horiz) but order YXZ means z applied? Need simple: group.rotation.y = -atan2(vz,vx); group.rotation.z = Math.atan2(vy,Math.hypot(vx,vz)) - but rotation z is world z after Y depending order. Set group.rotation.order='YZX' maybe. Could use quaternion based on direction plus maintain up: matrix.lookAt(position, position+vel, up) creates -Z forward by default; if geometry forward +X then quaternion.setFromRotationMatrix lookAt with desired and maybe roll. Easier yaw plus local group inner tilt: root yaw group and body subgroup tilt. Fish group root rotation.y yaw; visual group rotation.z = pitch. Tail is within visual.
- Each fish speed around .4-.8 world units/s, flee speed 2.3.
- School steering:
 For each fish i, compute separation if dist<1, alignment/cohesion within same school. Avoid boundaries with smooth force.
 Demo pointer: a moving target in NDC within tank projection? We can define demoCursor as Vector3 world coordinate drifting sinusoidally across front portion; then fish avoid it in 3D world directly. But we also want visual cursor. We can place a small glowing ring sprite in world at z=1.2 and x/y sinusoidal. It appears like a hand under water. Fish flee when within world radius 1.6. User pointer screen-space separate.
 On hover, pointerActive; use pointer screen distance. Fish in front. We can maybe compute pointer world using raycaster intersection with a plane at z=0.8 facing camera. A plane normal camera direction at tank front. Use THREE.Plane().setFromNormalAndCoplanarPoint(camera.getWorldDirection(normal), new Vector3(0,2.4,1.0)); raycaster intersects. Then fish avoid 3D distance weighted by z; but screen approach simpler and accurate to "near them". We can combine screen avoidance + world demo.
 Projection each frame for all fish to screen. Use vector.project(camera). Avoid if ndc distance screen pixel < 150. Compute away 2D and world force using camera right/up. We can also compute forward strength.
 Need avoid fish jitter from pointer at boundary. Smooth pointer NDC (lerp).
 pointer active only after pointermove. If pointer stays still, fish may keep away? They should swim away when move mouse near them; if stationary, maybe continue mild. Use pointerFresh timer if last move <2 sec, else still mild.
 Demo auto pointer should work until real pointer moves. Keep demo mode true until pointermove; perhaps user pointer move triggers start overlay hide. Demo cursor world visible with halo and little concentric ring, label maybe DOM overlay? Could create sprite circle.
- Ripple at pointer: On pointermove inside tank, occasionally spawn expanding ring at surface? User moves near fish in water, perhaps ring at fish depth. We can create ripple sprites at world point (x,y,z=1.4) that expand/fade, in vertical plane facing camera. But pointer screen world mapping using raycast plane. Add "shockwaves" in screen space. Maybe nice.
- Add HUD pointer indicator: small CSS cursor? Avoid custom cursor due usability. Could show ring follows pointer via DOM `.cursor-halo` when on canvas; but native cursor already. Maybe not.
- UI:
 body background #03131c with radial gradient.
 canvas fixed inset 0.
 .topbar absolute top 28 left 32 right 32 display flex justify.
 brand small circle wave icon using inline SVG (allowed inline). title "PELAGIC" etc.
 subtitle "NO. 07 · REEF OBSERVATORY"
 status chip with pulsing green dot "LIVE ECOSYSTEM"
 right metrics maybe "17 residents" etc.
 bottom left glass info card "The shy school" / "Move closer, watch them yield."
 bottom center hint overlay.
 bottom right small "Drag to orbit" but we're not implementing orbit? Could implement camera parallax based pointer rather than orbit. Say "Move cursor • Startle fish" only.
 Control buttons bottom right: sound? no. "Bubbles on/off" maybe, "Calm school" resets. Could be interactive and polished but maybe more code. We can implement a single pill "Calm the school" with icon and a button "Reset". Might not need.
 Title panel shouldn't obstruct.

Maybe page layout with side rail? But 3D should dominate. At 1280x800, tank centered, top header and bottom legend. Good.

Additional overlay shimmer/water edge.

Need no external assets besides three. Use import map exact.
 Need import module:
 import * as THREE from 'three';
 import { EffectComposer }? Allowed addons but not necessary. Could use bloom via UnrealBloomPass for glow but more imports and perf. Could use CSS glows. Since Three.js import map allows addons. We can avoid external addon to keep simple. But bloom would polish. Could import EffectComposer, RenderPass, UnrealBloomPass from three/addons. Allowed. But bloom can make bright whole scene. Need zero errors. Three r170 module valid. Use import map and module script. Could use composer with bloom strength .35 radius .5 threshold .8. Fish vivid. Performance on 1280x800 with pixel ratio min 1.6 fine. On mobile ratio 1.5. Bloom adds passes. Could be okay. But auto screenshot in sandbox might run WebGL; likely. Simpler direct renderer to avoid risk. Use CSS vignette overlay and tone mapping.
 We can create soft glow with sprites canvas textures (additive) around coral and water surface.
- create radial texture canvas.

Need ensure no uncaught errors from audio etc. No console logs maybe.

Detailed scene build:

const app = document.getElementById('app');
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x031820,0.025); But scene background transparent CSS; fog color may blend objects to blue. okay.
camera.
renderer = new THREE.WebGLRenderer({canvas, antialias:true, alpha:true, powerPreference:'high-performance'});
renderer.setClearColor(0x000000,0); outputColorSpace=THREE.SRGBColorSpace; toneMapping=THREE.ACESFilmicToneMapping; toneMappingExposure=1.12; shadowMap.
 Add renderer.domElement.

Scene groups: world = new THREE.Group(); scene.add(world).
 Floor large PlaneGeometry(40,40) y=-.05 color #031016 roughness .65 metalness .3; receives shadow; rotate.
 Backdrop large vertical plane at z=-8 maybe gradient texture, to create depth. But CSS enough. We can add a large circular glow behind tank as Sprite.
 Tank dimensions W=9.6, H=4.5, D=3.4, bottomY=.42, topY=4.92, floorY=.62.
 Base:
 base box W+.3, .38, D+.3 y=.2; material dark #082c34 metalness .55 roughness .32.
 trim box W+.1,.08,D+.1 y=.42 color #1b5960.
 pedestal maybe under tank y=-.25 height .5? Since floor -0.05 and tank base .2, floating. Put base from y=0 to .42. Feet.
 back panel: PlaneGeometry(W,H) at (0, 2.68, -D/2), material standard map gradient. ReceiveShadow.
 side panels: PlaneGeometry(D,H) rotate y ±pi/2 at x ±W/2; material physical transparent .09.
 front panel: PlaneGeometry(W,H) at z=D/2; physical transparent opacity .025; maybe MeshPhysical transmission .1? Transparent physical may render odd. use MeshBasicMaterial transparent opacity .035 color #9aefff, depthWrite false. Add diagonal glass reflection streak: PlaneGeometry(1.3,H*1.2) rotated z -0.25, at z=1.73 x=-2.7, material Basic white opacity .025. Another streak.
 edges: BoxGeometry(W,H,D) EdgesGeometry, LineSegments at center y, opacity .22.
 top rim: maybe thin LineSegments.
 Water surface: PlaneGeometry(W-.08,D-.08,40,20), rotate x -pi/2 at y=topY. Need orientation: PlaneGeometry default XY; rotate x -pi/2 gives horizontal. Animate position attr z originally vertical? After rotation, original y becomes z. We can modify z attribute before rotation? Easier use geometry rotateX(-Math.PI/2), then positions x,z? geometry's position after rotation coordinates x,y,z where y ~0 and z from original y. Update position.setZ(i, wave) (local z corresponds vertical after rotation? After rotateX, old y maps z, new z is vertical. Actually rotateX(-90): x'=x, y'= z*sin(-90)+y*cos = -z? Let's derive: y'= y cos - z sin = z (since sin -90=-1 => y'=z), z'= y sin + z cos = -y. Default plane z=0 => y'=0, z'=-y. So vertical is local y (0), update local y wave -> world y wave? position.setY works. Great. Need setY. Material MeshPhysical transparent opacity .32 color #3ad9e0 roughness .18 metalness .1, side DoubleSide, depthWrite false. Animate vertices each frame using sin. But computing normals each frame 861 verts okay maybe every 2 frames. use material.
 Waterline: rectangular line at y top with points and slight? Add LineLoop BufferGeometry. Also 2 planes? fine.
 bubbles at surface maybe.

Sand:
 create PlaneGeometry(W-.18,D-.16,48,22); rotateX(-PI/2); position y=floorY. Need deforms y but after rotate, y is vertical; setY. Use sin patterns. Colors per vertex: base #d8bf91 with shade. color attribute. MeshStandard vertexColors roughness .95.
 Add caustics overlay plane slightly above, ShaderMaterial:
 uniforms uTime
 vertex vUv; fragment:
 float caustic = pow(abs(sin(vUv.x*... + sin(...)) ...), maybe produce lines. Use iterative.
 Could be too complex. Write:
 vec2 p = vUv*vec2(12.,7.);
 float c=0.;
 for i 0..2:
 p += vec2(sin(p.y*1.7 + t*.4), cos(p.x*1.5 - t*.3))*0.35;
 c += abs(sin(p.x + p.y + t*.3))*0.333;
 float lines = smoothstep(.78,.98,c);
 gl_FragColor=vec4(vec3(.35,.95,1.), lines*.22);
 Additive.
 At water bottom visible. Needs no loop in WebGL? fixed loop fine.
 Overlay plane W-.2,D-.16 rotate horizontal y=floorY+.025.

Rocks:
 function createRock(x,z,sx,sy,sz,color):
 geo=new IcosahedronGeometry(1,2); mutate positions noise based original coordinate. ComputeVertexNormals. MeshStandard flatShading maybe. Position y floor + sy*.55. cast/receive.
 Place 10 rocks back and right. Add speckle texture? no.

Plants:
 createSeaweed(x,z,height,color,count):
 group. For each blade:
 custom tapered ribbon geometry in local XY extending upward from 0. Build segments 10 with center x = sin(t*...) small, center y=t*h, width=0.12*(1-t)^.6 + .01. vertices left/right. Indices. Normal compute. MeshStandard color, side DoubleSide, roughness .75, emissive tiny.
 Sway group.rotation.z = sin(time + phase)*.08. Could also shader vertex displacement. Good.
 Place clusters at x=-4.1,-3.6,3.7 etc.
 Kelp tubes: create branch with TubeGeometry curve for taller.
 Add little glowing dots.

Coral:
 function branchCoral(origin,color,scale):
 Group; recursively create branches from starting. Could build 3D cylinders between Vector3. For each branch, cylinder geometry radius etc align quaternion from y-axis to direction. Add sphere tips.
 Use 3 clusters with 8 branches.
 Branch start at (0,0,0), directions upward/outward. At each branch create cylinder between a,b, with taper? CylinderGeometry(r1,r2,len,7). position midpoint, quaternion setFromUnitVectors(Y, dir).
 color coral #ff7d72 or #b879ff. material MeshStandard roughness .58, emissive color* .08.
 Add smaller branches.
 Anemone at right x=3.5,z=-.8: 18 tentacles as TubeGeometry along curves (CatmullRomCurve3) from base to end with sin curve, radius .025. Tip sphere emissive.
 Fan coral: create shape in XY with radial branches lines using cylinders, color #ffb169. Put back center x=1.
 Could have orange cup coral: torus geometry plus cone.
 Shells etc.

Fish details:
Define species configs:
- "sunburst" 6 fish orange body #ff8f3d, tail #ffd45c, fin, accent maybe dark stripe.
- "azure" 5 fish #2f8fff body, tail #52e5ff, yellow dorsal.
- "violet" 3 fish #bf78ff, tail #ff92cf
- "silver" 6 small #b8e8ef silver, tail #f3ffff
total 20. At 1280 tank enough. Status "20 residents".
Each fish root position random within tank, z [-1.2,1.1], y [1.0,4.3]. Ensure not inside plants maybe.
Species body length .75-1.15; small .5-.75.
Fish material create per fish (20* mats okay). Body MeshStandard with color and maybe emissive low. Use body sphere. For rounded fish body, sphere scaled. Tail triangles.
Create tail shape in X-Y plane at z 0; group tail pivot at x=-length*.42; tail geometry vertices relative: (0,0,0),(-length*.45,height*.36,0),(-length*.38,0,0),(-length*.45,-height*.36,0). Use BufferGeometry with indices and compute normals. Material same accent. Add second smaller tail at z maybe .02 to show.
Dorsal triangle above body pivot. Pectoral fins flattened ellipsoid at x=.1,y=-.03,z ±.15 with scale(.16,.07,.05) rotated. Mouth maybe tiny torus? not needed.
Eye: white sphere radius .045 at x=len*.28,y=height*.1,z=width*.45 (surface) and black sphere slightly out. But body ellipsoid at x .28 of radius? Need body scaling. Let's define body geo SphereGeometry(1,18,12); body scale.set(length*.56, height*.52, width*.52). At x=len*.28 => normalized .5, y=.08 => .3, z surface solving. Place z=width*.45 maybe okay. eye white sphere scale .05; black .027 offset.
Stripe for clown style: TorusGeometry(1,.075,6,20) radius? We want ring around X-axis with radius y/z matching body cross-section. At x positions, body cross-section radius varies. TorusGeometry radius .5? Use base body scales, ring centerline radius .5 in local then scale in y/z to height,width? If geometry Torus radius .5 tube .06, rotateY(pi/2); then scale.set(1,height*1.02,width*1.05)? Since local after rotation: original ring x/y? Torus lies XY normal Z, rotateY 90 maps x? Ring plane becomes ZY, local x dimension thickness etc. scaling after rotation in parent may alter. Could instead create band as thin flattened sphere with a hole impossible. Use Torus radius .5, tube .075, then mesh.scale.set(height? Actually ring dimensions in y and z: y radius .5 * scaleY, z radius .5 * scaleZ. body half height = height*.52, half width=width*.52. set scaleY=height*1.04, scaleZ=width*1.04. x scale = .6 perhaps. Position x=-.15 etc. That gives a band. Good. Color white.
 For tang maybe add black ellipse near tail as flattened sphere body overlay; okay.

Update fish:
 properties pos, vel, speed, schoolIndex, phase, wanderTarget, start positions.
 Use temp vectors no allocation heavy.
 Each frame:
 for each fish:
  desired = new Vector3();
  pointer avoidance:
   project pos: v.copy(pos).project(camera); screen coords (v.x*.5+.5)*w etc. pointer screen from ndc. dx = screenX - pointerX; dy = screenY - pointerY (screen y invert). distance.
   threshold = 120 + len*30. If d<thr:
     force = (1-d/thr)^2 * 9.0; desired.x += dx/d*force; desired.y -= dy/d*force; because screen y down, camera up vector.
     Additionally world away vector from camera to fish projected maybe. But horizontal screen right world camera.right; vertical screen up world camera.up.
   Real pointer only active when pointer inside and lastMove recent (infinity). We want fish to flee even stationary? Use last move 3s.
  Demo cursor:
   world distance from fish.position to demoCursor.position (z maybe 1.2). if <1.5, desired += normalized diff * (1-d/1.5)^2 * 6.
  school:
   centroid same species? Compute school centers precomputed each frame. For each species config group center average. cohesion = center-pos scaled .25; alignment average vel - vel scaled .25; separation neighbors.
   We can O(n²=400).
  wander: update target timer; direction target-pos normalized * .4; plus noise sin.
  bounds:
   x boundaries [-4.2,4.2], if near steer to center; y [floor+.5, top-.55], steer.
   z [-1.25,1.25].
  desired scaled to max force; vel.addScaledVector(desired,dt); clamp speed between base .35 and max (flee 2.5). pos += vel*dt.
  Smooth yaw from direction using MathUtils.damp? angle. Compute targetYaw = Math.atan2(-vel.z, vel.x); use shortest angle with current. root.rotation.y += angleDelta*min(1,dt*5). visual rotation.z damp pitch.
  Tail wag speed proportional vel length: tail.rotation.y = sin(time*(5+speed*3)+phase)*.45. Note tail plane XY and rotation y swivels z.
  Body bob maybe visual.position.y=sin(...)*.02.
  Update world pos root position.

Need school centers. config fish.school integer maybe 3 schools. Use group per school. We can set school 'gold','blue','silver'. Compute center and avg vel each frame.
 For separation all fish dist <.65.

Demo cursor world:
 create Group with ring: TorusGeometry(.24,.025,8,32), material MeshBasic color #bffcff transparent opacity .65; plus sphere small. Billboard? Torus default plane XY facing camera at z front. Add halo sprite. Position e.g. x = sin(t*.25)*2.4, y=2.4+sin(t*.38)*1.1, z=1.15. It drifts. Make opacity pulse. Label DOM maybe "AUTO POINTER" near projected screen coords. We can update a DOM element position. Nice but overlay already. Could add `#demo-label` positioned with coordinates, small uppercase "AI CURSOR", line. It follows. Hide when user pointer active. This shows self-playing demo mode. Yet requirement says "Games must start in self-playing demo mode (a simple AI plays) behind a 'Click or press a key to start' message". This satisfies: fish auto-school and avoid AI cursor, overlay visible. We can mention in overlay "Auto-pilot is swimming. Move your cursor to take over."
 Start overlay exact "Click or press a key to start".
 On pointermove, demoCursor fades out and label hidden, fish use user pointer.
 On pointerleave, demo resumes after 5 sec? Perhaps user pointer active until moved again. Use pointerInside boolean. On pointerleave set lastMove=-inf and demoMix transitions back.
 Start overlay hide on pointerdown/keydown or first pointermove? If first pointermove, user can interact but keyboard not yet. Requirement says click or press key, but pointermove isn't mentioned. Better overlay remains until click/key. But the demo cursor should maybe still avoid; user moving near fish should still work even if overlay not dismissed. Mouse events captured if overlay pointer-events none except button. So set overlay pointer-events none? Then click anywhere canvas works; message still. button pointer-events auto. pointermove window. Overlay fade on first pointermove perhaps. To exactly "Click or press a key to start", show that phrase and have button. User can click to start; until then auto demo. If they move mouse, fish will respond but overlay can fade maybe after pointermove. Could be okay. We'll dismiss on pointerdown or keydown. Keep overlay compact but visible. At 3s screenshot message present. Great.
 keydown handler hide overlay, add started class.

Pointer events: Use renderer.domElement.addEventListener('pointermove') and window? overlay pointer-events none except card? card pointer events auto blocks underlying pointermove in card only. We'll set card pointer-events:none, button pointer-events:auto. Canvas receives.

UI metrics:
 top left brand:
 <div class="brand"><svg wave...></svg><div><span class=eyebrow>INTERACTIVE HABITAT</span><h1>TIDEGLASS</h1></div>
 top right:
 <div class="live-chip"><span dot></span><span>REEF ONLINE</span></div>
 <button id="bubbleToggle"> maybe.
 bottom left panel:
 <span class=eyebrow>BEHAVIOR</span>
 <div class=behavior-title>Give them space.</div>
 <p>Move your cursor near a fish. The school reacts instantly.</p>
 maybe status bars.
 bottom right "WATER 24°C | 18 RESIDENTS" small.
 center start card:
 circular icon cursor; h2 "Click or press a key to start"; p "Auto-pilot is already exploring the reef."; button "Enter the tank".
 But exact message includes "Click or press a key to start".
 Need likely screenshot judged visual; too much UI can distract. Keep elegant.

CSS:
 :root --ink etc.
 body overflow hidden, font-family system (no external fonts). Could use "Inter", "Avenir Next", etc.
 .vignette fixed pointer-events none background radial-gradient transparent 50%, rgba(0,8,14,.45) 100%; mix.
 .grain maybe CSS noise impossible without image; can use repeating patterns subtle.
 .topbar safe.
 .start-card position absolute left 50% bottom 74px transform translateX(-50%), width min(420px,calc(100%-32px)); padding 18 22; background rgba(3,23,31,.55); backdrop-filter blur(18px); border.
 But bottom panel at left overlaps; position center bottom maybe card. Could instead card centered top? Better tank fish center; bottom card could overlap tank bottom only. Set bottom 24? UI bottom left also. Maybe start card bottom 88. Wide.
 On first paint, show card with button and message. Fine.
 .start-card.is-hidden opacity 0 transform translate(-50%,10px) pointer-events none.
 Responsive hide bottom left description, adjust.
 Mobile: topbar padding 18; brand title 20; live chip smaller; bottom card width calc 100%-28, bottom 16; bottom-left panel hide; bottom-right metrics top maybe below chip. Camera fit.

Need maybe include audio? no.

Water lighting and particles:
 - Add spotlights? RectAreaLight requires uniforms import maybe not. Use SpotLight position above tank pointing down, angle .7, penumbra .7, intensity 35? In Three r155 physical units changed; intensity values for SpotLight candela. Use 55, distance 15. Cast shadow maybe.
 - Directional intensity 2.4.
 - point lights: cyan 2.5? In new units PointLight intensity maybe candela; 30. use reasonable.
 - Hemisphere 1.4.
 - Ambient 0.15.
 Materials.

Backdrop:
 create radial gradient canvas 512, sprite behind tank at z=-5 scale 14, color. SpriteMaterial additive opacity .6, depthWrite false. Position (0,2.6,-4). Since scene transparent, CSS.
 Floor plane receives shadows. Tank base shadows.
 Add caustic shader overlay and maybe animated projected texture on back wall? We can use another shader plane at z=-1.68 slightly front, additive, opacity .1. But sand enough.

Glass reflections:
 front glass with MeshPhysicalMaterial:
 color 0xbff7ff, transparent true, opacity .07, roughness .05, metalness .05, side DoubleSide, depthWrite false. Plane.
 edge lines.
 top water line animated.

Bubbles:
 create 42 Mesh spheres? InstancedMesh:
 geometry SphereGeometry(.05,8,6), material MeshPhysical color #b9f7ff transparent opacity .38 roughness .05 transmission .1 depthWrite false.
 Each bubble x around 3.55 + random .4, z=-.75+random .4, y random; rise speed .4-.9; wobble. Need respawn at floor .7.
 Instance matrices per frame. Bubble sizes scale .4-1.4. Add few larger from left maybe.
 Also 100 plankton Points with positions; shader? PointsMaterial with circle texture, color #bffbff, size .035, transparent opacity .45, depthWrite false, blending Additive. Animate positions slight upward and wrap. Store base arrays and update positions attr each frame; 120 fine.
 Light shafts: transparent PlaneGeometry perhaps 3 angled beams from top with gradient texture. Create canvas gradient 64x256 from rgba(190,255,255,.18) top to transparent bottom; PlaneGeometry(1.0,H) at z=-.7, rotations z .25, opacity. Material Basic map, transparent, additive, depthWrite false. These look nice. Maybe 3.
 Use additive sprites for caustic/glow.

Need camera parallax on user pointer maybe slight: camera position.x = damp to pointer.x*.25; camera.position.y = 2.9 + pointer.y*.08; lookAt fixed. But screen avoidance using camera basis. Good. Don't orbit to avoid messing.
 The pointer should map screen to fish avoidance. camera basis recomputed after camera update. camera.matrixWorld updated by renderer but we need updateMatrixWorld before projecting. Call camera.updateMatrixWorld(); update camera then.

Maybe "near fish" radius should be 170 px. The pointer could be anywhere on full viewport, fish project at center. Fine.
 To visually indicate disturbance, on pointer move if near fish, fish scales slightly? Not needed.
 Add small "!" ripple at fish location? We can create `startleRipples` group: when pointer within threshold, spawn a ring at fish position every .5 sec. Ring Mesh RingGeometry(.08,.11,32), Basic color cyan opacity .8 transparent, side double, depthWrite false; billboard to camera. animate scale and fade. This visually shows startled reaction. Need spawn maybe when dist < 90 and pointer moved. Max 12.
 For demo cursor, maybe ring at cursor itself.

Need frame update with clock. Use `requestAnimationFrame`.
 At start, fish visible immediately. We need avoid async textures etc.

Potential issue import map script in head then module script at bottom. Use exact 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>
 This exactly allowed. Use no other external.

Could implement no external font. Good.

Let's plan code structure:

HTML:
<!DOCTYPE html>
<html lang=en>
<head>
<meta charset utf-8>
<meta name viewport...>
<title>Tideglass — Living Reef</title>
<style>...</style>
<script type=importmap>...</script>
</head>
<body>
<main id=app>
 <canvas id=scene aria-label...></canvas>
 <div class=ambient ...></div>
 <header class=topbar>...</header>
 <aside class=info-card>...</aside>
 <div class=metrics>...</div>
 <div id=demoLabel ...>AI CURSOR</div>
 <section id=startCard ... role=dialog aria...>
   <div class=start-icon> svg pointer
   <div><span class=eyebrow>SANDBOX MODE</span><h2>Click or press a key to start</h2><p>...</p></div>
   <button id=startButton>Enter the tank <svg arrow></svg></button>
 </section>
 <div class=bottom-note><span ...></span> ... maybe.
 <div class=footer...>
</main>
<script type=module>...</script>
</body>

Need exact one fenced code block. No text outside.

Let's code JS carefully.

Three imports:
import * as THREE from 'three';

Constants:
const TAU=Math.PI*2;
const canvas = document.querySelector('#scene');
const renderer = new THREE.WebGLRenderer({...});
renderer.setPixelRatio(Math.min(window.devicePixelRatio ||1, 1.75));
renderer.setSize(innerWidth,innerHeight,false)
renderer.shadowMap.enabled=true; type PCFSoftShadowMap.
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping=THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure=1.15;
scene = new THREE.Scene();
scene.fog = new THREE.Fog(0x031b25, 14, 30); maybe with transparent bg fog objects fade to color, floor edge. okay.
camera = new THREE.PerspectiveCamera(40, aspect, .1,80);
Set in resize.
world group.

Color helper.

Gradient texture functions:
function makeCanvasTexture(width,height,draw) { const c=document.createElement('canvas'); ... const tex=new THREE.CanvasTexture(c); tex.colorSpace=THREE.SRGBColorSpace; tex.anisotropy=renderer.capabilities.getMaxAnisotropy(); return tex; }
Back gradient: draw linear gradient plus noise speckles maybe. Use c 256x256. fill. For x columns maybe.
Radial glow texture: 128, gradient.
Bubble/point texture.

Tank setup:
const W=9.5,H=4.45,D=3.35, FLOOR=.68,TOP=5.05, BACK=-D/2;
ground plane y=-.12 size 50.
base.
backdrop sprite maybe position (0,2.6,-5), scale 14x10.
Lights.

Tank back panel: PlaneGeometry(W,H), position y=(FLOOR+TOP)/2? H = TOP-FLOOR=4.37. set (0,2.865,-D/2). Use MeshStandardMaterial({map:backTex, roughness:1, metalness:0}). The texture maybe.
 Add back rim vertical gradient. Actually plane normal +Z default facing camera, good.
 side panels: PlaneGeometry(D,H), rotate y PI/2, position x=-W/2; for right rotate -PI/2. Material transparent.
 front panel.
 Edges geometry box W,H,D position y center; line.
 But EdgesGeometry on transparent box shows all 12 edges; nice.
 Use LineBasicMaterial transparent opacity .3 color #9ceeff.
 Add vertical edge lines.

Surface:
 geometry PlaneGeometry(W-.05,D-.05,36,16); geo.rotateX(-PI/2)
 position y=TOP
 mat MeshPhysicalMaterial({color:0x36c7d1,transparent:true,opacity:.27,roughness:.16,metalness:.12,side:DoubleSide,depthWrite:false, clearcoat:1, clearcoatRoughness:.2})
 cast no.
 Update surfaceBase = Float32Array from position y? After rotation all y near0. In update for each vertex x,z and setY(i, wave). But initial position attr contains x, y=0, z=-old y. Save baseX/Z. update position.setY. computeVertexNormals every 2 frame.
 Surface line rectangle: points [-W/2,TOP,D/2] etc. LineLoop.

Water volume: BoxGeometry(W-.1,H-.2,D-.08) maybe material MeshPhysical transparent .06, side BackSide, depthWrite false. position center. Potential sorting with fish (transparent volume rendered after opaque fish but depthWrite false, blending overlay tint) okay. If side BackSide, only back faces and depth test; transparent.
 Add top light shaft planes.

Sand geometry:
const sandGeo=new THREE.PlaneGeometry(W-.16,D-.16,48,22); sandGeo.rotateX(-PI/2);
const pos=... For each vertex x,z, h=0.06*sin(x*1.7)+... + noise; position.setY(i,h); color.
Need color attribute: base new THREE.Color(0xd9c18d), shadow etc. random deterministic. Use seeded RNG to ensure composition. function rand from seed.
 We'll create mulberry32.
 Colors HSL slight.
 position.y=FLOOR+.02.
 MeshStandard vertexColors.
 Add pebbles: DodecahedronGeometry maybe 20 small.

Caustic plane:
 const causticGeo = new PlaneGeometry(W-.2,D-.2); rotateX;
 mat ShaderMaterial with transparent true, blending AdditiveBlending, depthWrite false, uniforms uTime.
 vertex simple.
 fragment:
precision highp float;
uniform float uTime;
varying vec2 vUv;
float wave(vec2 p, float t){ return sin(p.x*1.9+t)+sin(p.y*2.1-t*.8)+sin((p.x+p.y)*1.35+t*.4); }
void main(){
 vec2 p=vUv*vec2(15.0,8.0);
 float a=abs(sin(p.x+sin(p.y*1.3+uTime*.25)));
 float b=abs(sin(p.y*1.4+cos(p.x*1.1-uTime*.22)));
 float c=pow(1.0-min(a,b), 7.0);
 float shimmer=0.65+0.35*sin(uTime*1.2+p.x);
 gl_FragColor=vec4(0.45,0.95,1.0,c*0.34*shimmer);
}
This maybe creates patterns. Fine.

Rocks function:
geometry Icosahedron detail2; mutate each vertex position normalized multiply 1 + noise; Since indexed? Icosahedron maybe non-indexed; update each. compute normals. use color.
 Set cast/receive.
 Add 8.

Plants function:
createRibbonGeometry(height,width,bend,phase):
 segments=10; arrays. for i t 0..1, centerX=Math.sin(t*2.2+phase)*bend*t; centerY=t*height; w=width*(1-t*.8)+.01; left/right x center±w, y center; z maybe sin(t*4)*.02.
 indices each quad.
 Normals.
 Each blade random phase, height.
 Group position.
 animate arrays plants with base rotation, phase.
 Materials each plant cluster maybe shared.
 Place clusters. Since leaves in XY and face camera, good.

Tall tube kelp maybe ribbon enough. 7 clusters *6 blades=42 draw calls. okay.
 Use some flat oval leaves (SphereGeometry scaled) maybe.
 Plant colors rich.

Coral branch:
function cylinderBetween(a,b,r1,r2,mat,radial=7) {
 len=a.distanceTo(b); geo=new CylinderGeometry(r2? Cylinder top at +y and bottom -y. a base radius r1, b tip radius r2; geometry CylinderGeometry(r2,r1,len,...); midpoint; quaternion setFromUnitVectors(Y, dir).
}
 makeBranchCoral(pos,color,scale):
 group with base trunk cylinder between 0 and .8; branches etc.
 Could manually build.
 Material MeshStandard color, roughness .5, emissive new Color(color).multiplyScalar(.08).
 Group scale.
 Points.
 Add 3 coral groups.

Anemone:
 create tentacles group 22. Each curve points (0,0,0)-> random radius at end, TubeGeometry(curve,10,.025,5,false). Maybe 22* geometry draw calls but okay.
 position x=3.3 etc.
 Emissive tip spheres.
 Add glowing point light near coral intensity 3 distance 3? PointLight intensity 6.

Fish create:
const fishMaterials maybe per fish to allow glow; can share body mat by species but stripe variations. Use createMat.
`bodyGeo` shared sphere. `tailGeo` per fish due lengths? Could build tail using unit geometry and scale group. Build unit tail geometry vertices [0,0,0],[-.5,.4,0],[-.38,0,0],[-.5,-.4,0]. Then tailPivot.scale maybe length etc. But scaling tailPivot y should height etc. Use tail geometry unit, tailPivot.position.x=-length*.44; tailMesh.scale.set(length*.9,height*.8,1). Since unit tail extends x -.5, scaling length .9 => extension .45 len; good.
 dorsal geometry unit triangle (0,0,0) etc; scale.
 Eye positions.
 Body:
 const body=new Mesh(bodyGeo, mat); body.scale.set(length*.55,height*.5,width*.5).
 `visual` group contains body, tail, fins.
 root group.
 Eye local z=width*.38; x=length*.28; y=height*.08. White radius .05; black .028 at z+ .02. Since width half = width*.5? if `width` parameter is half-width? Let's define `height` and `width` as full heights maybe. Body scale y=height*.5 means full height=height. width scale z=width*.5. Place eyes z=width*.42. Good.
 Add eyes both z.
 For both eye whites, one at z+ and z-. Black outside.
 Stripes: if species.pattern ==='banded', torus at x -0.15*len,0.1*len, radius .5; scale y=height*1.03, z=width*1.03, x=1? Geometry radius .5 then tube. Mesh rotation y PI/2. `mesh.scale.set(1, height*1.03, width*1.03)` but after geometry rotation, its local y? Mesh scale applied after rotation in local axes? Object scale axes then rotation. scale.y maps pre-rotation y which after rotation goes to z; so dimensions may swap. Could instead rotate geometry itself: torusGeo.rotateY(Math.PI/2), then mesh.scale.set(1,height*1.02,width*1.02) maps y and z correctly, x thickness 1. Tube radius in geometry .055 scaled anisotropic resulting stripe thickness weird but okay. Use clone geometry shared.
 Another small torus for stripes.
 For yellow tang, add yellow tail.
 Add dark spot via flattened sphere near tail.
 Pectoral fin: use SphereGeometry(.5?) shared small sphere scale; position x=.02,y=-.02,z=±width*.46; scale(.15,.06,.04), rotation x maybe. Fins can animate slightly? store.
 Root initial.
 School id.
 `baseSpeed = .42 + random*.25`.
 `tailPivot`.
 `fin`.
 `root.position`.
 Need place within bounds.

School centers update:
schools Map with arrays.
for each school average positions and vel.
Neighbor separation O(n²). For each fish i loop j; if same/diff all, d < .75.

Steering:
Use `desired` vector. To avoid allocation each frame, fish can store temp desired Vector3. But global temp and per fish. We'll allocate some vectors at creation and one temp each frame; fine.
For each fish:
 desired.set(0,0,0).
 screen avoidance:
 fish.screenPos.copy(root.position).project(camera) after camera update.
 const px=(ndc.x*.5+.5)*width; py=(-ndc.y*.5+.5)*height;
 pointerPx etc. Since pointer NDC y positive up, convert.
 dx=px-pointerPx; dy=py-pointerPy.
 if pointerFresh and d<radius:
  intensity=(1-d/radius)^2
  desired.addScaledVector(cameraRight, dx/d*intensity*8)
  desired.addScaledVector(cameraUp, -dy/d*intensity*8) [camera up should positive when moving screen up, pointer above fish dy negative => -dy positive -> up].
  also maybe desired.addScaledVector(cameraForward? no)
  set startled=1.
 pointerNDC smoothed. On pointermove set target; update current lerp.
 Real pointer world demo? no.
 Demo cursor world avoid:
 dist3 = root.position.distanceTo(demoCursor.position); if <1.55:
  desired += diff normalized * force*7.
 Demo visible based demoMix.
 Schools:
 cohesion desired.add((center-pos)*0.16)
 alignment desired.add((avgVel-vel)*0.18)
 separation for near <.7: desired.add(diffNormalized*(.7-d)*1.8)
 wander: target random updated every 2-4s. desired.add(target-pos normalize*.3)
 vertical sinus preference? Add sin phase.
 bounds forces:
 margin .8; if x > W/2-1.0 desired.x -=; etc.
 y < FLOOR+.55 desired.y +=; if y > TOP-.55 desired.y-=.
 z > D/2-.55 desired.z -=.
 Apply desired clamp length maybe max 8.
 acceleration = 1.4 normal, 4.5 startled? `vel.addScaledVector(desired, dt*1.3)`. For immediate, desired forces.
 speed clamp: min base*.55? If pointer avoidance should not slow. max = startled? 2.3 : base*1.35. Normal fish speed should settle ~0.6. `if speed>max speed=...`; if speed<base*.55 and not startled, speed damp up.
 fish.startle = damp to 0.
 Add damping vel*=pow(.985,dt*60)? desired keeps.
 position add vel dt.
 Keep exact bounds.
 Smooth orientation:
 targetYaw=Math.atan2(-vel.z, vel.x)
 current yaw root.rotation.y. `angle = Math.atan2(Math.sin(target-current),Math.cos(...)); root.rotation.y += angle*Math.min(1,dt*5)`.
 pitch = atan2(vel.y, hypot(vx,vz)); visual.rotation.z += (pitch - visual.rotation.z)*... But rotation z world after root y? root y rotates then child z local, which rotates local forward X into y via z rotation? Actually child rotation.z rotates around its local z, but local axes after parent yaw still x/z orientation; with order default XYZ child transform = parent * Rz? object.rotation applies local then parent? Three matrix compose: quaternion = qx*qy*qz? Object Euler XYZ means R = Rx Ry Rz applied to vector, so Rz first then Ry => forward X -> (cos z, sin z,0), then Ry rotates, good. So visual.rotation.z pitch works.
 Tail wag rotation.y = sin(t*(5+speed*4)+phase)*(.35 + speed*.12). Tail rotates about y which in XY plane swings z.
 Fins rotation? pectoral z maybe.
 Body scale subtle breathing.

Need demo cursor position update and fish avoid it. Demo mode always active until pointer first movement. Once pointer moves, `userControl=true`; demo cursor opacity fades. If pointer leaves, maybe demo mix back after 6 sec. But pointer fresh only 2.2 sec. Could reenable demo cursor after pointer leaves. Simpler userControl true permanently after first move, demo cursor hidden, fish still AI school when not pointer. Requirement demo start only. fine.
 Start overlay hides on pointerdown/key. But user may move first and fish flee; good.
 On start button click, event pointerdown maybe bubbles to canvas? Button overlay above; add listener to start. `startExperience()` hides.
 Need not use localStorage.

Camera:
resize:
 width=innerWidth,height=innerHeight
 renderer.setSize(w,h,false)
 camera.aspect=w/h
 if aspect<0.85 { camera.fov=46; camera.position.set(0,2.85,20.5); world.scale.setScalar(.9);} else { fov=40; z=12.8; scale1}
 But world scaling includes floor/back? Put only tankGroup separate. We can create `tank = new Group` and `scene.add(tank)`, floor and backdrop scene. all aquarium objects tank. On mobile tank.scale .86. Camera z maybe 19.8. Determine visible width = 2*z*tan(23°)*aspect(0.46875)=2*19.8*.4245*.46875=7.88. tank scaled width 9.5*.86=8.17 slightly crop. z=21 gives8.35 fits. Set z=21.5. vertical visible=18.25, tank height 4.4 -> 24% viewport, too small. Actually camera perspective and tank on landscape desktop tank height 4.4 visible vertical at z12.8 fov40 =9.33 -> 47% height. On portrait, to fit width, aspect restricts, tank height appears 24%, leaving big vertical. That's inherent; but can scale tank height? Could scale y more? Distort. Better crop width and maintain height: camera z=13.5, fov42 vertical10.35, horiz at .469=4.85, only half width. Not acceptable.
 Maybe rotate tank orientation slightly on mobile so depth visible? still width.
 Could make mobile layout tank fill width and 55% height with 9.5 aspect ratio => width 375, height184. That's okay with UI; tank appears smaller but still polished. If tank height 220, width 375 ratio1.7, while tank ratio2.17. crop 20%. Could accept cropping edges but no horizontal scrollbar. Let's set mobile camera z=17, fov=47: horiz=2*17*.435*.469=6.94; tank scaled .8 width7.6 slight crop. height visible14.8, tank h3.5 -> 24%. Hmm.
 Wide desktop tank screen width: visible horiz =2*12.8*tan20*.1.6=14.9, tank width9.5 =64% viewport, good. height=9.32 tank=47%. Nice.
 Mobile with width 375 desired tank width 92%=345, height ratio 2.17 =>159 pixels. Fine. UI occupies rest. Camera perspective z set to 20 maybe tank scaled .85, visual height ~180. Good. So use .9 and z=20.8. Looks like a wide aquarium strip centered; CSS top/bottom dark with UI. "gracefully".
 Could rotate camera y slightly to show side on mobile, but then projected width smaller. Fine.
 Maybe desktop tank width 68%, with side negative space good.

Actually screenshot likely 1280x800. Focus desktop.

Set camera base z 12.6, y3.15, lookAt(0,2.65,0). The tank vertical center 2.7. Floor visible. Top 5.05. At z12.6 vertical9.2 => tank from screen y about? 4.4/9.2=48%, centered. Bottom and top. Great.
 Desktop top UI at 28, tank top maybe y screen 800*(.5 - (5.05-2.65)/9.2)=800*(.5-.261)=191, so top at 191. bottom at 800*.739=591. Nice.
 Width tank=9.5/(14.6)*1280=833, centered x223-1056. Good.
 Add pedestal floor.

Maybe tank tilted perspective? camera y3.15 target2.65 gives slight downward. Good.

Lighting:
 const hemi = new THREE.HemisphereLight(0xa8eeff,0x04252c,1.8)
 directional = new THREE.DirectionalLight(0xd9ffff,2.8) pos(-4,8,6), shadow map 1024, camera bounds -7..7.
 point cyan 18 pos(0,5,1) distance 12.
 warm point 8 pos(-4,2,2).
 directional maybe shadows.
 Ambient.

Materials bright with ACES.

Foam/caustics:
 Add "surface shimmer" line.

UI CSS polished:
Use background:
body {background:
 radial-gradient(circle at 50% 42%, rgba(17,82,94,.33), transparent 46%),
 linear-gradient(180deg,#031018 0%,#061e29 55%,#020a10 100%);}
 canvas opacity etc.
 .chrome position.
 topbar border-bottom subtle.
 brand icon in rounded square.
 h1 letter spacing .16em.
 info-card glass.
 start-card width 430.
 Buttons.
 .start-icon ring with pointer svg.
 animation pulse.
 .key kbd style.
 bottom status.
 Add corner brackets around tank? CSS `.frame-corner` positions maybe subtle. But actual tank edges.
 `#demo-label` absolutely pointer-events none, transform, small. Initially show. It follows projected cursor. We can update.
 `#reticle` DOM circle following pointer? Could add custom subtle ring to show pointer influence. Native cursor still. CSS ring 46px border cyan low opacity, appears when pointer active. It can communicate radius. Position fixed, pointer-events none, transform translate. Add label "QUIET ZONE". Might be nice but custom could feel. Let's implement `.influence` 54x54 border rgba, radial gradient, opacity 0; when active opacity. position. On pointermove update transform. On leave fade. The fish flee within 150 px but ring 54 only small. Could use 100px faint field. Circle diameter 120 with dashed border, but UI clutter. Maybe 72.

`start-card` pointer-events none and button pointer-events auto. If overlay center bottom, info panel bottom left may overlap at 1280? Card centered width430 x425-855; info card left32 width280 x32-312 no overlap. bottom metrics right32 x maybe. okay.
 Start card bottom 42? Info card bottom 38. Center card bottom 46, overlaps vertical only separate x. Fine.
 Bottom hint at top? Maybe add instruction near top right.

The card can be compact:
 <div class=start-copy><span>SANDBOX / AUTO-PILOT</span><h2>Click or press a key to start</h2><p>The reef is already alive. Move close to watch the school yield.</p></div>
 <button>Take the wheel</button>
 horizontal at desktop. At mobile vertical.
 Behind card visible fish.
 On start, card fades.

Need count residents dynamic to DOM.

Add water status values maybe 24.6°C, 8.2 pH, current. CSS.

Need "meaningful visible on first paint": scene rendered after script loads ~ CDN maybe network. 3 sec likely loaded. Overlay plus UI. Good.

Potential issue CDN load time but allowed.

Could use `renderer.setAnimationLoop(animate)` rather than rAF. fine.
 We should call resize after building, then animate.

Let's flesh geometry functions.

Back texture:
function createBackTexture() {
 canvas 256x256; grad top '#0c7180', bottom '#032b38'; ctx.fill. Add radial glow at center top; add vertical lines with alpha. noise random dots.
}
Need use CanvasTexture and set colorSpace.

Glow sprite texture:
canvas 128 radial from rgba(74,224,255,.6) to transparent. SpriteMaterial color 0x4ddfff opacity .25 blending Additive depthWrite false.
Place behind tank.

Point texture:
canvas 64 radial white alpha.
Use PointsMaterial map, alphaTest .01.

Light shaft texture:
canvas 64x256 gradient top white .18 to bottom transparent, maybe edges fade with horizontal gradient impossible one gradient; use 2D createImageData? draw linear gradient horizontal transparent-white-transparent then multiply vertical gradient using globalCompositeOperation 'destination-in'. Draw horizontal gradient, then destination-in vertical. Good.
 MeshBasicMaterial map, transparent opacity .35 blending Additive.
 planes at positions x=-2.5,0.4,2.9, z=-.9, y=(FLOOR+TOP)/2, scale width .7-1.2, height4.2, rotate z=-.18. Since plane facing camera, additive beam.

Base material maybe physical:
const baseMat = new THREE.MeshPhysicalMaterial({color:0x06222b, roughness:.28, metalness:.62, clearcoat:.5});
 Box W+.3,.36,D+.28 y=.18.
 A top trim box W+.14,.09,D+.12 y=.4 color 0x2b6a70 metalness .8 roughness .24.
 Add front thin light line.
 Feet cylinders at ±W*.38 z ±? small.
 Maybe base front label with DOM not.

Glass:
function glassPanel(geo,pos,rot) etc.
 frontPlane at z=D/2+.005. `renderOrder=4`.
 Side planes.
 Line edges `renderOrder=5`.
 front reflection streak geometry PlaneGeometry(.7,H*.95) at x=-3.1 y center z=D/2+.02 rotated z=-.22; opacity .045.
 Another x=2.9 width.25.

Water line at TOP with glowing edges maybe LineLoop and a thin box? Use BoxGeometry(W,.012,.012) positions z front and back; color emissive.

Fish schooling:
 Add fish to tank so mobile scaling.
 Need fish positions in local coordinates.

Let's define species:
const species = [
 {id:'sun', count:5, colors body 0xff8a3d, fin 0xffc04a, stripe:0xffe3b0, size:[.9,1.2], y:[1.7,3.8], school:'sun'},
 {id:'blue', count:5, body0x198df2, fin0x59e9ff, stripe:null, size .75..1.05, school:'blue'},
 {id:'violet', count:4, body0xb56df3, fin0xff8acb, size .65..1, school:'violet'},
 {id:'silver', count:8, body0xbfe9e7, fin0xe8ffff, size .45..68, school:'silver'}
] total22. UI 22.
 Some fish should be near front z .6..1.2 and back -1.2.
 Random.
 For silver school, move faster coherent. Different school centers:
 center positions predetermined per school: sun (-1.8,2.6,.2), blue(1.8,3.1,-.2), violet(1.0,1.6,.5), silver(-.2,3.6,-.6). School steering toward center plus wandering.
 We can set school "anchors" and add slight movement; cohesion to anchor can keep.
 Align each school.

To ensure variety and composition, manually place first few fish:
 sun school at x -1.8 etc.
 But random fine.

Fish orientation initial random.

Fish shadow: body.castShadow=true; fins no.

Create pattern:
- banded sun: 3 vertical white bands; gives clownfish-ish.
- blue tang: add dark oval at tail maybe a flattened sphere color dark at x=-.25*len, scale(.3,.25,.05) but placed on side surfaces, maybe.
- violet: small light stripe.
- silver: simple.

Maybe make species recognizable:
Sun = clownfish orange with white bands and black edging? white bands enough.
Blue = blue tang cobalt with yellow tail.
Violet = purple fish with magenta fins.
Silver = small neon silver school.

Tail material color, fins.

Bubbles:
 bubble positions near anemone x=3.5,z=-.8 and near left rocks -3.8,z=-.5. We'll choose x=3.45,z=-.72, and second source x=-3.8,z=-.8.
 InstancedMesh 48, matrices updated. Each bubble stores x0,z0,y,speed,phase,size.
 y from floor to top; if > top-.1 respawn floor+.1.
 Need matrix compose with position and scale. use dummy Object3D.
 Bubble material MeshPhysical transparent opacity .28, roughness .05, metalness .05, transmission maybe 0.2. Transmission expensive with instanced? avoid. MeshPhongMaterial with shininess 90 transparent opacity .28 color, specular. Use MeshPhysical with clearcoat. fine.
 Could render bubbles as sprites rings easier but spheres good.

Plankton Points positions update.

Ripples:
 array ripple objects {mesh, life,max, initialScale}.
 On spawn, create Mesh RingGeometry(.08,.11,32), material clone (opacity). position fish pos plus z maybe .1, billboard camera quaternion. add tank. update scale = 1+life*4, opacity.
 Remove and dispose geometry/material when done. Frequent disposal okay.
 Spawn when pointer d < 100 and pointer moved > threshold, at most every .25 sec. Use timestamp.
 For demo pointer, maybe cursor ring pulses itself.

Click behavior: On pointerdown in canvas, spawn a large ripple at projected point. Could raycast to plane z=D/2? Add function pointerToTank:
 const ndc = pointer; raycaster.setFromCamera(ndc,camera); const plane=new Plane(new Vector3(0,0,1), -D/2?) plane z=D/2 constant. But camera not straight. Better plane normal camera direction through (0,2.7,1.2). Intersect. Use for click ripples. We can define interactionPlane each frame `plane.setFromNormalAndCoplanarPoint(cameraDir, new Vector3(0,2.7,1.0))`. On pointerdown raycaster.ray.intersectPlane(plane, out). Spawn ring at out, face camera.
 No OrbitControls.

Screen pointer world:
 For avoidance, cameraRight and cameraUp derived from matrixWorld:
cameraRight.setFromMatrixColumn(camera.matrixWorld,0); cameraUp.setFromMatrixColumn(camera.matrixWorld,1). columns normalized. yes.

Avoid force along camera plane may push fish through z? camera forward has z component, right/up no z mostly, so fish move in screen plane, good.

Demo cursor world path avoid:
 demoCursor.position x = Math.sin(t*.28)*2.8 + Math.sin(t*.61)*.5
 y=2.6+Math.sin(t*.43)*1.05
 z=1.05 + Math.sin(t*.33)*.2
 Need cursor inside tank and visible.
 update demo material opacity, sprite etc. Since demo active = !userHasMoved.
 Label position: project demo pos; label style left/top; show.
 Demo cursor torus always facing camera: quaternion.copy(camera.quaternion). Ring at pos. It might be hidden behind fish but okay.
 Add small glow sprite.
 If user moved, opacity fade to0; label class hidden.
 The pointer might be over UI; canvas pointerleave. window pointermove on canvas only. We can listen window but if UI, still could cause. Use canvas.
 pointer coordinates relative canvas.
 pointerenter sets inside.
 pointerleave inside false.
 pointerFresh = performance.now()-lastPointerMove<3500. If pointer inside but not moved recently, still flee? could set lastPointerMove on enter. On move, fresh.
 userControl = true after first move.
 `startCard` maybe hide when first move? maybe no, but to avoid overlay while user interacting, hide on pointerdown only.
 Button.

Status UI "AUTO PILOT" changes to "MANUAL" after pointer.

Keyboard: keydown on window; if key not Tab? Any key starts. `if(e.key==='Tab')` no prevent. Add pressed visual? Start card. `startExperience()`.

CSS accessibility prefers-reduced-motion? Could disable CSS pulses but 3D still. fine.

No console errors. Watch for `ctx.roundRect` supported Chrome yes, but avoid.

Potential Three warnings? `THREE.ColorManagement.enabled` default. CanvasTexture colorSpace set.

ShaderMaterial output with tone mapping? custom shader not tone mapped unless includes chunks; fine. `transparent:true`.
 MeshPhysical `transmission` perhaps if transparent+opacity not need. Avoid transmission to keep perf.
 Glass physical opacity.

Shadow camera and fish: directional shadow camera bounds etc.

Coral branch geometry:
function makeCoralCluster(x,z,color,scale,type) {
 group.position.set(x,FLOOR+.02,z)
 if branch:
  trunk cylinder a (0,0,0) b(0,.75,0) r .12,.09
  branches arrays.
 add spheres at tips.
 if anemone:
 ...
 return group
}
Need random but deterministic.

Make sea grass clusters positions:
clusters = [
 [-4.0,-.95,1.7,0x1ea67a,7],
 [-3.35,-1.2,1.3,...],
 [3.9,-1.1,1.8,...],
 [4.1,-.65,1.1,...],
 [-1.9,-1.25,1.0],
 [1.5,-1.3,.8],
 [-4.15,.55,.9],
 [4.05,.65,.7]
]
But plants at front could obscure. Use back mostly and one front left.
 createSeaweed group each blade height random .8-1.6 times base.
 Material varied.
 Animate `plant.rotation.z = sin(t*.65+phase)*.07; rotation.x = cos(t*.5+phase)*.04`.

Rocks positions:
(-2.8,-.8) size1.2,.55,.7
(-3.4,.2). etc
(2.9,-1). etc.
 Add rock shadow.

Also coral:
 pink branch at -2.6,-.55 scale1.0
 purple branch at 2.4,-1.0 scale1.1
 anemone at 3.45,-.7 scale1.0
 orange fan at .9,-1.25.
 Starfish on sand perhaps mesh shape from shape extrude. Not needed.

Make coral cylinders and tip glows.

Cups:
 for x positions create cone/cylinder open top using CylinderGeometry(.18,.12,.3,16,1,true) material DoubleSide, color coral; inner dark circle maybe.

Fish creation code specifics:
const shared = {bodyGeo:new SphereGeometry(1,20,14), tailGeo...};
tailGeo:
positions Float32Array([0,0,0, -.5,.38,0, -.38,0,0, -.5,-.38,0]); indices [0,1,2,0,2,3]; compute normals.
dorsal positions [0,0,0, -.2,.35,0, .22,.25,0], indices.
pectoral shared sphere.
Eye sphere shared.
Stripes geometry shared `new TorusGeometry(.5,.055,6,28); rotateY(PI/2)`. Torus ring radius .5, scale y etc.
For body half widths:
body.scale.set(len*.55, h*.5, w*.5). So full body length 1.1len.
Tail pivot x=-len*.5, tail scale x=len*.95, y=h*.72,z=1.
Dorsal pivot x=-len*.05? geometry extends x -0.2..0.22 scaled len; position y=h*.43, z0; scale(len*.75,h*.55,1). But dorsal triangle should sit above body from y0 to .35; position y h*.43.
Pectoral: sphere at x=len*.04, y=-h*.02,z=±w*.43, scale(len*.18,h*.16,w*.08), rotate x? create separate mesh, maybe color fin.
Mouth: tiny dark sphere at x=len*.53, y=0? Eye x=len*.31.
Eye white radius based len. use sphere geometry and scale(.05...). Place y=h*.13,z=±w*.39. Body cross at x=.31 len (normalized .56), y .13h (normalized .26), z surface sqrt(1-.31-.07)*w*.5=.79*w*.5=.395w. good.
 Black pupil at z ±(w*.42). white sclera .05, black .027.
 Add slight eye shine maybe white tiny.

For banded fish, Torus ring dimensions:
 ring base radius .5. body cross half-height h*.5; ring y radius needs h*.5. scaleY=h. half? ring radius .5 * scaleY = h*.5 => scaleY=h. similarly scaleZ=w. So mesh.scale.set(1,h,w). Torus tube scales too; okay.
 positions x = -len*.18, len*.05, len*.28. Each ring may protrude due cross-section body x changes, but okay.
 For blue tang yellow tail.
 for violet perhaps a dorsal fin.

Fish `visual` includes body, tail pivot etc. `root.userData`.
 Store `finL` etc.

Fish colors with MeshStandard roughness .36 metalness .08 emissive body color maybe intensity .025. `emissive: new Color(body).multiplyScalar(.05)` but constructor accepts Color? `emissive: 0x000000`; then set.
 Maybe fish iridescence on MeshPhysicalMaterial: clearcoat .55, clearcoatRoughness .25, roughness .35. 22 materials. Use MeshPhysicalMaterial no transmission. Great.

Point light shadows maybe.

Caustic material update uniform.

Surface update:
const surfaceGeo attr; base values x,z. function updateSurface(t):
 for i: pos.setY(i, sin(x*1.6+t*1.4)*.025 + sin(z*2.3-t*1.1)*.018)
 pos.needsUpdate=true; if frame%3==0 compute normals.
 No issue.

Glass front maybe transparent overlays fish dim. Set opacity .025.

Scene background CSS and fog: Transparent background with fog uses fog color objects; okay.

Renderer `alpha:true`; `scene.background=null`.

Floor material MeshStandard with color #03121a; plane 60; rotate x -PI/2 at y=-.1. Camera sees floor behind and under. Maybe horizon visible due camera target. Add a reflective elliptical glow on floor via sprite rotated? Sprites always face camera, not floor. Use CircleGeometry with MeshBasic transparent radial texture, rotate floor. create glow plane 12x5 y=-.095 with radial texture, additive opacity .12.
 Create `PlaneGeometry(13,5)` rotate x. texture radial.

Aesthetic.

Update UI stats maybe count; no need dynamic.

We should ensure start card doesn't block canvas pointer events except button. CSS `pointer-events:none`; child button pointer-events:auto.

Now code length maybe ~1000 lines but okay. Desired output only code. Need ensure syntax.

Let's start mentally drafting full JS.

```
import * as THREE from 'three';

const canvas=...
const startCard...
let width=innerWidth...
const renderer...
...
function mulberry32(a){...}
const rnd=mulberry32(20250816);
```

Canvas creation:
```
function canvasTexture(w,h,painter) {
 const c=document.createElement('canvas'); c.width=w;c.height=h;
 painter(c.getContext('2d'),w,h);
 const t=new THREE.CanvasTexture(c);
 t.colorSpace=THREE.SRGBColorSpace;
 t.anisotropy=Math.min(8,renderer.capabilities.getMaxAnisotropy());
 return t;
}
```
For alpha radial textures colorSpace maybe sRGB yes.

Backdrop sprite:
```
const glowTex=canvasTexture(256,256,(ctx,w,h)=>{g=ctx.createRadialGradient(...);...});
const glowSprite=new THREE.Sprite(new THREE.SpriteMaterial({map:glowTex,color:0x27bcd4,transparent:true,opacity:.24,blending:THREE.AdditiveBlending,depthWrite:false}));
glowSprite.position.set(0,3,-4); glowSprite.scale.set(14,9,1); scene.add
```
Sprite behind floor? z -4, floor extends; depth test with floor maybe sprite behind floor hidden lower, fine.

Lights.
Tank group.

Back panel map.
We might want a gradient overlay on back with vignetting. Canvas gradient.

Sand height base FLOOR=.62 maybe. TOP=5.05. Base top .44, sand .62. Fish lower boundary 1.1.
Tank box edges center (FLOOR+TOP)/2=2.835.

Floor at -.11.

Plant geometry and coral.

Let's write helpers robustly.

`makeRibbon(height,width,bend,phase)`:
```
const segments=12, positions=[], indices=[], uvs=[];
for i<=segments:
 t=i/segments;
 cx=Math.sin(t*2.6+phase)*bend*t;
 cy=t*height;
 const w=width*Math.pow(1-t,.65)+.012;
 positions.push(cx-w,cy, Math.sin(t*5+phase)*.025);
 positions.push(cx+w,cy,...);
 uvs...
 if i<segments {a=i*2; indices.push(a,a+1,a+2, a+1,a+3,a+2)}
...
```
Normal compute. Material side DoubleSide.
 Blade mesh pos maybe x random within cluster and rotation y random (since ribbon plane XY; rotation y makes width orientation depth but visible narrow if edge-on; set small random z rotation). group.

`makeRock` mutate geometry:
```
const g=new THREE.IcosahedronGeometry(1,2);
const p=g.attributes.position;
for i: const x=p.getX..., n=...; const k=1+0.12*Math.sin(x*7+...)*...; p.setXYZ(i,x*k,...); 
g.computeVertexNormals();
```
Icosahedron non-indexed yields faceted if material.flatShading true. good.
 mesh.scale(sx,sy,sz); position y=FLOOR+sy*.55.

Coral branch helper:
```
const up=new Vector3(0,1,0)
function segment(a,b,rA,rB,mat){
 const len...
 const geo=new CylinderGeometry(rB,rA,len,7);
 const mesh...
 mesh.quaternion.setFromUnitVectors(up,b.clone().sub(a).normalize())
...
}
```
In a group local positions.
 branch cluster manually:
 trunk (0,0,0)->(.02,.75,0) r .14 .10.
 branches endpoints at around:
 left from(.0,.3,0)->(-.45,.75,.05)
 right etc.
 second levels.
 sphere tip radius .09.
 Add base rock.

Anemone:
 local tentacles angle; curve points [0,.02,0], [cos a*.15,.25,sin a*.15], [cos a*.38,.48+rand, sin a*.38], [cos a*.5,.68,sin a*.5]
 TubeGeometry(curve,12,.028,6)
 mesh material color; tip sphere.
 Add glowing center.
 `anemone.userData.tentacles` maybe animate group scale/rotation.

Fan coral:
 7 rays cylinders from base to endpoints, plane XY at z, group rotate x maybe .1 and y. Use material orange; add cross arcs? not.

Cups: geometry open cylinder. place.

Bubbles instanced. Material transparent with depthWrite false. `bubbles.renderOrder=3`.

Plankton:
 positions array within tank; use BufferGeometry. PointsMaterial sizeAttenuation true. Need transparent map.

Demo cursor:
 Group with torus material. Torus plane XY, camera facing. Add sprite glow maybe sprite inside group. `demoCursor.userData.mat`.
 `demoCursor.position.z=1.05`.
 We should add to tank? yes, tank group local. Mobile scaling.

Pointer:
```
const pointer={ndc:new THREE.Vector2(0,0), smooth:new THREE.Vector2(0,0), px:0,py:0,inside:false,last:-1e9, moved:false};
canvas.addEventListener('pointermove', e=>{
 const rect=canvas.getBoundingClientRect();
 pointer.ndc.set((e.clientX-rect.left)/rect.width*2-1, -((e.clientY-rect.top)/rect.height*2-1));
 pointer.px=e.clientX; pointer.py=e.clientY;
 pointer.last=performance.now(); pointer.inside=true; pointer.moved=true;
 influence.style.transform=...
 influence.classList.add('visible');
 modeLabel.textContent='MANUAL CONTROL';
});
```
Need `pointer.smooth.lerp(pointer.ndc,1-Math.exp(-dt*10))`.
When user pointer moves, start overlay maybe still. Add pointerdown start.
`canvas.addEventListener('pointerleave',()=>{pointer.inside=false; influence...})`.
`canvas.addEventListener('pointerdown',e=>{ startExperience(); pointerDownRipple();})`.
The start button is above and click won't reach canvas, but button listener.
`window.addEventListener('keydown', e=>{ if (e.key==='Tab') return; startExperience(); });`
Any key. If Shift etc, starts.

Ripple click world:
```
const raycaster=new THREE.Raycaster();
const interactionPlane=new THREE.Plane();
function updateInteractionPlane() {
 camera.getWorldDirection(planeNormal);
 interactionPlane.setFromNormalAndCoplanarPoint(planeNormal, new Vector3(0,2.7,1));
}
```
pointerdown coordinates from e normalized; raycaster.setFromCamera; intersect; spawnRing(point,...)
For pointermove ripple near fish, use fish world pos.
spawnRing:
```
const mesh=new Mesh(new RingGeometry(.8,1,48), mat);
mesh.position.copy(pos); mesh.quaternion.copy(camera.quaternion);
...
```
Ring geometry radius .8-1 maybe scale. Use base .1. RingGeometry(.08,.105).
 update scale scalar = .4+age*4.
 Add to tank. Note if fish position local and camera quaternion world, parent tank no rotation except mobile scale; quaternion unaffected by uniform scale. okay.
 Remove dispose.

Mouse influence circle position fixed top/left e.client. CSS transform translate(-50,-50). `influence.style.left = e.clientX+'px'; top`.
 On touch pointermove maybe.

Animate:
```
const clock=new THREE.Clock();
let elapsed=0, frame=0;
function animate(){
 const dt=Math.min(clock.getDelta(),.035); elapsed+=dt;
 frame++;
 updateCamera(dt);
 updateSchoolStats();
 updateFish(dt,elapsed);
 updateSurface...
 updateBubbles...
 updatePlankton...
 updateRipples...
 updateDemo...
 renderer.render(scene,camera);
 requestAnimationFrame(animate)
}
```
Clock getDelta first maybe 0. fine.
 update camera before fish avoidance and project.
 camera parallax:
```
if(!pointer.moved) {
 camera.position.x = damp(camera.position.x, Math.sin(elapsed*.2)*.08,...)
 camera.position.y = damp(...,3.05+...)
} else {
 camera.position.x = damp(camera.position.x, pointer.smooth.x*.22,2,dt)
 camera.position.y = damp(camera.position.y, baseY+pointer.smooth.y*.10,2,dt)
}
camera.lookAt(0,2.65,0);
camera.updateMatrixWorld();
```
But `lookAt` each frame with position x shift; cameraRight from matrix.
 Mobile baseY maybe 2.85. Use `cameraBase` object from resize.
 Ensure camera z stays.
 `MathUtils.damp` available r170: THREE.MathUtils.damp(x,y,lambda,dt).

Demo cursor position and opacity. `demoMix = damp(demoMix, pointer.moved?0:1,3,dt)`. But pointer.moved becomes true forever. Good. Set materials opacity. torus material; glow sprite opacity.
 update label projection each frame.
 `demoLabel.style.opacity = demoMix * .8`.
 If pointer moved but hasn't clicked, card remains. demo hidden. Fine.

Fish stats:
schools object with arrays. Each frame average.
```
for key: center.set(0...); avgVel...
```
Store schoolMap.

Update fish loop. Need careful use camera projection and screen. 
```
fish.root.position (Vector3)
const projected=fish.projected.copy(fish.root.position).project(camera);
const sx=(projected.x*.5+.5)*width;
const sy=(-projected.y*.5+.5)*height;
const dx=sx-pointerPx; const dy=sy-pointerPy;
const d=Math.hypot(dx,dy);
const radius=145 + fish.len*25;
if(pointer.inside && performance.now()-pointer.last<3500 && d<radius) { ... }
```
PointerPx should from smooth ndc not event client:
 px=(pointer.smooth.x*.5+.5)*width
 py=(-pointer.smooth.y*.5+.5)*height
This ensures pointer ring actual event may lag slightly.
`cameraRight` and `cameraUp`.
```
desired.addScaledVector(cameraRight, dx/d*strength)
desired.addScaledVector(cameraUp, -dy/d*strength)
```
Strength maybe 11.
Also if pointer is on same screen but behind fish? all okay.
Set `fish.startle = Math.max`.
 Demo avoid:
```
temp.copy(fish.root.position).sub(demoCursor.position);
const d3=temp.length();
if(d3<1.6&&demoMix>.05){ desired.addScaledVector(temp.normalize(), (1-d3/1.6)^2*8*demoMix);}
```
But temp reused.
 School:
```
const s=schools[fish.school]; desired.addScaledVector(s.center.clone().sub(pos), .12)
```
Avoid clone per fish; use tmp2.
 Alignment.
 Wander target: fish.wanderTimer -= dt; if <=0 random target around school center plus random. Use `fish.wanderTarget.set(...)`.
 desired add temp2 = wanderTarget-pos normalized*.22.
 Separation O:
```
for other of fish:
 if other!==fish:
 delta=pos-other.pos; distSq...
 if dist<.75: desired.addScaledVector(delta.normalize(), (.75-dist)*1.5)
```
delta temp reused.
 Bounds.
 desired clamp length 10.
 acceleration multiplier `1.4 + fish.startle*2.2`.
 `vel.addScaledVector(desired, dt*mult)`.
 Then damping normal `vel.multiplyScalar(Math.pow(.995,dt*60))` gives slight.
 speed.
 min speed: if speed<fish.baseSpeed && startle<.1: vel normalized * damp.
 max.
 update pos.
 if bounds hard clamp.
 orientation.
 startle decays `*= exp(-dt*1.6)` so visibly startled then calm.
 Tail wag.
 `visual.rotation.z` pitch; note root yaw and child z.
 `visual.position.y = sin(...)*.015`.

Potential issue desired units: school center difference ~2*0.12=.24; wander .2; avoidance 10; speed clamp 2.3. Good.

School center could drift; fine.

Ripples from pointer:
 inside update, if fish startle>.7 and elapsed-lastStartleRipple>.35 and random? spawn at fish pos. This may spawn many. limit ripples <14.
 Need avoid spawn for demo. pointer only.
 `if(pointer.inside && d<100 && fish.startle>.65 ...)`.
 Use global lastRipple.

Demo cursor itself maybe fish avoid and could generate startles but no ring.

Bubbles update:
bubbles instanced with count 44.
Data:
 source index alternating. `x = sourceX + sin(...) * .08`, y, z.
 For each:
 y += speed dt; if y>TOP-.06 reset FLOOR+.05
 wobble.
 dummy.position...
 dummy.scale.setScalar(size*(.85+sin...))
 dummy.updateMatrix(); setMatrixAt.
 bubbleMesh.instanceMatrix.needsUpdate=true.
 Bubble geometry radius .055, sizes .45-1.5 => .025-.08.
 Add occasional large bubble maybe size 2.2.

Plankton:
 positions stored Float32Array x,y,z and speeds. Each update y += speed*dt; x += sin... dt small; wrap. position.needsUpdate.
 Points may extend.

Plants animate.

Coral anemone animate tentacle group rotation and scale slight.
 Update `coralPulse` point light intensity.

Surface line no.

Renderer render.

Status "22 RESIDENTS" update.

Could also update `temperature` text no.

One concern: CanvasTexture gradient map used on plane with no `needsUpdate` automatically yes.

One concern: `THREE.SRGBColorSpace` constant valid.

One concern: use `material.emissiveIntensity` on MeshStandard.

One concern: using `MeshPhysicalMaterial` clearcoat properties valid.

One concern: `renderer.shadowMap.type = THREE.PCFSoftShadowMap`.

One concern: `point light intensity` values. Fine.

One concern: `Fog` near14 far30 with camera distance20 mobile, objects around12, fog slight. Desktop unaffected.

One concern: mobile world scale. tank.scale changes but fish positions etc consistent. Camera projection uses root local positions but world matrix includes scale. `fish.root.position` is local tank coords; project needs world position. Since tank scale .9 and position 0, using local position yields minor difference. More correct `fish.root.getWorldPosition(worldPos)`. Use temp world. But `project` accepts local coordinates treated world. Tank uniform scale .9 means screen avoidance slightly off negligible. Could use worldPos. However fish update positions local. Use `fish.world.copy(root.position); tank.localToWorld(fish.world);` each frame; tank matrix updated. `tank.updateMatrixWorld()`. Do.
 Demo cursor position local; avoid in local same units, good.
 camera parallax etc.

Now visual layering of front glass and transparent water volume. renderer sorts transparent by distance. Water volume center might render after front glass etc. Set renderOrder:
 water volume renderOrder=1
 glass panels renderOrder=5
 surface renderOrder=4
 particles renderOrder=3.
 Since transparent, depthWrite false.

Could omit volume to avoid weird wash. Use subtle `MeshBasicMaterial` Box with opacity .025 color cyan side BackSide. Keep.

Maybe add inner back gradient dark enough fish stand.

Sand and back:
 back gradient top #0a6170, bottom #032733.
 light makes.

Top water surface maybe almost invisible. Add white line.

Rocks and plants.

Starfish: create Shape with 10 points alternating radius, ShapeGeometry rotate x. position on sand, color orange. Nice.
```
shape points angle, r alternating .25/.1; ShapeGeometry; mesh.rotation.x=-PI/2; rotation.z...
```
Add 2.

Shell maybe.

Could add "treasure" no.

UI details:
 top header:
```
<div class=brand-mark><svg viewBox=...><path...></svg></div>
<div><div class=kicker>NO. 07 / LIVING COLLECTION</div><div class=brand-name>TIDEGLASS</div></div>
```
 right:
 live chip with dot "AQUARIUM ONLINE"; stats.
 bottom left behavior card:
 small signal bars maybe.
 h3 "A little space goes a long way."
 p "Your cursor is a gentle disturbance..."
 chips "20 fish" "4 schools" "Reactive".
 right:
 `.readouts` rows TEMPERATURE 24.8°C, CURRENT 0.4 kn, LIGHT 72%.
 Maybe too much. Good aesthetic.
 bottom center card.
 start card:
```
<div class=start-orbit><svg pointer></svg></div>
<div class=start-copy>...
<button>Take the wheel</button>
```
 exact phrase in h2.
 After start, info card remains. mode label changes.
 A small top center "AUTO-PILOT ACTIVE" chip perhaps. Demo label follows cursor.
 `.demo-label::before` line.

Need ensure responsive 375:
 @media max-width:700:
 topbar top16 left16 right16
 brand kicker hidden maybe; brand-name font16.
 live chip text "LIVE" only via CSS? Use spans.
 readouts display none.
 behavior card display none.
 start-card bottom:20 left:16 right:16 transform:none width:auto; display grid maybe icon and copy, button full.
 Since overlay at bottom could cover sand only.
 h2 font17.
 .start-card copy.
 canvas.
 Camera tank maybe center lower? Camera lookAt y2.45; top UI. On portrait tank at screen center ~380? visual height 180. Good. Could position tank at y=2.7. okay.

CSS glass card border `rgba(157,240,255,.14)`, background `linear-gradient(135deg,rgba(...),...)`, box-shadow.
 Vignette pointer none.
 `canvas{display:block;width:100%;height:100%;touch-action:none;cursor:crosshair;}` Custom crosshair might fit. On mobile pointer.
 Influence circle.

Overlay card pointer-events none; button pointer events auto.

Start button style.

`@keyframes pulse`.
 `prefers-reduced-motion` reduce CSS.

Need no network except CDN. Great.

Let's consider if user expected just aquarium, UI perhaps good. Three.js loaded through import map exact. No addons. Fine.

Let's estimate code length ~800 lines, acceptable. Need ensure final one fenced html.

Let's now craft exact code with robust syntax.

Detailed JS code:

```
const rand = (()=>{let s=...; return ()=>{s|=0;s=s+0x6D2B79F5|0; let t=Math.imul(s^s>>>15,1|s); ...}})()
```
Use mulberry32 simpler.

Create materials after.

Floor:
```
const floor = new THREE.Mesh(new THREE.PlaneGeometry(60,60), new THREE.MeshStandardMaterial({color:0x031017,roughness:.72,metalness:.18}));
floor.rotation.x=-Math.PI/2; floor.position.y=-.12; floor.receiveShadow=true; scene.add(floor);
```
The tank base bottom at 0, floor -0.12 leaves gap .12; add feet or set base y=.08 height .4 => bottom -.12. Good.
 base Box W+.28,.38,D+.24 pos y=.07 (bottom -.12 top .26). trim y=.31 height .1. sand floor .57. Set FLOOR=.59. bottom glass begins floor .59, tank bottom glass. Tank H = TOP-FLOOR=4.46. Good.
 Back panel from FLOOR to TOP.
 Edges box W,H,D center.
 Base under.
 Feet maybe no.

Water box center.

Light shafts from top down.

Surface at TOP maybe exactly top glass edges.

Sand at FLOOR.

Rocks position y=FLOOR+sy*.5.

Fish lower bound FLOOR+.55=1.14.

Plants.

UI stats count species sum 22.

Fish band torus geometry reused. Stripe color off-white. Could add outline black impossible. fine.
 Fish `bodyMesh.material` per species shared? If shared, cannot modify emissive individually. share species materials fine. `speciesMat` created once per species. Tail mat.
 But blue tang pattern needs tail same species. okay.
 `createFish` receives sp and individual size.
 Body material `sp.mat`; all fish.
 Tail `sp.finMat`.
 Some random color variations via body scale? no.
 Add dorsal stripe maybe.
 Fins slight transparency `transparent:true opacity:.94`. Transparent meshes many; okay. Could keep opaque.

Eye material shared.

`fish.tail.rotation.y`; because tail pivot local.
 Pectoral `finL`,`finR` store; animate rotation x? The sphere orientation no.

Fish scale breathing:
 body scale initial; visual scale maybe 1+sin*.008.
 no.

School anchor:
 `schools` object keys with arrays, centers etc.

Update school stats:
```
for (const key in schools) { const arr=...; center.set(0); avgVel...; for ...; center.multiplyScalar(1/n); }
```
School center drifts due avg.

Wander targets update based school center? At random:
```
const s=schools[fish.school].center;
fish.wanderTarget.set(clamp(s.x+(rnd()-.5)*4), clamp(s.y+(rnd()-.5)*2.2,...), clamp(s.z+(rnd()-.5)*2.0))
```
Use rnd during runtime deterministic continuing.

Bubbles:
 mesh instance count 46. Dummy.
 source data x etc.
 if bubbles y > top reset. source random.
 `bubbleMesh.frustumCulled=false` maybe.

Plankton:
 use Float32Array count 120. Store x,y,z,speed arrays.
 update positions.
 `plankton.frustumCulled=false`.

Demo label update:
```
const v=demoCursor.getWorldPosition(tmp).project(camera)
demoLabel.style.left=((v.x*.5+.5)*width)+'px';
demoLabel.style.top=((-v.y*.5+.5)*height)+'px';
```
But getWorldPosition of demoCursor within tank scaled, fine.
 CSS transform translate(-50%,-50%)? label with line.
 Demo label class `.is-hidden`.
 `modeChip` top? update text.
 Use `#mode-text`.
 If user moved, "MANUAL CURSOR"; initial "AUTO-PILOT".

Start card hide:
```
function startExperience() {
 if(started)return; started=true;
 startCard.classList.add('is-hidden');
 document.body.classList.add('started');
 modeText.textContent='MANUAL CONTROL';
}
```
But before user moved, if click start, mode should still auto until pointer moves. Don't set manual. Set "INTERACTIVE". maybe mode text remains auto-pilot. On pointer move set manual.
Button click.

Pointerdown on canvas sets user moved? pointermove likely happened. `pointer.last=performance.now`.
 startExperience.
 create ripple.

Keyboard start only; pointer still not move, auto pilot continues behind. Fine.

Keydown any key but avoid repeated.

Influence ring:
 pointermove show; pointerleave remove.
 Could show only after moved.

Ripple on click:
```
const clickNdc = new Vector2();
function spawnClickRipple(clientX,clientY) {
 const rect...
 ndc...
 raycaster.setFromCamera(ndc,camera);
 camera.getWorldDirection(normal);
 interactionPlane.setFromNormalAndCoplanarPoint(normal, new Vector3(0,2.7,1.0));
 if(raycaster.ray.intersectPlane(interactionPlane,hit)) {
   // hit world
   tank.worldToLocal(hit) maybe ripple local to tank
   spawnRipple(hit);
 }
}
```
If camera lookAt and plane through front tank, hit coordinates maybe outside tank; clamp x/y to bounds.
 `tank.worldToLocal(hit.clone())`.
 For fish ripples local.

Update interactionPlane each frame for correct camera:
```
camera.getWorldDirection(planeNormal);
interactionPlane.setFromNormalAndCoplanarPoint(planeNormal, new THREE.Vector3(0,2.7,1.0));
```
The plane passes through point in world, but tank scale .9. Fine.

Click ripple ring maybe cyan.

Ripple update uses mesh.quaternion.copy(camera.quaternion) each frame if parent tank rotated no.

Pointer fear visual: fish startle ring.

Potential requirement "fish swim away when I move mouse near them" must be obvious. Increase radius to 190px and force. At 1280 screen, fish cluster around center, mouse near. Need pointer world may be near UI. Fine.
 Use pointer screen proximity; fish shift away.

Maybe add on-screen small message after pointer move "The school is yielding" but not needed.

Pointer fear visual: fish startle ring.

Fish movement response should turn not just slide. Velocity avoidance enough; orientation turns as vel changes.

Shoaling:
 Separation, alignment.

Surface update normals every other frame.

Coral pulse.

Ripples:
```
function spawnRipple(pos, color=0xaefcff) {
 if(ripples.length>18) remove oldest
 const geo=sharedRingGeo? Can't dispose shared. Create material clone, use shared geometry. Keep shared ringGeo and don't dispose. Use new Mesh(sharedRingGeo,mat)
...
}
```
Shared geometry, material dispose only. Good.

Startle ripple at fish position with z maybe fish.root.position.z+0.2 (toward camera), quaternion camera.

Ring material MeshBasic transparent opacity, depthWrite false, blending Additive.

Bubbles render behind surface.

Lighting and shadows:
Directional shadow camera top etc:
```
sun.shadow.mapSize.set(1024,1024)
sun.shadow.camera.left=-7...
sun.shadow.bias=-.0004
```
Fish cast shadow maybe true. 22 fish into shadow map fine.
 Point lights no shadow.

Caustic shader uses `gl_FragColor` okay WebGL2.

Screen:
 `.vignette` background radial + linear, z-index2. Canvas z0. UI z3.
 `body::before` maybe fine.

HTML line start.

Let's maybe use inline SVG icons:
brand wave:
```
<svg viewBox="0 0 40 40"><path d="M6 17c4-5...">...
```
cursor start icon circle and arrow.

Accessibility button.

Now potential issue "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 task." Import map allowed. No other network. Great.

No local storage etc.

Need zero errors console. Avoid `THREE.MathUtils.damp` maybe exists. r170 yes. But implement our own `damp` to be safe:
`const damp=(a,b,l,dt)=>THREE.MathUtils.lerp(a,b,1-Math.exp(-l*dt));`
Use.

Potential `MeshPhysicalMaterial` `iridescence` not; avoid.
 `clearcoat` valid.
 `anisotropy` texture.

`ctx.ellipse` supported.

`renderer.capabilities.getMaxAnisotropy()` returns 0 maybe min.

`THREE.AdditiveBlending`.

`BufferAttribute.setUsage` optional.

Maybe custom shader has `uniform float uTime; varying vec2 vUv;` and material transparent. good.

Caustic plane could render black outside due alpha 0 but additive blending ignores? alpha is 0 and rgb positive, additive still adds rgb * srcAlpha? THREE blending uses SrcAlpha,One, so zero alpha no add. fine.

Sand color:
```
const c = new THREE.Color();
for ... c.setHSL(.105+..., .28, .69+...); colors.push(c.r,c.g,c.b)
```
At h 0.105 tan. good.

Back texture use random but fine.

Floor glow plane with map radial and MeshBasic blending Additive.

Plant materials maybe `emissive: color` with intensity .08. MeshStandardMaterial has emissiveIntensity. Set.

Fish body MeshPhysicalMaterial, per species. For emissive, `emissive: new THREE.Color(body).multiplyScalar(.06)`. Three Color multiplyScalar exists. Good.
 `opacity` no.

Tail geometry normals with one side, material side DoubleSide. Dorsal same.

Eye black material roughness .25.

Fish `root.rotation.y` initial.

Start positions maybe some fish hidden behind coral. Random y>=1.3.

Fish avoid boundary z.

Add some foreground tiny fish.

Now camera responsive update and tank scale:
```
function resize(){
 width=innerWidth;height=innerHeight;
 const aspect=width/height;
 renderer.setPixelRatio(Math.min(devicePixelRatio, width<700?1.5:1.8));
 renderer.setSize(width,height,false);
 camera.aspect=aspect;
 if(aspect<0.8){
 camera.fov=46; cameraBase.z=21.2; cameraBase.y=2.95; tank.scale.setScalar(.88);
 } else if(aspect<1.2){...}
 else { fov40,z12.7,y3.08,scale1}
 camera.position.z=cameraBase.z;...
 camera.lookAt(0,2.62,0)
}
```
At mobile tank visible width: 2*21.2*tan23*.46875=8.25; tank width8.36, nearly fits. Good.
 height visible=18, tank h3.9, appears 22%. maybe 175px. okay.
 Could make mobile camera z=19.5 and tank scale .82 => width7.79, visible width7.59, tiny crop. height tank 3.65 /16.65=22%. Similar. Keep.
 In mobile, set camera look target y=2.5, tank screen center maybe 400. UI top and bottom.

Desktop tank group scale1.

`onresize` renderer.setPixelRatio again.

Need tank `updateMatrixWorld(true)` after scale before project.

Camera parallax on mobile minimal.

CSS tank itself actual.

Let's write code in final. But before, let's consider if using `importmap` must be exact script content. We'll use exactly JSON no spaces maybe:
<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>
Matches.

No addons used but map includes.

We can maybe use `UnrealBloomPass` but no.

Now let's more deeply reason visual aesthetics from code:
Scene tank glass, sand, plants, corals, fish. UI top. Camera looks at tank, base. CSS dark background. This should look good.

Water surface wave with MeshPhysical may render as thin rectangle from slight angle. Great.
Light shafts add atmosphere.
Caustics visible.
Fish vivid.

Could add underwater fog inside tank: no.

Need maybe fish visible against back. Body colors.

Coral branch function details. Let's flesh exact:
```
function buildCoral(x,z,color,scale,style) {
 const group=new THREE.Group(); group.position.set(x,FLOOR+.02,z); group.scale.setScalar(scale);
 const mat=...
 if(style==='branch'){
   segment(0,0,0, .03,.72,0, .14,.10)
   const branches=[
    [new V(0,.25,0),new V(-.45,.62,.02),.09,.055],
 ...
   ];
   branches for each segment; endpoint sphere.
   child branches from endpoints.
 } else if anemone ...
}
```
We need `segment` defined accepting numeric? Could create helper with Vectors.
```
function addSegment(parent,a,b,r1,r2,mat)...
```
Then use V constructors.
Tip spheres.

Anemone center at local y .05. tentacles curves.
 Add `anemones.push(group)` for animation.

Cup coral separate group.

Fan coral with rays.

Starfish:
```
const shape=new THREE.Shape();
for i 0..9 angle...
shape.lineTo...
const geo=new ShapeGeometry(shape); geo.rotateX(-PI/2)? Shape lies XY, rotate X -90 gives plane horizontal, shape normal? yes.
mesh.position.set(...,FLOOR+.04,...); rotation.y...
```
But ShapeGeometry after rotate, shape coordinates x,y -> world x,z=-y, fine.
 material orange.
 Add.

Sand dunes perhaps normals.

Light shaft map maybe.
 `makeShaftTexture`:
 horizontal gradient and destination-in vertical. CanvasTexture.
 plane material opacity .45 maybe.
 positions.

Glass streaks:
create PlaneGeometry(.42,4.2) and material white opacity .045; rotate z -.25; position. Since plane is in XY and at z front. nice.

Tank edges LineSegments with EdgesGeometry of BoxGeometry. LineBasic line opacity .34. At z front.

Add top rim as thin `BoxGeometry(W+.04,.025,D+.04)` wireframe? Edges already.

Maybe glass side panel catches color.

`glassMaterial` MeshPhysical transparent opacity .07, color 0x9ce8f1, roughness .04, metalness 0, clearcoat1, side DoubleSide, depthWrite false. But one material for both side and front, front opacity same .07 maybe too much. Use front separate .022.
 Back panel opaque.

Water volume box maybe not needed; add `volumeMaterial` MeshBasic color 0x16c9d2 opacity .025 side BackSide.

Potential transparent sorting: volume centered behind fish? With side BackSide only renders back faces at far side, so tint behind fish, good. Front glass overlays.

Let's make water volume `side:THREE.BackSide`, so no wash over fish except far depth. Great.

Fish can go z up to1.25, front glass z1.675, so inside.

Camera maybe at z12.7, perspective. Tank D3.35.

Point light inside maybe.

UI overlay start card maybe centered bottom at 46; it can overlap fish if fish low, but okay.

Demo label:
CSS:
```
#demo-label{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);...}
#demo-label .crosshair...
```
We'll update.
 It says "AUTO POINTER" and a little line.

Manual mode chip in header shows `AUTO-PILOT` initially, change.

Status count.

Maybe start card exact phrase on one line desktop, mobile wraps.

After start, show toast "Manual control active" maybe. CSS `.started #mode-text`.
No.

Implementation of influence:
```
<div id=influence class=influence><span></span></div>
```
CSS circle with box-shadow.

When mouse near fish, ring stays.

Potential cursor crosshair on canvas. Fine.

Now let's ensure no duplicate IDs and event errors.

Detailed code ordering:
- DOM refs exist before module (module at end).
- renderer.
- functions.
- build scene.
- build fish.
- events.
- resize.
- animate.

Need renderer first render after all.

`clock.start` auto.

No explicit `document.body` class maybe.

Let's construct JS step by step in final. But since no testing, need be meticulous.

Let's write utility:
```
const V3=(x=0,y=0,z=0)=>new THREE.Vector3(x,y,z);
```
Use many vectors.

Texture functions.

Background:
```
const bgTexture = makeTexture(256,256,(ctx,w,h)=>{
 const grad=ctx.createLinearGradient(0,0,0,h);
 grad.addColorStop(0,'#0b6d79');...
 ctx.fillStyle=grad;ctx.fillRect...
 const glow=ctx.createRadialGradient(w*.52,h*.2,0,...);
 glow.addColorStop...
 ctx.fillStyle=glow;ctx.fillRect...
 ctx.globalAlpha=.06;
 for i<60 {ctx.fillStyle=...; ctx.fillRect(rnd()*w, rnd()*h,1,2)}
});
```
rnd defined before texture. yes.

Floor glow texture radial.

Water caustics.

Build lights:
```
scene.add(new THREE.AmbientLight(0x88bbc4,.18));
```
Hemisphere.

Tank base.

Maybe tank front frame bottom.

Create tank edges after.

`const tank = new THREE.Group(); scene.add(tank);`

Base in tank, so scales.

Floor scene.

Back sprite scene.

`backdropSprite` maybe behind tank and floor.

Set `tank.position.y=0`.

Camera look at.

Plant function uses `plantMats` map by color:
```
const plantMatCache=new Map();
function plantMaterial(color){ if not create MeshStandard...}
```
Emissive maybe.

Rocks.

Coral.

Fish.

Now sand geometry function:
```
function createSand(){
 const geo=new THREE.PlaneGeometry(W-.16,D-.16,52,24);
 geo.rotateX(-PI/2);
 const p=geo.attributes.position; const colors=[];
 const col=new Color();
 for ... {
  const x=p.getX(i), z=p.getZ(i);
  const y= .055*Math.sin(x*1.35+...)+...;
  p.setY(i,y);
  const light=.58 + .12*Math.sin(x*2.4)+...
  col.setHSL(.105+..., .27, clamp(light...));
 }
 geo.setAttribute('color', new Float32BufferAttribute(colors,3));
 geo.computeVertexNormals();
 mesh.position.y=FLOOR;
}
```
Wait after `geo.rotateX(-PI/2)`, original plane y maps world z, position attr z = -old y. The x and z available. Setting y adds wave. good.
 Light HSL .62 maybe. Set l=.62+ .09*... not .58.
 Sand appears light. Use roughness .93.

Caustic overlay y=FLOOR+.085 (above max dune .08). good.

Rocks y based.

Fish lower.

Bubbles spawn at FLOOR+.1.

Plants base y=FLOOR+.02.

Coral base.

Tank back panel bottom at FLOOR.

Tank glass side panels y center.

Surface.

Lights shaft.

Potential z-fighting back panel and volume back face at z -1.63 while back panel -1.675; okay.

Edges.

Let's calculate W=9.4 maybe. Use W=9.4 D=3.3.
screen desktop width 9.4/14.6*1280=824. Fine.
Set W=9.5.

Fish world boundaries x W/2-.55=4.2.

School positions.

Fish species size:
sun len .78-1.05 h .42-.58 w .24-.32
blue .65-.9
violet .62-.8
silver .4-.58
22 fish enough.
Maybe user can see small.

Fish initial positions by school anchor:
```
const schoolAnchors = {
 sun: V3(-1.65,2.65,.25),
 blue: V3(1.5,3.35,-.05),
 violet: V3(1.2,1.75,.55),
 silver: V3(-.2,3.75,-.55)
}
pos = anchor + random spread (x±1.4, y±.6,z±.45)
```
Silver at top.
Some foreground blue z .8.

School cohesion anchor not directly used except wander. Stats center.

Fish avoidance all fish.

Fish orientation.

Add a couple fish crossing foreground to show depth.

Bubbles.

School count 4.

UI count 22.

Potential fish look like balls with tails; recognizable enough.

Let's maybe add body taper? Sphere scaled is oval. Tail triangular, fins, eyes => fish.

Add gill line: small torus/arc? Could add a thin dark curved line mesh at x=len*.18, but not necessary.
 For blue tang, add black patch as flattened sphere on both sides near tail:
```
const patchMat dark;
for z sign, sphere scale(.22,.25,.035), pos x=-len*.25,y=0,z=sign*w*.4; black.
```
Might z-fight. body surface at x=-.25 normalized .45, z max sqrt(1-.2-.04)=.87*w*.5=.43w. place .44w. Good.
Sun stripes.

Violet glow.

Demo cursor maybe looks like glowing orb; fish flee.

Implement ring label.

Let's think first screenshot. At t=0 fish positions; school moves. Demo cursor path starts x0,y2.6. Fish near may react after seconds. start card. Tank visible. Great.

We could initialize some fish with velocities toward interesting directions to avoid static. Set velocity random. Yes.

Now ensure animation request if WebGL context fails? likely no. Could add fallback message but not necessary. `try` catch renderer? no.

No console errors due `ctx` null? Canvas always.

One subtle issue `renderer.capabilities.getMaxAnisotropy()` after renderer exists yes.

One subtle issue CanvasTexture `colorSpace=SRGB` for glow alpha; okay.

One subtle issue setting `material.emissive` to Color from multiply; MeshStandard constructor copies. yes.

One subtle issue `THREE.MathUtils.clamp`.

One subtle issue `THREE.BufferGeometry().setFromPoints` exists.

One subtle issue `LineLoop` points.

One subtle issue `MeshPhysicalMaterial` `opacity` and `transparent`, okay.

One subtle issue transparent shadow? no.

One subtle issue `PointLight` constructor (color,intensity,distance,decay). valid.

Now code for surface:
```
const surfPos=surfaceGeo.attributes.position;
const surfBase=new Float32Array(surfPos.count*2);
for i {surfBase[i*2]=getX; surfBase[i*2+1]=getZ;}
```
After rotateX, position z ~ original y. yes.
 update setY.

Normals compute maybe.

Coral animation:
`anemones.forEach((g,i)=> {g.rotation.y=Math.sin(t*.3+i)*.08; g.scale.y = originalScale*(1+sin*.015)})`. But g.scale already set scale; store baseScale in userData. `g.userData.baseScale=scale`.
plants groups similar.

Caustic uniform.

Demo label DOM pointer events none.

Camera right/up:
```
camera.updateMatrixWorld();
cameraRight.setFromMatrixColumn(camera.matrixWorld,0).normalize();
cameraUp.setFromMatrixColumn(camera.matrixWorld,1).normalize();
```
Column 1 with camera world matrix is up local. yes.

Screen pointer after camera parallax.

Update camera parallax uses pointer.smooth even before pointer moved (0). Demo no parallax? use subtle auto orbit:
 if !pointer.moved x=sin(t*.17)*.1.
 cameraBase y.
 `camera.lookAt(0,2.62,0);`

Maybe look target slight x pointer*? If camera moves x, target fixed, changes view. fine.

`tank.updateMatrixWorld(true);`.

Fish world projection:
```
fish.worldPos.copy(fish.root.position);
tank.localToWorld(fish.worldPos);
projected.copy(fish.worldPos).project(camera);
```
tank.localToWorld mutates and uses matrixWorld. good.
 Avoid demo cursor local positions.

Pointer radius in pixels constant. On mobile 145 maybe screen; okay.

Desired force using camera right/up world directions, but desired is in tank local coords. On mobile tank scaled .88; camera right local differs slightly due uniform scale, negligible. Could transform camera right into tank local by apply inverse tank quaternion (identity). fine.

School center local.

Boundary.

Orientation velocity local, root in tank local.

Update positions.

Ripples at fish root local; camera quaternion world but tank no rotation.

Now spawn ripples from fish:
```
if(fish.startle>.72 && pointerActive && elapsed-lastFishRipple>.4){
 spawnRipple(fish.root.position); lastFishRipple=elapsed;
 break;
}
```
global timer prevents too many. But maybe only first fish. okay.

Need avoid ring geometry material opacity after removed; dispose material.

Click ripple color.

`spawnRipple` creates mesh and adds tank; sharedRing. `mesh.scale.setScalar(.5)`.
 update:
```
const t=(elapsed-r.birth)/r.life; scale=1+t*5; opacity=(1-t)*.8; mesh.scale.setScalar(scale)
```
Since geometry radius .08, final .4. good.
 Add small point glow? no.

Pointer influence circle appears at event position, but fish avoidance radius larger. okay.

Demo label projected maybe behind start card. okay.

Mode text:
`modeText.textContent = pointer.moved ? 'MANUAL CONTROL' : 'AUTO-PILOT';`

Start button text maybe.

Now CSS responsive. Use `backdrop-filter`; Chrome supports.

Maybe `canvas` needs width 100% height100%.

Add `touch-action:none`.

Let's formulate final HTML. Ensure one code block.

Before final, let's inspect possible requirement "Games must start in a self-playing demo mode (a simple AI plays) behind a 'Click or press a key to start' message" Our aquarium is not game but includes start card and auto-pilot. The phrase exact capitalization "Click or press a key to start". Good.

Should the message be visible first paint behind? yes.

Maybe start card should be behind in z? "behind a message" means overlay over game. done.

We need "nothing hidden behind high score/later level". all visible.

No external images.

Let's now draft final answer code. Need careful not too verbose outside; exactly fenced.

Let's write full code now in final, but we have plenty token budget. I should ensure no overlooked JS errors.

Let's simulate code skeleton:

```
const startCard = document.getElementById('start-card');
...
const renderer=...
...
const rnd=mulberry32(42);
...
const tank = new THREE.Group();
...
```

Function declarations hoisted but variables used after definitions? `makeTexture` uses renderer defined yes. `rnd` defined yes.

Building back texture after rnd.

`renderer.setClearColor(0x000000,0);`

`scene.fog = new THREE.Fog(0x031720, 16, 34);` On desktop camera distance13, fog starts16 no effect; mobile camera21, tank distance21 gets slight fog (transparent? fog blends to fog color opaque on objects) nice.

Floor shadow.

Tank:
```
const tankBack = new Mesh(...); tank.add
```
base receives shadow.

Tank glass material reused; transparent sorting.

Base might block sand? sand at .59, base top .36, fine.

Tank edges box from floor to top.

Add base front accent thin box:
`new BoxGeometry(W*.5,.025,.02)` at y=.24,z=D/2+.14, emissive teal.

Surface lines.

Light shafts.

Corals.

Fish.

Resize.

One issue `scene.add(floor)` then tank; transparent back sprite at z -4 may be hidden by floor plane extending behind; sprite at y3 not floor.

Caustic shader `vUv` from plane geometry after rotate remains.

Sand material vertexColors true.

Now plant geometry. Ensure `indices` order correct:
positions pair left/right. For quad with lower i and upper i+1:
a=i*2 left lower, b=a+1 right lower, c=a+2 left upper,d=a+3 right upper.
front face vertices a,b,c and b,d,c. Depending normal maybe negative; DoubleSide. compute normals.
Material side double.

Plant group each blade. Add slight z position.

Rock material each unique maybe.

Coral segment helper:
```
const dir=b.clone().sub(a); const len=dir.length(); dir.normalize();
const geo=new CylinderGeometry(r2,r1,len,7,1,false);
mesh.position.copy(a).add(b).multiplyScalar(.5);
mesh.quaternion.setFromUnitVectors(Y,dir);
```
a,b are local vectors. `position.copy(a).add(b)` works because copy(a), add(b).
Tip sphere.
Branch endpoints.

Anemone curve:
`new THREE.CatmullRomCurve3([new V3(0,0,0), ...])`.
TubeGeometry.
Tip position endpoint.
Base sphere maybe.

Coral group scale set; for anemone userData baseScale.

Cup group: cylinder open top. Use MeshStandard with side DoubleSide. Position.

Fan: group at .8,FLOOR,-1.25, rotation y? Rays in XY plane, facing camera. Base endpoints.
 Add 7 branches from base to tips. Material orange. Could use segment helper.
 Add thin arcs? no.

Starfish shape. `shape.moveTo` then line. geometry `ShapeGeometry(shape)`; mesh.rotation.x=-Math.PI/2; position y=FLOOR+.035. But shape coordinates with y radial; after rotation z=-y. fine.

Bubbles source at x 3.6,z-.8 and -3.8,z-.85.
 bubble positions local.
 `bubbleMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);`

Plankton points.

Demo cursor:
Torus material `MeshBasicMaterial({color:0xc5fbff,transparent:true,opacity:.75,depthWrite:false,depthTest:false})`. depthTest false means always visible in front, could overlay UI? Canvas only. Fine. RenderOrder10.
Glow sprite `depthTest:false`.
Group in tank.
Torus quaternion copy camera each frame.

Maybe demo cursor ring at z1.1 near front; visible.

Fish:
species defs and materials:
```
const sunBody = new THREE.MeshPhysicalMaterial({color:0xff8b3d,...});
sunBody.emissive.set(0x5b1c00); sunBody.emissiveIntensity=.15;
```
Maybe emissive too orange. Use body emissive Color multiply .03.
```
function fishMaterial(color, opts={}) {
 const m=new THREE.MeshPhysicalMaterial({...});
 m.emissive.copy(new THREE.Color(color).multiplyScalar(0.06));
 m.emissiveIntensity=.35; // effective .021
 return m;
}
```
Actually emissive color is already multiplied, intensity .35 reduces. fine.
 Tail MeshStandard.

Species count.
 Body stripe mat MeshStandard color 0xffeed3 roughness .5.
 Dark patch mat.

Fish create:
- root
- visual
- body mesh
- tail pivot:
 tailGeo unit. `tailMesh.scale.set(len*.95,h*.74,1)`. But pivot position x=-len*.46.
- dorsal mesh with dorsalGeo. Scale x len*.8,y h*.52. position y=h*.38, x=-len*.03.
- ventral fin maybe dorsal geometry rotated z? skip.
- pectoral spheres shared, material fin, scale etc.
- eye sclera/pupil.
- stripes.
 `visual.userData`.
 root.add visual.
 initial pos.
 tailPivot userData.
 store.
 Add to tank.

Band torus geometry: Torus radius .5 tube .055; rotatedY. mesh.scale.set(1,h,w). But after geometry rotation, ring plane? Torus originally in XY plane, ring normal Z. RotateY 90 maps ring normal X, ring plane YZ. Object scale x affects thickness, y maps ring vertical, z depth. Scale y=h, z=w. ring outer radius .5*h etc. Tube thickness scales. Fine.
At x positions body cross radius varies; stripes may float. Could set scales varying per stripe (0.8-1). Good enough.
Maybe band intersects body, since body cross-section shrinks at ends. At x .28 len normalized .51, cross radius .86, ring scale h (outer .5h + tube .055*h maybe), body half h .5h, ring extends .055h too much. okay.

Blue patch.

Eye white material; black.

Fish initial velocity random.

`fish.baseSpeed` maybe .55; fish size affects.

School arrays.

Update.

Let's make `const fish = []` conflicts with variable name perhaps okay.

Use `fishList`.

Create species:
```
const speciesDefs = [...]
for def for i count:
 const f=createFish(def, i)
 schools[def.school].push(f)
```

Counts 6+6+4+6=22 maybe. Use sun6 blue5 violet4 silver7 total22.
UI 22.

School anchor.

Fish body color etc.

Now update school stats and fish. Need `fishList` and `schools`.

Potential performance with `new Vector3` inside loops avoided mostly.
 Use `tmpA`, `tmpB`, `tmpC`; but nested loops needs careful not overwrite. Use dedicated:
- `deltaV`
- `schoolDelta`
- `desired`.
Each fish has desired vector stored, so no cross.
 In separation loop use `deltaV.copy(...)`, normalized; immediately add.
 In pointer world use `demoDelta`.
 In wander `wanderDelta`.
All separate vectors global but sequential.

Screen projection stored fish.projected.

Pointer force cameraRight/cameraUp.

Startle:
```
const wasStartled = fish.startle > .1;
...
fish.startle = Math.min(1, fish.startle + intensity*dt*5);
```
Then decays.
 For spawn ripple use `fish.startle>.65`.

Desired:
cohesion center difference local.
alignment.

School `center` maybe average includes current pos.
 
Velocity clamp:
```
const minSpeed = fish.baseSpeed*(.65+ .25*sin(...));
let maxSpeed=fish.baseSpeed*1.45 + fish.startle*1.8;
if (speed>maxSpeed) vel.multiplyScalar(maxSpeed/speed);
if(speed<minSpeed && fish.startle<.15) vel.multiplyScalar(minSpeed/speed)
```
This means fish always move.

Desired acceleration maybe enough. `vel.addScaledVector(desired, dt*(1.0+fish.startle*2.0))`.

School center force .09 perhaps.

Wander .18.

Avoid force 10.

Boundary force 2.

No vertical sinking.

Fish position update.

Hard clamp x [-W/2+.35,W/2-.35], z[-D/2+.35,D/2-.35], y[FLOOR+.45,TOP-.35]. Fish body may clip at surface, okay.

Orientation:
```
const horiz=Math.hypot(vx,vz)
targetYaw=Math.atan2(-vz,vx)
angle...
root.rotation.y += ...
targetPitch = clamp(Math.atan2(vy,horiz),-.55,.55)
visual.rotation.z += (targetPitch-visual.rotation.z)*min(1,dt*6)
```
Potential Euler child z sign: forward +X rotated z positive -> y positive, yes.

Tail rotation y.

Pectoral fins wiggle:
 `fin.rotation.x = sin(...)*.25` but fin sphere local; okay.

Visual bob.

Startle ring.

Now bubble update and plant.

Plankton positions attr:
Initialize y between FLOOR and TOP. update each.

Caustic.

Camera update before fish. Need pointer smooth updated in animate.

```
pointer.smooth.lerp(pointer.ndc,1-Math.exp(-dt*12));
```
If pointer never moved, ndc 0. camera auto parallax.

Influence ring event positions.

Demo mode mix:
`const targetMix=pointer.moved?0:1; demoMix=damp...`
Since pointer.moved true permanently, label hide. Fine.
 `demoCursor.visible = demoMix>.01`.
 Material opacities.
 Demo cursor still moves.

Could let demo resume after 8 sec of inactivity? Then fish self-play always. But after manual, user might leave and screenshot later; no matter. To satisfy self-playing, maybe resume demo after 12 seconds no movement:
 `const manualIdle = pointer.moved && (performance.now()-pointer.last > 9000 || !pointer.inside); targetMix=manualIdle?1:0;`
Then demo returns. Nice. The demo label shows AUTO PILOT again. But user may be reading. Fine.
 However if pointer not inside initially, pointer.moved false.
 Use `userIdle = pointer.moved && (!pointer.inside || now-pointer.last>10000);`
 Then targetMix userIdle?1:0.
 Mode text accordingly.

Start card remains until click.

Camera parallax if pointer moved and inside; else auto.

`updateDemo` calculates cursor even if invisible.

Demo cursor avoid uses demoMix.

Camera plane for click.

Now resize camera and tank scale:
Tank includes base; scaling whole.
`cameraBase` initial.
 `camera.lookAt(0,2.63,0)`.
 For mobile, maybe `cameraBase.z=21.2`.
 `cameraBase.y=2.95`.
 Desktop y=3.2.
 FOV 40.
 
Renderer set size.

Potential devicePixelRatio and CSS.

Now stats DOM update once.

Let's include `aria-live` mode.

Need no `console`.

CSS start card initial visible. `body.started #start-card`? JS class. JS adds.

Button has pointer events. Section pointer-events none but button can receive? CSS pointer-events on parent none means descendants cannot receive? In HTML, if parent pointer-events:none, descendants can be target if they set pointer-events:auto. Yes.

Keyboard event only after iframe clicked per platform; message.

Now let's maybe include instructions that movement works before start? "Auto-pilot is swimming. Your cursor takes over as soon as you move." But overlay says click/key. Fine.

Let's write final code. We need be mindful of one code block only. I'll produce complete.

Before final, let's consider potential three.js warning due using `scene.fog` with transparent background and MeshBasic transparent; fine.

Potential "THREE.WebGLRenderer: WebGL 2 context" no error.

Potential `renderer.domElement` is canvas passed; no need append.

One thing: module script at end after importmap in head. yes.

One thing: `canvas` has width/height set by renderer and CSS 100%. good.

One thing: `renderer.setSize(width,height,false)` doesn't update canvas style, CSS handles.

One thing: `ctx.createRadialGradient` args.

One thing: `MeshStandardMaterial` `emissiveIntensity` default 1.

Let's outline code exact.

Start card:
```
<section id="start-card" class="start-card" aria-label="Start experience">
 <div class="start-icon"><svg...></svg></div>
 <div class="start-copy">
  <span class="kicker">SANDBOX / AUTO-PILOT</span>
  <h2>Click or press a key to start</h2>
  <p>The reef is already alive. Move near a fish and watch the school make room.</p>
 </div>
 <button id="start-button" type="button">Take the wheel <span>→</span></button>
</section>
```
Exact phrase.

Behavior card:
```
<aside class="behavior-card glass">
 <div class=behavior-top><span class=kicker>FIELD NOTE 07</span><span class=signal>...</span></div>
 <h3>A little space goes a long way.</h3>
 <p>Your cursor becomes a soft disturbance...</p>
 <div class=tags>...
</aside>
```
No huge.

Readouts:
```
<div class=readouts glass>
 row ...
</div>
```
At desktop bottom right.
 Top right live.

Mode chip perhaps in top right.

CSS start card center bottom 42; behavior bottom 34 left32. readouts bottom34 right32. all separate at 1280 (behavior width280, card width450, readout200; total 930 plus gaps, fits 1280).
 At 1024 maybe overlaps. media max1100 hide readouts, behavior width250. At mobile hide behavior/readouts.

Topbar pointer-events none except chip.

Vignette.

Let's check exact dimensions start card width 460, center x410-870; behavior x32-306; readout x1030-1248. no overlap.

Tank behind.

CSS card background not too opaque.

Okay.

Let's now prepare final.