Models 44

GLM 5.3

by Z.aiReasoningClean
Open in new window

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

How it went

Total time
9 min 24 s
Cost
$0.262
Output tokens
59,410
Spent thinking
47,736 tokens
Speed
105.5 tok/s
Console errors
0
First token after
841 ms
First visible text after
8 min 6 s
Prompt tokens
424
Code size
31.5 KB
Finish reason
stop
Served by
Fireworks
Model id
z-ai/glm-5.3
Reasoning sent
{"effort":"high"}
Max output
943,718 tokens
Generated
Nov 12, 2026

What it was asked

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

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

Code run-1.html · 833 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>The Glass Reef — a living aquarium</title>
<style>
  *{margin:0;padding:0;box-sizing:border-box}
  html,body{height:100%;overflow:hidden;background:#04141c}
  body{font-family:"Avenir Next","Helvetica Neue","Segoe UI",Arial,sans-serif;color:#eaf6f8}
  canvas{display:block;position:fixed;inset:0;cursor:crosshair}
  body.touched canvas{cursor:none}
  #veil{position:fixed;inset:0;pointer-events:none;
    background:
      radial-gradient(120% 95% at 50% 42%, rgba(0,0,0,0) 55%, rgba(2,12,18,.55) 100%),
      linear-gradient(168deg, rgba(190,235,250,.075), rgba(0,0,0,0) 20%, rgba(0,0,0,0) 80%, rgba(120,205,225,.05));}
  #title{position:fixed;top:28px;left:34px;pointer-events:none;user-select:none}
  #title h1{font-weight:500;font-size:15px;letter-spacing:.46em;text-transform:uppercase;color:#f2fbfc;text-shadow:0 2px 14px rgba(0,20,30,.6)}
  #title p{margin-top:8px;font-family:Georgia,"Times New Roman",serif;font-style:italic;font-size:12.5px;letter-spacing:.14em;color:rgba(189,228,235,.72)}
  #stats{position:fixed;right:30px;bottom:24px;pointer-events:none;user-select:none;font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:rgba(170,214,224,.45)}
  #pill{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);pointer-events:none;user-select:none;
    display:flex;align-items:center;gap:10px;
    background:rgba(6,26,34,.55);border:1px solid rgba(150,215,228,.22);border-radius:999px;
    padding:10px 18px;font-size:11.5px;letter-spacing:.12em;color:#cfeef2;
    backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);
    transition:opacity .8s ease;box-shadow:0 6px 24px rgba(0,10,16,.35);max-width:92vw;text-align:center}
  #pill.fade{opacity:0}
  #pill .dot{flex:none;width:7px;height:7px;border-radius:50%;background:#7fe3c8;box-shadow:0 0 10px #7fe3c8;animation:pulse 1.8s infinite}
  @keyframes pulse{0%,100%{opacity:.45;transform:scale(.8)}50%{opacity:1;transform:scale(1.15)}}
  @media (max-width:600px){
    #title{top:18px;left:20px}#title h1{font-size:12px;letter-spacing:.32em}#title p{font-size:10.5px}
    #stats{display:none}
    #pill{font-size:10px;padding:8px 13px;bottom:16px}
  }
</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>
<div id="veil"></div>
<div id="title"><h1>The Glass Reef</h1><p>twenty-six fish · three species · one cursor to fear</p></div>
<div id="stats">26°c · ph 8.2 · salinity 1.025</div>
<div id="pill"><span class="dot"></span><span>a phantom hand is wandering the tank — move your cursor to take over · click to drop food</span></div>

<script type="module">
import * as THREE from 'three';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';

/* ============ renderer / scene / camera ============ */
const renderer = new THREE.WebGLRenderer({ antialias:true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.12;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.prepend(renderer.domElement);

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x06222e);
scene.fog = new THREE.FogExp2(0x0c4050, 0.023);

const camera = new THREE.PerspectiveCamera(55, innerWidth/innerHeight, 0.1, 220);
camera.position.set(0, 1.6, 26);

const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
scene.environmentIntensity = 0.35;
pmrem.dispose();

const clock = new THREE.Clock();
const uTime = { value: 0 };

/* deterministic prng so the reef is always composed the same way */
let seed = 1234567;
const rand = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };
const rr = (a, b) => a + rand() * (b - a);

/* horizontal bounds follow the frustum so fish never leave the screen */
let BX = 15;
const computeBounds = () => {
  const visW = Math.tan(THREE.MathUtils.degToRad(27.5)) * 26 * camera.aspect;
  BX = THREE.MathUtils.clamp(visW * 0.8, 5.5, 15);
};
computeBounds();

/* ============ lights ============ */
scene.add(new THREE.HemisphereLight(0xaadcec, 0x0a2530, 0.85));
const sun = new THREE.DirectionalLight(0xcfeef7, 2.6);
sun.position.set(9, 22, 7);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -22; sun.shadow.camera.right = 22;
sun.shadow.camera.top = 22;   sun.shadow.camera.bottom = -22;
sun.shadow.camera.near = 2;   sun.shadow.camera.far = 60;
sun.shadow.bias = -0.0004; sun.shadow.normalBias = 0.03;
scene.add(sun);
const fill = new THREE.PointLight(0x69c8d8, 60, 60, 2);
fill.position.set(0, -2, 8);
scene.add(fill);

/* ============ gradient water-column backdrop ============ */
const dome = new THREE.Mesh(
  new THREE.SphereGeometry(70, 32, 24),
  new THREE.ShaderMaterial({
    side: THREE.BackSide, depthWrite: false,
    uniforms: {
      cTop: { value: new THREE.Color(0x2f8496) },
      cMid: { value: new THREE.Color(0x0e4a5a) },
      cBot: { value: new THREE.Color(0x041a22) }
    },
    vertexShader: `varying vec3 vP; void main(){ vP = position; gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
    fragmentShader: `varying vec3 vP; uniform vec3 cTop,cMid,cBot;
      void main(){
        float h = normalize(vP).y;
        vec3 c = mix(cBot, cMid, smoothstep(-0.6, 0.05, h));
        c = mix(c, cTop, smoothstep(0.05, 0.7, h));
        gl_FragColor = vec4(c, 1.0);
      }`
  })
);
dome.renderOrder = -10;
scene.add(dome);

/* ============ small canvas-texture helper ============ */
function makeCanvas(w, h, draw){
  const c = document.createElement('canvas'); c.width = w; c.height = h;
  draw(c.getContext('2d'), w, h);
  const t = new THREE.CanvasTexture(c);
  t.colorSpace = THREE.SRGBColorSpace;
  return t;
}

/* ============ sandy floor with animated caustics ============ */
const dune = (x, z) =>
  Math.sin(x*0.16 + 1.3)*0.38 + Math.sin(z*0.21 + 4.0)*0.34 +
  Math.sin(x*0.43 + z*0.35)*0.18 + Math.sin(x*1.1 - z*0.7)*0.07;

const sandGeo = new THREE.PlaneGeometry(100, 70, 96, 64);
sandGeo.rotateX(-Math.PI/2);
{
  const pos = sandGeo.attributes.position;
  const colors = new Float32Array(pos.count * 3);
  const cA = new THREE.Color(0xb59f78), cB = new THREE.Color(0xdccfa9), cC = new THREE.Color(0x8b9364);
  const tmp = new THREE.Color();
  for (let i = 0; i < pos.count; i++){
    const x = pos.getX(i), z = pos.getZ(i);
    const h = dune(x, z) + (rand()-0.5)*0.06;
    pos.setY(i, h);
    tmp.copy(cA).lerp(cB, THREE.MathUtils.clamp((h+0.9)/1.8, 0, 1));
    const algae = Math.sin(x*0.33+2.0) * Math.sin(z*0.41+0.7);
    if (algae > 0.35) tmp.lerp(cC, (algae-0.35)*0.9);
    colors[i*3] = tmp.r; colors[i*3+1] = tmp.g; colors[i*3+2] = tmp.b;
  }
  sandGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
  sandGeo.computeVertexNormals();
  sandGeo.translate(0, -6.5, 0);   // baked so local coords == world coords
}
const sandMat = new THREE.MeshStandardMaterial({ vertexColors:true, roughness:1, metalness:0 });
sandMat.onBeforeCompile = (shader) => {
  shader.uniforms.uTime = uTime;
  shader.vertexShader = shader.vertexShader
    .replace('#include <common>', '#include <common>\nvarying vec3 vWP;')
    .replace('#include <begin_vertex>', '#include <begin_vertex>\n vWP = transformed;');
  shader.fragmentShader = shader.fragmentShader
    .replace('#include <common>', `#include <common>
      varying vec3 vWP;
      uniform float uTime;
      float caustic(vec2 uv, float t){
        vec2 p = mod(uv*6.28318, 6.28318) - 250.0;
        vec2 i = p; float c = 1.0; float inten = 0.005;
        for (int n = 0; n < 4; n++){
          float tt = t * (1.0 - (3.5/float(n+1)));
          i = p + vec2(cos(tt - i.x) + sin(tt + i.y), sin(tt - i.y) + cos(tt + i.x));
          c += 1.0/length(vec2(p.x/(sin(i.x+tt)/inten), p.y/(cos(i.y+tt)/inten)));
        }
        c /= 4.0; c = 1.17 - pow(c, 1.4);
        return pow(abs(c), 8.0);
      }`)
    .replace('#include <emissivemap_fragment>', `#include <emissivemap_fragment>
      float ca = caustic(vWP.xz*0.13, uTime*0.55);
      totalEmissiveRadiance += vec3(0.28, 0.68, 0.72) * ca * 0.6;`);
};
const sand = new THREE.Mesh(sandGeo, sandMat);
sand.receiveShadow = true;
scene.add(sand);

/* ============ rocks, pebbles, coral ============ */
const rockMat   = new THREE.MeshStandardMaterial({ color:0x49564f, roughness:0.95, flatShading:true });
const pebbleMat = new THREE.MeshStandardMaterial({ color:0x8f8168, roughness:1, flatShading:true });

function rock(x, z, sx, sy, mat){
  const g = new THREE.IcosahedronGeometry(1, 1);
  const p = g.attributes.position;
  for (let i = 0; i < p.count; i++){
    const px = p.getX(i), py = p.getY(i), pz = p.getZ(i);
    const hsh = Math.sin(px*12.9898 + py*78.233 + pz*37.719) * 43758.5453;
    const k = 1 + ((hsh - Math.floor(hsh)) - 0.5) * 0.55;   // position-hash: no cracks
    p.setXYZ(i, px*k, py*k, pz*k);
  }
  const m = new THREE.Mesh(g, mat);
  m.position.set(x, dune(x,z) - 6.5 + sy*0.3, z);
  m.scale.set(sx, sy, sx*rr(0.75, 1.15));
  m.rotation.y = rand()*Math.PI*2;
  m.castShadow = m.receiveShadow = true;
  scene.add(m);
}
rock(-13.5, -7.5, 3.2, 2.4, rockMat);
rock(-10.5, -9.0, 2.2, 1.6, rockMat);
rock( 12.5, -8.0, 3.0, 2.2, rockMat);
rock(  9.0, -9.5, 1.8, 1.2, rockMat);
rock( 14.5,  2.0, 2.4, 1.8, rockMat);
rock(-14.8,  0.5, 2.0, 1.5, rockMat);
for (let i = 0; i < 14; i++) rock(rr(-16,16), rr(-9,8), rr(0.18,0.5), rr(0.14,0.4), pebbleMat);

function coral(x, z, hue){
  const g = new THREE.Group();
  const n = 5 + Math.floor(rand()*4);
  for (let i = 0; i < n; i++){
    const hgt = rr(0.5, 1.6);
    const cone = new THREE.Mesh(
      new THREE.ConeGeometry(rr(0.09, 0.16), hgt, 7),
      new THREE.MeshStandardMaterial({ color: hue.clone().offsetHSL(0, 0, rr(-0.06, 0.06)), roughness:0.7 })
    );
    const a = rand()*Math.PI*2, r = rand()*0.7;
    cone.position.set(Math.cos(a)*r, hgt*0.45, Math.sin(a)*r);
    cone.rotation.set(Math.sin(a)*0.35, 0, Math.cos(a)*0.35);
    cone.castShadow = true;
    g.add(cone);
  }
  g.position.set(x, dune(x,z) - 6.4, z);
  scene.add(g);
}
coral(-6.5, -6.5, new THREE.Color(0xd96a52));
coral( 4.5, -7.5, new THREE.Color(0xc75f86));
coral(11.0, -3.0, new THREE.Color(0xd9a04e));

/* ============ swaying kelp (vertex-shader bend) ============ */
function kelpPatch(cx, cz, count, tint){
  for (let i = 0; i < count; i++){
    const h = rr(3.2, 7.2), w = rr(0.3, 0.55);
    const geo = new THREE.PlaneGeometry(w, h, 1, 12);
    geo.translate(0, h/2, 0);
    const pos = geo.attributes.position;
    const cols = new Float32Array(pos.count*3);
    const cBot = new THREE.Color(0x0c2e1c).lerp(tint, 0.25);
    const cTop = new THREE.Color(0x39a352).lerp(tint, 0.55);
    const tmp = new THREE.Color();
    for (let j = 0; j < pos.count; j++){
      const k = pos.getY(j)/h;
      tmp.copy(cBot).lerp(cTop, THREE.MathUtils.clamp(k + (rand()-0.5)*0.1, 0, 1));
      cols[j*3] = tmp.r; cols[j*3+1] = tmp.g; cols[j*3+2] = tmp.b;
    }
    geo.setAttribute('color', new THREE.BufferAttribute(cols, 3));
    const mat = new THREE.MeshStandardMaterial({ vertexColors:true, roughness:0.8, side:THREE.DoubleSide });
    const phase = rand()*20, amp = rr(0.4, 0.9);
    mat.onBeforeCompile = (sh) => {
      sh.uniforms.uTime = uTime;
      sh.uniforms.uPhase = { value: phase };
      sh.uniforms.uAmp = { value: amp };
      sh.uniforms.uH = { value: h };
      sh.vertexShader = sh.vertexShader
        .replace('#include <common>', '#include <common>\nuniform float uTime; uniform float uPhase; uniform float uAmp; uniform float uH;')
        .replace('#include <begin_vertex>', `#include <begin_vertex>
          float kk = clamp(transformed.y / uH, 0.0, 1.0);
          transformed.x += sin(uTime*1.1 + uPhase + kk*2.2) * uAmp * kk * kk;
          transformed.z += cos(uTime*0.8 + uPhase*1.37 + kk*1.6) * uAmp * 0.6 * kk * kk;`);
    };
    const blade = new THREE.Mesh(geo, mat);
    blade.position.set(cx + rr(-1.6,1.6), dune(cx,cz) - 6.55, cz + rr(-1.6,1.6));
    blade.rotation.y = rand()*Math.PI;
    scene.add(blade);
  }
}
kelpPatch(-11.5, -4.5, 6, new THREE.Color(0x2a7f6a));
kelpPatch(-12.5,  2.5, 5, new THREE.Color(0x3c9f5a));
kelpPatch( 10.5, -5.5, 7, new THREE.Color(0x2a8f7a));
kelpPatch( 13.0,  3.5, 4, new THREE.Color(0x57a83f));
kelpPatch( -3.0, -8.5, 5, new THREE.Color(0x2f8f66));

/* ============ shimmering water surface ============ */
const waterGeo = new THREE.PlaneGeometry(100, 70, 64, 44);
waterGeo.rotateX(-Math.PI/2);
const water = new THREE.Mesh(waterGeo, new THREE.ShaderMaterial({
  transparent: true, depthWrite: false, side: THREE.DoubleSide,
  uniforms: { uTime: uTime },
  vertexShader: `
    uniform float uTime;
    varying vec3 vW; varying float vD;
    void main(){
      vec3 p = position;
      p.y += sin(p.x*0.22 + uTime*0.7)*0.3 + cos(p.z*0.28 + uTime*0.55)*0.25;
      vec4 wp = modelMatrix * vec4(p, 1.0);
      vW = wp.xyz;
      vec4 mv = viewMatrix * wp;
      vD = -mv.z;
      gl_Position = projectionMatrix * mv;
    }`,
  fragmentShader: `
    uniform float uTime;
    varying vec3 vW; varying float vD;
    void main(){
      vec2 q = vW.xz;
      float w = sin(q.x*1.3 + uTime*1.4) * sin(q.y*1.6 - uTime*1.1);
      w += 0.6  * sin(q.x*2.7 - uTime*2.1) * sin(q.y*2.2 + uTime*1.6);
      w += 0.35 * sin(q.x*4.4 + uTime*2.8) * sin(q.y*3.9 - uTime*2.3);
      float glint = smoothstep(0.45, 1.0, w);
      vec3 col = mix(vec3(0.07,0.35,0.42), vec3(0.8,0.97,1.0), glint);
      float a = 0.38 + glint*0.45;
      a *= smoothstep(75.0, 28.0, vD);
      gl_FragColor = vec4(col, a);
    }`
}));
water.position.y = 9.4;
water.renderOrder = -1;   // everything below the surface composites over it
scene.add(water);

/* ============ soft light shafts ============ */
const shaftTex = makeCanvas(64, 256, (g, w, h) => {
  const v = g.createLinearGradient(0, 0, 0, h);
  v.addColorStop(0, 'rgba(255,255,255,0.85)');
  v.addColorStop(0.5, 'rgba(255,255,255,0.35)');
  v.addColorStop(1, 'rgba(255,255,255,0)');
  g.fillStyle = v; g.fillRect(0, 0, w, h);
  const hor = g.createLinearGradient(0, 0, w, 0);
  hor.addColorStop(0, 'rgba(0,0,0,0)');
  hor.addColorStop(0.5, 'rgba(255,255,255,1)');
  hor.addColorStop(1, 'rgba(0,0,0,0)');
  g.globalCompositeOperation = 'destination-in';
  g.fillStyle = hor; g.fillRect(0, 0, w, h);
});
const shafts = [];
for (const [sx, sz] of [[-9,-4],[-3.5,-6],[1.5,-3],[6.5,-5.5],[11,-2.5]]){
  const m = new THREE.Mesh(
    new THREE.PlaneGeometry(rr(1.8, 2.8), 17),
    new THREE.MeshBasicMaterial({ map:shaftTex, color:0xa8e4f4, transparent:true, opacity:0.09,
      blending:THREE.AdditiveBlending, depthWrite:false, side:THREE.DoubleSide, fog:false })
  );
  m.position.set(sx, 1.4, sz);
  m.renderOrder = 3;
  m.userData = { baseRot: rr(-0.06, 0.06), ph: rand()*10, baseOp: rr(0.06, 0.11) };
  scene.add(m); shafts.push(m);
}

/* ============ rising bubbles ============ */
const bubTex = makeCanvas(64, 64, (g) => {
  const r = g.createRadialGradient(32, 32, 0, 32, 32, 32);
  r.addColorStop(0,    'rgba(255,255,255,0.05)');
  r.addColorStop(0.62, 'rgba(255,255,255,0.12)');
  r.addColorStop(0.85, 'rgba(255,255,255,0.85)');
  r.addColorStop(1,    'rgba(255,255,255,0)');
  g.fillStyle = r; g.fillRect(0, 0, 64, 64);
});
const BUB = 64;
const bubArr = new Float32Array(BUB*3);
const bubs = [];
const emitters = [[-12.5,-5], [11.8,-4.5], [1.5,-7.5]];
for (let i = 0; i < BUB; i++){
  const e = emitters[i % 3];
  bubs.push({ x:e[0]+rr(-0.8,0.8), y:rr(-6,9), z:e[1]+rr(-0.8,0.8), s:rr(0.7,1.6), ph:rand()*10 });
}
const bubGeo = new THREE.BufferGeometry();
bubGeo.setAttribute('position', new THREE.BufferAttribute(bubArr, 3));
const bubPts = new THREE.Points(bubGeo, new THREE.PointsMaterial({
  size:0.28, map:bubTex, transparent:true, opacity:0.55, depthWrite:false,
  blending:THREE.AdditiveBlending, color:0xbfe8f2, sizeAttenuation:true }));
bubPts.frustumCulled = false;
scene.add(bubPts);
function updateBubbles(dt, t){
  for (let i = 0; i < BUB; i++){
    const b = bubs[i];
    b.y += b.s*dt;
    b.x += Math.sin(t*1.8 + b.ph)*0.3*dt;
    if (b.y > 9.2){
      const e = emitters[(rand()*emitters.length)|0];
      b.x = e[0]+rr(-0.7,0.7); b.y = -6.2; b.z = e[1]+rr(-0.7,0.7); b.s = rr(0.7,1.7);
    }
    bubArr[i*3] = b.x; bubArr[i*3+1] = b.y; bubArr[i*3+2] = b.z;
  }
  bubGeo.attributes.position.needsUpdate = true;
}

/* ============ fish — geometry ============ */
const bodyGeo = (() => {
  const pts = [];
  const N = 30;
  for (let i = 0; i <= N; i++){
    const tt = i / N;                     // 0 nose → 1 tail
    const tp = Math.pow(tt, 0.72);        // bulge toward the head
    let r = 0.34 * Math.pow(Math.sin(Math.PI*tp), 0.85);
    if (i === 0) r = 0.05;
    if (i === N) r = 0.03;
    pts.push(new THREE.Vector2(r, 0.55 - tt*1.1));
  }
  const g = new THREE.LatheGeometry(pts, 22);
  g.rotateX(Math.PI/2);   // axis along +Z, nose forward
  g.scale(0.6, 1, 1);     // laterally compressed body
  return g;
})();
const tailGeo = (() => {
  const s = new THREE.Shape();
  s.moveTo(0, 0.15);
  s.quadraticCurveTo(-0.22, 0.34, -0.46, 0.40);
  s.quadraticCurveTo(-0.30, 0.16, -0.28, 0.0);
  s.quadraticCurveTo(-0.30, -0.16, -0.46, -0.36);
  s.quadraticCurveTo(-0.22, -0.30, 0, -0.13);
  s.lineTo(0, 0.15);
  const g = new THREE.ShapeGeometry(s);
  g.rotateY(-Math.PI/2);
  return g;
})();
const dorsalGeo = (() => {
  const s = new THREE.Shape();
  s.moveTo(-0.42, 0.02);
  s.quadraticCurveTo(-0.30, 0.34, -0.08, 0.46);
  s.quadraticCurveTo(0.10, 0.40, 0.30, 0.10);
  s.lineTo(0.30, 0.0); s.lineTo(-0.42, 0.0); s.lineTo(-0.42, 0.02);
  const g = new THREE.ShapeGeometry(s);
  g.rotateY(-Math.PI/2);
  return g;
})();
const analGeo = (() => {
  const s = new THREE.Shape();
  s.moveTo(0.06, 0);
  s.quadraticCurveTo(-0.08, -0.28, -0.30, -0.30);
  s.quadraticCurveTo(-0.26, -0.10, -0.30, -0.01);
  s.lineTo(0.06, 0);
  const g = new THREE.ShapeGeometry(s);
  g.rotateY(-Math.PI/2);
  return g;
})();
const pectGeo = (() => {
  const s = new THREE.Shape();
  s.moveTo(0, 0);
  s.quadraticCurveTo(0.18, 0.03, 0.30, 0.16);
  s.quadraticCurveTo(0.12, 0.12, 0, 0.05);
  s.lineTo(0, 0);
  return new THREE.ShapeGeometry(s);
})();
const eyeGeo = new THREE.SphereGeometry(0.045, 10, 8);
const eyeMat = new THREE.MeshStandardMaterial({ color:0x101418, roughness:0.25 });

/* ============ fish — painted skins ============ */
function fishTexture(o){
  return makeCanvas(128, 256, (g) => {
    const gr = g.createLinearGradient(0, 0, 128, 0);   // u=0.5 is the back
    gr.addColorStop(0, o.belly); gr.addColorStop(0.5, o.back); gr.addColorStop(1, o.belly);
    g.fillStyle = gr; g.fillRect(0, 0, 128, 256);
    if (o.bands) for (const b of o.bands){
      const y = (1 - b.at)*256, h = b.h*256;
      g.fillStyle = b.edge;  g.fillRect(0, y - h/2 - 4, 128, h + 8);
      g.fillStyle = b.color; g.fillRect(0, y - h/2, 128, h);
    }
    if (o.mark){
      g.fillStyle = 'rgba(9,20,54,0.85)';
      g.beginPath(); g.ellipse(64, 112, 30, 44, 0, 0, Math.PI*2); g.fill();
      g.fillStyle = 'rgba(9,20,54,0.5)';
      g.beginPath(); g.ellipse(64, 205, 24, 26, 0, 0, Math.PI*2); g.fill();
    }
    if (o.speckle){
      g.globalAlpha = 0.12; g.fillStyle = '#2a3c46';
      for (let i = 0; i < 300; i++) g.fillRect(rand()*128, rand()*256, 2, 2);
      g.globalAlpha = 1;
    }
  });
}
const finMat = (color, op) => new THREE.MeshStandardMaterial({
  color, transparent:true, opacity:op, side:THREE.DoubleSide, roughness:0.55 });

const SPECIES = [
  { count:7,  size:1.15, speed:3.1, wander:0.9, coh:0.8, ali:0.7,   // ember clown
    bodyMat: new THREE.MeshStandardMaterial({ map: fishTexture({
      back:'#c8501a', belly:'#ffb066',
      bands:[{at:0.30,h:0.055,color:'#f7f2e8',edge:'#33241a'},
             {at:0.55,h:0.060,color:'#f7f2e8',edge:'#33241a'},
             {at:0.80,h:0.050,color:'#f7f2e8',edge:'#33241a'}]
    }), roughness:0.6 }),
    finMat: finMat(0xff9a45, 0.9) },
  { count:6,  size:1.45, speed:2.8, wander:0.8, coh:0.7, ali:0.6,   // blue tang
    bodyMat: new THREE.MeshStandardMaterial({ map: fishTexture({
      back:'#1c3fae', belly:'#8fb4f2', mark:true }), roughness:0.5 }),
    finMat: finMat(0xffd23f, 0.95) },
  { count:13, size:0.62, speed:4.4, wander:1.2, coh:2.4, ali:2.6,   // silver sprats
    bodyMat: new THREE.MeshStandardMaterial({ map: fishTexture({
      back:'#7e98a4', belly:'#eef4f5', speckle:true }), roughness:0.3, metalness:0.75 }),
    finMat: finMat(0xbfd2d8, 0.55) },
];

function makeFish(sp){
  const outer = new THREE.Group();
  const inner = new THREE.Group();
  inner.scale.setScalar(sp.size);
  outer.add(inner);

  const body = new THREE.Mesh(bodyGeo, sp.bodyMat);
  body.castShadow = true;
  inner.add(body);

  const tail = new THREE.Group();
  tail.position.set(0, 0, -0.48);
  const tailM = new THREE.Mesh(tailGeo, sp.finMat);
  tailM.castShadow = true;
  tail.add(tailM);
  inner.add(tail);

  const dorsal = new THREE.Mesh(dorsalGeo, sp.finMat);
  dorsal.position.set(0, 0.13, 0);
  inner.add(dorsal);
  const anal = new THREE.Mesh(analGeo, sp.finMat);
  anal.position.set(0, -0.1, -0.05);
  inner.add(anal);

  const pectR = new THREE.Mesh(pectGeo, sp.finMat);
  pectR.position.set(0.13, -0.04, 0.15);
  pectR.rotation.set(0.55, Math.PI/2 - 0.55, 0);
  inner.add(pectR);
  const pectL = new THREE.Mesh(pectGeo, sp.finMat);
  pectL.position.set(-0.13, -0.04, 0.15);
  pectL.rotation.set(0.55, Math.PI/2 + 0.55, 0);
  inner.add(pectL);

  const eyeR = new THREE.Mesh(eyeGeo, eyeMat);
  eyeR.position.set(0.115, 0.07, 0.335);
  inner.add(eyeR);
  const eyeL = eyeR.clone();
  eyeL.position.x = -0.115;
  inner.add(eyeL);

  return { outer, inner, tail, pectR, pectL };
}

/* ============ food flakes ============ */
const foodTex = makeCanvas(32, 32, (g) => {
  const r = g.createRadialGradient(16, 16, 0, 16, 16, 16);
  r.addColorStop(0, 'rgba(255,255,255,1)');
  r.addColorStop(0.55, 'rgba(255,255,255,0.9)');
  r.addColorStop(1, 'rgba(255,255,255,0)');
  g.fillStyle = r; g.fillRect(0, 0, 32, 32);
});
const MAXF = 90;
const foods = [];
const foodArr = new Float32Array(MAXF*3);
for (let i = 0; i < MAXF; i++){
  foods.push({ active:false, pos:new THREE.Vector3(0,-999,0), vy:0, wob:0, age:0 });
  foodArr[i*3+1] = -999;
}
const foodGeo = new THREE.BufferGeometry();
foodGeo.setAttribute('position', new THREE.BufferAttribute(foodArr, 3));
const foodPts = new THREE.Points(foodGeo, new THREE.PointsMaterial({
  size:0.26, map:foodTex, transparent:true, alphaTest:0.3, color:0xd9b273, sizeAttenuation:true }));
foodPts.frustumCulled = false;
scene.add(foodPts);

let lastFood = -10;
function dropFood(at){
  const now = clock.elapsedTime;
  if (now - lastFood < 0.18) return;
  lastFood = now;
  let n = 8;
  for (const fd of foods){
    if (n <= 0) break;
    if (fd.active) continue;
    fd.active = true;
    fd.pos.set(at.x + rr(-1.1,1.1), Math.min(at.y + rr(0.5,2.0), 8.4), at.z + rr(-1.1,1.1));
    fd.vy = rr(0.45, 0.75);
    fd.wob = rand()*10;
    fd.age = 0;
    n--;
  }
}
function updateFood(dt, t){
  for (let i = 0; i < foods.length; i++){
    const fd = foods[i];
    if (!fd.active){ foodArr[i*3+1] = -999; continue; }
    fd.age += dt;
    const floor = dune(fd.pos.x, fd.pos.z) - 6.5 + 0.12;
    if (fd.pos.y > floor) fd.pos.y -= fd.vy*dt*(fd.pos.y - floor < 1 ? 0.35 : 1);
    fd.pos.x += Math.sin(t*1.3 + fd.wob)*0.1*dt;
    fd.pos.z += Math.cos(t*1.1 + fd.wob)*0.08*dt;
    if (fd.age > 32) fd.active = false;
    foodArr[i*3] = fd.pos.x; foodArr[i*3+1] = fd.pos.y; foodArr[i*3+2] = fd.pos.z;
  }
  foodGeo.attributes.position.needsUpdate = true;
}

/* ============ the hand: scare point, glow, light ============ */
const glowTex = makeCanvas(64, 64, (g) => {
  const r = g.createRadialGradient(32, 32, 0, 32, 32, 32);
  r.addColorStop(0,    'rgba(255,255,255,0.9)');
  r.addColorStop(0.35, 'rgba(255,255,255,0.28)');
  r.addColorStop(1,    'rgba(255,255,255,0)');
  g.fillStyle = r; g.fillRect(0, 0, 64, 64);
});
const hand = new THREE.Sprite(new THREE.SpriteMaterial({
  map:glowTex, color:0xa8e8ff, transparent:true, opacity:0.4,
  blending:THREE.AdditiveBlending, depthWrite:false, fog:false }));
hand.scale.setScalar(3);
scene.add(hand);
const handLight = new THREE.PointLight(0xbfe9ff, 26, 18, 2);
scene.add(handLight);

const scare = new THREE.Vector3(0, 1, 0);
const scareTarget = new THREE.Vector3();
const ndc = new THREE.Vector2(0, 0);
const raycaster = new THREE.Raycaster();
let userActive = false, handPulse = 0, nextAutoFood = 2.2;
const FLEE_R = 5.4;

function updateScareTarget(t){
  if (userActive){
    raycaster.setFromCamera(ndc, camera);
    const o = raycaster.ray.origin, d = raycaster.ray.direction;
    if (Math.abs(d.z) > 1e-4){
      const k = (0 - o.z) / d.z;
      if (k > 0) scareTarget.copy(o).addScaledVector(d, k);
    }
  } else {
    /* phantom hand: slow Lissajous sweep through the tank */
    scareTarget.set(
      Math.sin(t*0.50)*8.0 + Math.sin(t*1.13)*2.5,
      1.6 + Math.sin(t*0.77 + 1.0)*3.2,
      Math.sin(t*0.36 + 2.0)*4.0
    );
  }
  scareTarget.x = THREE.MathUtils.clamp(scareTarget.x, -BX+1, BX-1);
  scareTarget.y = THREE.MathUtils.clamp(scareTarget.y, -5.2, 8.2);
  scareTarget.z = THREE.MathUtils.clamp(scareTarget.z, -7, 7);
}

function markUser(){
  if (userActive) return;
  userActive = true;
  document.body.classList.add('touched');
  setTimeout(() => document.getElementById('pill').classList.add('fade'), 3800);
}
addEventListener('pointermove', (e) => {
  ndc.set((e.clientX/innerWidth)*2 - 1, -((e.clientY/innerHeight)*2 - 1));
  markUser();
});
addEventListener('pointerdown', (e) => {
  ndc.set((e.clientX/innerWidth)*2 - 1, -((e.clientY/innerHeight)*2 - 1));
  markUser();
  updateScareTarget(clock.elapsedTime);
  scare.copy(scareTarget);
  dropFood(scare);
  handPulse = 1.6;
});
addEventListener('keydown', () => { markUser(); dropFood(scare); });

/* ============ spawn the fish ============ */
const fishes = [];
function randomTarget(f){
  f.target.set(rr(-BX+2, BX-2), rr(-4.8, 5.5), rr(-7, 7));
  f.retarget = rr(3, 8);
}
for (const sp of SPECIES){
  for (let i = 0; i < sp.count; i++){
    const f = makeFish(sp);
    f.sp = sp;
    f.pos = new THREE.Vector3(rr(-BX*0.7, BX*0.7), rr(-4, 5), rr(-6, 6));
    f.vel = new THREE.Vector3(rr(-1,1), rr(-0.2,0.2), rr(-1,1)).normalize().multiplyScalar(sp.speed*0.5);
    f.fear = 0; f.phase = rand()*Math.PI*2; f.prevYaw = 0; f.bank = 0;
    f.sepR = sp.size*1.15 + 0.4;
    f.target = new THREE.Vector3();
    randomTarget(f);
    f.outer.position.copy(f.pos);
    scene.add(f.outer);
    fishes.push(f);
  }
}

/* ============ fish behaviour ============ */
const _a = new THREE.Vector3(), _v1 = new THREE.Vector3(),
      _v2 = new THREE.Vector3(), _v3 = new THREE.Vector3(),
      _o  = new THREE.Object3D();

function updateFish(dt, t){
  let heat = 0;
  for (let i = 0; i < fishes.length; i++){
    const f = fishes[i], sp = f.sp, p = f.pos;

    f.fear *= Math.exp(-dt*0.65);
    f.retarget -= dt;
    if (f.retarget <= 0 || p.distanceToSquared(f.target) < 1.44) randomTarget(f);

    _a.set(0, 0, 0);

    /* wander */
    _v1.subVectors(f.target, p);
    const dl = _v1.length();
    if (dl > 0.001) _a.addScaledVector(_v1, sp.wander * (1 - f.fear*0.85) / dl);

    /* neighbours: separation + light schooling within a species */
    for (let j = 0; j < fishes.length; j++){
      if (j === i) continue;
      const g = fishes[j];
      _v2.subVectors(p, g.pos);
      const d = _v2.length();
      if (d < 0.001) continue;
      const sepR = f.sepR + g.sepR;
      if (d < sepR) _a.addScaledVector(_v2, 5.5*(1 - d/sepR)/d);
      if (g.sp === sp && d < 6.5){
        _a.addScaledVector(_v2, -sp.coh*0.02);
        _v3.subVectors(g.vel, f.vel);
        _a.addScaledVector(_v3, sp.ali*0.03);
      }
    }

    /* flee the hand — this is the heart of the piece */
    _v1.subVectors(p, scare);
    const ds = _v1.length();
    if (ds < FLEE_R){
      const fall = 1 - ds/FLEE_R;
      if (ds > 0.001) _a.addScaledVector(_v1, 17*fall*fall/ds);
      f.fear = Math.min(1, f.fear + fall*dt*4.5);
      heat = Math.max(heat, fall);
    }

    /* food — but not while panicked */
    let best = -1, bestD = 100;
    for (let k = 0; k < foods.length; k++){
      const fd = foods[k];
      if (!fd.active) continue;
      const d = p.distanceTo(fd.pos);
      if (d < bestD){ bestD = d; best = k; }
    }
    if (best >= 0 && bestD < 11){
      const fd = foods[best];
      if (bestD < 0.55*sp.size + 0.3){
        fd.active = false; fd.pos.y = -999;
        f.fear = 0;
      } else {
        _v1.subVectors(fd.pos, p).normalize();
        _a.addScaledVector(_v1, 2.8*(1 - f.fear));
      }
    }

    /* soft tank walls */
    const M = 2.2, K = 9;
    if (p.x >  BX-M) _a.x -= (p.x - (BX-M))*K;
    if (p.x < -BX+M) _a.x += ((-BX+M) - p.x)*K;
    if (p.y >  6.2)  _a.y -= (p.y - 6.2)*K;
    if (p.y < -5.8)  _a.y += (-5.8 - p.y)*K;
    if (p.z >  7.3)  _a.z -= (p.z - 7.3)*K;
    if (p.z < -7.3)  _a.z += ((-7.3) - p.z)*K;

    /* integrate */
    f.vel.addScaledVector(_a, dt);
    f.vel.multiplyScalar(Math.max(0, 1 - 0.35*dt));
    const maxS = sp.speed * (1 + f.fear*1.5);
    let s = f.vel.length();
    if (s > maxS){ f.vel.multiplyScalar(maxS/s); s = maxS; }
    const minS = sp.speed*0.35;
    if (s < minS){
      if (s < 0.001) f.vel.set(rr(-1,1), 0, rr(-1,1));
      else f.vel.multiplyScalar(minS/s);
    }
    p.addScaledVector(f.vel, dt);

    /* orientation + banking into turns */
    _v1.copy(f.vel);
    if (Math.abs(_v1.x) + Math.abs(_v1.z) < 0.02) _v1.x += 0.05;
    _o.position.copy(p);
    _o.lookAt(_v1.add(p));
    f.outer.quaternion.slerp(_o.quaternion, Math.min(1, dt*3.2));
    const yaw = Math.atan2(f.vel.x, f.vel.z);
    let dy = yaw - f.prevYaw;
    dy = Math.atan2(Math.sin(dy), Math.cos(dy));
    f.prevYaw = yaw;
    const bankT = THREE.MathUtils.clamp(-dy/Math.max(dt, 0.001)*0.22, -0.65, 0.65);
    f.bank += (bankT - f.bank)*Math.min(1, dt*3);
    f.outer.rotateZ(f.bank);
    f.outer.position.copy(p);

    /* tail kick & fin flutter, faster when frightened */
    const wag = 5 + s*2.1 + f.fear*7;
    f.tail.rotation.y = Math.sin(t*wag + f.phase) * (0.38 + 0.25*f.fear);
    f.inner.rotation.y = Math.sin(t*wag*0.5 + f.phase) * 0.05;
    const flap = Math.sin(t*4.2 + f.phase)*0.22;
    f.pectR.rotation.x = 0.55 + flap;
    f.pectL.rotation.x = 0.55 - flap;
  }
  return heat;
}

/* ============ main loop ============ */
renderer.setAnimationLoop(() => {
  const dt = Math.min(clock.getDelta(), 0.05);
  const t = clock.elapsedTime;
  uTime.value = t;

  /* camera: gentle drift in demo mode, parallax once the user takes over */
  const cx = userActive ? ndc.x*2.3 : Math.sin(t*0.11)*1.6;
  const cy = userActive ? 1.6 + ndc.y*1.1 : 1.6 + Math.sin(t*0.17 + 1.2)*0.6;
  camera.position.x += (cx - camera.position.x)*Math.min(1, dt*2.2);
  camera.position.y += (cy - camera.position.y)*Math.min(1, dt*2.2);
  camera.lookAt(0, 0.2, 0);

  updateScareTarget(t);
  scare.lerp(scareTarget, 1 - Math.exp(-dt*7));

  if (!userActive && t > nextAutoFood){ dropFood(scareTarget); nextAutoFood = t + rr(7, 11); }

  updateFood(dt, t);
  const heat = updateFish(dt, t);

  handPulse *= Math.exp(-dt*2.6);
  hand.position.copy(scare);
  hand.material.opacity = 0.30 + heat*0.45 + handPulse*0.2;
  hand.scale.setScalar(2.6 + heat*1.1 + handPulse*1.6 + Math.sin(t*2.6)*0.14);
  handLight.position.set(scare.x, scare.y + 0.9, scare.z + 3);
  handLight.intensity = 22 + heat*45 + handPulse*30;

  updateBubbles(dt, t);

  for (const s of shafts){
    s.rotation.z = s.userData.baseRot + Math.sin(t*0.14 + s.userData.ph)*0.055;
    s.material.opacity = s.userData.baseOp * (0.75 + 0.35*Math.sin(t*0.4 + s.userData.ph*2.0));
  }

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

addEventListener('resize', () => {
  camera.aspect = innerWidth/innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
  computeBounds();
});
</script>
</body>
</html>
Reasoning trace (47,736 tokens)
Let me build a 3D aquarium with Three.js using the import map. Requirements:
- Single self-contained HTML file
- Import map for Three.js 0.170.0
- Fish swim away when mouse moves near them (raycasting / projecting mouse to 3D, fish within radius flee)
- Must look good at 1280×800, adapt to 375px
- Something meaningful visible on first paint (screenshot at ~3s)
- No external files except the allowed CDN
- No console errors

Design plan:
- Underwater scene: fog, blue-green gradient background, god rays maybe via light shafts (simple planes), caustics on sand, bubbles, seaweed swaying, rocks, coral.
- Fish: procedurally built from cones/spheres, or a custom geometry with body + tail fin, animated tail wiggle via vertex shader or just rotating tail meshes. Simpler: each fish is a group with body (scaled sphere or lathe), tail fin (cone/plane) that oscillates, dorsal fin, eye. Boids-like behavior: wander + flee from mouse cursor point (unprojected to z-plane in tank), plus tank bounds containment.
- Mouse interaction: on mousemove, project mouse into 3D at a certain depth (e.g., plane at fish average z or intersect an invisible plane facing camera). Compute a "hand" position; fish within radius get fleeing force away from it.
- Show a glowing indicator at the hand position so interaction is visible.
- OrbitControls maybe? Could allow drag to look around — but mouse move is the interaction. I can add subtle camera parallax with mouse. Keep camera fixed with slight parallax; the mouse is the "predator". Actually adding OrbitControls would conflict. I'll do camera parallax based on mouse, plus the scare point. Hmm, if camera moves with mouse, the unprojection should use the actual camera. I'll unproject each frame with current camera — fine.

Better: The "scare" point: raycast from mouse through camera into the tank, place a point at fixed distance or intersect with plane z=0 (mid tank). Fish flee from that 3D point.

Demo mode: "Games must start in a self-playing demo mode" — this is not a game, but a nice touch: an "auto hand" that wanders around scaring fish before user interaction? The requirement says games must start in demo mode. This is an interactive demo, not really a game with score. But to be safe and interesting: I'll have an invisible "phantom hand" that moves around in auto mode until the user moves the mouse, showing fish scattering on first paint. That makes the first screenshot lively. Show a subtle hint message "Move your mouse to scare the fish" that fades.

Actually a wandering scare point could keep fish perpetually panicked. Make it gentle: a slow-moving point, or periodic ripples. Let me make the auto-hand move in a smooth Lissajous path, moderately. Fish scatter visibly. On first user mousemove, switch to real control.

Fish behavior (boids-lite):
- Each fish: position, velocity, wander noise, flee force from scare point (strong, within radius ~6 units), tank boundary containment (soft push), slight schooling (cohesion/alignment optional — with 2-3 species, maybe per-species schooling light).
- Orientation: fish looks along velocity, with banking on turns.
- Tail animation: tail wag speed proportional to speed.

Visuals:
- Background: large gradient via scene background — use a canvas texture gradient or fog + colored background. Use scene.fog = new THREE.FogExp2(deep blue, density). Background color deep teal.
- Water surface: a plane at top with animated vertices or a shader — simple: semi-transparent plane with moving normal-ish shimmer. Could do a shader plane with sine waves, viewed from below, giving shimmer. Keep it simple: PlaneGeometry with many segments, animate vertices in JS or via onBeforeCompile. Use a custom ShaderMaterial for water surface with caustic-like pattern and transparency.
- Light shafts: a few additive-blended planes/cones from top, slowly swaying, very subtle.
- Sand floor: plane with noise displacement, sandy color, receive light.
- Rocks: dodecahedrons with flat shading, dark.
- Seaweed: cylinders/planes swaying via shader (bend by sin(time + y)). I'll do custom shader for seaweed blades: plane geometry, bend based on uv.y. Or simpler: stack of segments rotated. Shader bend is nicer: position.x += sin(time*speed + phase + uv.y*3) * uv.y^2 * amp.
- Bubbles: small spheres rising, reset at bottom. Or points with texture. Sprites with additive material; simple circles drawn via canvas texture.
- Caustics on sand: could use a moving light or projected texture. Simple: a MeshStandardMaterial sand with an animated spotlight? Cheaper: overlay a plane just above sand with additive caustic shader (voronoi-ish using sin combos). I'll add a subtle caustics shader plane.

Fish geometry: Build a decent-looking fish from primitives:
- Body: LatheGeometry or scaled sphere (scale (1, 0.55, 0.35)) — sphere scaled gives ellipsoid; use SphereGeometry scaled. Add slight taper by using a custom profile: easier with LatheGeometry rotated... Lathe is around Y axis; I'd rotate to Z. Alternative: use a "fish body" via SphereGeometry with vertex modification to taper tail. Let's just do: body = sphere scaled (1.1, 0.6, 0.45); tail = cone (flattened) at back; dorsal fin = flattened cone/triangle on top; side fins = small planes; eyes = small spheres black + white ring.

Better looking: create fish body via LatheGeometry with a profile: points from nose (radius 0) to tail (radius ~0.05), max radius mid. Rotate lathe (Y-axis) so it lies along X axis: lathe revolves around Y; I need axis along X, so rotate mesh z by -90°? Lathe axis vertical (Y). If I rotate the geometry rotateZ(Math.PI/2), the axis becomes X. Then scale Y (vertical) vs Z (width) differently: scale(1, 1, 0.6) to flatten. Profile: radius as function of t along length: r(t) = sin(pi * t^0.8) * maxR roughly, tapering at both ends. Tail: attach a flattened cone or custom triangle plane at x=-length/2.

Tail fin wag: rotate tail around Y axis with sin(time * wagSpeed). Also body slight bend: skip, just tail and fins.

Coloring: 3 species with different colors/sizes:
- Orange clownfish-ish (orange with white band? Could add stripe via texture — maybe a canvas texture with stripes! Nice touch: generate striped texture on canvas, apply to body). Yes: canvas texture per species — stripes/bands. That elevates quality.
- Blue tang-ish (blue with yellow tail).
- Silver/teal school of small fish.

Species school: small silver fish in a group of ~12, plus 6 medium, plus 2 large. Total ~25 fish. Performance fine.

Lighting: HemisphereLight (top bluish-white, bottom dark), DirectionalLight from above with shadows? Shadows may be costly but okay with low-res map. Let's enable soft shadows: directional light from above, sand receives, fish cast? Fish shadows on sand would be nice. Shadow map 1024, small area. Should be fine.

Post-processing: maybe skip (keeps things simple, avoid composer). Use renderer with tone mapping ACES.

Water tint: fog exp2 color #06384a density 0.028. Background color slightly lighter at top? Can't gradient easily without skybox; use scene.background = color and rely on fog. Or make a big gradient backdrop sphere with shader (top light aqua -> bottom deep). A large inverted sphere/box with gradient shader material, fog=false. That looks nicer. I'll do a big sphere (BackSide) with vertical gradient shader.

Mouse indicator: a soft glowing "hand shadow"/cursor ring where the scare point is — a small point light plus a ring sprite? Use a PointLight (white-blue) attached to scare point, so fish area illuminates — makes the interaction perceivable. Plus a soft sprite (radial gradient canvas texture, additive) at scare point. Subtle, like a diver's hand light. 

UI: Title overlay "Aquarium" with instructions, minimal elegant typography. Use a nice font? No external fonts allowed except CDN... fonts via Google Fonts are external network requests — the rules say no external files/fonts except CDN URLs explicitly allowed by the task. So use system font stack but styled nicely (letter-spacing, weight). Fine — use "Georgia/serif" or system sans with tracking. I'll design a minimal overlay: top-left title "The Reef" small caps, bottom hint "Move your cursor — the fish are watching" that fades after interaction. Also a small counter or legend? Keep minimal.

Also add click = ripple/food drop? Extra: click to drop food pellets that fish swim toward and eat! That adds meaningful interaction. But task is about fish swimming away from mouse. Adding food on click is a nice bonus. Let's do: click drops food pellets at scare point (upper area), fish are attracted to food but still flee from cursor — creates playful tension. Implement: food array of small particles sinking slowly; fish within sense radius steer toward nearest food; when close, food is eaten (remove) and fish gets brief "nibble" speed. Keep code manageable.

Hmm, scope: keep it focused but polished. I'll include food — it's ~40 lines.

Also mobile: touch events — pointermove works for touch too (pointer events). Use pointermove.

Camera: fixed position (0, 2, 26) looking at (0, 0, 0), with subtle parallax: camera.x = mouse.x*1.5 etc. But then the scare point should be recomputed — it's recomputed each frame from stored NDC mouse coords, fine.

Tank dimensions: invisible bounds x:[-16,16], y:[-7,8], z:[-9,9]. Sand at y=-7.

Scare point depth: intersect mouse ray with plane z = 0? If camera at z=26, ray through mouse NDC; intersect with plane z=0 gives a point. But fish span z range; use plane z=0 and radius 3D distance; fish far in z less scared — actually good, feels 3D. But fish deep behind might never be reachable... z from -9..9, scare at z=0, radius 7 in 3D covers most. Fine. Alternatively intersect with the ray at multiple depths. Keep plane z=0, radius ~5.5, plus falloff.

Flee force: dir = fishPos - scarePoint; dist; if dist < radius: force = dir.normalized * strength * (1 - dist/radius)^1.5 * escapeBoost. Also add panic: increase max speed temporarily (fear factor decays).

Wander: each fish has a target point that changes periodically; steer toward target with weak force. Plus slight vertical bobbing. Plus cohesion within species (light) and separation (always) to avoid overlap. With 25 fish, O(n²)=625 checks per frame — fine.

Containment: soft force pushing inside bounds when within margin.

Orientation: use quaternion lookAt along velocity: m.lookAt or fish.lookAt(pos + vel). Fish model built facing +X; lookAt aligns +Z... I'll build fish facing -Z or rotate geometry so head points +Z, then use Object3D.lookAt(target). Actually easiest: build fish with head toward +X, then set group.rotation.y = Math.atan2(-vel.z? ...). Let me just compute yaw/pitch:
- yaw = Math.atan2(vel.x, vel.z) gives rotation.y for a model facing +Z. Build fish head toward +Z then. Tail at -Z. Fine, build along Z: body lathe axis along Z, tail at negative z.

Lathe: create lathe around Y then rotateX(Math.PI/2)? Lathe axis Y; rotateX(90°) maps Y->Z? rotateX(π/2): y->z? Rotation about X by +90°: y->z, z->-y. So point (0,1,0) -> (0,0,1)? (x, y cosθ - z sinθ, y sinθ + z cosθ) with θ=90°: (x, -z, y). So (0,1,0)->(0,0,1). Yes, geometry.rotateX(Math.PI/2) puts lathe axis along +Z with head (top of profile) at +Z if profile starts at y max. Good.

Body profile: length L ~1 for unit fish, scaled per fish. Profile points (y from tail to nose): y in [-0.5, 0.5], radius r(y): 
r = 0.5 * sin(π * (t)) ^0.7-ish where t = (y+0.5). At t=0 r=0 (tail point), t=1 r=0 (nose). But nose shouldn't be pointy-zero... slightly rounded nose: use r = maxR * pow(sin(π*t), 0.6) gives fuller body; endpoints still zero — fine for stylized fish, nose slightly blunt by adding a small sphere? It's okay — with smooth shading looks like a fish.

Actually a cuter approach used commonly: r(t) = maxR * (sin(pi*t)^0.8) with more bulge toward front: t' = pow(t, 0.8) shifts bulge forward. Good.

UV for lathe: u around, v along — stripes: I want vertical bands along length. v runs along profile length. So texture with horizontal stripes in v direction = bands along body. Canvas texture: draw stripes as horizontal bands? For LatheGeometry, v goes along the profile (length), u around circumference. So stripes across body = vary with v = texture y. Draw canvas with stripes along y. Good.

Tail fin: custom shape geometry (triangle-ish) — use THREE.Shape with points, ShapeGeometry, DoubleSide, positioned at tail. Wag: rotate around Y through pivot at tail joint: create tailGroup at tail position, fin geometry offset behind pivot.

Dorsal fin: shape geometry placed on top, in x=0 plane... Fish faces +Z, up is +Y; dorsal fin plane should be in the Y-Z plane (x=0). ShapeGeometry lies in XY plane; rotate to be in ZY plane: rotateY(90°). Similarly tail fin is vertical plane in Y-Z too (x=0), tail extends -Z. Shape drawn in XY then rotateY(π/2) maps x-> -z? Rotation about Y by 90°: x->? (x cosθ + z sinθ, y, -x sinθ + z cosθ), θ=90°: (z, y, -x). So drawing shape with x negative extends to... point (x,y,0) -> (0, y, -x). So drawing tail shape with x from 0 to +1 extends z from 0 to -1 (backwards). Good: draw tail shape with positive x = backward, after rotation it goes -Z. Wait (x>0) -> z=-x < 0. Yes backward. 

Tail shape: start (0, 0.12) top, curve out to (0.42, 0.28), notch to (0.32, 0), to (0.42,-0.26), back to (0,-0.1). Like a fishtail silhouette. Fine.

Pectoral fins: small shape planes near head sides, angled, oscillate slightly.

Fish colors: 
- Species A "Sunburst tang": body orange-coral with darker stripes, tail yellow.
- Species B "Blue tang": deep blue body, yellow tail.
- Species C "Silver sprat": small, silvery with faint teal stripe, big school.

Use MeshStandardMaterial with the canvas texture, roughness 0.5, metalness slight for silver.

Number: 8 orange, 6 blue, 14 silver = 28 fish. n² = 784 fine.

Shadows: fish cast shadow — with 28 fish each with a few meshes, shadow rendering doubles draw calls (~200). Fine. Sand receives. Directional light shadow camera covering tank.

Water surface shader plane at y=9: custom ShaderMaterial, transparent, with animated normals fake — simplest: color aqua with fresnel-ish and moving sine "sparkle" pattern; DoubleSide so visible from below. I'll write a small shader: mixes deep blue to light cyan by fresnel + adds specular glints using sin bands. Keep subtle, opacity 0.5, blending normal.

Light shafts: 4-6 planes from surface to floor, additive, alpha low, slowly rotating/swaying, with vertical gradient alpha (canvas texture radial/linear). Simple: PlaneGeometry(width 2.5, height 17), material with custom canvas gradient texture (white top fading), blending: AdditiveBlending, opacity 0.06-0.12, depthWrite false. Sway rotation.z slight sin. Nice.

Bubbles: ~40 sprites (or points). Use THREE.Points with a canvas circle texture, positions animated upward with wobble; reset at top/bottom. Point size attenuation. Additive-ish, low opacity.

Caustics on sand: plane just above sand (y=-6.98) with additive shader: pattern = sum of sines / pseudo-voronoi. Write fragment: use classic caustic approximation:
c = pow(abs(sin(...)*sin(...)), ...) — I'll use a simple layered sine interference moving with time, mask by radial falloff. Opacity low (0.15). Good enough.

Actually simpler well-known fake caustics: 
float caust(vec2 uv, float t){ ... iterative sin kaleidoscope } — there's a famous compact caustic shader (by David Hoskins-ish). I'll write a compact one:

```
vec2 p = mod(uv*TAU, TAU)-250.0; // classic
```
That's the "water caustic" from shadertoy (TDM's). Might be heavy-ish but one plane, fine. I'll implement a simplified 3-iteration version. Let me recall TDM's caustic:

```
vec2 p = mod(uv*TAU, TAU)-250.0;
vec2 i = vec2(p);
float c = 1.0;
float inten = .005;
for (int n = 0; n < MAX_ITER; n++) {
    float t = time * (1.0 - (3.5 / float(n+1)));
    i = p + vec2(cos(t - i.x) + sin(t + i.y), sin(t - i.y) + cos(t + i.x));
    c += 1.0/length(vec2(p.x / (sin(i.x+t)/inten), p.y / (cos(i.y+t)/inten)));
}
c /= float(MAX_ITER);
c = 1.17-pow(c, 1.4);
vec3 colour = vec3(pow(abs(c), 8.0));
```
I'll implement with 4 iterations, scale to plane, tint cyan-white. It's a known common shader — using standard technique is fine.

Sand: PlaneGeometry rotated -90°, with vertex noise displacement (radial-ish dunes), MeshStandardMaterial color #d9c7a0 tinted underwater... with fog it'll tint. Add some small stones.

Rocks: 5-7 dodecahedron/icosahedron with random scale, dark gray-green, flatShading, near back and sides. Coral: a few branching? Could make simple coral with cones cluster in coral pink/orange. A couple of "tube corals" (cylinders cluster) and a "brain rock". Keep modest.

Seaweed/kelp: 12-18 blades: PlaneGeometry(0.35, height 4-8, 1, 12) with custom vertex shader bending: 
pos.x += (sin(t*speed + phase + pos.y*0.5) ) * pow(uv.y,2.0) * amp;
color gradient green by uv.y. Use ShaderMaterial with side DoubleSide, slight transparency? Opaque is fine. Actually to use uv in vertex shader with onBeforeCompile of MeshStandardMaterial for lighting... simpler: MeshStandardMaterial + onBeforeCompile injection to displace — gets lighting. Let me do onBeforeCompile: add uniform time; in vertex shader, after #include <begin_vertex>, add transformed.x += bend. Need varying? Just use position.y directly (local y from 0..height since geometry translated so base at 0). uv available via vUv only in fragment; in vertex we have 'uv' attribute available in ShaderLib? In onBeforeCompile the vertex shader includes attributes uv (defined when map used?) — uv attribute is always declared in common vertex? Actually attributes uv are declared in the shader for all materials via #include <uv_pars_vertex> only when defines... Safer: compute factor from position.y / uHeight uniform per blade — pass height as uniform. Good, avoids uv.

Group seaweed per patch, place several patches.

Also foreground dark rocks at bottom edges to frame.

Camera: PerspectiveCamera fov 55, position (0, 1.5, 26). Parallax: camera.position.x = lerp toward mouse.x * 2.2, y = 1.5 + mouse.y * 1.2; lookAt(0, 0.5, 0).

Renderer: antialias, ACESFilmicToneMapping, exposure 1.1, outputColorSpace default SRGB, shadowMap PCFSoft, setPixelRatio min(devicePixelRatio, 2).

Hand sprite: radial gradient canvas texture, additive, scale pulsing, opacity rises when fish are near (or constant). Plus PointLight cyan intensity 30 distance 12 attached. The sprite follows scare point smoothed.

Auto/demo mode: before first pointermove, scare point follows Lissajous: 
sx = sin(t*0.5)*10, sy = sin(t*0.9+1)*4+1, z = sin(t*0.33)*5.
This is the "phantom hand" — visible sprite moves around, fish scatter. First screenshot shows action. When user moves pointer, take over (blend). Show hint chip bottom-center: "Move your cursor near the fish — they'll scatter" / "Click to drop food". After user interaction, hint fades.

Food on click (pointerdown): spawn 6-10 pellets at scare point with slight spread, they sink slowly with drift; fish seek nearest pellet within 10 units (attraction force), eat when within 0.5 (remove pellet, tiny particle? skip). Food also draws small brown dots (Points or small meshes — use small InstancedMesh? Simplicity: individual small sphere meshes with shared geometry/material, max ~60). Or use Points with per-point positions — easier to update. I'll use a Points cloud for food with dynamic positions, and eat by moving to y=-999. Points with round texture. Fine.

Hmm, but food as points: sizes ~0.15 world with attenuation. OK.

Eat logic: each frame, for each fish find nearest active pellet within 9; steer toward it with moderate force; if dist < 0.6: deactivate pellet (set position far away / mark inactive), fish fear 0 and gets small burst.

Edge case: pellets resting on sand: clamp y >= -6.7 and stop sinking; despawn after 25s.

UI text overlay: HTML absolutely positioned. Title top-left: "GLASSWATER" or "Aquarium №9"? Let's do something elegant: top-left small caps "T H E   S H A L L O W S" with subtitle "a living reef". Bottom-center hint pill. Bottom-right maybe fish count / "28 fish". Keep minimal. Vignette overlay via CSS radial-gradient (pointer-events none) to add depth. Also a subtle top light gradient.

Depth-of-field? Skip.

Performance: 28 fish × ~6 meshes = 168 draw calls + scenery ~40 = ~210, ×2 for shadows = 420. Should be OK on desktop. Could merge... fine. Actually shadow casting for fish: enable castShadow on body meshes only (body + tail), fins skip. Reduce.

Let me consider reducing silver fish to 12. Total 26.

Now write the code carefully.

Structure:

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Shallows — a living reef</title>
<style> ... </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>
<div id="ui"> title, hint </div>
<canvas? renderer appends>
<script type="module"> ... </script>
</body>
</html>
```

CSS: body margin 0, overflow hidden, background #04141d. Overlay text styles. Hint pill with backdrop-filter blur. Fade class.

JS module:

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

(No addons needed unless OrbitControls — I'm not using it. Then import map includes addons anyway per instructions — fine to keep unused import map entry.)

Scene setup, sizes from window with resize handler.

Gradient background: 
```js
const bg = new THREE.Mesh(new THREE.SphereGeometry(60, 32, 32), new THREE.ShaderMaterial({
  side: THREE.BackSide, depthWrite:false, fog: false,
  uniforms: { top: {value:new THREE.Color(0x2a7f8f)}, bottom:{value:new THREE.Color(0x03141d)} },
  vertexShader: varying vy from position normalized...
  fragment: mix(bottom, top, smoothstep(-0.35, 0.7, vDir.y))
}));
```
With fog on other objects colored 0x0a3a4a... fog color should match mid background ~ 0x0b4250. Use FogExp2(0x0b4a58, 0.02)? Tank depth ~ 40 from camera to far wall; density 0.02 gives factor at 30: exp(-(30*0.02)^2)= exp(-0.36)=0.7 visible — mild. I want stronger depth: 0.028: at 30: exp(-0.7)=0.5. Good. Actually FogExp2 density d: factor = 1 - exp(-(d*dist)^2)? Three's FogExp2: fogFactor = 1.0 - exp( - fogDensity * fogDensity * fogDepth * fogDepth ). Yes squared. d=0.028, dist 35 => (0.98)^2=0.96 -> exp(-0.96)=0.38 → 62% fogged. Good.

Lights:
- HemisphereLight(0x9fd8e8, 0x0a2430, 0.9)
- DirectionalLight(0xcfeef7, 2.2) pos (6, 18, 4), castShadow, shadow.camera left/right ±20, top 12 bottom -12, near 1 far 40, mapSize 2048.
- Ambient small? Hemisphere covers.
- PointLight for hand: color 0xbfe9ff, intensity 0 (animate to ~40 when active), distance 14, decay 2. In three 0.170, lights use physical units; intensity for PointLight with decay 2: intensity is candela-ish. With renderer.useLegacyLights default false in modern three, point light intensity 40 at distance 3 gives 40/9≈4.4 — visible. Let me set ~60.

Sand:
```js
const sandGeo = new THREE.PlaneGeometry(70, 46, 64, 40); rotateX(-PI/2)
displace: for each vertex, y = noise-ish: combination of sines + random-ish but deterministic: use pseudo-noise function n(x,z) = sin(x*0.35+..)*.. + sin(z*0.5+..)*.. ; plus small random via seeded rand.
```
I'll write a small seeded PRNG (mulberry32) for determinism.

Material: MeshStandardMaterial color 0xcbb794, roughness 1. receiveShadow. Fog applies — good. Maybe vertexColors for subtle variation: skip.

Rocks: function makeRock(scale): IcosahedronGeometry(scale, 1) with vertices randomly perturbed, MeshStandardMaterial color 0x4a5a55 flatShading, roughness .9. Place ~8 around back/sides, castShadow, receiveShadow.

Coral: cluster of cones (ConeGeometry) in pink 0xd96a5a and tube coral (cylinders bent? just tilted cylinders) in 0xc9a2b8? Add 2-3 coral clusters: group of 5-9 cones varied heights from a base point, colors coral/rose. And maybe a fan coral: plane with alpha? skip.

Seaweed patches: function makeKelp(patchX, patchZ, count). Each blade: geometry PlaneGeometry(w, h, 1, 10), translate y h/2 (base at 0). Material per patch (shared but with uniforms time & phase & amp per blade — need per-blade uniforms → separate material per blade is expensive but ~24 blades okay; or store phase/amp as attributes... simplest: clone material per blade with own uniforms; 24 materials fine).

onBeforeCompile injection:

```js
mat.onBeforeCompile = (shader) => {
  shader.uniforms.uTime = uniforms time shared object;
  shader.uniforms.uPhase = {value: phase};
  shader.uniforms.uAmp = {value: amp};
  shader.uniforms.uH = {value: h};
  shader.vertexShader = shader.vertexShader
    .replace('#include <common>', '#include <common>\nuniform float uTime; uniform float uPhase; uniform float uAmp; uniform float uH;')
    .replace('#include <begin_vertex>', `#include <begin_vertex>
      float k = clamp(position.y / uH, 0.0, 1.0);
      float sway = sin(uTime*1.2 + uPhase + k*2.0) * uAmp * k*k;
      transformed.x += sway;
      transformed.z += cos(uTime*0.9 + uPhase*1.3 + k*1.5) * uAmp * 0.6 * k*k;
    `);
};
```
Careful: shadow depth material won't have displacement — acceptable (kelp shadows static; or disable kelp castShadow to avoid mismatch — disable).

Blades color: gradient from deep green base to yellow-green top: use vertexColors? Set vertex colors by y: iterate geometry positions, color = lerp(0x123f24, 0x3f8f3a, k) plus slight random. Set material vertexColors: true. Good. MeshStandardMaterial roughness .8.

Wait, but begin_vertex displacement happens in local space; blade group rotated/positioned — fine.

Also need normals ok — flat plane normal (0,0,1) local; lighting fine.

Water surface: plane 90×60 at y=9.5, segments... ShaderMaterial:

```
uniforms: uTime
vertex: pass world pos, slight wave displacement y += sin(x*0.3+t)*0.15 (plane is horizontal, displace along normal? plane rotated -90 x so local z is up; use position before rotation? Easier: create PlaneGeometry and rotateX(-PI/2) in geometry, then in shader displace transformed.y. With ShaderMaterial custom, write full shader:

vertex:
varying vec3 vWorld;
uniform float uTime;
void main(){
  vec3 p = position;
  p.y += sin(p.x*0.25 + uTime*0.8)*0.25 + cos(p.z*0.3 + uTime*0.6)*0.2;
  vec4 wp = modelMatrix * vec4(p,1.0);
  vWorld = wp.xyz;
  gl_Position = projectionMatrix * viewMatrix * wp;
}

fragment:
uniform float uTime; varying vec3 vWorld;
// pattern of sparkles
void main(){
  vec2 q = vWorld.xz;
  float w = sin(q.x*1.4 + uTime*1.5)*sin(q.y*1.7 - uTime*1.1);
  w += 0.5*sin(q.x*3.1 - uTime*2.0)*sin(q.y*2.3 + uTime*1.4);
  float glint = smoothstep(0.55, 0.95, w);
  vec3 col = mix(vec3(0.10,0.45,0.50), vec3(0.75,0.95,1.0), glint);
  float alpha = 0.35 + glint*0.5;
  gl_FragColor = vec4(col, alpha);
}
```
transparent: true, side: DoubleSide, depthWrite: false. From below, plane visible. Add fog? ShaderMaterial ignores fog unless included; fine — surface far edges should fade... Add manual fade by distance from camera: alpha *= smoothstep(60, 20, dist). Compute dist in vertex varying. OK add.

Light shafts: canvas texture 64×256 vertical gradient white->transparent; plane (3, 18) positioned x in [-10..10], top at y 9: geometry translate y -9 so top at 0? Plane center at y=1, height 18 → spans -8..10. material: map: grad, color 0x9fe8ff, transparent, opacity .07, blending Additive, depthWrite false, side DoubleSide. Animate: rotation.z = sin(t*0.2+i)*0.06; opacity flicker slightly. Also make shafts slightly tilted (rotation.z base 0.05 varied). 5 shafts. Also they should be positioned behind/around, not blocking view too much. Additive with opacity 0.06-0.1 subtle.

Order/renderOrder: transparent objects sorted automatically; set shafts renderOrder 2.

Bubbles: THREE.Points, ~50, positions random in tank column area near rocks; velocities up with wobble; texture: canvas radial white ring; material: PointsMaterial size 0.35, map, transparent, opacity 0.5, depthWrite false, blending additive slight. Update in loop: y += speed; x += sin(t*2+ i)*0.003; if y > 9 reset to near bottom at random x,z (near a "vent" spot? random spots fine, or 2 emitter spots near rocks — cute). Use 2 emitters.

Fish creation:

```js
const species = [
 { name:'garibaldi', count:8, size:1.0, speed:3.2, body:'#e86a2a'... stripe pattern canvas, finColor, tailColor }
 ...
]
```

Texture generation: canvas 256×128, fill base color, draw stripes: for species A: 3 white vertical bands (bands along v: canvas y axis? v corresponds to canvas y). LatheGeometry uv: v along profile segments (from first point to last). Profile from tail (y=-0.5) to nose (0.5)? I'll construct profile points ordered from nose to tail or tail to nose — v follows order. Doesn't matter much; stripes symmetric-ish. For band placement: draw 2-3 horizontal stripes on canvas (since canvas y maps to v). Also add belly gradient lighter bottom: but belly vs top corresponds to u (around circumference) — canvas x maps to u (around). So gradient along x = around body: can make top darker/back lighter? u=0 at some seam... Light belly: darker on top half of circumference. u goes 0..1 around; belly is around u≈0.25 or 0.75? Unknown seam orientation. Simpler: skip belly shading; add stripes only, rely on lighting.

Silver: base #b8c8cc with faint dark top stripes and a horizontal? Sprat: silvery with blue-green sheen: base gradient along v (darker back... v is length not vertical). Hmm. Just give silver slight reflective metalness 0.6, roughness 0.35, color #aebfc6, plus one dark stripe along length: stripe along v = constant u band = vertical line on canvas x at center? A line along length means for all v, u in narrow range — vertical stripe in canvas (x narrow, full y). Draw thin dark vertical stripe at x=0.5w? But around circumference where's that... meh. Sprat lateral line: skip precise; instead speckle noise. Fine — simple noise dots.

Keep textures: 
- orange fish: base #e07020, white bands (3) with dark thin edges — clownfish-like.
- blue tang: base #2456d8 body with darker markings, tail fin yellow (#ffd21f separate material).
- silver: #b7c4c9 + subtle noise + darker back? Let's also add u-based shading experiment-free.

Eyes: two small spheres: white sclera? For stylized: black sphere 0.06*size placed at head sides. Slight emissive? Just black MeshBasicMaterial-ish? Use MeshStandardMaterial color 0x111111.

Fish assembly function makeFish(species):
- body: LatheGeometry(profile, 20) — profile points 24. rotateX(PI/2) so axis along Z with nose +Z (first profile point at +?). Profile array from y=+0.55 (nose) to y=-0.55 (tail)? After rotateX(π/2), y maps to +z... point (0, y, 0) -> (0, -0? wait earlier: rotateX(θ=π/2): (x, y, z) -> (x, y cos - z sin, y sin + z cos) = (x, -z, y). So (0, y, 0) -> (0, 0, y). So profile y=+0.55 → z=+0.55 = nose at +Z. Good: build profile from nose (y=0.55, r≈0.02) down to tail (y=-0.55, r=0.02).

Radius profile: t = (0.55 - y)/1.1 ∈ [0 nose .. 1 tail]. r = 0.5 * pow(sin(π * pow(t, ... )), ...) Let me define: r(t) = 0.5 * sin(π * t')^0.9 where t' = mix(t, t*t, 0.35)? To bias bulge toward nose (t small): t' = pow(t, 0.8)? pow(t,0.8) > t for t<1 → shifts bulge toward tail... sin(π t') peaks at t'=0.5 → t = 0.5^(1/0.8)=0.42 — toward nose. Good: t' = pow(t, 0.75): peak at t=0.5^(1/0.75)=0.397. Bulge at 40% from nose — realistic.

Scale Z? Body flatten: after rotateX, the cross-section circle is in XY (x width, y height). Scale geometry: scale(0.55, 1, 1) → width narrower than height (fish are laterally compressed). And overall scale by species size.

- tail group at z = -0.5: ShapeGeometry tail; wag animation rotation.y = sin(phase)*0.5.
- dorsal fin: ShapeGeometry placed at top, plane x=0: shape drawn in XY (x along length? after rotateY(π/2) shape x→ -z). Draw dorsal silhouette with x from -0.45..0.45 (noseward positive?) After rotateY(π/2): (x,y,0)->(0? let's compute: rotY(θ=π/2): (x cos + z sin, y, -x sin + z cos) = (z, y, -x). So shape x=+0.3 → z=-0.3 (toward tail). So draw dorsal with negative x toward nose... or just rotateY(-π/2): (x cos - ... ) θ=-π/2: (x*0 + z*(-1)?? compute: cos(-90)=0, sin(-90)=-1: (x*0 + z*(-1), y, -x*(-1)+z*0) = (-z, y, x). Shape point (x,y,0) -> (0, y, x). x→z directly! rotateY(-π/2) maps +x to +z. 

So use geometry.rotateY(-Math.PI/2) for vertical median fins: draw with x from tail(-) to nose(+)... wait x→z means shape x positive = +z = toward nose. Dorsal fin sits on top (y positive), spanning z from -0.45..0.35. Draw shape in XY with x = z coordinates, y = height. Base along y≈0.14 (top of body ~0.28? body radius 0.5*height... body max radius 0.5 in profile units; cross-section: height = r (y dir unscaled = up to 0.5? wait I scale x by 0.55 for width; height stays = r max 0.5?? That's too thick relative to length 1.1. Hmm: profile radius max 0.5 → body height 1.0 vs length 1.1 — too fat. Set profile maxR = 0.32: height 0.64, width 0.64*0.55=0.35, length 1.1 — good proportions (like a tang).

So dorsal base y ≈ 0.25 where body top at mid... body top height = maxR=0.32 at bulge. Dorsal fin base line y from 0.15..0.3, fin height to 0.55. It'll intersect body — fine, embedded look ok.

- pectoral fins: small shapes at sides near z=+0.15, x=±0.14, rotated: plane roughly in XZ? Pectoral fin plane oriented diagonally: rotateY slight + rotateZ. Keep simple: small ellipse-ish shape, position (±0.16, -0.02, 0.18), rotation.y = ±0.9, flap: rotation.z? animate slight. Optional; include with simple animation skip. I'll add static angled fins with subtle flap via rotation.z sin per fish (store refs).

- eye: sphere r 0.05 at (±0.11, 0.08, 0.32). Compute width at that z: near nose-ish... body half-width at bulge = 0.32*0.55 = 0.176. At z=0.32 (t=(0.55-0.32)/1.1=0.209, t'=0.209^0.75≈0.316, sin(π*0.316)=0.84, r=0.32*0.84=0.27, halfwidth x = 0.27*0.55=0.148). Eye at x=±0.12, y=0.06 — embedded, protrudes slightly. r_eye=0.045.

Materials: bodyMat = MeshStandardMaterial({map: tex, roughness:0.55, metalness: species.metal}), finMat = MeshStandardMaterial(color: finColor, side:DoubleSide, roughness .6, transparent? fins slightly translucent: transparent true opacity 0.85, depthWrite true fine).

castShadow: body & tail only.

Fish object:
```js
{ group, vel: Vector3, target: Vector3, phase, species, fear:0, tail, pectL, pectR, wag }
```

Update per frame dt:
```js
// fear decay
f.fear *= exp(-dt*0.8) 
// forces
acc = 0
// wander target
if (f.pos.distanceTo(f.target) < 1.5 || f.retarget < now) pick new target random in bounds
seek(f.target, 0.6)
// schooling for silver (and mild for others): within same species: cohesion+alignment; all: separation
// flee scare point
d = pos - scare; dist; if dist < R (R = 5 + fear*?): strength = fleeK * (1-dist/R)^2; acc += dir/dist * strength; fear = min(1, fear + (1-dist/R)*dt*3)
// food attraction
// bounds containment: for each axis, if beyond bound-margin, push back proportional
// speed limit: maxSpeed = base*(1 + fear*1.3); vel += acc*dt; clamp speed; also min speed 0.5
pos += vel*dt
// orient
group.position.copy(pos)
// smooth orientation: desired quaternion from lookAt
tmpObj.position.copy(pos); tmpObj.lookAt(pos+vel); group.quaternion.slerp(tmpObj.quaternion, min(1, dt*4))
```
Careful: Object3D.lookAt makes -Z? No — lookAt orients the object's +Z? Three's lookAt points the object's local +Z axis toward... Actually for cameras -Z faces target; for regular objects +Z faces the target? Object3D.lookAt rotates so that its +Z? Documentation: "Rotates the object to face a point in world space" — for non-camera objects, the +Z axis points toward the target? Hmm: Object3D.lookAt uses Matrix4.lookAt which builds a matrix whose -Z? In three, Object3D.lookAt makes the object's positive Z axis point at the target (cameras look down -Z so they're special-cased — actually camera's lookAt points -Z toward target because Camera overrides? Let me recall implementation: Object3D.lookAt: if isCamera or isLight: m1.lookAt(position, target, up) else m1.lookAt(target, position, up). Matrix4.lookAt(eye, target, up) constructs Z = eye - target (so +Z away from target). For non-camera: lookAt(target, position, up): eye=target, target=position → z = target - position = direction toward target → object +Z faces target. Yes: non-camera objects face +Z toward target. Great — our fish head is +Z, so group.lookAt(pos + vel) works directly. But fish swimming mostly horizontally; when vel near vertical, up vector issues (roll). Use slerp with temp quaternion:

```js
_m.lookAt(_v1.copy(pos).add(vel), pos, up) ... 
```
Simpler: tmpGroup hidden: tmp.position.copy(pos); tmp.lookAt(pos.clone().add(vel)); q = tmp.quaternion. Then slerp. Also add banking: roll based on turn rate? Nice touch: compute lateral turn = angle change; apply extra rotation around Z after slerp: group.rotateZ(bank). Let me store smoothed yaw/pitch instead and compute roll from yaw rate:

Alternative simpler orientation: 
yaw = atan2(vel.x, vel.z); pitch = -asin(vel.y/len)? pitch = atan2(vel.y, horizLen) → rotation order YXZ: group.rotation.set(-pitch? Let me define: fish faces +Z. yaw around Y: yaw = Math.atan2(vel.x, vel.z). pitch: rotation.x = -Math.atan2(vel.y, sqrt(x²+z²))? For facing +Z, pitching up means rotating around X negative (since +X right, nose up = rotate -X). rotation.x = -pitch where pitch = atan2(vy, hLen). Then roll (rotation.z) from yaw rate: roll = clamp(-yawRate*0.5, ±0.6). Smooth with damped values: store smoothedYaw etc using lerp of angles (careful wrap — use shortest-angle lerp helper).

I'll implement with quaternions via temp object — cleaner and handles wrap. For banking: compute yawRate from vel change: prevYaw. Let me do: desired quaternion from lookAt; then apply roll: qRoll from axis (0,0,1) angle bank in local space: group.quaternion = qDesired * qRoll(slerped). Compute bank target = clamp((prevYaw - yaw)/dt * -0.3? Simplify: bank = clamp(-turnRate * 0.4, -0.7, 0.7), turnRate = wrappedDeltaYaw/dt smoothed. Fine — small detail; implement carefully with helper wrapAngle.

Tail wag: f.tail.rotation.y = sin(time*wagSpeed + phase) * (0.35 + fear*0.3); wagSpeed = 6 + speed*2. Body slight yaw wiggle too: body mesh? skip.

Pectoral flap: pect.rotation.z = base ± sin(...)*0.25.

Bounds: X ±14, Y [-6.2, 7.5], Z ±8.5. Contain: if pos.x > 14-2 add force -x proportional (pos.x - (14-2))*k. Standard soft spring outside margin.

Also keep fish away from rocks? skip (rocks at edges mostly).

Scare point smoothing: scarePos.lerp(targetScare, min(1, dt*8)). For mouse: compute each pointermove: raycaster.setFromCamera(ndc, camera); intersect plane z=0: t = (0 - origin.z)/dir.z; point = origin + dir*t. Store targetScare. In auto mode: targetScare from Lissajous, slower lerp.

Hand visual: sprite (SpriteMaterial map radial, additive, color 0xcaffec?) scale 2.2, opacity 0.35 + pulse; position = scarePos. Point light at scarePos + offset (0,0.5,2) toward camera so it lights fish: put light at scarePos with slight z toward camera (+3). Light intensity animated: base 20, plus flicker.

Show hand sprite also in auto mode (it's the demo's phantom). Yes.

Food: Points geometry with MAXFOOD=80, attribute positions; food array objects {pos, active}. On pointerdown (and not dragging?), spawn at scarePos + random spread, but food should drop from above: spawn at (scareX±1, min(scareY+2, 8), scareZ±1)? Simply at scare point with upward offset; pellets sink vy = -0.5 with drift sin. Fish attraction: find nearest active food within 10: acc += (food - pos).normalized * 2.0. Eat radius 0.55.

Points material for food: canvas small brown-ish dot; size 0.22; map; transparent; opacity 0.95; depthWrite false? food should occlude properly-ish; keep depthWrite true? Points with map & alphaTest 0.3 depthWrite true works. Use alphaTest.

Hide inactive food: set position y = -100.

Hint UI: 
- top-left: title block.
- bottom-center: pill "Move your mouse to startle the fish · click to drop food".
- On first pointermove within canvas: fade the pill to a smaller version? Keep: after user interacts (pointermove), change pill text to "click anywhere to drop food" then fade after 4s. Simple: mark interacted; add class 'hide' after timeout.

Also "Click or press a key to start" message requirement applies to games; this is a demo, not a game, but the auto demo covers first-paint liveliness. I'll still show a subtle "click or move your mouse to take over" style hint — covered by the pill.

Cursor: hide default cursor? A custom cursor ring via CSS following mouse could be neat, but sprite in 3D serves that. Keep default cursor none? If cursor hidden and only 3D sprite, feels immersive: body {cursor: none} after interaction? Might confuse. Use cursor: crosshair maybe. I'll set cursor: none on canvas and rely on the glowing sprite — risky for usability on edges but UI has pointer-events none... The pill is informative. Actually keep 'crosshair' — clear and thematic? Hmm, crosshair + glow sprite both. I'll use cursor:none for immersion since sprite tracks precisely. But the sprite is smoothed (lerp) — slight lag is fine aesthetically. I'll go with cursor:none but ensure sprite opacity decent. Edge case: pointer leaves window — sprite stays; ok.

Vignette CSS overlay: radial-gradient(ellipse, transparent 55%, rgba(1,10,14,0.55) 100%). Plus top gradient. pointer-events: none.

Mobile: pointermove works on touch drag; also touchstart gives pointerdown → food. Fine. Layout: title small, pill wraps. Media query for small width.

Resize: update camera aspect, renderer size.

Time: clock delta clamp 0.05.

Caustics shader plane: size 60×40 at y=-6.55 (sand top around -6.5±noise; place caustic at -6.35 maybe clipping sand dunes... sand noise amplitude ±0.35, base y=-6.5 → surface between -6.85 and -6.15. Caustics plane at -6.1 slightly above highest dune? Then it floats visibly above sand in dips... with additive blending and low opacity it's a soft glow layer, acceptable. Alternatively apply caustics INTO sand material via onBeforeCompile emissive — classier! Inject into sand's MeshStandardMaterial fragment: add caustic pattern to diffuse. Let's do onBeforeCompile on sand material:

```
shader.fragmentShader = replace('#include <emissivemap_fragment>', `#include <emissivemap_fragment>
  // caustic
  vec2 cuv = vWorldPos.xz * 0.16; ... compute c; 
  totalEmissiveRadiance += causticColor * c;`)
```
Need vWorldPos varying: add in vertex. Inject:
vertex: replace '#include <common>' with common + varying vec3 vWPos; and '#include <worldpos_vertex>' append `vWPos = (modelMatrix * vec4(transformed,1.0)).xyz;` — worldpos_vertex may not compute worldPosition unless needed... Safer: inject after begin_vertex? transformed is local; sand plane rotated via geometry (rotateX applied to geometry), so local == world minus offset (position at origin) — mesh at origin, no rotation on mesh → local coords = world coords. So vWPos = transformed works with model at origin. Use `vWP = transformed;` after begin_vertex? But begin_vertex defines transformed; worldpos happens later with displacement... my displacement is in JS (static). So inject after '#include <begin_vertex>' plus varying declaration in common.

The caustic loop (TDM's, 4 iterations) in fragment for every sand pixel — fine.

Caustic tint: vec3(0.35,0.75,0.8) * c * 1.2.

Also apply subtle caustics to rocks? Skip.

Let me now also consider a "back glass" hint: not needed; fog handles depth.

Light shafts need to render after water surface? Transparent sorting by distance; fine.

Now the auto-demo scare behavior in first 3 seconds: fish initially at random positions with random velocities; auto hand at Lissajous moving moderately fast. Make auto hand speed decent: t*0.6.

One more: title & meta. Title: "The Shallows — living reef". UI text color rgba(220,240,245,.9), letter-spacing .3em uppercase small.

Fish count indicator? A small "26 fish · 3 species" bottom-right? Might be a nice authored touch. Add small stats line bottom-right: "three species · twenty-six fish". Eh — I'll include subtle bottom-left caption. Keep tasteful: title top-left with two lines; bottom-center pill; bottom-right tiny "water temp 24°C · pH 8.1" flavor? Fun flavor, adds authored feel. Yes small flavor text bottom-right, hidden on very small screens.

Now write everything. Be careful with three r170 API:
- renderer.outputColorSpace = THREE.SRGBColorSpace (default already).
- renderer.toneMapping = THREE.ACESFilmicToneMapping; toneMappingExposure 1.05.
- shadow: renderer.shadowMap.enabled = true; type = THREE.PCFSoftShadowMap.
- Lights intensity: r155+ uses physically correct by default; directional intensity 2.5 okay; hemisphere 0.8.

PointLight intensity: with decay 2 physical: intensity ~ candela. For radius ~5 illum: I = 30 gives at 3 units: 30/9≈3.3 — good. Set 35, animate 0→ when active. Actually keep hand light always on (auto mode too) intensity 25 + flicker — shows the glow following the phantom.

Points size: with sizeAttenuation, size in world units? PointsMaterial size is in pixels unless sizeAttenuation... size is world-ish scaled by perspective when attenuation true: gl_PointSize = size * (scale / -mvPosition.z) where scale = canvas height/2 roughly. So size ~ world units approx at distance where scale matches. Set bubble size 0.5, food 0.3.

Sprite for hand: THREE.Sprite with SpriteMaterial(map radial, color 0x9fe6ff, transparent, opacity, blending Additive, depthWrite false). Scale ~3.

Also add slight ring? Just glow.

Bubble texture: radial gradient circle ring (hollow sphere look): center transparent-ish white edge. 

Canvas texture helpers:
```js
function canvasTex(w,h,draw){ const c=document.createElement('canvas'); c.width=w;c.height=h; draw(c.getContext('2d'),w,h); const t=new THREE.CanvasTexture(c); t.colorSpace=THREE.SRGBColorSpace; return t; }
```

Fish texture per species (shared per species, 3 textures).

Stripes for clown: base gradient: top of canvas? v direction along length: canvas y. Bands at y positions 0.3, 0.55, 0.8 heights 0.08 white with dark outline. Also darker fin-ish tail region near y end.

Blue tang texture: base #2049c8; darker navy pattern: draw a "palette" marking: dark shape near upper middle (like blue tang's black palette) — approximate with dark rounded rectangle at (x center, y 0.35), plus lighter belly? Keep: dark horizontal wedge + darker edges.

Silver: base gradient light, noise speckles, dark top via... u = around circumference — canvas x. Top of fish = some x value depending on lathe seam orientation. Lathe starts at phi=0 around Y; after rotateX, around Z. The seam position — fish's up is +Y (local). Lathe: x = r sin(phi)? THREE LatheGeometry: vertex.x = points[i].x * sin(phi), y = points[i].y, z = points[i].x * cos(phi)? Let me recall LatheGeometry code: 
```
vertex.x = points[j].x * Math.sin(phi)
vertex.y = points[j].y
vertex.z = points[j].x * Math.cos(phi)
```
phi from 0..2π. After rotateX(π/2): (x,y,z)->(x, -z, y). So new y = -old z = -r cos(phi). At phi=0: y=-r (bottom), phi=π: y=+r (top). u = phi/2π → u=0 bottom seam, u=0.5 top. So canvas x=0.5 is the fish's back (top), x=0/1 is belly. So I CAN shade: darker near x=0.5, lighter near x=0! For all species: draw horizontal gradient? Fill canvas with vertical-line gradient: use createLinearGradient across x: at x=0.5 darker. Great: gives counter-shading. Do it: for each species texture: linear gradient along x: belly light (x 0) → back dark (x 0.5) → belly light (x 1). Canvas gradient with stops. Then draw stripes over (bands along y = canvas horizontal stripes).

Wait — bands across body should be rings around circumference = constant v = horizontal line on canvas spanning all x. Yes draw full-width rects. 

Colors:
- Clownfish "Ember tetra"? Name flavor: species names displayed? skip names in UI.
- Species A: orange #ff7a1f, belly #ffd9a0? back #c94a10, white bands with thin #222 edges.
- Species B: royal blue #2b5bd7, back #1b2f8f, belly #9fc3ff; yellow tail fin #ffcf2e; dark marking.
- Species C silver: #c3ccd1, back #6f8896, belly #e8f0f2, speckles.

Fin colors: A: #ff9c4a with transparency; B: yellow #ffd23f; C: #9fb3ba translucent.

Now fish stats:
A: count 7, size 1.15, maxSpeed 3.4, wanderK..
B: count 6, size 1.35, speed 3.0
C: count 13, size 0.62, speed 4.2 (darting school)

Total 26.

Schooling for C: strong cohesion/alignment; A/B mild.

Behavior constants (tune plausibly):
- separation radius 1.2*scale, force 4
- cohesion radius 6 (same species only), force 0.6 (C: 1.2)
- alignment force 0.8 (C: 1.6)
- wander: retarget every 4-9s or on arrival; seek force 0.8
- flee: R=5.2; force 14 * falloff²; fear adds maxSpeed multiplier up to 2.2x and wag boost
- food attract radius 11, force 2.5; eat 0.6
- bounds margin 2.5, spring 8

Velocity damping slight: vel *= (1 - dt*0.2)? Use drag: vel.multiplyScalar(Math.max(0, 1 - 0.3*dt)).

Min speed: if len < minS, scale up. minS = 0.8 + fear*... fish shouldn't stop.

Integration semi-implicit Euler.

dt clamp 1/20.

Siver school target: they also share group center offset target... cohesion covers.

Auto-hand path: 
```
ax = Math.sin(t*0.5)*9 + Math.sin(t*1.1)*2
ay = 1.5 + Math.sin(t*0.8)*3.5
az = Math.sin(t*0.35+2)*4
```
Moves around. Fish will scatter periodically. 

Transition: when user first moves pointer, userActive=true forever (or until pointer leaves window? keep forever; simpler).

Also keyboard: any key spawns food? Not needed. Requirement about keyboard doesn't apply (no keyboard use). 

Pointer events: listen on window (renderer canvas). pointermove: update mouse NDC & userActive. pointerdown: spawn food + also update NDC.

For screenshot liveliness at ~3s: scene must be rich immediately: fish mid-tank random, kelp, bubbles, shafts, auto hand glowing. Good.

Sand geometry displaced: compute normals after displacement: geo.computeVertexNormals().

Sand ripples: also add directional ripple bumps: y = 0.3*sin(x*0.5 + z*0.2) + 0.2*sin(z*0.7) + noise*0.15. Fine.

Rock geometry: IcosahedronGeometry(1,1), perturb vertices by rand ±0.25, flatShading. castShadow.

Also add a few small "stones" scattered: sphere scaled squashed, color sandy-gray.

Kelp patches: 3 patches at (-11,-4), (9,-6), (-4,-8) & (12, 4)? Keep within floor 70×46: floor extends beyond tank visually — fine, kelp anywhere in ±16 x, ±10 z. ~5 patches × 4-6 blades.

Height: 3-7. Width 0.3-0.5. Colors vary per patch (green to teal).

Now, caustics on sand injection code:

```js
sandMat.onBeforeCompile = (shader)=>{
  shader.uniforms.uTime = uTime; // shared {value:0}
  shader.vertexShader = shader.vertexShader
   .replace('#include <common>', '#include <common>\nvarying vec3 vWP;')
   .replace('#include <begin_vertex>', '#include <begin_vertex>\n vWP = transformed;');
  shader.fragmentShader = shader.fragmentShader
   .replace('#include <common>', `#include <common>
    varying vec3 vWP; uniform float uTime;
    float caustic(vec2 uv, float t){
      vec2 p = mod(uv*6.28318, 6.28318) - 250.0;
      vec2 i = p; float c = 1.0; float inten = 0.005;
      for (int n=0; n<4; n++){
        float tt = t * (1.0 - (3.5/float(n+1)));
        i = p + vec2(cos(tt - i.x) + sin(tt + i.y), sin(tt - i.y) + cos(tt + i.x));
        c += 1.0/length(vec2(p.x/(sin(i.x+tt)/inten), p.y/(cos(i.y+tt)/inten)));
      }
      c /= 4.0; c = 1.17 - pow(c, 1.4);
      return pow(abs(c), 8.0);
    }`)
   .replace('#include <emissivemap_fragment>', `#include <emissivemap_fragment>
    float ca = caustic(vWP.xz*0.12 + vec2(0.0, t? no use uTime*0.05), uTime*0.5);
    totalEmissiveRadiance += vec3(0.3,0.7,0.75) * ca * 0.55;`);
};
```
uTime shared object so kelp also uses it: define const uTime = {value:0} and assign shader.uniforms.uTime = uTime. Since onBeforeCompile shaders copy uniforms into shader.uniforms — assigning the same object reference means updating uTime.value updates all. Yes that works (common pattern).

Note: materials with onBeforeCompile need customProgramCacheKey? Different materials different shaders — fine as each is unique.

Kelp material per blade with onBeforeCompile — many programs compiled (they're identical code though; three caches by shader code — onBeforeCompile changes produce same code so cache shared? The cache key includes onBeforeCompile.toString() — same function → same key → shared program. But uniforms differ per material instance — uniforms are per material. Wait — if program cached but material instances each have own uniforms clones? In onBeforeCompile, shader.uniforms is per-material; assigning shared uTime object works per material. Phase/amp as per-blade uniform objects passed — but if the program is cached and shared, uniforms still per material instance. Yes WebGLPrograms handles: program shared, uniforms per material. But careful: uniforms added in onBeforeCompile are stored in material's uniform list via the shader object... In r170, onBeforeCompile's shader.uniforms get assigned into the material's program uniforms — yes, standard pattern (three docs "custom uniforms via onBeforeCompile"): use material.userData.shader = shader pattern. The pattern works.

Hmm, actually there's a subtlety: uniforms added in onBeforeCompile are uploaded? Yes — WebGLRenderer collects uniforms from shader.uniforms (the object possibly mutated in onBeforeCompile). This is a well-established pattern. Good.

Alternatively to be safe, for kelp I could use a single ShaderMaterial with lighting faked... no, onBeforeCompile is fine.

Actually simpler and safer for kelp: use MeshStandardMaterial but do bending in JS via bones? No. Keep onBeforeCompile; it's widely used.

Water surface: also fade near camera? fine.

Let me also add godray-shaft rotation sway and opacity pulse.

Also the tank "glass": maybe add very subtle box edges? A faint front glass reflection overlay via CSS gradient is enough (vignette + a diagonal white sheen at top corner, very subtle). I'll add a CSS `#glass` overlay with two subtle diagonal gradients — gives aquarium-glass feel.

Now numbers check: camera at (0,1.5,26), fov 55 → visible height at z=0: 2*26*tan(27.5°)=2*26*0.5206=27 units; width at 1280 aspect 1.6: 43. Tank X bounds ±14=28 wide fits with margin; on mobile aspect ~0.5: width visible ≈ 13.5 — tank too wide; fish will swim offscreen. Should I adapt bounds to aspect? Yes: compute half-width visible at z=0: visW = tan(fov/2)*camDist*aspect; tank X bound = min(14, visW-2)? But then fish crowd on mobile — acceptable; they'd be in view. Let me set bounds from visible frustum each resize: 
```
const dist = 26; visH = Math.tan(fov*0.5 rad)*dist; visW = visH*aspect;
boundX = Math.max(6, Math.min(15, visW*0.85)); boundZ fixed 8.5; boundY fixed.
```
On 1280×800 (aspect 1.6): visW= 0.5206*26*1.6=21.7 → boundX = min(15, 18.4)=15. Use 15. On 375×800 aspect 0.469: visW=6.34→ boundX=max(6, 5.4)=6. Fish within ±6 — visible. 

Also camera parallax reduces margin slightly — small (±2). boundX formula uses 0.85 factor to keep margin. OK.

Fish initial positions: random within bounds*0.7.

Scare radius on mobile smaller? R=5.2 fine.

Now write the fish geometry precisely.

```js
function makeFishBodyGeometry(){
  const pts=[];
  const N=28;
  for(let i=0;i<=N;i++){
    const t=i/N; // 0 nose .. 1 tail
    const tp=Math.pow(t,0.72);
    let r=0.34*Math.pow(Math.sin(Math.PI*tp),0.85);
    // ensure nose not zero-point sharp: clamp min small
    if(i===0) r=0.04; if(i===N) r=0.03;
    const y=0.55 - t*1.1; // nose y=+0.55 -> tail y=-0.55
    pts.push(new THREE.Vector2(r, y));
  }
  const g=new THREE.LatheGeometry(pts, 24);
  g.rotateX(Math.PI/2); // axis to Z: y -> z? earlier: (x,y,z)->(x,-z,y): point (0,y,0)->(0,0,y). 
```
Wait recheck: rotateX(π/2) transformation matrix: y' = y cosθ − z sinθ = −z; z' = y sinθ + z cosθ = y. So (0, y, 0) → (0, 0? y'=−0=0, z'=y) → (0,0,y). ✓ nose (y=0.55) → z=0.55. 

Then g.scale(0.6, 1, 1) → width × 0.6 (x). Height stays = r. Hmm but lathe radius becomes both x and z? No — after rotation, cross-section circle lies in XY plane (x,y), z is length. Cross-section radius r: x and y both range ±r. Scale x by 0.6 → width 0.6r, height r. ✓. Also maybe scale overall z slightly? length 1.1 fine.

UV: LatheGeometry uv: u = i/segments (around), v = j/points (along profile). ✓ as assumed.

Wait: does scaling geometry after creation mess texture? No.

Tail shape (drawn in shape XY; will rotateY(-π/2) so x→z... check rotateY(-π/2): cos=0,sin=-1: x' = x*0 + z*(-1)?? formula rotY: x' = x cosθ + z sinθ; z' = −x sinθ + z cosθ. θ=−π/2: cos=0, sin=−1 → x' = −z; z' = x. Point (x,y,0) → (0, y, x). x→z ✓ (positive shape-x = +z toward nose). For tail I want it extending backward (−z): draw shape with negative x. Let me draw tail pointing −x:

```js
const ts=new THREE.Shape();
ts.moveTo(0, 0.14);
ts.quadraticCurveTo(-0.25, 0.32, -0.45, 0.38);
ts.quadraticCurveTo(-0.32, 0.12, -0.30, 0);
ts.quadraticCurveTo(-0.32, -0.12, -0.45, -0.34);
ts.quadraticCurveTo(-0.25, -0.26, 0, -0.12);
ts.closePath? lineTo(0,0.14) auto close.
```
Roughly a forked tail. Scale 1. Then geometry = ShapeGeometry(ts); rotateY(-π/2). Now extends −z. Position at z=−0.5, y=0. Pivot: tailGroup at (0,0,-0.5); mesh inside offset 0 (shape already relative to pivot at x=0). Wag: tailGroup.rotation.y = sin(...)*0.45.

Dorsal fin shape (x = z coordinate, from −0.45 tail to +0.3 nose):
```js
const ds=new THREE.Shape();
ds.moveTo(-0.42, 0.10);
ds.quadraticCurveTo(-0.25, 0.42, -0.05, 0.5);
ds.quadraticCurveTo(0.12, 0.42, 0.3, 0.12);
ds.lineTo(0.3, 0.02); ds.lineTo(-0.42, 0.02); close
```
rotateY(-π/2) → spans z −0.42..0.3, on top. Body top at ~0.3 near mid — fin base 0.02..0.1 embedded ✓.

Anal fin (bottom, smaller): similar mirrored, y negative, z −0.05..−0.4:
```js
moveTo(-0.05,-0.1) → curve to (−0.3,−0.32) → (−0.42,−0.08) → line back base y=0? 
```
Simple triangle-ish. OK.

Pectoral fin: small shape:
```js
ps: ellipse-ish: moveTo(0,0); quadraticCurveTo(-0.2,0.05,-0.28,0.18); quadraticCurveTo(-0.1,0.14,0,0.04)... 
```
small ~0.3 long. Keep in XY plane; attach at (±0.12, −0.03, 0.18), rotation: for right fin (x+): rotate so it sticks out sideways+back: rotation.y = -0.7 (plane faces...) ShapeGeometry in XY plane faces +Z. For a fin we want plane roughly horizontal-ish diagonal: rotateY(±1.1) then rotateZ slight. I'll do mesh.rotation.set(0.2, ±1.2, ±0.4)? Let me think: place fin plane containing vertical? Real pectoral fins fan out backward-downward. Simple: rotation.y = ∓? For right side (+x): fin extends −z and +x: shape drawn extending −x (backward when rotated)... I'll just draw shape pointing +x and set rotation.y = -(π/2+0.5) for right so it maps x→ mostly −z plus tilt... honestly fine-tuning blind; approximate: right fin: rotation.y = -2.0 rad (x→ z*cos... x axis rotates toward −z when rotation.y negative? rotation.y=−π/2 maps +x→+z? earlier geometry rotateY(−π/2) maps shape x→z. For mesh.rotation.y = −π/2 same: local +x → world +z. I want fin pointing backward: rotation.y = +π/2 maps +x→−z? mesh rotation +π/2: x' = x cos + z sin → for local x axis (1,0,0) → (cos, 0, −sin)=(0,0,−1) ✓ so rotation.y=+π/2 makes +x point −z (backward). Then add tilt outward: rotation.z? Use Euler order default XYZ. Set right fin rotation.y = π/2 − 0.6 (points back and rotated outward? rotation.y=π/2−0.6 ≈ 0.97: +x → (cos0.97,0,−sin0.97)=(0.56,0,−0.83): back-right ✓. Left: rotation.y = π/2+0.6 → (cos(2.17),0,−sin(2.17)) = (−0.56,0,−0.83) back-left ✓. Then rotate x slight downward: rotation.x = 0.4? applied first in XYZ order... whatever, small tilt: I'll add rotation.x after via mesh.rotateX? Use Euler (0.5, ±(π/2∓0.6), 0)? Order XYZ applies X then Y then Z (extrinsic? three Euler default 'XYZ' intrinsic rotations applied R = Rx*Ry*Rz? Matrix made as Rz? Actually makeRotationFromEuler XYZ: R = RX * RY * RZ? I'll not overthink: small visual detail; set rotation.order default and values (0.55, yAngle, 0) — fin tilted down & back-sideways. Looks plausible.

Also flap: store fin meshes; anim rotation.z base? Their base rotation stored; animate rotation.x += sin*0.2? I'll animate rotation.z = sin(t*3+phase)*0.15 for gentle flutter. Eh, set pect flap on rotation.x offset from stored base.

Eyes: SphereGeometry(0.045, 10, 8), black #141414 roughness 0.25. Position (±0.13, 0.07, 0.34). At z=0.34 body half-width: t=(0.55−0.34)/1.1=0.19, tp=0.19^0.72≈0.315, sin(π*0.315)=0.837, r=0.34*0.837=0.285, halfW=0.171. Eye x=±0.13 with r=0.045 → outer 0.175 pokes out slightly ✓.

Scale entire fish group by species size. Fear/wag etc multiplied.

Materials sharing: per species: bodyMat (map), finMat. Eyes shared black mat. All fish share species materials ✓ (6 materials total).

Now behavior tuning — write the update loop:

```js
const fishes=[];
function spawnFish(sp, i){ ... random pos in bounds*0.75, vel random dir * speed*0.5, phase rand*π2, retarget timer 0, target random }
```

Update:
```js
for f of fishes:
  f.fear *= Math.exp(-dt*0.7);
  // wander retarget
  f.retarget -= dt; if (f.retarget<=0 || dist(pos,target)<1.2){ pick target; f.retarget = 3+rand*5 }
  acc.set(0,0,0);
  // seek target
  v1.subVectors(f.target, f.pos); d=v1.length(); if d>0: acc.add(v1.multiplyScalar(sp.wander/d));
  // neighbors
  for g of fishes (j != i):
    v1.subVectors(f.pos, g.pos); d = v1.length();
    if (d < sepR && d>1e-4): acc.add(v1.multiplyScalar(4*(1-d/sepR)*scale? /d)); // normalized dir * strength falloff
    if (g.sp===f.sp && d<6 && d>0):
       // cohesion
       v2.subVectors(g.pos, f.pos); acc.add(v2.multiplyScalar(sp.coh*0.05));
       // alignment
       v3.copy(g.vel).sub(f.vel); acc.add(v3.multiplyScalar(sp.ali*0.05));
  // flee
  v1.subVectors(f.pos, scare); d=v1.length();
  if (d < R): 
     const fall = 1-d/R; 
     acc.add(v1.normalize().multiplyScalar(16*fall*fall));
     f.fear = Math.min(1, f.fear + fall*dt*4);
  // food
  nearest active food within 11: acc.add(dir * 3.0); if d<0.55 eat.
  // bounds
  acc.x += spring(f.pos.x, BX, 2.5, 8) ... helper: if x> BX-m: acc.x -= (x-(BX-m))*k; if x<-(BX-m): acc.x += ...
  // integrate
  f.vel.addScaledVector(acc, dt);
  f.vel.multiplyScalar(Math.max(0,1-0.35*dt)); // drag
  const maxS = sp.speed*(1+f.fear*1.4);
  const s=f.vel.length();
  if (s>maxS) f.vel.multiplyScalar(maxS/s);
  if (s<sp.speed*0.35) f.vel.multiplyScalar? ensure min: if s<minS: f.vel.addScaledVector(f.vel, ... if s<1e-3 set random) → use: if (s < minS) { if (s<1e-4) f.vel.set(rand..) else f.vel.multiplyScalar(minS/s); }
  f.pos.addScaledVector(f.vel, dt);
```

Orientation with temp:
```js
_o.position.copy(f.pos);
_v1.copy(f.pos).add(f.vel); if _v1 close to pos? vel nonzero mostly.
_o.lookAt(_v1);
f.group.quaternion.slerp(_o.quaternion, Math.min(1, dt*3.5));
```
Bank: compute yaw now & prev:
```js
const yaw = Math.atan2(f.vel.x, f.vel.z);
let dy = yaw - f.prevYaw; dy = Math.atan2(Math.sin(dy), Math.cos(dy));
const turn = dy/Math.max(dt,1e-3);
f.bank += ((THREE.MathUtils.clamp(-turn*0.25, -0.6, 0.6)) - f.bank)*Math.min(1,dt*3);
f.prevYaw = yaw;
f.group.rotateZ(f.bank);
```
rotateZ each frame after slerp — apply on top of current quaternion (local roll) ✓.

Tail:
```js
const wag = (5 + s*2.2 + f.fear*6);
f.tail.rotation.y = Math.sin(time*wag + f.phase)* (0.35+0.25*f.fear);
pect flap: pect.rotation.z = ±(Math.sin(time*4+phase)*0.18 + baseZ?) — I set their rotation via Euler; modifying rotation.z fine.
```
Store base rotations for pect: pectL.userData.bz etc. Simpler: create flap by rotation.x? I'll store initial rotation clone and set rotation.x = base.x + sin*0.15? Hmm base.x was the 0.55 tilt. Do: pect.rotation.x = pect.userData.bx + sin*0.2.

Position y bob: group.position.y += small sin? Velocity handles vertical; skip.

Food points:
```js
const MAXF=80; foodPos Float32Array(MAXF*3) all set y=-999; geometry with attribute; foods = array of {x,y,z,vy,active,age,wob}
spawnFood(p): for 7 pellets: find inactive slot; pos = p + rand sphere*1.2, y clamp < 8; vy=-0.45-rand*0.2; active.
update: for active: y += vy*dt (slow near sand: if y<-6.2 vy->0, settle); x += sin(time*1.5+wob)*0.15*dt; age+=dt; if age>30 deactivate.
write into positions array; needsUpdate.
```
Food points material: size 0.28, map dotTex, color 0x8a5a2e? PointsMaterial color tints map. alphaTest 0.4, transparent true, depthWrite false? With depthWrite false food may show through fish — small dots, acceptable; use depthWrite true with alphaTest to be safe.

Bubbles similar: 60 points, positions updated each frame: 
```js
b.y += b.s*dt; b.x += Math.sin(time*1.7+b.ph)*0.12*dt; if (b.y>9) reset to emitter.
```
Emitters at rock positions (-12,-5,-5) & (11,-4,-6) & maybe (0,-6.5,2). Reset: y=-6.2, x=ex+rand*0.6, z=ez+rand*0.6. size 0.35.

Bubble points: depthWrite false, blending additive, opacity 0.6, color 0xcfeef5.

Hand sprite & light positions update: sprite.position.copy(scare); light.position.set(scare.x, scare.y+0.6, scare.z+2.5).

Sprite scale pulse: 2.4 + sin(t*3)*0.25; opacity 0.5 (0.35 min?). Also ripple ring when user moves? skip.

Auto mode: also make auto hand "pause" occasionally? Lissajous fine.

Pointer handling:
```js
let userActive=false;
function onMove(e){ 
  const nx = (e.clientX/innerWidth)*2-1; ny=-(e.clientY/innerHeight)*2+1; ndc.set(nx,ny);
  if(!userActive){ userActive=true; hidePillSoon(); }
}
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerdown', e=>{ onMove(e); updateScareFromNDC(); spawnFood(scareTarget); });
```
Scare target from NDC computed every frame (camera parallax moves): if userActive: raycast ndc → plane z=0. Use THREE.Raycaster? Manual: 
```js
raycaster.setFromCamera(ndc, camera);
const t = (0 - raycaster.ray.origin.z)/raycaster.ray.direction.z;
```
direction.z negative (camera looks −z from z=26) → t positive ✓. target.copy(origin).addScaledVector(dir, t).
Clamp target to bounds (x ±(BX+2), y −6..8.5).

If !userActive: auto path.

Then scare.lerp(target, 1-exp(-dt*6)).

Hint pill logic: initially: "Move your cursor — the fish will scatter · Click to drop food". After userActive: after 3.5s add .fade (opacity 0, transition). Also change text? Keep same and fade. But before interaction, keep visible (pulsing slightly). Add small dot indicator.

Title: "S H A L L O W S" — maybe "The Glass Reef". I like "THE GLASS REEF". Subtitle: "a small aquarium · three species". Bottom-right flavor: "26°c · salinity 1.025 · ph 8.2". Cute.

Fonts: system stack: "Avenir Next, Helvetica Neue, Segoe UI, sans-serif" with letterspacing; title serif alternative: Georgia italic subtitle. Fine.

Now think about console errors: 
- Ensure all uniforms/replace targets exist: MeshStandardMaterial fragment includes '#include <emissivemap_fragment>' ✓ and '#include <common>' ✓; vertex includes '#include <begin_vertex>' ✓.
- Sprite raycast n/a.
- Points frustumCulled: set false for points & fish? Fish groups have geometry computed bounding sphere per mesh; group fine. Points geometry: positions include y=-999 inactive → bounding sphere computed once? Points geometry bounding sphere computed from initial positions — compute after setting initial, then set frustumCulled=false to be safe. Fish meshes: geometry bounding computed automatically on first render ✓ (positions static).
- Kelp onBeforeCompile replace: MeshStandardMaterial vertex contains '#include <common>' ✓.
- LatheGeometry with points ✓.
- No OrbitControls import needed — do NOT import addons since unused (avoids fetch).
- importmap still includes addons per instruction ✓ harmless.

Performance: shadow map 2048 with ~40 casters ok.

Renderer sortObjects default true.

One concern: water surface transparent + shafts + sprites + bubbles ordering — all transparent, sorted by depth from camera; sprites/points individual. Should look fine mostly.

Water surface seen from below at y=9.5, camera y=1.5 looking slightly up? lookAt(0,0.5,0) from (0,1.5,26) — looking slightly down; the surface at y=9.5 with plane 90×60 covers up to y≈? At distance 26, half-height visible 13.5 → top of screen at y≈1.5+13.5≈15 at z=0 plane; the surface at y=9.5 visible in upper part ✓.

Sand at −6.5: bottom of screen at z=0: y≈1.5−13.5=−12 ✓ sand visible.

Kelp heights up to 7 ✓.

Light shafts from y≈9.5 down: geometry plane height 17, centered y=1.2 spans −7.3..9.7 ✓.

Now camera parallax: camera.position.x = px (lerp to ndc.x*2.4), y = 1.5 + ndc.y*1.0? ndc.y positive up when mouse top. Keep subtle. lookAt(0,0.3,0) each frame.

But wait: in auto mode, ndc stays (0,0) — camera static, fine.

Edge: pointermove also fires for touch — good.

The scare target when user hasn't moved but clicks? pointerdown triggers ndc set + food ✓.

Also allow key press? "press a key to start" — add keydown → userActive + drop food at center? Fine: keydown: userActive=true, spawnFood at auto pos. Minor.

Food attract makes fish come toward cursor — nice contrast with fleeing. Fear suppresses feeding: skip attraction if f.fear>0.5? Fish hesitate: attraction force scaled by (1-fear). Nice detail.

Fish "eat": also spawn a tiny bubble? skip.

Pill: also show a small "auto" indicator? no.

Now, let me also double-check LatheGeometry normals & shape closing: profile starts r=0.04 at nose (small hole cap) — acceptable (small). End r=0.03.

Fins ShapeGeometry normals face +z; DoubleSide ✓.

Tail group: I'll create group at (0,0,-0.52*scaleFactor?) — build all parts in unit fish coordinates inside a subgroup 'model', then model.scale.setScalar(size). Group (outer) used for position/orientation. inner = new THREE.Group(); inner.scale.setScalar(sp.size); outer.add(inner).

Shadow: body castShadow, tail castShadow; dorsal no; small fish shadows negligible.

Directional light shadow camera must cover x ±15, z ±9, y −6..8 — set d=20: left -20 right 20 top 20 bottom -20, position (8,20,6) target (0,-2,0). Bias -0.0005 to avoid acne; normalBias 0.02.

Hemisphere + dir; maybe add a subtle cyan point light center tank (fill) intensity 30 at (0,2,4)? Adds glow. OK add faint.

Tone mapping exposure 1.15.

Fog: FogExp2(0x0e4a5a, 0.024)? Compute far rock at dist ~30: factor=1−exp(−(0.024*30)²)=1−exp(−0.518)=0.4 — decent. Back of gradient sphere unaffected (fog:false? ShaderMaterial default no fog ✓). Fog color should blend with gradient sphere's lower-mid color; sphere gradient mid ~ #0e4a5a ✓ matches.

Gradient sphere top #35879b bottom #052029.

Directional light color 0xbfe8f0.

Water surface from below shows color mix — DoubleSide ✓.

Let me now also handle the "pill" text and pointer-events none for overlays.

Small screens: media query (max-width: 600px): hide flavor, shrink title.

Also `overflow:hidden` html,body; canvas block.

Write dot/radial textures:

```js
function radialTex(size, inner, outer, stops...) 
```
Glow: gradient stops: 0 rgba(255,255,255,1) ... 1 transparent. For hand: color via material.

Bubble texture: ring: stops 0: rgba(255,255,255,0.1), 0.55: rgba(255,255,255,0.35), 0.8: rgba(255,255,255,0.9)?? A bubble look: bright rim, hollow center. I'll do: 0 → 0.05 alpha, 0.7 → 0.15, 0.85 → 0.8, 1 → 0. Additive so center dark.

Food dot: solid round with soft edge: 0→1 opaque, 0.7→1, 1→0.

Now, kelp patch function:

```js
function makeKelpPatch(x,z,count,hueBase){
 for i: h=3+rand*4; w=0.35+rand*0.2;
 geo = PlaneGeometry(w,h,1,10); geo.translate(0,h/2,0);
 vertex colors: for each vertex: k=y/h; color lerp bottom (0x0d3320) top (0x57a860 or teal 0x3fa08a) with rand jitter;
 mat = MeshStandardMaterial({vertexColors:true, roughness:0.85, side:DoubleSide});
 phase=rand*10, amp=0.5+rand*0.5;
 mat.onBeforeCompile = ... uses uH=h, uPhase, uAmp, uTime shared
 mesh pos (x+rand*2-1, -6.4, z+rand*2-1), rotation.y rand*π
}
```
Per-material uniforms: in onBeforeCompile assign shader.uniforms.uPhase={value:phase} etc. Since closure captures values ✓.

Note: materials with vertexColors and onBeforeCompile — fine.

Also give kelp slight emissive? no.

Corals:
```js
function makeCoral(x,z,color){ group; base sphere squashed dark; 6-9 cones (ConeGeometry(0.12+r*0.15, 0.5+r*1.2, 6)) positioned around, tilted outward, color variations; }
```
Colors: 0xd4694f, 0xc05a7a, 0xd99a4e. Add 3 corals. Plus a "fan": skip.

Rocks: 6 big: positions back/left/right: (-13,-6.5,-7) scale (3,2,2.5)... place y so partially buried: y=-6.8+scale*0.3. Also 10 pebbles.

Check: sand plane 70×46 extends z −23..23, x −35..35 ✓ covers view width at z far? At z=−23, visible half width = tan(27.5°)*(26+23)*1.6... = 0.52*49*1.6 = 40 → ±40 > 35 → edges of sand might show at far corners. Enlarge sand to 100×70. Fog hides far edges anyway. And gradient sphere r=60 encloses ✓. Water surface 100×70 too? At far distance the surface fades via alpha distance fade I added. Let me include distance fade in water shader: varying vViewDist; alpha *= smoothstep(70.0, 25.0, vViewDist).

Background sphere r=60 — camera at z=26, sphere far side at z=−60 dist 86 — beyond far plane? camera far set 200 ✓.

Kelp/rocks shadows: rocks castShadow ✓.

Now UI copy language: user request in English → all English.

Title block:
```
THE GLASS REEF
— a small living aquarium
```
Pill: "◈ move your cursor near a fish — it will dart away · click to drop food"

After interaction fade.

Let me now write the final code fully and carefully. I'll aim for clean organized code with comments.

Also remember: no console errors; guard division by zero.

Potential three r170 nuances:
- THREE.CanvasTexture colorSpace SRGB ✓ for color maps.
- PointsMaterial map with alphaTest ✓.
- Sprite scale set.
- LatheGeometry(points, segments) points must be Vector2 with x≥0 ✓.
- renderer.setAnimationLoop for loop.

Write orientation temp objects outside loop: const _o = new THREE.Object3D(); _o.up default (0,1,0) ✓. lookAt when vel vertical → up parallel issue; fish rarely perfectly vertical. Guard: if abs(vel.y) > 0.98*speed, add slight x to target.

Actually lookAt target = pos+vel; if vel == (0,±1,0)*s exactly up parallel → degenerate. Add tiny offset: _v1.copy(f.vel); if (Math.abs(_v1.x)+Math.abs(_v1.z) < 1e-3) _v1.x = 0.01;

Now species definitions:

```js
const SPECIES = [
 { key:'ember', count:7, size:1.15, speed:3.2, wander:0.9, coh:0.8, ali:0.7, sep:1.6,
   tex: makeStripeTex(...), fin:0xff9a45, metal:0.05 },
 { key:'tang', count:6, size:1.4, speed:2.9, wander:0.8, coh:0.7, ali:0.6, sep:2.0,
   tex:..., fin:0xffd23f, metal:0.05, tailMat separate? use fin color },
 { key:'sprat', count:13, size:0.6, speed:4.3, wander:1.1, coh:2.2, ali:2.4, sep:0.9,
   tex:..., fin:0xaebfc6 semi, metal:0.7, roughness:0.35 }
];
```

Textures:

makeFishTexture(baseTop, baseBottom, opts{bands:[y..], bandColor, edge, speckle}):
canvas 128×256? u around = x (128), v along = y (256). Draw:
- counter-shading: horizontal gradient along x: at x=0 belly color, x=64 top color, x=128 belly. createLinearGradient(0,0,128,0) stops: 0 belly, 0.5 top, 1 belly.
- bands: for each y fraction: fillRect(0, y*256-? , 128, h) with band color; edge: darker 2px lines.
- speckles: random dots alpha 0.08.

Wait check mapping direction: v=0 at first profile point (nose, y=0.55) → canvas y=0 = nose? UV v corresponds to j/points index; texture y: v=0 maps to bottom of texture (y=height)? Three textures: uv (0,0) is bottom-left of image with flipY=true default (CanvasTexture flipY default true). Ugh. With flipY true, v=0 samples image bottom row (y=h in canvas coords). So nose (v=0) = canvas bottom (y=256). Direction along body just mirrors; stripes symmetric-ish so fine. Band placement: clownfish bands near head/mid/tail — mirrored doesn't matter aesthetically. I'll place bands at 0.28, 0.52, 0.78 fractions of v.

Top/back at u=0.5 = canvas x=64 ✓ regardless of flipY.

Clownfish: top #d45818, belly #ff9a50; bands white #f8f4ec with dark edge #26221e thin. 
Tang: top #1e3fae, belly #7fa8ef; marking: dark #101c3f rounded wedge near top region (canvas y around 0.45h? whatever). Plus tail area yellow-ish? tail fin separate material yellow; body near tail could tint lighter — skip.
Sprat: top #7d97a4, belly #e6eef0, speckles dark.

Band widths: clownfish: 3 bands each ~14px on 256 (≈5% of length) ✓.

Also thin dark outline at band edges via strokeRect.

Now the shadow of transparent fins: castShadow false for fins ✓ (only body+tail).

Water shader alpha fade & glint — write it.

Light shafts texture: linear gradient top white→transparent bottom? Real shafts brighter at top. canvas 16×256: gradient y0 (top) rgba(255,255,255,0.9) → y1 0. Also horizontal soft edges? Plane with just vertical gradient has hard side edges; make canvas 64×256 with radial-ish horizontal falloff: draw vertical gradient then multiply horizontal: simpler: create gradient both: use ctx radial? I'll draw: for horizontal falloff, use createLinearGradient horizontal as alpha mask via composite 'destination-in'. Do:
1. fill vertical gradient white.
2. g2 horizontal: 0 transparent, .5 white, 1 transparent; composite 'destination-in' fill.
Result: soft beam ✓.

Shaft mesh: PlaneGeometry(2.2, 17); mat MeshBasicMaterial({map, color:0xaee6f5, transparent, opacity:0.10, blending:Additive, depthWrite:false, side:DoubleSide, fog:false?}) — fog on additive basic material would fade... MeshBasicMaterial fog:true would tint toward fog color making it add fog color — with additive that adds haze; set fog:false to keep beams clean. ✓

Animate: shaft.rotation.z = baseTilt + sin(t*0.15+i)*0.05; opacity = 0.07+0.04*sin(t*0.4+i*2).

Position x: -9, -3.5, 2, 7.5, 11 random-ish; z: -6..-2 behind; renderOrder 5.

Hand glow texture: radial.

Now assembling counts of lines — big but fine.

Also FPS safety: 26 fish nested loops 676 iterations/frame trivial.

Let me make sure "something meaningful visible within first seconds": yes.

Pill styling: position absolute bottom 18px center; background rgba(6,24,32,0.55); border 1px solid rgba(160,220,230,0.25); backdrop-filter blur(6px); padding 10px 18px; border-radius 999px; font 12px; letter-spacing 0.08em; color #cfeef2. Transition opacity 0.6s.

Title: top 26px left 30px; h1 font-weight 300? letter-spacing 0.42em; font-size 15px; color #eaf6f8; text-transform uppercase. Sub: 11px letter-spacing .18em color rgba(190,225,232,.65) italic Georgia.

Flavor bottom-right: 10px mono-ish rgba .4.

Vignette + glass sheen:
```css
#veil{position:fixed;inset:0;pointer-events:none;
 background:
  radial-gradient(120% 90% at 50% 40%, transparent 55%, rgba(2,12,17,.5) 100%),
  linear-gradient(165deg, rgba(200,240,255,.07) 0%, transparent 22%, transparent 78%, rgba(120,200,220,.05) 100%);
}
```

Cursor none: canvas{cursor:none}? Then before interaction the real cursor invisible while pill says move cursor — bad. Use cursor: crosshair until interaction, then none? Manage via class on body: body.touched canvas{cursor:none}. Hmm — the 3D glow lags slightly; acceptable and looks intentional. I'll do that.

Loading: three module import async; first paint before module runs shows CSS background — fine, screenshot at 3s will include scene.

Edge: WebGL not available? Assume available.

Write food seeking: iterate foods each fish → 26*80 = 2080 dist checks — fine.

Eat: also fish.fear = 0 and brief 'fed' wag? skip.

Spawn food only if userActive? pointerdown sets ndc first then compute scare immediately (call updateScareTarget before spawn) ✓.

Also limit food spawn rate: min 150ms between.

Now write auto hand also does occasional food drops? Fun: every ~9s auto drop food so demo shows feeding! Yes — auto mode drops food periodically (every 8s at hand position). Nice for first screenshot dynamics. Do it.

Food visual: Points with brown dots — maybe better little sinking flakes color #caa06a. ok.

Now code. Also remember tail fin material for tang yellow, but body fin same? Species fin color covers tail+all fins ✓ (tang: yellow fins on blue body — iconic).

Dorsal/anal/pectoral: same finMat.

Eyes: shared material.

Fish build function returns {outer, inner, tail, pectL, pectR}.

One more check — `LatheGeometry` rotateX then scale then computeVertexNormals? Lathe has normals already; after rotate/scale normals transform with geometry methods (rotateX applies to normals ✓; scale(0.6,1,1) non-uniform — geometry.scale applies matrix which normalizes normals? Geometry.applyMatrix4 updates normals with normal matrix and normalizes ✓).

ShapeGeometry rotateY ✓.

Let me now also handle pixel ratio and resize.

Also `renderer.setClearColor` not needed (background sphere covers). But set scene.background = new Color(0x06222e) as fallback behind sphere ✓.

Order of adding: fine.

Camera parallax lerp: px += (ndc.x*2.2 - px)*min(1,dt*2).

In auto mode ndc=(0,0) → camera centers ✓.

Also slight camera sway in auto mode (cinematic): camera.position.x = sin(t*0.1)*1.2 in auto? Then mouse ndc (0,0) still center. Nice: gentle drift. When user active, parallax target. Blend: targetX = userActive? ndc.x*2.2 : sin(t*0.12)*1.5. ✓ y similarly.

Scare raycast uses current camera each frame ✓.

Now let me carefully write shader replace strings with proper escaping (template literals fine).

Caustic function float literal care: write GLSL with floats (1.0 etc). int loop with float(n+1) ✓.

Let me write the water surface:

```glsl
// vertex
uniform float uTime;
varying vec3 vW;
varying float vD;
void main(){
  vec3 p = position;
  p.y += sin(p.x*0.22 + uTime*0.7)*0.3 + cos(p.z*0.28 + uTime*0.55)*0.25;
  vec4 wp = modelMatrix * vec4(p, 1.0);
  vW = wp.xyz;
  vec4 mv = viewMatrix * wp;
  vD = -mv.z;
  gl_Position = projectionMatrix * mv;
}
// fragment
uniform float uTime;
varying vec3 vW; varying float vD;
void main(){
  vec2 q = vW.xz;
  float w = sin(q.x*1.3 + uTime*1.4) * sin(q.y*1.6 - uTime*1.1);
  w += 0.6*sin(q.x*2.7 - uTime*2.1) * sin(q.y*2.2 + uTime*1.6);
  w += 0.35*sin(q.x*4.4 + uTime*2.8) * sin(q.y*3.9 - uTime*2.3);
  float glint = smoothstep(0.45, 1.0, w);
  vec3 col = mix(vec3(0.07,0.35,0.42), vec3(0.8,0.97,1.0), glint);
  float a = 0.38 + glint*0.45;
  a *= smoothstep(75.0, 28.0, vD);
  gl_FragColor = vec4(col, a);
}
```
Plane geometry rotated: I'll bake rotation into geometry (rotateX(-π/2)) so position.y is up in shader local? After geometry.rotateX, vertex positions already in XZ plane with y up ✓, then modelMatrix translation only.

renderOrder for water: 1.

Now, all code. Also handle `three` import only.

Also note: `sprite` for hand — SpriteMaterial ignores fog by default? SpriteMaterial has fog prop default true; set fog:false.

Let me write JS carefully.

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

const renderer = new THREE.WebGLRenderer({ antialias:true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.12;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.getElementById('stage').appendChild(renderer.domElement); // or body prepend
```

CSS canvas position fixed inset 0.

Scene, camera:

```js
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x06222e);
scene.fog = new THREE.FogExp2(0x0e4a5a, 0.023);
const camera = new THREE.PerspectiveCamera(55, innerWidth/innerHeight, 0.1, 200);
camera.position.set(0, 1.6, 26);
```

Bounds recompute on resize:
```js
let BX = 15;
function computeBounds(){
  const visH = Math.tan(THREE.MathUtils.degToRad(55/2)) * 26;
  const visW = visH * camera.aspect;
  BX = THREE.MathUtils.clamp(visW*0.8, 5.5, 15);
}
```

Seeded rand:
```js
let seed = 7;
function rand(){ seed = (seed*1664525 + 1013904223) % 4294967296; return seed/4294967296; }
```
plus randRange.

Lights as planned.

Background dome:

```js
const dome = new THREE.Mesh(
 new THREE.SphereGeometry(70, 32, 24),
 new THREE.ShaderMaterial({
   side: THREE.BackSide, depthWrite:false,
   uniforms:{ cTop:{value:new THREE.Color(0x2f8496)}, cMid:{value:new THREE.Color(0x0e4a5a)}, cBot:{value:new THREE.Color(0x041a22)} },
   vertexShader:`varying vec3 vP; void main(){ vP = position; gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
   fragmentShader:`varying vec3 vP; uniform vec3 cTop,cMid,cBot;
     void main(){
       float h = normalize(vP).y;
       vec3 c = mix(cBot, cMid, smoothstep(-0.6, 0.05, h));
       c = mix(c, cTop, smoothstep(0.05, 0.7, h));
       gl_FragColor = vec4(c, 1.0);
     }`
 })
);
scene.add(dome);
```
dome renderOrder −1? It's opaque BackSide depthWrite false — draw first: set dome.renderOrder = -10; and material depthTest true fine. Actually with depthWrite false and drawn first, everything else overwrites ✓. Ensure it's first in opaque render order: renderOrder -10 ✓.

Sand with caustics injection.

Rocks etc.

Then fish system, bubbles, food, hand, shafts, water.

Animation loop with clock.

Let me write out makeFishTexture:

```js
function fishTexture(opts){
  const c = document.createElement('canvas'); c.width=128; c.height=256;
  const x = c.getContext('2d');
  // counter-shading: belly (u=0/1) light, back (u=0.5) dark
  const g = x.createLinearGradient(0,0,128,0);
  g.addColorStop(0, opts.belly); g.addColorStop(0.5, opts.back); g.addColorStop(1, opts.belly);
  x.fillStyle = g; x.fillRect(0,0,128,256);
  if(opts.bands){ for(const b of opts.bands){
      const y = (1-b.at)*256; // flipY: v=0 (nose) = canvas bottom; direction minor
      const h = b.h*256;
      x.fillStyle = b.edge; x.fillRect(0, y-h/2-3, 128, h+6);
      x.fillStyle = b.color; x.fillRect(0, y-h/2, 128, h);
  }}
  if(opts.speckle){ x.globalAlpha=0.10; x.fillStyle='#20323a';
    for(let i=0;i<380;i++){ x.fillRect(rand()*128, rand()*256, 2, 2); }
    x.globalAlpha=1; }
  if(opts.mark){ // tang palette marking
    x.fillStyle='rgba(10,22,58,0.85)';
    x.beginPath(); x.ellipse(64, 118, 34, 46, 0, 0, Math.PI*2); x.fill();
    x.fillStyle='rgba(8,16,44,0.5)';
    x.beginPath(); x.ellipse(64, 210, 26, 30, 0, 0, Math.PI*2); x.fill();
  }
  const t = new THREE.CanvasTexture(c);
  t.colorSpace = THREE.SRGBColorSpace;
  t.wrapS = THREE.RepeatWrapping;
  return t;
}
```
Note rand() is seeded — used inside texture creation, ok.

Bands 'at' in v fraction (0 nose,1 tail).

Clownfish bands at 0.30 (head), 0.55, 0.8 with white.

Now fish geometry function (shared geometries across fish! body geometry same unit for all — yes create once):

```js
const bodyGeo = (()=>{ ... })();
const tailGeo, dorsalGeo, analGeo, pectGeo, eyeGeo — create once.
```

Pect shape:
```js
const ps = new THREE.Shape();
ps.moveTo(0,0);
ps.quadraticCurveTo(0.16, 0.02, 0.26, 0.14);
ps.quadraticCurveTo(0.10, 0.10, 0, 0.035);
ps.closePath? 
```
Hmm shape extends +x; with mesh rotation.y = π/2∓0.6 maps +x → mostly −z ✓.

pect rotation values: right: rotation.set(0.5, Math.PI/2 - 0.55, 0); left: rotation.set(0.5, Math.PI/2 + 0.55, 0); Wait left should mirror: also x position negative and maybe rotation.x negative? rotation.x tilts; mirror: left rotation.set(-0.5?, ...). Rotation.x positive rotates +x toward... For right fin we want tilt downward-outward. Honestly small; set left.rotation.set(0.5, Math.PI/2+0.55, 0) and rely on symmetry of y-rotation (mirror across x) ✓ (rotation.x same both — slight asymmetry invisible).

Position pect: (±0.15, -0.02, 0.14).

Flap animation: store base rotation and animate rotation.x: pect.rotation.x = 0.5 + sin(t*4+phase)*0.25 (right), left = 0.5 + sin(t*4+phase+π)*0.25? Just same sign fine. I'll store bx.

Dorsal/anal: created via shapes, geometry rotateY(-π/2). Dorsal position y=0? shape already has base at y≈0.02..0.1 in local; mesh at (0,0.05,0) fine; actually geometry after rotateY: shape coords x→z ✓ y stays. Position (0, 0.16, 0.02) — embed into body top. Body top at bulge 0.34 (maxR). Hmm dorsal base must be near body surface: body height max r=0.34 at t 0.4 (z ≈ 0.55−0.44=0.11). So set dorsal mesh position y=0.22 (inside body since body top at ~0.34 near z 0.11, less toward tail: at z=−0.4 (t=0.86, tp=0.86^0.72≈0.9? sin(π*0.9)=0.31, r=0.34*0.31^? wait 0.85 pow: sin^0.85: 0.31^0.85≈0.37, r≈0.126). Dorsal spans z −0.42..0.3 — at tail end body top only 0.13, fin base 0.22 floats above. Lower base to y=0.10 and shape heights accordingly (dorsal rises to 0.5). Some floating at tail tip barely visible; acceptable. Or curve dorsal base along body — overkill. Set dorsal pos y=0.12.

Body cross-section at various z heights: max 0.34. Dorsal shape drawn with base y 0.02..0.1, mesh y offset 0.14 → base at 0.16..0.24 world — intersects body mid region ✓ near tail floats slightly — fine, tail region narrow anyway, fin trailing edge near tail narrow too. Good enough.

Anal fin: mirrored small at y=-0.1? base −0.02, mesh y=−0.12, spans z −0.05..−0.38.

Tail: pivot z −0.5; tail extends to −0.5−0.45=−0.95 ✓ within length.

Eye pos z 0.34, y 0.07, x ±0.13.

All unit; inner scaled by size.

Wag rotation on tailGroup.rotation.y — tail shape in x?? tail geometry after rotateY(-π/2): shape x→z. Tail drawn with −x extending... I said draw tail with negative x to extend −z? With rotateY(−π/2), shape x maps to +z (x→z per earlier: (x,y,0)→(0,y,x)). So to extend backward (−z), draw shape with NEGATIVE x ✓ as I planned (curve to −0.45).

Wag rotates group around Y — fin sweeps horizontally ✓.

Double-check dorsal rotateY(-π/2) maps shape +x→+z (noseward). I drew dorsal from −0.42..+0.3 ✓ meaning 0.3 toward nose. ✓.

Now behavior loop and rest. Also fish should avoid dome/sand: bounds Y lower −5.8 (sand −6.5 + margin) ✓.

Rock collision: fish may pass through rocks — rocks near edges; fine.

Bubbles data:

```js
const BUB=70; positions Float32Array; bubs = [] {x,y,z,s,ph}
emitters = [[-12,-5,-6],[11.5,-5.5,-4],[2,-6,-8],[ -3,-6,3]];
init: random y from -6..9, around emitters random.
update: y += s*dt; x += sin(time*2+ph)*0.2*dt; if y>9.2: pick emitter; y=-6.3.
```

Food:

```js
const MAXF=90; foods=[]; inactive pool; positions attr; spawnFood(p){ for k<8: slot... }
```

Food update & eating inside fish loop? Separate: update foods first, then fish loop reads positions. foods as array of objects {active, pos:Vector3, vy, wob, age}.

Eat check inside fish update: iterate foods (90) → ok.

Write pos array each frame.

Hand:

```js
const glowTex = radial...
const hand = new THREE.Sprite(new THREE.SpriteMaterial({map:glowTex, color:0xaeeaff, transparent:true, opacity:0.5, blending:THREE.AdditiveBlending, depthWrite:false, fog:false}));
hand.scale.setScalar(3);
handLight = new THREE.PointLight(0xbfe9ff, 30, 16, 2);
```

In r170 PointLight constructor (color,intensity,distance,decay) ✓.

Scare:

```js
const scare = new THREE.Vector3(0,0,0);
const scareTarget = new THREE.Vector3();
const ndc = new THREE.Vector2(0,0); // stays 0 in auto
const raycaster = new THREE.Raycaster();
function updateScareTarget(t){
  if(userActive){
    raycaster.setFromCamera(ndc, camera);
    const o=raycaster.ray.origin, d=raycaster.ray.direction;
    if (Math.abs(d.z)>1e-4){
      const k=(0-o.z)/d.z;
      scareTarget.copy(o).addScaledVector(d,k);
    }
  } else {
    scareTarget.set(
      Math.sin(t*0.5)*8 + Math.sin(t*1.13)*2.5,
      1.6 + Math.sin(t*0.77+1.0)*3.2,
      Math.sin(t*0.36+2.0)*4.0
    );
  }
  scareTarget.x = clamp(scareTarget.x, -BX+1, BX-1);
  scareTarget.y = clamp(scareTarget.y, -5.5, 8.2);
  scareTarget.z = clamp(scareTarget.z, -7, 7);
}
```

Auto food timer: if !userActive && t>nextAutoFood: spawnFood(scare); nextAutoFood = t+7+rand*4.

Hand visuals each frame: hand.position.copy(scare); pulse; handLight.position.set(scare.x, scare.y+0.8, scare.z+3); intensity 24+ sin flicker*6 + fearActivity? Also boost intensity when fish nearby recently: track scareHeat = max over fish falloff → intensity += heat*30. Nice feedback. Compute in fish loop: heat = max(heat, fall).

Opacity hand: 0.35 + heat*0.4.

R=5.4.

Fish spawn positions: within 0.7 bounds, y −4..5.

Init vel random horizontal-ish.

Now — vertical wander targets: y range −4.5..5.5.

Silver school: give them a shared "school target" periodically? Cohesion/alignment enough.

Also fish should gently avoid the dome edges near camera: z bounds ±8.5 minus margin — camera at 26, fine.

Write the loop:

```js
renderer.setAnimationLoop(()=>{
  const dt = Math.min(clock.getDelta(), 0.05);
  const t = clock.elapsedTime;
  uTime.value = t;
  // camera
  ...
  updateScareTarget(t);
  scare.lerp(scareTarget, 1-Math.exp(-dt*7));
  let heat=0;
  updateFoods(dt,t);
  for fish... heat
  hand visuals
  bubbles, shafts
  renderer.render(scene,camera);
});
```

Kelp uses shared uTime via onBeforeCompile uniform assignment: shader.uniforms.uTime = uTime ✓ (uTime={value:0}).

Water material uniforms uTime: uTime shared ✓.

Caustic uses uTime shared ✓.

Resize:
```js
addEventListener('resize', ()=>{ camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth,innerHeight); computeBounds(); });
```

Pill fade:
```js
let pillHidden=false;
function markUser(){ if(userActive) return; userActive=true; document.body.classList.add('touched'); setTimeout(()=>pill.classList.add('fade'), 4000); }
```
pointermove → markUser; pointerdown → markUser + food; keydown → markUser (+food at scare if never? only if !userActive before). fine.

Pill content includes a little animated dot (CSS pulse) for life.

Double check no alert/localStorage ✓.

Also we must not have horizontal scrollbar: body overflow hidden ✓; overlays fixed.

Now, are shafts + water + dome possibly z-fighting? No overlaps.

Sand y at −6.5 with displacement ±~0.4; caustics embedded in sand material ✓ no extra plane. 

Pebbles: 12 small dodecahedrons scattered, y = sand height approx −6.4, scale 0.15-0.5, color mix #8a7f66 / #5c6a63.

Rocks y: partially buried: y = -6.5 + sy*0.35.

Kelp base y = -6.3 (buried slightly).

Corals y=-6.3.

Check sand displacement function: 
```js
function dune(x,z){
  return Math.sin(x*0.16 + 1.3)*0.5 + Math.sin(z*0.21 + 4.0)*0.45 + Math.sin(x*0.43 + z*0.35)*0.22 + Math.sin(x*1.1 - z*0.7)*0.08 + (rand()-0.5)*0.15;
}
```
Amplitude ~±1.2 max; a bit much — scale to ~±0.7: multiply 0.75. Place objects at y = −6.5 + dune(x,z)*0.9 + small. I'll compute same function for object placement to sit on sand ✓. Use dune for base y: y = -6.5 + dune(x,z). Rocks bury: y − scale*0.3.

Dome radius 70 vs sand 100×70: sand corners at (±50, ±35): dist 61 < 70 ✓ inside dome ✓ (dome sphere radius 70 centered origin; corner (50,−6.5,35) length ~61.5 ✓ inside).

Water surface 110×80 → corners (55,9.5,40): length ~68.6 < 70 ✓ barely. Set water 100×70 ✓ same as sand.

Far plane 200 ✓.

Fog on dome: ShaderMaterial no fog ✓ good (dome is the "distance color").

FogExp2 0.023: at 60 (sand far corner): factor 1−exp(−(1.38)²)=1−exp(−1.9)=0.85 → far sand mostly fog colored 0x0e4a5a matching dome mid — dome at horizon bottom is cBot (0x041a22) darker vs fog lighter... seam may show where sand ends against dome bottom. Make fog color closer to dome bottom-mid blend: 0x0a3c4c. And dome cBot 0x072630, cMid 0x0e4a5a. Fog 0x0c4050. At far sand ~85% fog 0x0c4050 vs dome bottom 0x072630 — slight difference, reads as deeper distance — acceptable, actually gives depth layering. Fine.

Fish flee radius: also give slight preference to flee downward/away? natural: dir from scare ✓.

Speeds: silver 4.3*(1+fear*1.4)= up to 10 — fast darting ✓ fun.

Max acc: flee 16 with dt .016 → dv .26/frame — reaches speed in ~0.3s ✓.

Drag 0.35 → terminal ~ acc/0.35.

Wander seek 0.9 → cruise ~2.6 ✓.

Bounds spring k=8 margin 2.

Food attract 2.6.

Cohesion: v2 * (sp.coh*0.05) summed over up to 12 neighbors → ~0.05*2.2*12*avg5 = 6.6 too strong? acc.add(v2.multiplyScalar(...)) where v2 = g.pos − f.pos magnitude up to 6 → per neighbor up to 0.66, ×12 = 8 — too strong. Scale: coh factor 0.02: per neighbor ≤0.26 ×12 = 3.2 ok-ish. Set formula: acc.addScaledVector(v2, sp.coh*0.02). Silver coh 2.2 → 0.044 per unit ×6 = 0.26 each. Fine. Alignment similar 0.03 factor: v3 = g.vel − f.vel magnitude ~2 → 0.06 each ×12=0.72 ✓.

Separation: dir normalized*(1−d/sepR)*5.5 ✓.

Let me define per-species sepR: size*1.6+0.4? Global sepR per pair: use (f.sepR + g.sepR)*0.5 where sepR = sp.size*1.1+0.35.

OK. Also avoid fish jitter when many forces: drag helps.

Also: fish group scale: inner.scale = size. Collision radii scale ✓.

Fear effect on wander: when fear>0.5, ignore target seek (panicked) — scale seek by (1−fear*0.8) ✓.

Now fish also should scatter from hand in auto mode same as user — yes same scare.

I think design is solid. Time to write final HTML. I'll write ~600-700 lines. Keep code tidy with section comments.

Also add subtle "click" feedback: on pointerdown, quick ring pulse: animate hand scale to 4.5 then back (store pulse timer). And spawn food ✓.

Keyboard: keydown marks user & maybe drops food — implement drop on keydown too (fun). But space scrolling prevented? body no scroll. ok.

Now write. Also ensure `const clock = new THREE.Clock();`.

Careful: in module scope, `rand` used by textures etc. — define early.

Fish creation:

```js
function makeFish(sp){
  const outer = new THREE.Group();
  const inner = new THREE.Group();
  outer.add(inner);
  inner.scale.setScalar(sp.size);
  const body = new THREE.Mesh(bodyGeo, sp.bodyMat);
  body.castShadow = true;
  inner.add(body);
  const tail = new THREE.Group(); tail.position.set(0,0,-0.5); inner.add(tail);
  const tailM = new THREE.Mesh(tailGeo, sp.finMat); tailM.castShadow = true; tail.add(tailM);
  const dorsal = new THREE.Mesh(dorsalGeo, sp.finMat); dorsal.position.set(0,0.12,0.02); inner.add(dorsal);
  const anal = new THREE.Mesh(analGeo, sp.finMat); anal.position.set(0,-0.12,-0.1); inner.add(anal);
  const pr = new THREE.Mesh(pectGeo, sp.finMat); pr.position.set(0.14,-0.03,0.16); pr.rotation.set(0.55, Math.PI/2-0.55, 0); inner.add(pr);
  const pl = new THREE.Mesh(pectGeo, sp.finMat); pl.position.set(-0.14,-0.03,0.16); pl.rotation.set(0.55, Math.PI/2+0.55, 0); inner.add(pl);
  const er = new THREE.Mesh(eyeGeo, eyeMat); er.position.set(0.125,0.075,0.33); inner.add(er);
  const el = er.clone(); el.position.x = -0.125; inner.add(el);
  ...
}
```

Note dorsal position z=0.02 & drawn shape spans −0.42..0.3 around 0 → world −0.4..0.32 ✓.

Species material fin: transparent opacity 0.9, side DoubleSide, roughness 0.6. Sprat fin opacity 0.7.

Silver bodyMat metalness 0.65 roughness 0.3 — with env? No envmap → metal looks dark. Without env map, metalness high = dark. Use metalness 0.35 roughness 0.35 and brighter base color #cdd8dd. Or add scene.environment via RoomEnvironment? That needs addons import (PMREMGenerator + RoomEnvironment from three/addons) — allowed! Import map includes addons. RoomEnvironment would make fish/silver look much better. 

```js
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
```
In r170 RoomEnvironment() constructor without renderer ✓ (older needed renderer? RoomEnvironment(renderer?) — it's just a Scene subclass; fromScene handles). Yes standard usage. Environment intensity default 1 — underwater might look too shiny; set scene.environmentIntensity = 0.35 (r163+ supports scene.environmentIntensity ✓ in r170). 

This will nicely light metals. Keep silver metalness 0.7 roughness 0.3.

Water tint though — env is neutral white-ish; 0.35 intensity okay.

Alright. Also scene.environment affects standard materials everywhere — fine.

One more risk: RoomEnvironment import path 'three/addons/environments/RoomEnvironment.js' ✓ exists.

PMREM dispose after.

Now write everything. Also `THREE.ColorManagement` default fine.

Potential pitfall: assigning shader.uniforms.uTime = uTime in onBeforeCompile — but three caches programs; for kelp each material has own onBeforeCompile closure (created in loop) — different Function objects → different programCacheKey? Program cache key includes onBeforeCompile via material.customProgramCacheKey() which default returns onBeforeCompile.toString() — same source string → same key → shared program, per-material uniforms still uploaded ✓ (uniforms are stored per material via properties.shader.uniforms? hmm: In WebGLRenderer, when program acquired, materialProperties.uniforms = shader.uniforms (from onBeforeCompile result) — per material ✓.)

Edge: kelp materials share program but each has own uniform values ✓ standard.

Now food Points: geometry.setAttribute position BufferAttribute(foodArr,3). material PointsMaterial({size:0.26, map:foodTex, transparent:true, alphaTest:0.35, color:0xd8b078, sizeAttenuation:true, depthWrite:true? Points depthWrite with alphaTest works}). Slight brown-gold flakes.

Bubbles Points additive: PointsMaterial({size:0.32, map:bubTex, transparent:true, opacity:0.55, depthWrite:false, blending:THREE.AdditiveBlending, color:0xbfe8f2, sizeAttenuation:true}).

Points frustumCulled=false both.

Also sprite hand: renders through water? transparent sorting by distance; hand at z~0 nearer than water surface (y 9.5 far?) distance from camera: hand ~26; water surface center ~ sqrt(26²+8²)=27 → hand nearer, drawn later? Three sorts transparent back-to-front: farther first ✓ water first, hand after ✓ good.

Shafts also transparent additive.

Order fine.

Text: also small top-right "demo · the hand is automatic until you move" indicator? The pill covers. When auto, pill says... initial pill: "A phantom hand is teasing the fish — move your cursor to take over · click to feed". Two-line pill. After user: fades. Good, explains demo mode explicitly (requirement-ish).

Let me finalize pill text:
" until you touch the water, a phantom hand wanders the tank " + second row: "move your mouse to take over · click to drop food".

Keep single row: "Phantom hand on duty — move your cursor to take over · click to drop food". OK.

Title: THE GLASS REEF / "twenty-six fish · three species · one cursor to fear".

Flavor bottom right: "26°C · pH 8.2 · salinity 1.025".

Bottom-left maybe depth marker? skip.

Media query <560px: hide flavor, pill font 10.5px, title 12px.

Now write the code. Watch GLSL string escapes — none needed.

One caution: In caustic GLSL, `mod(uv*6.28318, 6.28318) - 250.0` the -250 is from original; keep.

Also caustic uv scale: vWP.xz*0.14 → pattern tiles every ~7 units; fine.

Let me define caustic fn with MAX_ITER 4 and time factor uTime*0.6.

Also apply caustics also to rocks? skip.

emissivemap_fragment include exists in MeshStandardMaterial fragment ✓ (it's part of lights_fragment? The chain: '#include <emissivemap_fragment>' appears in meshphysical_frag? Let me recall meshphysical.glsl.js fragment contains:
```
#include <emissivemap_fragment>
```
Yes ✓ (after vec3 totalEmissiveRadiance declaration). ✓

And '#include <common>' in both shaders ✓.

Kelp injection: vertexShader has '#include <common>' ✓ and '#include <begin_vertex>' ✓.

Sand material also gets dune vertex colors? Add subtle color variation via vertexColors: color mix by dune height (lighter crests): nice. Compute colors while displacing: c = lerp(#b9a47e, #d8c8a4, h normalized) with greenish patches random: add slight algae tint patches where sin(x*0.3)*sin(z*0.4)>0.4: mix toward #7a8a5e. Do simple: base sand color + noise tint. Use vertexColors true and material color white? Set colors directly.

Sand: 
```js
for each vertex (x, z after rotate? plane rotated after? Do displacement before rotateX? Plane geometry initial XY; rotateX(-π/2) maps y→? Let's just rotateX first then positions have (x, y≈0, z). Then set pos.y = dune(x,z) — but sign: plane rotated -π/2 ( facing +y) ✓ then add height.
```
PlaneGeometry(100,70,90,60).rotateX(-Math.PI/2) → normal +y ✓. Then iterate positions attr: y = dune(x,z). computeVertexNormals ✓.

Colors: sandCol = lerp between 0xbfa87f and 0xd9caa2 by (y+0.8)/1.6; occasionally darker pebble tint rand*0.05. Set 'color' attribute; material vertexColors: true, color 0xffffff? With vertexColors true final = color × vertexColor; set material color white and put full color in vertex ✓. roughness 1.

Then caustic injection adds emissive ✓.

Also make directional light cast shadows across dunes ✓.

Sand receiveShadow = true.

Rocks receiveShadow true too.

Now write final answer. Ensure exactly one fenced code block, no text around. Language: English (user English).

Also double-check: "The page must look right in 1280×800" ✓; "no horizontal scrollbar" ✓.

Let me write fish update code with proper temp vectors to avoid allocs.

Also add fish count constant adjusting? fine.

One subtle thing: `renderer.domElement` style — canvas {display:block; position:fixed; inset:0}.

Pill interactions: pointer-events none on all overlay divs so canvas receives events (events on window anyway).

Cursor: body.touched canvas cursor none; default cursor: crosshair? 'default' fine; use crosshair for thematic. I'll set canvas cursor: crosshair initially, none when touched. Wait — with cursor none and glow sprite, if user moves to UI area still fine.

Write the keydown listener: markUser + spawnFood(scare).

spawnFood guard: if(!canSpawn) etc. time-based: lastFood.

Also when user clicks rapidly: fine 150ms throttle.

Now, the tail wag phase per fish; also whole inner slight yaw wiggle: inner.rotation.y = sin(t*wag*0.5+phase)*0.06 adds life ✓.

Let me also add small chance fish idles (slow cruise) — fear dynamics cover.

Final check on lathe profile min radius at nose: i==0 r=0.05 (rounded nose cap hole small) fine.

Actually LatheGeometry requires points sorted? Any order fine.

Edge: fish material map wrapS RepeatWrapping needed? u in [0,1] no repeat needed; fine default clamp. Remove wrapS line? Keep clamp (default). Set anisotropy 4.

Also texture flipY default true for CanvasTexture ✓ assumed in band positions (mirrored—fine).

Now produce code. I'll be meticulous with GLSL and API details.

Rough line count ~650. Good.

Additional safety: wrap entire init in try/catch? Not needed.

Write HTML:

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>The Glass Reef — a living aquarium</title>
<style>
  :root{ --ink:#eaf6f8; }
  *{margin:0;padding:0;box-sizing:border-box}
  html,body{height:100%;overflow:hidden;background:#04141c}
  body{font-family:"Avenir Next","Helvetica Neue","Segoe UI",Arial,sans-serif;color:var(--ink)}
  canvas{display:block;position:fixed;inset:0;cursor:crosshair}
  body.touched canvas{cursor:none}
  #veil{position:fixed;inset:0;pointer-events:none;
    background:
      radial-gradient(120% 95% at 50% 42%, rgba(0,0,0,0) 55%, rgba(2,12,18,.55) 100%),
      linear-gradient(168deg, rgba(190,235,250,.075), rgba(0,0,0,0) 20%, rgba(0,0,0,0) 80%, rgba(120,205,225,.05));}
  #title{position:fixed;top:28px;left:34px;pointer-events:none;user-select:none}
  #title h1{font-weight:500;font-size:15px;letter-spacing:.46em;text-transform:uppercase;color:#f2fbfc;text-shadow:0 2px 14px rgba(0,20,30,.6)}
  #title p{margin-top:8px;font-family:Georgia,"Times New Roman",serif;font-style:italic;font-size:12.5px;letter-spacing:.14em;color:rgba(189,228,235,.72)}
  #stats{position:fixed;right:30px;bottom:24px;pointer-events:none;user-select:none;font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:rgba(170,214,224,.45)}
  #pill{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);pointer-events:none;user-select:none;
    display:flex;align-items:center;gap:10px;white-space:nowrap;
    background:rgba(6,26,34,.55);border:1px solid rgba(150,215,228,.22);border-radius:999px;
    padding:10px 18px;font-size:11.5px;letter-spacing:.12em;color:#cfeef2;
    backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);
    transition:opacity .8s ease; box-shadow:0 6px 24px rgba(0,10,16,.35)}
  #pill.fade{opacity:0}
  #pill .dot{width:7px;height:7px;border-radius:50%;background:#7fe3c8;box-shadow:0 0 10px #7fe3c8;animation:pulse 1.8s infinite}
  @keyframes pulse{0%,100%{opacity:.45;transform:scale(.8)}50%{opacity:1;transform:scale(1.15)}}
  @media (max-width:600px){
    #title{top:18px;left:20px} #title h1{font-size:12px;letter-spacing:.32em} #title p{font-size:10.5px}
    #stats{display:none}
    #pill{font-size:10px;padding:8px 13px;bottom:16px;white-space:normal;text-align:center;max-width:82vw}
  }
</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>
<div id="veil"></div>
<div id="title"><h1>The Glass Reef</h1><p>twenty-six fish · three species · one cursor to fear</p></div>
<div id="stats">26°c · ph 8.2 · salinity 1.025</div>
<div id="pill"><span class="dot"></span><span>a phantom hand is wandering the tank — move your cursor to take over · click to drop food</span></div>
<script type="module">
...
</script>
</body>
</html>
```

Now the module script. Write it fully.

```js
import * as THREE from 'three';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';

/* ---------- basics ---------- */
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.12;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.prepend(renderer.domElement);

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x06222e);
scene.fog = new THREE.FogExp2(0x0c4050, 0.023);

const camera = new THREE.PerspectiveCamera(55, innerWidth/innerHeight, 0.1, 220);
camera.position.set(0, 1.6, 26);

const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
scene.environmentIntensity = 0.35;
pmrem.dispose();

const uTime = { value: 0 };
const clock = new THREE.Clock();

let seed = 1234567;
const rand = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };
const rr = (a, b) => a + rand() * (b - a);

/* bounds that follow the frustum so fish stay on screen */
let BX = 15;
const computeBounds = () => {
  const visH = Math.tan(THREE.MathUtils.degToRad(27.5)) * 26;
  BX = THREE.MathUtils.clamp(visH * camera.aspect * 0.8, 5.5, 15);
};
computeBounds();
```

Wait: `seed = (seed*1664525 + 1013904223) >>> 0` — multiplication overflow: JS numbers double; seed*1664525 up to 2^32*1.6e6 ≈ 7e15 < 2^53 ✓ exact, then >>>0 truncates to 32 bits ✓ fine.

Lights:

```js
scene.add(new THREE.HemisphereLight(0xaadcec, 0x0a2530, 0.85));
const sun = new THREE.DirectionalLight(0xcfeef7, 2.6);
sun.position.set(9, 22, 7);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -22; right 22; top 22; bottom -22; near 2; far 60;
sun.shadow.bias = -0.0004; sun.shadow.normalBias = 0.03;
scene.add(sun);
const fill = new THREE.PointLight(0x69c8d8, 60, 60, 2); fill.position.set(0, -2, 8); scene.add(fill); // faint fill from front? intensity 60 at 10 dist = 0.6 subtle ✓
```

Dome (as above).

Sand:

```js
const dune = (x, z) =>
  Math.sin(x*0.16 + 1.3)*0.38 + Math.sin(z*0.21 + 4.0)*0.34 +
  Math.sin(x*0.43 + z*0.35)*0.18 + Math.sin(x*1.1 - z*0.7)*0.07;

const sandGeo = new THREE.PlaneGeometry(100, 70, 96, 64);
sandGeo.rotateX(-Math.PI/2);
{
  const pos = sandGeo.attributes.position;
  const colors = new Float32Array(pos.count*3);
  const cA = new THREE.Color(0xb59f78), cB = new THREE.Color(0xdccfa9), cC = new THREE.Color(0x8b9364), tmp = new THREE.Color();
  for(let i=0;i<pos.count;i++){
    const x=pos.getX(i), z=pos.getZ(i);
    const h = dune(x,z) + (rand()-0.5)*0.06;
    pos.setY(i, h);
    const k = THREE.MathUtils.clamp((h+0.9)/1.8, 0, 1);
    tmp.copy(cA).lerp(cB, k);
    const algae = Math.sin(x*0.33+2.0)*Math.sin(z*0.41+0.7);
    if(algae > 0.35) tmp.lerp(cC, (algae-0.35)*0.9);
    colors[i*3]=tmp.r; colors[i*3+1]=tmp.g; colors[i*3+2]=tmp.b;
  }
  sandGeo.setAttribute('color', new THREE.BufferAttribute(colors,3));
  sandGeo.computeVertexNormals();
}
const sandMat = new THREE.MeshStandardMaterial({ vertexColors:true, roughness:1.0, metalness:0.0 });
sandMat.onBeforeCompile = (shader) => {
  shader.uniforms.uTime = uTime;
  shader.vertexShader = shader.vertexShader
    .replace('#include <common>', '#include <common>\nvarying vec3 vWP;')
    .replace('#include <begin_vertex>', '#include <begin_vertex>\n vWP = transformed;');
  shader.fragmentShader = shader.fragmentShader
    .replace('#include <common>', `#include <common>
      varying vec3 vWP;
      uniform float uTime;
      float caustic(vec2 uv, float t){
        vec2 p = mod(uv*6.28318, 6.28318) - 250.0;
        vec2 i = p; float c = 1.0; float inten = 0.005;
        for (int n = 0; n < 4; n++){
          float tt = t * (1.0 - (3.5/float(n+1)));
          i = p + vec2(cos(tt - i.x) + sin(tt + i.y), sin(tt - i.y) + cos(tt + i.x));
          c += 1.0/length(vec2(p.x/(sin(i.x+tt)/inten), p.y/(cos(i.y+tt)/inten)));
        }
        c /= 4.0; c = 1.17 - pow(c, 1.4);
        return pow(abs(c), 8.0);
      }`)
    .replace('#include <emissivemap_fragment>', `#include <emissivemap_fragment>
      float ca = caustic(vWP.xz*0.13, uTime*0.55);
      totalEmissiveRadiance += vec3(0.28, 0.68, 0.72) * ca * 0.6;`);
};
const sand = new THREE.Mesh(sandGeo, sandMat);
sand.receiveShadow = true;
scene.add(sand);
```

Wait — sand mesh at origin, local == world ✓ (vWP=transformed ok).

Note: sand positions y around ±0.9 around 0 — sand should be at y≈−6.5! The plane at y=0 with dune height — I need the whole floor at −6.5: either mesh.position.y=−6.5 (then vWP local ≠ world: vWP = transformed local, missing offset! Fix: vWP = transformed + vec3(0,-6.5,0)? Or bake offset: after setting heights, translate geometry y −6.5 → then local==world again ✓. Do sandGeo.translate(0,-6.5,0) after displacement. ✓ And dune() function used for placement should then be: baseY = dune(x,z) − 6.5.

Rocks:

```js
const rockMat = new THREE.MeshStandardMaterial({ color:0x49564f, roughness:0.95, flatShading:true });
function rock(x, z, s, sy=s){
  const g = new THREE.IcosahedronGeometry(1, 1);
  const p = g.attributes.position;
  for(let i=0;i<p.count;i++){
    const k = 1 + (rand()-0.5)*0.5;
    p.setXYZ(i, p.getX(i)*k, p.getY(i)*k*0.8, p.getZ(i)*k);
  }
  g.computeVertexNormals();
  const m = new THREE.Mesh(g, rockMat);
  m.position.set(x, dune(x,z)-6.5 + sy*0.32, z);
  m.scale.set(s, sy, s*rr(0.8,1.1));
  m.rotation.y = rand()*Math.PI*2;
  m.castShadow = m.receiveShadow = true;
  scene.add(m);
  return m;
}
```
Note flatShading with modified verts needs computeVertexNormals but flat shading uses derivatives... with flatShading true, normals computed in shader from position? In three, flatShading uses computed vertex normals? flatShading:true makes material use `#ifdef FLAT_SHADED` normal = derivatives — yes fragment derivatives, so normals not needed. Fine.

IcosahedronGeometry non-indexed? Icosahedron is indexed? Detail 1 icosahedron via PolyhedronGeometry — vertices shared? PolyhedronGeometry subdivides and duplicates? It produces non-indexed with duplicates I believe (polyhedron.js creates vertices per face?). Actually PolyhedronGeometry builds with duplicated vertices per face (it has its own subdivision producing vertex buffer without index). If duplicated, random per-vertex perturb creates cracks! PolyhedronGeometry: it pushes vertices per subdivided face — yes duplicates → cracks. To avoid: perturb deterministically by position hash instead of random per index:

```js
const h = (x,y,z)=> fract(sin(x*12.9898+y*78.233+z*37.719)*43758.5453);
k = 1 + (h(px,py,pz)-0.5)*0.5 — same for coincident vertices ✓.
```
Write helper hash3. Then remove computeVertexNormals (flatShading). Also icosahedron radius param: new THREE.IcosahedronGeometry(1,1) then scale.

Pebbles: same function small, different color material sandy #97876b.

Rocks placement: 
rock(-13.5,-7.5, 3.2, 2.4); rock(-10.5,-9, 2.2, 1.6); rock(12.5,-8, 3.0, 2.2); rock(9,-9.5, 1.8,1.2); rock(14.5,2, 2.4,1.8); rock(-14.8,0.5,2.0,1.5); plus pebbles loop 14 random within ±16, ±9 scale 0.2-0.55.

Corals:

```js
function coral(x, z, hue){ // hue: THREE.Color base
  const g = new THREE.Group();
  const n = 5 + Math.floor(rand()*4);
  for(i<n){
    const hgt = rr(0.5, 1.6), rad = rr(0.09, 0.16);
    const cone = new THREE.Mesh(new THREE.ConeGeometry(rad, hgt, 7),
      new THREE.MeshStandardMaterial({ color: hue.clone().offsetHSL(0, 0, rr(-0.06,0.06)), roughness:0.7 }));
    const a = rand()*Math.PI*2, r = rand()*0.7;
    cone.position.set(Math.cos(a)*r, hgt*0.45, Math.sin(a)*r);
    cone.rotation.set(Math.sin(a)*0.35, 0, Math.cos(a)*0.35);
    cone.castShadow = true;
    g.add(cone);
  }
  g.position.set(x, dune(x,z)-6.4, z);
  scene.add(g);
}
coral(-6.5, -6.5, new THREE.Color(0xd96a52));
coral(4.5, -7.5, new THREE.Color(0xc75f86));
coral(11, -3, new THREE.Color(0xd9a04e));
```
Many materials small counts fine.

Kelp:

```js
function kelpPatch(cx, cz, count, tint){
  for(i<count){
    const h = rr(3.2, 7.2), w = rr(0.3, 0.55);
    const geo = new THREE.PlaneGeometry(w, h, 1, 12);
    geo.translate(0, h/2, 0);
    const pos = geo.attributes.position;
    const cols = new Float32Array(pos.count*3);
    const cBot = new THREE.Color(0x0c2e1c).lerp(tint, 0.25), cTop = new THREE.Color(0x2e8f4e).lerp(tint,0.55), tmp=new THREE.Color();
    for(j...){ const k = pos.getY(j)/h; tmp.copy(cBot).lerp(cTop, k + (rand()-0.5)*0.1); ... }
    geo.setAttribute('color', ...);
    const mat = new THREE.MeshStandardMaterial({ vertexColors:true, roughness:0.8, side:THREE.DoubleSide });
    const phase = rand()*20, amp = rr(0.4, 0.9);
    mat.onBeforeCompile = (sh)=>{
      sh.uniforms.uTime = uTime;
      sh.uniforms.uPhase = { value: phase };
      sh.uniforms.uAmp = { value: amp };
      sh.uniforms.uH = { value: h };
      sh.vertexShader = sh.vertexShader
        .replace('#include <common>', '#include <common>\nuniform float uTime; uniform float uPhase; uniform float uAmp; uniform float uH;')
        .replace('#include <begin_vertex>', `#include <begin_vertex>
          float kk = clamp(transformed.y / uH, 0.0, 1.0);
          transformed.x += sin(uTime*1.1 + uPhase + kk*2.2) * uAmp * kk * kk;
          transformed.z += cos(uTime*0.8 + uPhase*1.37 + kk*1.6) * uAmp * 0.6 * kk * kk;`);
    };
    const blade = new THREE.Mesh(geo, mat);
    blade.position.set(cx + rr(-1.6,1.6), dune(cx,cz)-6.55, cz + rr(-1.6,1.6));
    blade.rotation.y = rand()*Math.PI;
    scene.add(blade);
  }
}
kelpPatch(-11.5, -4.5, 6, new THREE.Color(0x2a7f6a));
kelpPatch(-12.5, 2.5, 5, new THREE.Color(0x3c9f5a));
kelpPatch(10.5, -5.5, 7, new THREE.Color(0x2a8f7a));
kelpPatch(13, 3.5, 4, new THREE.Color(0x57a83f));
kelpPatch(-3, -8.5, 5, new THREE.Color(0x2f8f66));
```

Water surface:

```js
const waterMat = new THREE.ShaderMaterial({
  transparent:true, depthWrite:false, side:THREE.DoubleSide,
  uniforms:{ uTime: uTime },
  vertexShader:`...`, fragmentShader:`...`
});
const waterGeo = new THREE.PlaneGeometry(100, 70, 64, 44); waterGeo.rotateX(-Math.PI/2);
const water = new THREE.Mesh(waterGeo, waterMat); water.position.y = 9.4; water.renderOrder = 1; scene.add(water);
```
Wait: geometry rotated so vertex y=0 plane; displacement in shader p.y += waves ✓ world offset via modelMatrix translation ✓.

Shafts:

```js
const shaftTex = (()=>{ canvas 64x256: vertical gradient + destination-in horizontal; })();
const shafts = [];
const shaftData = [[-9,-4],[-3.5,-6],[1.5,-3],[6.5,-5.5],[11,-2.5]];
for (const [sx,sz] of shaftData){
  const m = new THREE.Mesh(new THREE.PlaneGeometry(rr(1.8,2.8), 17),
    new THREE.MeshBasicMaterial({ map:shaftTex, color:0xa8e4f4, transparent:true, opacity:0.09, blending:THREE.AdditiveBlending, depthWrite:false, side:THREE.DoubleSide, fog:false }));
  m.position.set(sx, 1.4, sz);
  m.renderOrder = 3;
  m.userData = { baseRot: rr(-0.06,0.06), ph: rand()*10, baseOp: rr(0.06,0.11) };
  scene.add(m); shafts.push(m);
}
```

Canvas helpers:

```js
function makeCanvas(w,h,fn){ const c=document.createElement('canvas'); c.width=w; c.height=h; fn(c.getContext('2d'),w,h); const t=new THREE.CanvasTexture(c); return t; }
```

shaftTex draw:
```js
(g,w,h)=>{
  const v = g.createLinearGradient(0,0,0,h);
  v.addColorStop(0,'rgba(255,255,255,0.85)');
  v.addColorStop(0.5,'rgba(255,255,255,0.35)');
  v.addColorStop(1,'rgba(255,255,255,0)');
  g.fillStyle=v; g.fillRect(0,0,w,h);
  const hor = g.createLinearGradient(0,0,w,0);
  hor.addColorStop(0,'rgba(0,0,0,0)'); hor.addColorStop(0.5,'rgba(255,255,255,1)'); hor.addColorStop(1,'rgba(0,0,0,0)');
  g.globalCompositeOperation='destination-in';
  g.fillStyle=hor; g.fillRect(0,0,w,h);
}
```

Glow tex:
```js
(g,w,h)=>{ const r=g.createRadialGradient(32,32,0,32,32,32);
 r.addColorStop(0,'rgba(255,255,255,0.9)'); r.addColorStop(0.35,'rgba(255,255,255,0.28)'); r.addColorStop(1,'rgba(255,255,255,0)');
 g.fillStyle=r; g.fillRect(0,0,64,64); }
```

Bubble tex 64: ring.
Food tex 32: soft dot.

Fish textures as planned (128×256, colorSpace SRGB).

Bubbles system, food system, hand, fish.

Fish species:

```js
const SPECIES = [
  { count:7, size:1.15, speed:3.1, wander:0.9, coh:0.8, ali:0.7,
    bodyMat: new THREE.MeshStandardMaterial({ map: texClown, roughness:0.55, metalness:0.05 }),
    finMat: finMat(0xff9a45, 0.9) },
  { count:6, size:1.45, speed:2.8, wander:0.8, coh:0.7, ali:0.6,
    bodyMat: ... texTang, finMat: finMat(0xffd23f, 0.95) },
  { count:13, size:0.62, speed:4.4, wander:1.2, coh:2.4, ali:2.6,
    bodyMat: new THREE.MeshStandardMaterial({ map: texSprat, roughness:0.3, metalness:0.75 }),
    finMat: finMat(0xbfd2d8, 0.6) },
];
function finMat(color, op){ return new THREE.MeshStandardMaterial({ color, transparent:true, opacity:op, side:THREE.DoubleSide, roughness:0.55, depthWrite:true }); }
```

Transparent fins with depthWrite true: sorted transparent, may have sorting artifacts among fish — small; ok. Actually many transparent objects → three sorts by object; fins per fish sorted ok mostly.

Alternatively fins opaque — simpler rendering, no artifacts. Slight translucency nice but risk alpha sorting glitches between overlapping fish... I'll keep transparent but opacity high; artifacts minor.

Hmm, actually with 26 fish each 4 transparent fin meshes = 104 transparent objects + water + shafts + bubbles + food + sprite — sorting per frame fine.

Fish update implementation details done above. Let me now write fish code fully:

```js
const fishes = [];
const FLEE_R = 5.4;
const _a = new THREE.Vector3(), _v1 = new THREE.Vector3(), _v2 = new THREE.Vector3(), _v3 = new THREE.Vector3();
const _o = new THREE.Object3D();

function randomTarget(f){
  f.target.set(rr(-BX+2, BX-2), rr(-4.5, 5.5), rr(-7, 7));
  f.retarget = rr(3, 8);
}
```

But BX changes on resize; ok targets refreshed periodically.

spawn:
```js
for (const sp of SPECIES){
  for(let i=0;i<sp.count;i++){
    const f = makeFish(sp);
    f.pos = new THREE.Vector3(rr(-BX*0.7,BX*0.7), rr(-4,5), rr(-6,6));
    f.vel = new THREE.Vector3(rr(-1,1), rr(-0.2,0.2), rr(-1,1)).normalize().multiplyScalar(sp.speed*0.5);
    f.fear = 0; f.phase = rand()*Math.PI*2; f.prevYaw=0; f.bank=0;
    randomTarget(f);
    f.sp = sp;
    f.sepR = sp.size*1.15 + 0.4;
    scene.add(f.outer);
    f.outer.position.copy(f.pos);
    fishes.push(f);
  }
}
```

makeFish returns object with outer, tail, pectR, pectL, inner.

Update function:

```js
function updateFish(dt, t){
  let heat = 0;
  for (let i=0;i<fishes.length;i++){
    const f = fishes[i], sp = f.sp, p = f.pos;
    f.fear *= Math.exp(-dt*0.65);
    f.retarget -= dt;
    if (f.retarget <= 0 || p.distanceToSquared(f.target) < 1.44) randomTarget(f);

    _a.set(0,0,0);
    // wander
    _v1.subVectors(f.target, p);
    const dl = _v1.length();
    if (dl > 0.001) _a.addScaledVector(_v1, sp.wander * (1 - f.fear*0.85) / dl);
    // neighbours
    for (let j=0;j<fishes.length;j++){
      if (j===i) continue;
      const g = fishes[j];
      _v2.subVectors(p, g.pos);
      const d = _v2.length();
      if (d < 0.001) continue;
      const sepR = f.sepR + g.sepR;
      if (d < sepR) _a.addScaledVector(_v2, 5.5*(1-d/sepR)/d);
      if (g.sp === sp && d < 6.5){
        _a.addScaledVector(_v2, -sp.coh*0.02);           // cohesion (toward)
        _v3.subVectors(g.vel, f.vel);
        _a.addScaledVector(_v3, sp.ali*0.03);            // alignment
      }
    }
    // flee the hand
    _v1.subVectors(p, scare);
    const ds = _v1.length();
    if (ds < FLEE_R){
      const fall = 1 - ds/FLEE_R;
      if (ds > 0.001) _a.addScaledVector(_v1, 17*fall*fall/ds);
      f.fear = Math.min(1, f.fear + fall*dt*4.5);
      heat = Math.max(heat, fall);
    }
    // food
    let best = -1, bestD = 100;
    for (let k=0;k<foods.length;k++){
      const fd = foods[k];
      if (!fd.active) continue;
      const d = p.distanceTo(fd.pos);
      if (d < bestD){ bestD = d; best = k; }
    }
    if (best >= 0 && bestD < 11){
      const fd = foods[best];
      if (bestD < 0.55*sp.size + 0.25){
        fd.active = false; fd.pos.y = -999;
        f.fear = 0;
      } else {
        _v1.subVectors(fd.pos, p).normalize();
        _a.addScaledVector(_v1, 2.8*(1-f.fear));
      }
    }
    // soft tank bounds
    const M = 2.2, K = 9;
    if (p.x >  BX-M) _a.x -= (p.x-(BX-M))*K;
    if (p.x < -BX+M) _a.x += ((-BX+M)-p.x)*K;
    if (p.y >  6.2)  _a.y -= (p.y-6.2)*K;
    if (p.y < -5.2)  _a.y += (-5.2-p.y)*K;
    if (p.z >  7.3)  _a.z -= (p.z-7.3)*K;
    if (p.z < -7.3)  _a.z += (-7.3-p.z)*K;

    f.vel.addScaledVector(_a, dt);
    f.vel.multiplyScalar(Math.max(0, 1 - 0.35*dt));
    const maxS = sp.speed * (1 + f.fear*1.5);
    let s = f.vel.length();
    if (s > maxS){ f.vel.multiplyScalar(maxS/s); s = maxS; }
    const minS = sp.speed*0.35;
    if (s < minS){
      if (s < 0.001) f.vel.set(rr(-1,1), 0, rr(-1,1));
      else f.vel.multiplyScalar(minS/s);
    }
    p.addScaledVector(f.vel, dt);

    // orientation
    _v1.copy(f.vel);
    if (Math.abs(_v1.x) + Math.abs(_v1.z) < 0.02) _v1.x += 0.05;
    _o.position.copy(p);
    _o.lookAt(_v1.add(p));
    f.outer.quaternion.slerp(_o.quaternion, Math.min(1, dt*3.2));
    const yaw = Math.atan2(f.vel.x, f.vel.z);
    let dy = yaw - f.prevYaw;
    dy = Math.atan2(Math.sin(dy), Math.cos(dy));
    f.prevYaw = yaw;
    const bankT = THREE.MathUtils.clamp(-dy/Math.max(dt,0.001)*0.22, -0.65, 0.65);
    f.bank += (bankT - f.bank)*Math.min(1, dt*3);
    f.outer.rotateZ(f.bank);

    f.outer.position.copy(p);
    // kick
    const wag = 5 + s*2.1 + f.fear*7;
    f.tail.rotation.y = Math.sin(t*wag + f.phase) * (0.38 + 0.25*f.fear);
    f.inner.rotation.y = Math.sin(t*wag*0.5 + f.phase) * 0.05;
    const flap = Math.sin(t*4.2 + f.phase)*0.22;
    f.pectR.rotation.x = 0.55 + flap;
    f.pectL.rotation.x = 0.55 - flap;
  }
  return heat;
}
```

Careful: `_o.lookAt(_v1.add(p))` — _v1 was vel copy; add p → target ✓.

Bank sign: turning left (dy>0) should roll into turn... whichever, looks natural either; keep.

Note `f.pectR.rotation.x` — but I created rotation.set(0.55, yAngle, 0); overwriting rotation.x each frame keeps y ✓.

rotateZ applied every frame on top of slerped quaternion — slerp pulls toward _o quaternion (roll-free) then rotateZ adds bank — consistent ✓.

Food:

```js
const MAXF = 90;
const foods = [];
const foodArr = new Float32Array(MAXF*3).fill(-999? set y -999): fill(0) then set each y.
for(let i=0;i<MAXF;i++){ foods.push({ active:false, pos:new THREE.Vector3(0,-999,0), vy:0, wob:0, age:0 }); foodArr[i*3+1] = -999; }
const foodGeo = new THREE.BufferGeometry();
foodGeo.setAttribute('position', new THREE.BufferAttribute(foodArr, 3));
const foodPts = new THREE.Points(foodGeo, new THREE.PointsMaterial({ size:0.26, map:foodTex, transparent:true, alphaTest:0.3, color:0xd9b273, depthWrite:true }));
foodPts.frustumCulled = false;
scene.add(foodPts);

let lastFood = -10;
function dropFood(at){
  const now = clock.elapsedTime;
  if (now - lastFood < 0.18) return;
  lastFood = now;
  let n = 8;
  for (const fd of foods){
    if (n <= 0) break;
    if (fd.active) continue;
    fd.active = true;
    fd.pos.set(at.x + rr(-1.1,1.1), Math.min(at.y + rr(0.5,2.0), 8.4), at.z + rr(-1.1,1.1));
    fd.vy = rr(0.45, 0.75);
    fd.wob = rand()*10;
    fd.age = 0;
    n--;
  }
}
function updateFood(dt, t){
  for(let i=0;i<foods.length;i++){
    const fd = foods[i];
    if (!fd.active){ foodArr[i*3+1] = -999; continue; }
    fd.age += dt;
    const floor = dune(fd.pos.x, fd.pos.z) - 6.5 + 0.12;
    if (fd.pos.y > floor) fd.pos.y -= fd.vy*dt*(fd.pos.y - floor < 1 ? 0.4 : 1);
    fd.pos.x += Math.sin(t*1.3 + fd.wob)*0.1*dt;
    fd.pos.z += Math.cos(t*1.1 + fd.wob)*0.08*dt;
    if (fd.age > 32 || fd.pos.y <= floor + 0.01) { if (fd.age > 32) fd.active = false; }
    foodArr[i*3] = fd.pos.x; foodArr[i*3+1] = fd.pos.y; foodArr[i*3+2] = fd.pos.z;
  }
  foodGeo.attributes.position.needsUpdate = true;
}
```
Pellets rest on floor until age 32 then vanish — fish can graze bottom ✓ (fish lower bound −5.2 vs floor −6.4: fish can't reach floor food! Fish min y bound pushes at −5.2... floor at ~−6.3. Food at floor unreachable → lower fish bound to −5.6 and food floor −6.3, eat radius 0.55*size+0.25≈0.9 for tang... still gap. Options: let pellets dissolve before reaching deep floor: stop sinking at y −5.3 (mid-water "dissolve depth") and fade out (age limit shorter once stopped). Simpler: pellets sink but deactivate when below −5.0 with a small "dissolve"... visually popping out. Alternative: allow fish to dive to −5.9 (just above sand −6.4, fish size 1.45 tang half-height ~0.5 → body grazes sand at −6.4? fish center −5.9, belly at −6.4 ✓ touching sand fine). Set fish lower bound −5.8, and eat radius +0.4. Floor food at −6.28: distance from fish center 0.5 ✓ reachable. Good: bound y low = −5.8. Also randomTarget y range −4.5..5.5 → include −5.2. ok fine.

Eat radius: 0.55*sp.size + 0.3.

Bubbles:

```js
const BUB = 64;
const bubArr = new Float32Array(BUB*3);
const bubs = [];
const emitters = [[-12.5,-6.3,-5],[11.8,-6.0,-4.5],[1.5,-6.4,-7.5]];
for(i){ const e = emitters[i%3]; bubs.push({ x:e[0]+rr(-0.8,0.8), y:rr(-6,9), z:e[2]+rr(-0.8,0.8), s:rr(0.7,1.6), ph:rand()*10 }); }
update: y += s*dt; x += sin(t*1.8+ph)*0.25*dt; if y>9.1: reset to emitter (random of 3): x=e0+rr(-.7,.7), y=-6.2, z=e2+rr(-.7,.7);
points material size 0.3 etc.
```

Hand:

```js
const hand = new THREE.Sprite(new THREE.SpriteMaterial({ map:glowTex, color:0xa8e8ff, transparent:true, opacity:0.4, blending:THREE.AdditiveBlending, depthWrite:false, fog:false }));
hand.scale.setScalar(3);
scene.add(hand);
const handLight = new THREE.PointLight(0xbfe9ff, 26, 18, 2);
scene.add(handLight);
```

Interaction:

```js
let userActive = false, nextAutoFood = 4;
const ndc = new THREE.Vector2(0, 0);
const raycaster = new THREE.Raycaster();
const scare = new THREE.Vector3(0, 1, 0);
const scareTarget = new THREE.Vector3();

function markUser(){
  if (userActive) return;
  userActive = true;
  document.body.classList.add('touched');
  setTimeout(()=>document.getElementById('pill').classList.add('fade'), 3800);
}
addEventListener('pointermove', (e)=>{
  ndc.set((e.clientX/innerWidth)*2-1, -((e.clientY/innerHeight)*2-1));
  markUser();
});
addEventListener('pointerdown', (e)=>{
  ndc.set((e.clientX/innerWidth)*2-1, -((e.clientY/innerHeight)*2-1));
  markUser();
  updateScareTarget(clock.elapsedTime);
  scare.copy(scareTarget);
  dropFood(scare);
  handPulse = 1;
});
addEventListener('keydown', ()=>{ markUser(); dropFood(scare); });
```

updateScareTarget as planned. Auto food: in loop if(!userActive && t>nextAutoFood){ dropFood(scareTarget); nextAutoFood = t + rr(7,11); }

handPulse variable: hand scale = 3 + heat*1.2 + pulse; pulse decays: handPulse *= exp(-dt*3). On click set 1.6.

Loop:

```js
renderer.setAnimationLoop(()=>{
  const dt = Math.min(clock.getDelta(), 0.05);
  const t = clock.elapsedTime;
  uTime.value = t;

  // camera drift / parallax
  const cx = userActive ? ndc.x*2.3 : Math.sin(t*0.11)*1.6;
  const cy = userActive ? 1.6 + ndc.y*1.1 : 1.6 + Math.sin(t*0.17+1.2)*0.6;
  camera.position.x += (cx - camera.position.x)*Math.min(1, dt*2.2);
  camera.position.y += (cy - camera.position.y)*Math.min(1, dt*2.2);
  camera.lookAt(0, 0.2, 0);

  updateScareTarget(t);
  scare.lerp(scareTarget, 1 - Math.exp(-dt*7));

  if(!userActive && t > nextAutoFood){ dropFood(scareTarget); nextAutoFood = t + rr(7, 11); }

  updateFood(dt, t);
  const heat = updateFish(dt, t);

  handPulse *= Math.exp(-dt*2.6);
  hand.position.copy(scare);
  hand.material.opacity = 0.30 + heat*0.45 + handPulse*0.25;
  hand.scale.setScalar(2.6 + heat*1.1 + handPulse*1.6 + Math.sin(t*2.6)*0.14);
  handLight.position.set(scare.x, scare.y+0.9, scare.z+3);
  handLight.intensity = 22 + heat*45 + handPulse*30;

  updateBubbles(dt, t);

  for(const s of shafts){
    s.rotation.z = s.userData.baseRot + Math.sin(t*0.14 + s.userData.ph)*0.055;
    s.material.opacity = s.userData.baseOp * (0.75 + 0.35*Math.sin(t*0.4 + s.userData.ph*2.0));
  }

  renderer.render(scene, camera);
});
```

Resize handler.

Textures for fish — write:

```js
const texClown = fishTexture({
  back:'#c8501a', belly:'#ffb066',
  bands:[ {at:0.30,h:0.055,color:'#f7f2e8',edge:'#33241a'}, {at:0.55,h:0.06,color:'#f7f2e8',edge:'#33241a'}, {at:0.80,h:0.05,color:'#f7f2e8',edge:'#33241a'} ]
});
const texTang = fishTexture({ back:'#1c3fae', belly:'#8fb4f2', mark:true });
const texSprat = fishTexture({ back:'#7e98a4', belly:'#eef4f5', speckle:true });
```

fishTexture:
```js
function fishTexture(o){
  return makeCanvas(128, 256, (g)=>{
    const gr = g.createLinearGradient(0,0,128,0);
    gr.addColorStop(0, o.belly); gr.addColorStop(0.5, o.back); gr.addColorStop(1, o.belly);
    g.fillStyle = gr; g.fillRect(0,0,128,256);
    if (o.bands) for (const b of o.bands){
      const y = (1 - b.at)*256, h = b.h*256;
      g.fillStyle = b.edge; g.fillRect(0, y-h/2-4, 128, h+8);
      g.fillStyle = b.color; g.fillRect(0, y-h/2, 128, h);
    }
    if (o.mark){
      g.fillStyle = 'rgba(9,20,54,0.85)';
      g.beginPath(); g.ellipse(64, 112, 30, 44, 0, 0, Math.PI*2); g.fill();
      g.fillStyle = 'rgba(9,20,54,0.5)';
      g.beginPath(); g.ellipse(64, 205, 24, 26, 0, 0, Math.PI*2); g.fill();
    }
    if (o.speckle){
      g.globalAlpha = 0.12; g.fillStyle = '#2a3c46';
      for (let i=0;i<300;i++) g.fillRect(rand()*128, rand()*256, 2, 2);
      g.globalAlpha = 1;
    }
  });
}
```
Set colorSpace SRGB inside makeCanvas param? makeCanvas returns CanvasTexture; set colorSpace there generally: t.colorSpace = THREE.SRGBColorSpace — for glow/bubble textures also fine (they're mostly white). ✓

Fish geometry:

```js
const bodyGeo = (()=>{
  const pts = [];
  const N = 30;
  for (let i=0;i<=N;i++){
    const tt = i/N;                       // 0 nose → 1 tail
    const tp = Math.pow(tt, 0.72);
    let r = 0.34 * Math.pow(Math.sin(Math.PI*tp), 0.85);
    if (i===0) r = 0.05; if (i===N) r = 0.03;
    pts.push(new THREE.Vector2(r, 0.55 - tt*1.1));
  }
  const g = new THREE.LatheGeometry(pts, 22);
  g.rotateX(Math.PI/2);
  g.scale(0.6, 1, 1);
  return g;
})();
```
Wait — after rotateX(π/2), cross-section in XY. scale(0.6,1,1) narrows X (width) ✓ height stays.

Hmm, but check rotateX(π/2) mapping again with actual three: geometry.rotateX uses Matrix4.makeRotationX(θ): [1,0,0; 0,cosθ,sinθ; 0,-sinθ,cosθ]? Matrix4.makeRotationX: 
```
1 0 0
0 c -s
0 s c
```
Applied to vector (x,y,z): y' = c*y - s*z; z' = s*y + c*z. θ=π/2: y' = -z; z' = y. Point (0, y, 0) → (0, 0, y)?? y'=−0=0, z'=y ✓ nose y=0.55 → z=0.55 ✓ as computed. ✓

Lathe cross-section: at profile point (r, y): vertices (r sinφ, y, r cosφ). After rotate: x = r sinφ, y' = −r cosφ, z' = y. So at φ=0: (0, −r, y) bottom; φ=π: (0, r, y) top ✓ back at u=0.5 as assumed (u = φ/2π? Lathe uv: u = i/segments around ✓).

tailGeo:
```js
const tailGeo = (()=>{
  const s = new THREE.Shape();
  s.moveTo(0, 0.15);
  s.quadraticCurveTo(-0.22, 0.34, -0.46, 0.40);
  s.quadraticCurveTo(-0.30, 0.16, -0.28, 0.0);
  s.quadraticCurveTo(-0.30, -0.16, -0.46, -0.36);
  s.quadraticCurveTo(-0.22, -0.30, 0, -0.13);
  s.lineTo(0, 0.15);
  const g = new THREE.ShapeGeometry(s);
  g.rotateY(-Math.PI/2);
  return g;
})();
```
rotateY(-π/2): shape (x,y,0) → (0? compute Matrix4.makeRotationY(θ): [c,0,s; 0,1,0; -s,0,c]. θ=-π/2: c=0, s=-1: x' = 0*x + (-1)*z = -z; z' = -(-1)*x + 0 = x. So (x,y,0) → (0, y, x). Shape x negative (−0.46) → z=−0.46 backward ✓. Vertical fin in YZ plane ✓ (normal along ±x? plane contains y & z axes; normal x — vertical fin ✓ correct for tail).

dorsalGeo:
```js
const dorsalGeo = (()=>{
  const s = new THREE.Shape();
  s.moveTo(-0.42, 0.02);
  s.quadraticCurveTo(-0.30, 0.34, -0.08, 0.46);
  s.quadraticCurveTo(0.10, 0.40, 0.30, 0.10);
  s.lineTo(0.30, 0.0); s.lineTo(-0.42, 0.0); s.lineTo(-0.42, 0.02);
  const g = new THREE.ShapeGeometry(s); g.rotateY(-Math.PI/2); return g;
})();
```

analGeo:
```js
s.moveTo(0.02, 0.0); s.quadraticCurveTo(-0.15,-0.26,-0.34,-0.30); s.quadraticCurveTo(-0.22,-0.10,-0.36,-0.02); s.lineTo(0.02,0.0);
```
Hmm messy; simpler triangle: moveTo(0.05,0); quadraticCurveTo(-0.1,-0.3, -0.32,-0.26); lineTo(-0.3,0); close. ok.

pectGeo:
```js
const s = new THREE.Shape();
s.moveTo(0,0);
s.quadraticCurveTo(0.18, 0.03, 0.3, 0.16);
s.quadraticCurveTo(0.12, 0.12, 0, 0.05);
s.lineTo(0,0);
```
Shape extends +x; with mesh rotation.y = π/2∓0.55, local +x → direction... mesh rotation.y=π/2: +x → (cos? For object rotation Y θ: local +x axis maps to (cosθ, 0, −sinθ). θ=π/2: (0,0,−1) backward ✓. θ=π/2−0.55≈1.02: (0.55? cos(1.02)=0.52, −sin(1.02)=−0.85) → (0.52,0,−0.85) back-right ✓ for right fin (positive x). Left: θ=π/2+0.55≈2.12: cos=−0.52 → (−0.52,0,−0.85) back-left ✓.

eyeGeo = SphereGeometry(0.045, 10, 8); eyeMat MeshStandardMaterial({color:0x101418, roughness:0.25}).

makeFish:

```js
function makeFish(sp){
  const outer = new THREE.Group();
  const inner = new THREE.Group();
  inner.scale.setScalar(sp.size);
  outer.add(inner);

  const body = new THREE.Mesh(bodyGeo, sp.bodyMat);
  body.castShadow = true;
  inner.add(body);

  const tail = new THREE.Group();
  tail.position.set(0, 0, -0.48);
  const tailM = new THREE.Mesh(tailGeo, sp.finMat);
  tailM.castShadow = true;
  tail.add(tailM);
  inner.add(tail);

  const dorsal = new THREE.Mesh(dorsalGeo, sp.finMat);
  dorsal.position.set(0, 0.13, 0.0);
  inner.add(dorsal);

  const anal = new THREE.Mesh(analGeo, sp.finMat);
  anal.position.set(0, -0.1, -0.05);
  inner.add(anal);

  const pectR = new THREE.Mesh(pectGeo, sp.finMat);
  pectR.position.set(0.13, -0.04, 0.15);
  pectR.rotation.set(0.55, Math.PI/2 - 0.55, 0);
  inner.add(pectR);
  const pectL = new THREE.Mesh(pectGeo, sp.finMat);
  pectL.position.set(-0.13, -0.04, 0.15);
  pectL.rotation.set(0.55, Math.PI/2 + 0.55, 0);
  inner.add(pectL);

  const eyeR = new THREE.Mesh(eyeGeo, eyeMat);
  eyeR.position.set(0.115, 0.07, 0.335);
  inner.add(eyeR);
  const eyeL = eyeR.clone();
  eyeL.position.x = -0.115;
  inner.add(eyeL);

  return { outer, inner, tail, pectR, pectL };
}
```

Dorsal at y 0.13 — body max height 0.34 at mid; dorsal base y0 shape 0..0.02 → world 0.13..0.15 embedded at mid but near tail (z −0.42) body top ~0.13 → base roughly touches ✓ ok.

Everything set. updateBubbles:

```js
function updateBubbles(dt, t){
  for (let i=0;i<BUB;i++){
    const b = bubs[i];
    b.y += b.s*dt;
    b.x += Math.sin(t*1.8 + b.ph)*0.3*dt;
    if (b.y > 9.2){
      const e = emitters[(rand()*emitters.length)|0];
      b.x = e[0]+rr(-0.7,0.7); b.y = -6.2; b.z = e[2]+rr(-0.7,0.7); b.s = rr(0.7,1.7);
    }
    bubArr[i*3]=b.x; bubArr[i*3+1]=b.y; bubArr[i*3+2]=b.z;
  }
  bubGeo.attributes.position.needsUpdate = true;
}
```
Wait emitters defined as [x, y?, z] — I wrote [x, z] pairs earlier with y? Use [x, z]: emitters = [[-12.5,-5],[11.8,-4.5],[1.5,-7.5]] and z = e[1]. Adjust.

Bubble init spread across column heights ✓.

Points sizes: bubble 0.28.

Check PointsMaterial map + colorSpace: white texture fine.

One more: `scene.environmentIntensity` — exists r163+ ✓ r170.

RoomEnvironment import: 'three/addons/environments/RoomEnvironment.js' ✓.

pmrem.fromScene(new RoomEnvironment(), 0.04) — signature fromScene(scene, sigma) ✓.

Now — verify no name collisions: `fill` point light variable name fine.

Also don't forget dome.

Potential issue: SpriteMaterial fog:false valid prop ✓.

Water shader varying names — vD & vW ✓.

One concern: water plane 100×70 at y=9.4 — camera looks slightly downward from y1.6; water visible top region ✓. But is water plane ABOVE camera looking direction — the plane covers sky entirely? At top of screen, ray hits water plane at y 9.4: yes visible ✓. Beyond water plane edges (corners), dome shows ✓ fog-colored.

Also water waves displace y ±0.55 — the plane at 9.4, no clipping issues.

Light shafts top at y≈1.4+8.5=9.9 slightly above water ✓.

Dome top color at high angles light aqua ✓ visible through water plane alpha 0.38.

Now check first-person: "screenshot at 3s": auto hand moving, fish scattering, food may drop at t=4 (nextAutoFood initial 4 — screenshot at 3s no food yet; fine; set initial nextAutoFood = 2.5 to show food early? Food sinking slowly, fish feeding — nice for screenshot. Set 2.0.)

Fish initial fear 0 — auto hand at scare starts (0,1,0) lerp toward path... scare initial (0,1,0); auto target at t~0: sin(0)=0 → (0, 1.6+sin(1)*3.2=4.3? sin(0.77*0+1)=0.84*3.2=2.7+1.6=4.3, z sin(2)*4=3.6). Slight jump smoothed by lerp ✓.

Check auto hand speed: x = sin(t*0.5)*8+sin(t*1.13)*2.5 — max speed ~ 8*0.5+2.5*1.13 ≈ 6.8 units/s — brisk ✓ fish scatter continuously as it sweeps. 

Flee R 5.4 — hand sweeps big circles; fish near path scatter. Good.

Now heat drives glow — auto mode shows glow pulsing when scaring ✓.

Check fish separation vs size: sprat sepR = 0.62*1.15+0.4 = 1.11; pairs 2.2 — reasonable.

Tang: 1.45*1.15+0.4 = 2.07, pair 4.1 — big fish keep distance ✓.

Potential: silver school cohesion 2.4*0.02 = 0.048/unit; over 12 neighbors dist ~3 → each 0.14 → 1.7 total ✓.

Alignment 2.6*0.03=0.078 × vel diff ~2 → 0.16 each × 12 = 1.9 ✓ schooling forms.

Now finalize water shader and dome code, all written above.

Also add subtle particles "marine snow": could reuse bubbles? skip.

Double check `makeCanvas` sets colorSpace SRGB — for shaftTex (used as alpha map on colored basic material) srgb fine.

Now — one important check: `sandMat.onBeforeCompile` uses `transformed` in vertex — begin_vertex defines `vec3 transformed = vec3(position);` ✓. vWP varying declared in both via common replace ✓ (vertex common and fragment common both replaced ✓).

Kelp: uniforms declared in vertex only ✓ used only in vertex ✓.

Caustic: uses vWP in fragment ✓ declared ✓.

Emissive replace: `#include <emissivemap_fragment>` — present in meshphysical shader ✓.

Edge: `shader.uniforms.uTime = uTime` — for sand & kelp ✓.

Water ShaderMaterial uniforms: { uTime } referencing shared object ✓ (uniforms: { uTime: uTime }).

Renderer animation loop closure uses functions defined before ✓ order: define all then loop.

Let me also ensure food points don't cast shadow (no) ✓.

Check that fish outer group scale 1, inner scale size ✓ shadows: body.castShadow with inner scaled ✓ works (world matrix).

Now let me also think: do transparent fins of one fish render behind body properly? Body opaque renders first ✓, fins transparent after, depthTest against body ✓ fine.

Water transparent renderOrder 1 vs fins default 0: fins render before water ✓ (fish under water surface always — water drawn later blends over ✓ correct since water is above/farther? If fish is between camera and water (fish z > water z region)... water is at y 9.4 top; camera below it; fish always below water → water is farther along view ray above fish? When looking up through water: ray passes fish then water → water farther → drawn first (sorted back-to-front by distance)... renderOrder overrides: fins order 0 drawn before water order 1 → water blends OVER fish ✓ correct (water in front? no — water is BEHIND fish along that ray... wait camera at y1.6 looking up: first intersects water surface (y 9.4) at some distance, but a fish at y 5 is closer to camera along that ray. Correct compositing: draw water first (farther), then fish over it. But renderOrder forces water LAST → water alpha 0.38 drawn over nearer fish → fish seen through water — wrong-ish: fish would appear tinted as if behind water surface even when below it. Hmm.

Does it matter? Looking up at angle, fish silhouettes against bright surface getting a water tint — actually looks like fish is behind the surface plane from viewer perspective... Since camera is below water and fish below water, the water surface is farther, so fish should NOT be tinted. renderOrder 1 on water breaks this.

Fix: set water.renderOrder = -1? Then water draws before other transparents but transparent pass happens after opaque ✓, and three's sorting: within transparent list sorted by renderOrder first then distance. Water renderOrder -1 → drawn first among transparents ✓ (it's the farthest anyway typically). But bubbles rising to surface at y 9 near water — bubbles closer to camera than water? Bubbles at y up to 9.2 below surface 9.4 — mostly below; viewed from below at steep angle distance bubble < distance water along ray ✓ bubble drawn after ✓ correct.

Shafts renderOrder 3: shafts span whole height, some parts in front of fish — additive so order doesn't matter much visually (additive is commutative except depth writes... all these have depthWrite false ✓). Additive over fish = glow ✓ fine either order.

So: water.renderOrder = 0 default and let distance sorting handle? Mixed distances: water surface is large; its sort distance = center of plane (0,9.4,0) distance from camera ~ sqrt(26²+7.8²)=27.1; fish at z 0 distance 26 → fish nearer, drawn after ✓; fish near back z=−8: dist ~34 > 27 → those fish drawn BEFORE water → water tints them ✓ correct actually (they're behind... no wait a back fish at y 3 z −8 viewed from camera: water plane intersection along that ray... ugh, per-pixel ordering can't be resolved by object sort anyway. Water is horizontal overhead; any fish below it: ray from camera (y1.6) to fish (y3, z−8): does the ray pass the water plane before the fish? Ray going upward slightly: y increases; crosses y=9.4 beyond the fish (if ever). So water is behind the fish → fish drawn after water. Object-sort: fish dist 34 vs water 27 → fish drawn FIRST → water over fish → wrong tint for back fish.

Set water.renderOrder = -1 to force water first always ✓ then all fish/bubbles/food drawn after → correct (water behind everything below it — is that always true? Any object above y 9.4? Shafts top 9.9 above water! Shafts would be behind water... additive shafts above surface?? Shafts span up to 9.9 slightly above; negligible — additive so fine either way.)

hand sprite near y up to 8 < 9.4 ✓.

So water.renderOrder = -1. But wait — water must render AFTER dome (opaque, drawn in opaque pass first anyway ✓) and after sand ✓ opaque first. ✓

Good.

Similarly bubbles vs fish: both transparent-ish; bubbles additive depthWrite false; fish fins normal blending depthWrite... fins depthWrite true? If fins depthWrite true and drawn before bubbles, bubbles depth-tested against fins ✓ ok. Sorting distance-based ✓.

Points (bubbles/food) sorted as single objects with center distance — imperfections minor.

Food points alphaTest with depthWrite true — sorted among transparents; opaque-ish ✓.

Now, kelp materials: standard opaque ✓ depth ✓.

I think we're good. Let me also double check three r170: `new THREE.PointsMaterial({ map, alphaTest })` ✓; `THREE.AdditiveBlending` ✓.

`renderer.setAnimationLoop` ✓.

RoomEnvironment in r170: constructor takes no args ✓ (it creates its own lights/meshes). ✓

PMREM: `pmrem.fromScene(env, 0.04)` returns RenderTarget; `.texture` ✓.

One more risk: `scene.environmentIntensity` — added r163 ✓.

Also `texture.colorSpace = THREE.SRGBColorSpace` ✓ r152+.

Now compile mental check of GLSL water fragment: uses vD smoothstep(75,28,vD): vD positive ✓.

Shaft texture: CanvasTexture 64×256 ✓.

Everything's planned. Write final file. Also add `renderer.domElement` prepend before overlays? Canvas position fixed inset 0 via CSS `canvas{...}` — prepend puts canvas first, overlays after in DOM but they're fixed with z-index default — canvas fixed, overlays fixed later in DOM → on top ✓. Actually I appended veil/title in HTML already before script runs; prepend puts canvas BEFORE them → canvas behind ✓ good.

Wait — canvas is position:fixed inset:0 but has default size set by renderer (width/height attrs + style width/height set by setSize ✓ sets style.width px). fixed inset 0 with explicit width/height style → fine at 0,0.

Resize: renderer.setSize updates style ✓.

One more: pointermove on window — when hovering over pill (pointer-events none) still window event ✓.

iOS: pointer events fine.

Also `body.touched canvas{cursor:none}` — canvas selector matches renderer canvas ✓.

Now the flee heat for handLight — computed from updateFish return ✓.

Also initial pill: mention click food ✓.

Final code assembly. Also keep total fish = 26: 7+6+13 ✓ matches subtitle "twenty-six fish" ✓ and stats line.

Let me write anal fin shape properly:

```js
const analGeo = (()=>{
  const s = new THREE.Shape();
  s.moveTo(0.06, 0);
  s.quadraticCurveTo(-0.08, -0.28, -0.30, -0.30);
  s.quadraticCurveTo(-0.26, -0.10, -0.30, -0.01);
  s.lineTo(0.06, 0);
  const g = new THREE.ShapeGeometry(s);
  g.rotateY(-Math.PI/2);
  return g;
})();
```

Fine.

Also I should double check the `#include <emissivemap_fragment>` replace — in r170 meshphysical frag has that include ✓ (yes, still present).

And `#include <begin_vertex>` in meshphysical vertex ✓.

Kelp DoubleSide + shadows: blades castShadow false (default) ✓.

Rocks: after modifying icosahedron vertices with position-hash — implement:

```js
function rock(x, z, sx, sy, mat){
  const g = new THREE.IcosahedronGeometry(1, 1);
  const p = g.attributes.position;
  for (let i=0;i<p.count;i++){
    const px=p.getX(i), py=p.getY(i), pz=p.getZ(i);
    const hsh = Math.sin(px*12.9898 + py*78.233 + pz*37.719)*43758.5453;
    const k = 1 + ((hsh - Math.floor(hsh)) - 0.5)*0.55;
    p.setXYZ(i, px*k, py*k, pz*k);
  }
  const m = new THREE.Mesh(g, mat);
  m.position.set(x, dune(x,z) - 6.5 + sy*0.3, z);
  m.scale.set(sx, sy, sx*rr(0.75,1.15));
  m.rotation.y = rand()*Math.PI*2;
  m.castShadow = m.receiveShadow = true;
  scene.add(m);
}
```

Icosahedron detail 1 is indexed? If indexed with shared vertices, per-vertex random also fine (no cracks). Either way hash approach safe ✓.

flatShading:true material → normals via derivatives, no computeVertexNormals needed ✓.

Pebbles: rock() with pebbleMat, small scales, y more buried: use sy*0.3 offset same.

pebbleMat = MeshStandardMaterial({color:0x8f8168, roughness:1, flatShading:true}).

OK. Also maybe a couple of rocks closer to front-side for depth framing: rock(-16, 6, 2.2, 1.6) — at BX=15 mobile this is offscreen; harmless.

Sun shadow camera bounds ±22 covers x±16 ✓.

Also add very subtle THREE.FogExp2 influence on fish materials standard ✓ automatically.

Write final. Also guard: `if (Math.abs(d.z) > 1e-4)` — camera looks −z ✓ k positive.

Also `updateScareTarget` clamps z to ±7.

Let me make sure foods iteration in fish loop uses foods array with .pos Vector3 — distanceTo ✓.

Food eaten when bestD < 0.55*sp.size+0.3 — sprat: 0.64; tang: 1.1 ✓.

Fish grazing bottom: bound y min −5.8; food floor −6.28 (dune−6.5+0.12); tang center at −5.8, distance to pellet 0.5 < 1.1 ✓ eats. Sprat 0.64 ✓. 

Now, writing out the whole file. Also `dune` used in updateFood each pellet each frame — fine.

Also there might be an issue with pill text width on 1280 — fine.

Title says "one cursor to fear" cute ✓.

Let me write the water vertex shader with the plane local coords: after rotateX geometry positions y=0 all, x/z spread. displacement p.y += ... ✓.

One more consideration: `scene.add(fill)` PointLight front — intensity 60 distance 60 decay 2: at 20 units: 60/400=0.15 subtle ✓ ok.

Also add slight blue rim light from behind? skip.

Final: ensure everything referenced is defined: `handPulse` init let handPulse = 0.

`nextAutoFood = 2.2`.

Also on keydown dropFood(scare) — scare defined ✓.

Edge: keydown fires repeatedly on hold — throttled by dropFood's 0.18s ✓.

Now write it all. I'll be careful about template literal nesting inside the module script (GLSL strings contain no backticks ✓).

Also check: `renderer.setPixelRatio` before setSize? Order fine either.

Let me also confirm `PlaneGeometry(100,70,96,64)` vertex count 97*65=6305 ✓ fine; sand color attribute ✓.

For mobile perf: 6305-vert sand + shadows ok.

Double-check dome: SphereGeometry(70,32,24) BackSide; camera inside ✓; depthWrite false, renders in opaque pass (transparent false) — with depthWrite false other opaques drawn after will overwrite ✓ but opaques sorted front-to-back; dome renderOrder -10 ensures first ✓.

Dome & fog: ShaderMaterial fog default false ✓.

I also want slight darkening at bottom of dome — cBot dark ✓.

Camera far 220 > dome 70 ✓.

Write the file now. Also mention in stats maybe live? static fine.

Counting fish subtitle: "twenty-six fish · three species · one cursor to fear" ✓.

Also — subtle detail: fish tail ShapeGeometry double side ✓ (finMat DoubleSide ✓).

Potential visual: clownfish orange with white bands ✓ recognizable.

Let me write the final HTML file completely now, carefully, in order:

1. HTML head + CSS + importmap
2. body overlays
3. module script sections:
   - imports, renderer, scene, camera, env, clock, rand
   - bounds
   - lights
   - dome
   - canvas texture helpers
   - sand + caustics
   - rocks/pebbles/coral/kelp
   - water, shafts
   - bubbles
   - fish geometry + textures + species + spawn
   - food
   - hand + scare + input
   - loop
   - resize

Also define makeCanvas before usage.

Note: kelp tint colors: `new THREE.Color(0x0c2e1c).lerp(tint,0.25)` — Color.lerp exists ✓.

For kelp cTop brighter: 0x39a352 lerped.

Write it.

let me double check the Shape for tail: moveTo(0,0.15) then curves to (-0.46,0.40) top tip... shape may self-intersect slightly? Sequence: (0,0.15) → top tip (-0.46,0.40) → inward (-0.28,0) → bottom tip (-0.46,-0.36) → back to (0,-0.13) → close. Non-self-intersecting fork ✓.

ShapeGeometry triangulates ✓.

Also fins on sprat size 0.62 — pect tiny ✓ fine.

Eye on sprat 0.045*0.62 small ✓.

Final check on `fishTexture` uses `rand()` seeded — deterministic ✓.

Also for sprat metal 0.75 with env intensity 0.35 → shiny silver ✓.

Tang body roughness 0.5.

Clown roughness 0.6.

Write now. For the reply: exactly one code block, no other text. Language English.

One more subtle thing: `addEventListener('pointerdown'...)` — clicking on pill? pill pointer-events none ✓.

Also text selection user-select none ✓.

Let me write the auto scare also affect even when tab hidden — clock delta clamped ✓.

Also worth setting `renderer.info` — no.

Potential console warning: shadow map 2048 fine.

Write final code.

Actually one more check on `_o.lookAt(_v1.add(p))` — _v1 was copy of vel then added p → target ✓ but note earlier I possibly reused _v1 for food dir — recompute ✓ order in code: food uses _v1 then orientation re-copies ✓.

And heat returned.

In updateFish I referenced `scare` global ✓.

Also `randomTarget` uses rr ✓.

Also fish `f.outer.position.copy(p)` at spawn ✓.

double-check `THREE.MathUtils.clamp` ✓.

Write it.

After writing, quickly re-scan for errors: unmatched braces, correct replace targets, correct uniform hooking.

Also: the `#include <common>` replace in sand fragment adds uniform uTime — but kelp fragment? Kelp only modifies vertex ✓ no fragment change.

But CAREFUL: sandMat & kelp both use onBeforeCompile defined per material ✓.

For the sprat finMat color 0xbfd2d8 opacity 0.55.

Also hand sprite initial position (0,1,0) visible at load ✓ glow near center — good for screenshot.

Water: at t=0 pattern ✓.

I think ready. One last consideration — performance of caustic loop 4 iter per fragment on large sand plane: fine on GPU.

Also `precision` default ✓.

Compose the final answer.