Models 44

Qwen3.8 Max (0902)

by Qwenreasoning default37 console errors
Open in new window

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

How it went

Total time
10 min 36 s
Cost
$0.164
Output tokens
27,108
Spent thinking
16,761 tokens
Speed
42.8 tok/s
Console errors
37
First token after
1.9 s
First visible text after
7 min 23 s
Prompt tokens
483
Code size
27.5 KB
Finish reason
stop
Served by
Alibaba
Model id
qwen/qwen3.8-max-0902
Reasoning sent
model default
Max output
131,072 tokens
Generated
Nov 12, 2026

Console errors

Caught in the first 3 seconds after load, in a sandboxed iframe like the one above.

Cannot create property 'z' on number '0'

Cannot create property 'z' on number '0'

Cannot create property 'z' on number '0'

Cannot create property 'z' on number '0'

Cannot create property 'z' on number '0'

Cannot create property 'z' on number '0'

Cannot create property 'z' on number '0'

Cannot create property 'z' on number '0'

Cannot create property 'z' on number '0'

Cannot create property 'z' on number '0'

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 · 683 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tideglass — The Scatter School</title>
<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>
<style>
  :root{
    --ink:#eafcf6; --dim:#a8d8cf; --faint:#6faea4;
    --aqua:#7fe7d4; --coral:#ff8a5c; --panel:rgba(4,28,33,.5);
  }
  *{box-sizing:border-box}
  html,body{margin:0;height:100%;overflow:hidden;background:#02141a;
    font-family:"Segoe UI","Helvetica Neue",Helvetica,Arial,sans-serif;color:var(--ink)}
  #scene{position:fixed;inset:0;display:block;cursor:none}

  /* ---------- HUD ---------- */
  #hud{position:fixed;inset:0;z-index:10;pointer-events:none}
  .brand{position:absolute;top:30px;left:34px;max-width:400px}
  .eyebrow{font-size:11px;font-weight:600;letter-spacing:.34em;text-transform:uppercase;color:var(--aqua)}
  .eyebrow em{font-style:normal;color:var(--coral)}
  h1{font-family:"Palatino Linotype",Palatino,"Book Antiqua",Georgia,serif;
     font-style:italic;font-weight:400;line-height:.94;letter-spacing:-.01em;
     font-size:clamp(34px,4.6vw,58px);margin:12px 0 12px;
     text-shadow:0 2px 26px rgba(0,70,70,.55)}
  h1 .accent{color:var(--coral)}
  .lede{font-size:13.5px;line-height:1.6;color:var(--dim);max-width:36ch;margin:0}
  .lede b{color:#ffd9c2;font-weight:600}

  .stats{position:absolute;top:32px;right:34px;display:flex;flex-direction:column;align-items:flex-end;gap:9px}
  .row{display:flex;gap:22px;align-items:flex-start}
  .stat{display:flex;flex-direction:column;align-items:flex-end;gap:2px}
  .num{font-size:34px;font-weight:700;letter-spacing:-.02em;line-height:1;
       font-variant-numeric:tabular-nums;color:var(--ink);transition:color .25s}
  .num.hot{color:var(--coral);text-shadow:0 0 18px rgba(255,138,92,.55)}
  .stat label{font-size:9.5px;letter-spacing:.3em;text-transform:uppercase;color:var(--faint)}
  .meter{width:196px;height:4px;background:rgba(255,255,255,.13);overflow:hidden;border-radius:2px}
  .meter i{display:block;height:100%;width:0%;border-radius:2px;
    background:linear-gradient(90deg,#3ee0c0,#ffd35c,#ff8a5c);transition:width .25s ease}
  .tag{font-size:10px;letter-spacing:.26em;text-transform:uppercase;color:var(--faint)}

  .legend{position:absolute;left:34px;bottom:30px;display:flex;gap:10px;flex-wrap:wrap}
  .chip{display:flex;align-items:center;gap:8px;padding:7px 13px;border-radius:999px;
    border:1px solid rgba(127,231,212,.26);background:var(--panel);
    font-size:11.5px;letter-spacing:.05em;color:#bfe9df;backdrop-filter:blur(3px)}
  .chip i{width:9px;height:9px;border-radius:50%;background:var(--c);box-shadow:0 0 9px var(--c)}

  .meta{position:absolute;right:34px;bottom:30px;text-align:right;
    font-size:10.5px;letter-spacing:.22em;text-transform:uppercase;color:var(--faint);line-height:2}
  .meta b{color:var(--aqua);font-weight:600}

  /* ---------- atmosphere overlays ---------- */
  .vignette{position:fixed;inset:0;z-index:5;pointer-events:none;
    background:
      radial-gradient(120% 92% at 50% 38%, transparent 52%, rgba(1,10,14,.62) 100%),
      linear-gradient(180deg, rgba(3,24,30,.38), transparent 16%)}

  /* ---------- custom cursor ---------- */
  .cursor{position:fixed;left:0;top:0;z-index:20;width:36px;height:36px;margin:-18px 0 0 -18px;
    border:1.5px solid rgba(150,255,238,.9);border-radius:50%;pointer-events:none;
    mix-blend-mode:screen;opacity:0;
    transition:width .18s,height .18s,margin .18s,border-color .18s,opacity .35s}
  .cursor::after{content:"";position:absolute;left:50%;top:50%;width:4px;height:4px;
    margin:-2px 0 0 -2px;border-radius:50%;background:#aef7ea}
  .cursor.scared{width:58px;height:58px;margin:-29px 0 0 -29px;border-color:#ff9a70}
  .cursor.on{opacity:1}
  @media (pointer:coarse){.cursor{display:none}}

  /* ---------- loader ---------- */
  #loader{position:fixed;inset:0;z-index:50;display:grid;place-items:center;background:#02141a;
    font-size:11px;letter-spacing:.34em;text-transform:uppercase;color:var(--aqua);
    transition:opacity .9s ease}
  #loader span{animation:pulse 1.4s ease-in-out infinite}
  @keyframes pulse{50%{opacity:.35}}
  body.loaded #loader{opacity:0;pointer-events:none}

  @media (max-width:760px){
    .brand{left:18px;top:18px;max-width:62vw}
    .lede{display:none}
    .stats{right:18px;top:20px}
    .num{font-size:26px}
    .meter{width:128px}
    .legend{left:18px;bottom:16px}
    .chip{padding:5px 10px;font-size:10px}
    .meta{display:none}
  }
</style>
</head>
<body>

<canvas id="scene"></canvas>
<div class="vignette"></div>
<div class="cursor" id="cursor"></div>

<div id="hud">
  <header class="brand">
    <div class="eyebrow">Tideglass Institute · Tank <em>Nº 7</em></div>
    <h1>The Scatter<br><span class="accent">School</span></h1>
    <p class="lede">Glide your cursor through the water — to the fish it reads as a
      <b>predator's shadow</b> and the shoal breaks apart. <b>Click</b> to send a pressure wave.</p>
  </header>

  <div class="stats">
    <div class="row">
      <div class="stat"><span class="num" id="calmN">26</span><label>calm</label></div>
      <div class="stat"><span class="num" id="fleeN">0</span><label>startled</label></div>
    </div>
    <div class="meter"><i id="meterFill"></i></div>
    <div class="tag" id="tankTag">shoal at rest</div>
  </div>

  <div class="legend" id="legend"></div>

  <div class="meta">
    depth 12 m · 24&nbsp;°C · ph 8.1<br>
    curator note — <b>cursor = predator shadow</b>
  </div>
</div>

<div id="loader"><span>filling the tank…</span></div>

<script type="module">
import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';

/* ============================== helpers ============================== */
const rnd  = (a=1,b)=> b===undefined ? Math.random()*a : a+Math.random()*(b-a);
const pick = a => a[(Math.random()*a.length)|0];
const clamp= (v,a,b)=> Math.max(a,Math.min(b,v));

/* ============================== renderer ============================== */
const canvas = document.getElementById('scene');
const renderer = new THREE.WebGLRenderer({canvas, antialias:true});
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.75));
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.12;

const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x06333d, 0.014);

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

const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), 0.5, 0.6, 0.62));
composer.addPass(new OutputPass());

/* ============================== lights ============================== */
scene.add(new THREE.HemisphereLight(0x8fd8d8, 0x0a2a33, 0.9));
const sun = new THREE.DirectionalLight(0xcfeff0, 1.6);
sun.position.set(6, 20, 5);
scene.add(sun);
const warm = new THREE.PointLight(0xff9a5c, 9, 24, 2);
warm.position.set(-9.5, -3.4, 1.8);
scene.add(warm);

/* ============================== water gradient dome ============================== */
scene.add(new THREE.Mesh(
  new THREE.SphereGeometry(170, 24, 16),
  new THREE.ShaderMaterial({
    side: THREE.BackSide, depthWrite:false,
    vertexShader:`varying vec3 vP; void main(){ vP=position;
      gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}`,
    fragmentShader:`varying vec3 vP;
      void main(){
        vec3 n=normalize(vP); float h=n.y*.5+.5;
        vec3 deep=vec3(.008,.055,.075), mid=vec3(.02,.17,.21), top=vec3(.05,.36,.40);
        vec3 c=mix(deep,mid,smoothstep(.0,.45,h));
        c=mix(c,top,smoothstep(.45,.95,h));
        c+=vec3(.05,.14,.13)*pow(max(n.z,0.),3.)*smoothstep(.2,.9,h);
        gl_FragColor=vec4(c,1.);}`
  })
));

/* ============================== canvas textures ============================== */
function radialSprite(){
  const c=document.createElement('canvas'); c.width=c.height=64;
  const x=c.getContext('2d');
  const g=x.createRadialGradient(32,32,0,32,32,32);
  g.addColorStop(0,'rgba(255,255,255,1)'); g.addColorStop(.4,'rgba(255,255,255,.45)');
  g.addColorStop(1,'rgba(255,255,255,0)');
  x.fillStyle=g; x.fillRect(0,0,64,64);
  const t=new THREE.CanvasTexture(c); t.colorSpace=THREE.SRGBColorSpace; return t;
}
const spriteTex = radialSprite();

function shaftTexture(){
  const c=document.createElement('canvas'); c.width=64; c.height=256;
  const x=c.getContext('2d');
  const g=x.createLinearGradient(0,0,0,256);
  g.addColorStop(0,'rgba(255,255,255,.85)'); g.addColorStop(1,'rgba(255,255,255,0)');
  x.fillStyle=g; x.fillRect(0,0,64,256);
  x.globalCompositeOperation='destination-in';
  const h=x.createLinearGradient(0,0,64,0);
  h.addColorStop(0,'rgba(0,0,0,0)'); h.addColorStop(.5,'rgba(0,0,0,1)'); h.addColorStop(1,'rgba(0,0,0,0)');
  x.fillStyle=h; x.fillRect(0,0,64,256);
  return new THREE.CanvasTexture(c);
}
const shaftTex = shaftTexture();

function causticTexture(){
  const c=document.createElement('canvas'); c.width=c.height=512;
  const x=c.getContext('2d');
  x.fillStyle='#000'; x.fillRect(0,0,512,512);
  x.globalCompositeOperation='lighter';
  for(let i=0;i<190;i++){
    x.strokeStyle=`rgba(190,255,240,${.05+Math.random()*.11})`;
    x.lineWidth=1+Math.random()*2;
    x.beginPath();
    x.arc(rnd(512),rnd(512),8+Math.random()*34,rnd(Math.PI*2),rnd(Math.PI*2)+1+Math.random()*3);
    x.stroke();
  }
  const t=new THREE.CanvasTexture(c);
  t.wrapS=t.wrapT=THREE.RepeatWrapping; t.repeat.set(2.4,1.4);
  t.center.set(.5,.5); t.colorSpace=THREE.SRGBColorSpace;
  return t;
}

function bodyTexture(base, top, bottom, stripes){
  const c=document.createElement('canvas'); c.width=256; c.height=128;
  const x=c.getContext('2d');
  const g=x.createLinearGradient(0,0,0,128);
  g.addColorStop(0,top); g.addColorStop(.55,base); g.addColorStop(1,bottom);
  x.fillStyle=g; x.fillRect(0,0,256,128);
  for(const s of stripes){
    if(s.horizontal){ x.fillStyle=s.color; x.fillRect(0,s.v*128,256,s.w*128); continue; }
    x.fillStyle='#101418';
    x.fillRect((s.u-s.w/2)*256-2,0,(s.w*256)+4,128);
    x.fillStyle=s.color;
    x.fillRect((s.u-s.w/2)*256,0,s.w*256,128);
  }
  const t=new THREE.CanvasTexture(c); t.colorSpace=THREE.SRGBColorSpace; return t;
}

/* ============================== environment ============================== */
const FLOOR = -4.6;
function sandH(x,z){
  return Math.sin(x*.28)*Math.cos(z*.45)*.55 + Math.sin(x*.9+z*.6)*.22 + Math.cos(x*.13-z*.8)*.3;
}
{
  const g = new THREE.PlaneGeometry(120,60,64,32);
  g.rotateX(-Math.PI/2);
  const p=g.attributes.position;
  for(let i=0;i<p.count;i++) p.setY(i, sandH(p.getX(i),p.getZ(i)));
  g.computeVertexNormals();
  const sand=new THREE.Mesh(g,new THREE.MeshStandardMaterial({color:0xc7b287,roughness:1}));
  sand.position.y=FLOOR; scene.add(sand);
}

/* rocks */
{
  const rockMatA=new THREE.MeshStandardMaterial({color:0x3d514d,roughness:.95,flatShading:true});
  const rockMatB=new THREE.MeshStandardMaterial({color:0x2e4341,roughness:.95,flatShading:true});
  for(let i=0;i<10;i++){
    const r=rnd(.55,1.7);
    const m=new THREE.Mesh(new THREE.IcosahedronGeometry(r,0), Math.random()<.5?rockMatA:rockMatB);
    const x=rnd(-13,13), z=rnd(-3.5,3.5);
    m.position.set(x, FLOOR+sandH(x,z)+r*.25, z);
    m.rotation.set(rnd(6.28),rnd(6.28),rnd(6.28));
    m.scale.set(1,rnd(.7,1.1),1);
    scene.add(m);
  }
}

/* kelp — GPU-swaying blades */
const kelpMats=[];
{
  const spots=[[-12,-2],[-10,2.5],[10.5,-1.5],[12.5,2],[-4,-4.4],[5,-4.2],[-7,-3.8],[8,3.1],[1,-4.6]];
  for(const [sx,sz] of spots){
    const blades=2+(Math.random()*3|0);
    for(let b=0;b<blades;b++){
      const h=rnd(3.2,7.2);
      const g=new THREE.PlaneGeometry(.55,h,1,14); g.translate(0,h/2,0);
      const m=new THREE.ShaderMaterial({
        side:THREE.DoubleSide,
        uniforms:{ uTime:{value:0}, uPhase:{value:rnd(6.28)},
                   uFog:{value:new THREE.Color(0x06333d)}, uFogD:{value:.014} },
        vertexShader:`uniform float uTime,uPhase,uFogD; varying vec2 vUv; varying float vD;
          void main(){ vUv=uv; vec3 p=position; float w=pow(uv.y,1.7);
            p.x+=sin(uTime*1.3+uv.y*4.+uPhase)*.85*w;
            p.z+=cos(uTime*.9+uv.y*3.+uPhase*1.7)*.4*w;
            vec4 mv=modelViewMatrix*vec4(p,1.); vD=-mv.z;
            gl_Position=projectionMatrix*mv; }`,
        fragmentShader:`uniform vec3 uFog; uniform float uFogD; varying vec2 vUv; varying float vD;
          void main(){ vec3 c=mix(vec3(.03,.22,.15),vec3(.22,.68,.42),vUv.y);
            float f=1.-exp(-pow(uFogD*vD,2.));
            gl_FragColor=vec4(mix(c,uFog,f),1.); }`
      });
      kelpMats.push(m);
      const blade=new THREE.Mesh(g,m);
      const x=sx+rnd(-.9,.9), z=sz+rnd(-.7,.7);
      blade.position.set(x, FLOOR+sandH(x,z)-.05, z);
      blade.rotation.y=rnd(6.28);
      scene.add(blade);
    }
  }
}

/* god rays */
const shafts=[];
{
  for(let i=0;i<5;i++){
    const mat=new THREE.MeshBasicMaterial({map:shaftTex,transparent:true,
      blending:THREE.AdditiveBlending,depthWrite:false,fog:false,
      opacity:.14, color:0xbdf3ec});
    const m=new THREE.Mesh(new THREE.PlaneGeometry(rnd(2.4,4.6),36),mat);
    m.position.set(rnd(-11,11), 11, rnd(-2,1));
    m.rotation.set(0,rnd(6.28),-.18);
    scene.add(m);
    shafts.push({m,mat,base:rnd(.09,.2),i});
  }
}

/* animated caustics on the seabed */
const caustics=[];
{
  for(let i=0;i<2;i++){
    const tex=causticTexture();
    const mat=new THREE.MeshBasicMaterial({map:tex,transparent:true,opacity:.16,
      blending:THREE.AdditiveBlending,depthWrite:false,fog:false,color:0x9df0dd});
    const m=new THREE.Mesh(new THREE.PlaneGeometry(95,42),mat);
    m.rotation.x=-Math.PI/2; m.position.y=FLOOR+1.05+i*.12;
    scene.add(m); caustics.push({tex,i});
  }
}

/* ============================== fish ============================== */
const SPECIES=[
  {name:'Neon Tetra',     body:0x39d7ff, finC:0xff7a5c, ui:'#39d7ff', count:12, size:[.42,.55], shape:'slim',
   tex:()=>bodyTexture('#2fb9d8','#b8f4ff','#0a5e78',
        [{horizontal:true,v:.62,w:.12,color:'#ff5e4d'},{horizontal:true,v:.56,w:.05,color:'#dffcff'}])},
  {name:'Ember Clownfish',body:0xff8a3d, finC:0xffd9a0, ui:'#ff8a3d', count:8,  size:[.55,.7],  shape:'round',
   tex:()=>bodyTexture('#f07f2e','#ffc489','#a34d12',
        [{u:.78,w:.08,color:'#f4f8f5'},{u:.52,w:.08,color:'#f4f8f5'}])},
  {name:'Violet Angelfish',body:0x9a6bff,finC:0x63e6ff, ui:'#9a6bff', count:6,  size:[.68,.92], shape:'tall',
   tex:()=>bodyTexture('#8a5cf0','#cdb2ff','#3c2a78',
        [{u:.35,w:.035,color:'#241a4d'},{u:.55,w:.035,color:'#241a4d'},{u:.75,w:.035,color:'#241a4d'}])},
];
const SHAPES={ slim:[1.55,.55,.36], round:[1.35,.68,.44], tall:[1.05,1.05,.3] };

function shapeGeo(pts){ const s=new THREE.Shape(); s.moveTo(...pts[0]);
  for(let i=1;i<pts.length;i+=2) s.quadraticCurveTo(...pts[i],...pts[i+1]);
  s.closePath(); return new THREE.ShapeGeometry(s,6); }

const GEO={
  body:new THREE.SphereGeometry(.5,20,14),
  tail:shapeGeo([[0,0],[-.28,.16],[-.62,.46],[-.44,0],[-.62,-.46],[-.28,-.16],[0,0]]),
  dorsal:shapeGeo([[-.34,0],[-.08,.36],[.32,.05],[.05,-.02],[-.34,0]].length>=5?
    [[-.34,0],[-.08,.36],[.32,.05]]:[[-.34,0]]),
  pect:shapeGeo([[0,0],[-.2,-.02],[-.34,-.22],[-.1,-.12],[0,0]]),
  eye:new THREE.SphereGeometry(.045,8,8),
};
GEO.dorsal=new THREE.ShapeGeometry((()=>{const s=new THREE.Shape();
  s.moveTo(-.34,0); s.quadraticCurveTo(-.08,.36,.32,.05); s.closePath(); return s;})(),6);

const MAT={ eye:new THREE.MeshStandardMaterial({color:0x101418,roughness:.3}) };
for(const sp of SPECIES){
  MAT[sp.name]={
    body:new THREE.MeshStandardMaterial({map:sp.tex(),roughness:.5,metalness:.1,
      emissive:new THREE.Color(sp.body),emissiveIntensity:.16}),
    fin:new THREE.MeshStandardMaterial({color:sp.finC,transparent:true,opacity:.88,
      side:THREE.DoubleSide,roughness:.7,emissive:new THREE.Color(sp.finC),emissiveIntensity:.12})
  };
}

const fishes=[];
class Fish{
  constructor(sp){
    this.sp=sp;
    this.size=rnd(...sp.size);
    const s=this.size, [sx,sy,sz]=SHAPES[sp.shape], M=MAT[sp.name];
    const root=this.root=new THREE.Group();
    const rig=new THREE.Group(); rig.rotation.y=-Math.PI/2; root.add(rig);
    const body=this.body=new THREE.Group(); rig.add(body);

    const bm=new THREE.Mesh(GEO.body,M.body);
    bm.scale.set(sx*s,sy*s,sz*s); body.add(bm);

    const tp=this.tailPivot=new THREE.Group();
    tp.position.x=-.72*s; body.add(tp);
    const tail=new THREE.Mesh(GEO.tail,M.fin); tail.scale.setScalar(s*1.15); tp.add(tail);

    const dor=new THREE.Mesh(GEO.dorsal,M.fin);
    dor.position.y=.26*s; dor.scale.setScalar(s*1.05); body.add(dor);
    if(sp.shape==='tall'){
      const anal=dor.clone(); anal.rotation.z=Math.PI; anal.position.y=-.26*s; body.add(anal);
    }
    const pl=this.pectL=new THREE.Mesh(GEO.pect,M.fin);
    pl.position.set(.16*s,-.02*s, sz*s*.9); pl.scale.setScalar(s); body.add(pl);
    const pr=this.pectR=new THREE.Mesh(GEO.pect,M.fin);
    pr.position.set(.16*s,-.02*s,-sz*s*.9); pr.scale.set(-s,s,s); body.add(pr);

    for(const side of [-1,1]){
      const e=new THREE.Mesh(GEO.eye,MAT.eye);
      e.position.set(.42*s,.09*s,side*sz*s*.72); e.scale.setScalar(s); body.add(e);
    }

    root.position.set(rnd(-10,10),rnd(-2,7),rnd(-4,4));
    this.vel=new THREE.Vector3(rnd(-1,1),rnd(-.3,.3),rnd(-1,1)).normalize().multiplyScalar(2);
    this.goal=root.position.clone(); this.retarget=0;
    this.stress=0; this.phase=rnd(6.28); this.dart=Math.random()<.5?-1:1;
    scene.add(root);
  }
  update(dt, t, predator, others){
    const p=this.root.position, v=this.vel;
    const acc=Fish._a.set(0,0,0);

    if(t>this.retarget){ this.goal.set(rnd(-10,10),rnd(-2,7),rnd(-4,4)); this.retarget=t+rnd(3,7); }
    Fish._b.copy(this.goal).sub(p).normalize().multiplyScalar(2.6).sub(v);
    acc.addScaledVector(Fish._b,.35);
    acc.x+=rnd(-.5,.5); acc.y+=rnd(-.3,.3); acc.z+=rnd(-.5,.5);

    /* soft tank walls */
    const BX=12.5,BZ=5.2,FY=-3.1,CY=8.5;
    if(p.x> BX)acc.x-=(p.x-BX)*2.5; if(p.x<-BX)acc.x-=(p.x+BX)*2.5;
    if(p.z> BZ)acc.z-=(p.z-BZ)*2.5; if(p.z<-BZ)acc.z-=(p.z+BZ)*2.5;
    if(p.y< FY)acc.y+=(FY-p.y)*3;   if(p.y> CY)acc.y-=(p.y-CY)*3;

    /* loose schooling */
    for(const o of others){ if(o===this)continue;
      Fish._b.copy(p).sub(o.root.position); const d=Fish._b.length();
      if(d<1.6&&d>1e-4) acc.addScaledVector(Fish._b.normalize(), (1.6-d)*4);
      else if(d<6){ acc.addScaledVector(o.vel,.06); }
    }

    /* flee from the cursor-predator */
    if(predator.active){
      const d=p.distanceTo(predator.pos), R=predator.fearR;
      if(d<R){
        const k=1-d/R;
        Fish._b.copy(p).sub(predator.pos);
        if(Fish._b.lengthSq()<1e-6)Fish._b.set(1,0,0);
        acc.addScaledVector(Fish._b.normalize(), 22*k*k+6*k);
        acc.y+=this.dart*3*k;
        this.stress=Math.max(this.stress, Math.min(1,k*1.4));
      }
    }
    this.stress=Math.max(0,this.stress-dt*.55);

    v.addScaledVector(acc,dt);
    const sp=v.length(), maxs=2.6+6*this.stress;
    if(sp>maxs)v.multiplyScalar(maxs/sp);
    else if(sp<.5)v.multiplyScalar(.5/Math.max(sp,1e-4));
    p.addScaledVector(v,dt);

    /* orient */
    Fish._c.copy(v).normalize();
    Fish._dummy.position.set(0,0,0);
    Fish._dummy.lookAt(Fish._c);
    this.root.quaternion.slerp(Fish._dummy.quaternion, 1-Math.exp(-dt*(2.5+6*this.stress)));

    /* swim animation */
    const speedN=clamp(v.length()/8.5,0,1);
    const f=5+15*speedN, ph=this.phase;
    this.tailPivot.rotation.y=Math.sin(t*f+ph)*(.35+.45*speedN);
    this.body.rotation.y   =Math.sin(t*f*.85+ph)*.09;
    this.body.rotation.z   =Math.sin(t*.9+ph)*.05;
    const flap=Math.sin(t*6+ph)*.4;
    this.pectL.rotation.z=-.3+flap; this.pectR.rotation.z=.3+flap;
  }
}
Fish._a=new THREE.Vector3(); Fish._b=new THREE.Vector3(); Fish._c=new THREE.Vector3();
Fish._dummy=new THREE.Object3D();
for(const sp of SPECIES) for(let i=0;i<sp.count;i++) fishes.push(new Fish(sp));

/* legend chips */
{
  const el=document.getElementById('legend');
  for(const sp of SPECIES){
    const d=document.createElement('div'); d.className='chip';
    d.innerHTML=`<i style="--c:${sp.ui}"></i>${sp.name} × ${sp.count}`;
    el.appendChild(d);
  }
}

/* ============================== particles ============================== */
function makePoints(n, size, opacity, blending){
  const g=new THREE.BufferGeometry();
  const pos=new Float32Array(n*3);
  g.setAttribute('position',new THREE.BufferAttribute(pos,3).setUsage(THREE.DynamicDrawUsage));
  const m=new THREE.PointsMaterial({size,map:spriteTex,transparent:true,opacity,
    depthWrite:false,blending,sizeAttenuation:true});
  const pts=new THREE.Points(g,m); scene.add(pts);
  return {pts,pos,n};
}

/* rising bubbles */
const BUB=makePoints(130,.3,.7,THREE.AdditiveBlending);
const bubData=[];
for(let i=0;i<BUB.n;i++){
  const d={x:rnd(-13,13),z:rnd(-5,5),y:rnd(FLOOR,9),sp:rnd(.7,1.8),ph:rnd(6.28)};
  bubData.push(d);
  BUB.pos.set([d.x,d.y,d.z],i*3);
}

/* drifting marine snow */
const SNOW=makePoints(220,.09,.32,THREE.NormalBlending);
for(let i=0;i<SNOW.n;i++) SNOW.pos.set([rnd(-16,16),rnd(-4,10),rnd(-6,6)],i*3);

/* click-burst bubbles */
const BURST=makePoints(90,.26,.85,THREE.AdditiveBlending);
const burstV=[], burstL=new Float32Array(BURST.n);
for(let i=0;i<BURST.n;i++){ burstV.push(new THREE.Vector3()); burstL[i]=0;
  BURST.pos.set([0,-999,0],i*3); }
let burstCursor=0;
function spawnBurst(at){
  for(let k=0;k<26;k++){
    const i=(burstCursor++)%BURST.n;
    BURST.pos.set([at.x+rnd(-.3,.3),at.y+rnd(-.3,.3),at.z+rnd(-.3,.3)],i*3);
    burstV[i].set(rnd(-2.4,2.4),rnd(.5,3.2),rnd(-2.4,2.4));
    burstL[i]=rnd(.9,1.6);
  }
}

/* shockwave rings */
const ripples=[];
for(let i=0;i<5;i++){
  const m=new THREE.Mesh(new THREE.RingGeometry(.42,.5,48),
    new THREE.MeshBasicMaterial({color:0x9df5e6,transparent:true,opacity:0,
      blending:THREE.AdditiveBlending,depthWrite:false,side:THREE.DoubleSide,fog:false}));
  m.visible=false; scene.add(m); ripples.push({m,t:1});
}
function spawnRipple(at){
  const r=ripples.find(r=>r.t>=1)||ripples[0];
  r.t=0; r.m.visible=true; r.m.position.copy(at);
  r.m.quaternion.copy(camera.quaternion);
}

/* predator marker (cursor in 3-D) */
const predator={ pos:new THREE.Vector3(0,2,0), target:new THREE.Vector3(0,2,0),
                 active:false, pulse:0, fearR:5.2 };
const predatorSprite=new THREE.Sprite(new THREE.SpriteMaterial({map:spriteTex,
  color:0x9df5e6,transparent:true,opacity:0,blending:THREE.AdditiveBlending,depthWrite:false}));
predatorSprite.scale.setScalar(2.4); scene.add(predatorSprite);
const predatorLight=new THREE.PointLight(0x66ffe0,0,18,2); scene.add(predatorLight);

/* ============================== input ============================== */
const raycaster=new THREE.Raycaster();
const plane=new THREE.Plane(new THREE.Vector3(0,0,1),0);
const ndc=new THREE.Vector2();
let hasPointer=false, lastMove=-1e9, px=-200, py=-200, ringX=-200, ringY=-200;

function updateFromEvent(e){
  px=e.clientX; py=e.clientY;
  ndc.set(px/innerWidth*2-1, -(py/innerHeight)*2+1);
  raycaster.setFromCamera(ndc,camera);
  const hit=new THREE.Vector3();
  if(raycaster.ray.intersectPlane(plane,hit)) predator.target.copy(hit);
  hasPointer=true; lastMove=performance.now();
}
addEventListener('pointermove',updateFromEvent);
addEventListener('pointerdown',e=>{
  updateFromEvent(e);
  predator.pulse=1;
  spawnRipple(predator.pos);
  spawnBurst(predator.pos);
});

/* ============================== HUD refs ============================== */
const calmN=document.getElementById('calmN'), fleeN=document.getElementById('fleeN'),
      meterFill=document.getElementById('meterFill'), tankTag=document.getElementById('tankTag'),
      cursorEl=document.getElementById('cursor');
let hudTick=0;

/* ============================== main loop ============================== */
const clock=new THREE.Clock();
let booted=false;
const camTarget=new THREE.Vector3();

function animate(){
  requestAnimationFrame(animate);
  const dt=Math.min(clock.getDelta(),.033), t=clock.elapsedTime, now=performance.now();

  /* predator state */
  predator.active = hasPointer && (now-lastMove<3500);
  predator.pos.lerp(predator.target, 1-Math.exp(-dt*9));
  predator.pulse=Math.max(0,predator.pulse-dt*1.6);
  predator.fearR=5.2+predator.pulse*4.5;
  predatorSprite.position.copy(predator.pos);
  predatorSprite.material.opacity +=
    ((predator.active?.5+predator.pulse*.45:0)-predatorSprite.material.opacity)*(1-Math.exp(-dt*8));
  predatorSprite.scale.setScalar(2.4+predator.pulse*2.2+Math.sin(t*5)*.12);
  predatorLight.position.copy(predator.pos);
  predatorLight.intensity +=
    ((predator.active?26:0)+predator.pulse*70 - predatorLight.intensity)*(1-Math.exp(-dt*8));

  /* fish */
  let startled=0;
  for(const f of fishes){ f.update(dt,t,predator,fishes); if(f.stress>.35)started++; }

  /* bubbles */
  for(let i=0;i<BUB.n;i++){
    const d=bubData[i];
    d.y+=d.sp*dt;
    if(d.y>9.5){ d.y=FLOOR+rnd(0,.5); d.x=rnd(-13,13); d.z=rnd(-5,5); }
    BUB.pos[i*3]=d.x+Math.sin(t*2+d.ph)*.25;
    BUB.pos[i*3+1]=d.y; BUB.pos[i*3+2]=d.z;
  }
  BUB.pts.geometry.attributes.position.needsUpdate=true;

  for(let i=0;i<SNOW.n;i++){
    let y=SNOW.pos[i*3+1]-dt*.25;
    if(y<-4.5){ y=10; SNOW.pos[i*3]=rnd(-16,16); SNOW.pos[i*3+2]=rnd(-6,6); }
    SNOW.pos[i*3]+=Math.sin(t*.4+i)*dt*.12;
    SNOW.pos[i*3+1]=y;
  }
  SNOW.pts.geometry.attributes.position.needsUpdate=true;

  for(let i=0;i<BURST.n;i++){
    if(burstL[i]>0){
      burstL[i]-=dt;
      burstV[i].multiplyScalar(1-2.2*dt); burstV[i].y+=1.6*dt;
      BURST.pos[i*3]+=burstV[i].x*dt; BURST.pos[i*3+1]+=burstV[i].y*dt; BURST.pos[i*3+2]+=burstV[i].z*dt;
      if(burstL[i]<=0) BURST.pos[i*3+1]=-999;
    }
  }
  BURST.pts.geometry.attributes.position.needsUpdate=true;

  for(const r of ripples){
    if(r.t<1){
      r.t=Math.min(1, r.t+dt/0.9);
      r.m.scale.setScalar(.6+r.t*9);
      r.m.material.opacity=(1-r.t)*.8;
      r.m.quaternion.copy(camera.quaternion);
      if(r.t>=1)r.m.visible=false;
    }
  }

  /* environment life */
  for(const m of kelpMats) m.uniforms.uTime.value=t;
  for(const s of shafts){
    s.mat.opacity=s.base*(.72+.28*Math.sin(t*.6+s.i*1.7));
    s.m.rotation.z=-.18+Math.sin(t*.1+s.i)*.02;
  }
  caustics[0].tex.rotation.z+=dt*.03;  caustics[0].tex.offset.x+=dt*.006;
  caustics[1].tex.rotation.z-=dt*.022; caustics[1].tex.offset.y+=dt*.005;

  /* camera drift + parallax */
  const nx=hasPointer?ndc.x:Math.sin(t*.13)*.35;
  const ny=hasPointer?ndc.y:Math.cos(t*.1)*.15;
  camTarget.set(nx*2.4, 3.2+ny*1.3, 26+Math.sin(t*.2)*.4);
  camera.position.lerp(camTarget, 1-Math.exp(-dt*3));
  camera.lookAt(0,1.7,0);

  /* HUD */
  if(++hudTick%12===0){
    fleeN.textContent=started;
    calmN.textContent=fishes.length-startled;
    fleeN.classList.toggle('hot',started>0);
    meterFill.style.width=(started/fishes.length*100)+'%';
    tankTag.textContent = predator.pulse>.05 ? '!! pressure wave'
      : started>0 ? 'shoal scattering' : 'shoal at rest';
  }

  /* custom cursor ring */
  ringX+=(px-ringX)*(1-Math.exp(-dt*14));
  ringY+=(py-ringY)*(1-Math.exp(-dt*14));
  cursorEl.style.transform=`translate(${ringX}px,${ringY}px)`;
  cursorEl.classList.toggle('on',hasPointer);
  cursorEl.classList.toggle('scared',started>0);

  composer.render();
  if(!booted){ booted=true; document.body.classList.add('loaded'); }
}
animate();

addEventListener('resize',()=>{
  camera.aspect=innerWidth/innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth,innerHeight);
  composer.setSize(innerWidth,innerHeight);
});
</script>
</body>
</html>
Reasoning trace (16,761 tokens)
The user wants a 3D aquarium built with Three.js where the fish flee when the mouse approaches. Must use the given import map. Reply with a single fenced HTML code block only.

Let me design something rich and distinctive:
- A 3D aquarium scene using Three.js
- Fish (procedurally generated, no external models) — built from geometry: a body (stretched sphere/cone), a tail that undulates with animation.
- Flocking / wandering behavior, and flee behavior when the mouse ray (pointer projected into 3D space) gets close.
- Bubbles, seaweed (swaying kelp), sand floor, light rays (caustics-style), rock decorations, etc.
- Fog for depth, ambient gradient background.
- UI overlay: HUD title, fish count, and maybe an interaction hint like "Move the mouse near the fish." Since the mouse is the interaction, no click-to-start message needed for the mouse, but the instructions say a game should self-play… this is an interactive scene, not a game. Still, meaningful content must be visible on the first paint. Fish swim and react to the mouse. On touch devices, could support touch-move as a predator.

Implementation plan:

1. Scene setup:
   - renderer with antialias, tone mapping ACES, sRGB.
   - Camera with subtle orbit sway via mouse (parallax) — but note: the mouse also scares fish. Use a raycaster to find the point on an invisible plane facing the camera at some depth, and use that as the "predator" position.
   - Background: gradient achieved either by a big sphere using shader, or scene.fog + color. Underwater teal gradient. FogExp2 too.
   - Lighting: hemisphere + directional "sun" from above + point light for shimmering.

2. Fish: build a class Fish:
   - Use groups with a body mesh (LatheGeometry or scaled sphere), tail fin (custom shape geometry or cones), dorsal fin, pectoral fins.
   - Tail wiggle: rotate the tail mesh with a sine wave based on time and speed. Also a slight bend of the body: use a few segments? More simply: two-part body with front and back? Simplest good look: body mesh + tail group. Tail oscillates in yaw. Body also slight yaw wobble.
   - Material: MeshStandardMaterial or MeshPhysicalMaterial with varied vivid colors, slight emissive, could also use flat shading for a stylized look.
   - Behavior: boid-lite: wander (Perlin-like noise target), stay within tank bounds (soft boundaries via steering), weak cohesion/separation, flee when predator point is within radius: strong steering away, increased speed, faster tail wagging.
   - Update every frame: vel += steer; clamp speed; orient fish toward velocity direction (quaternion slerp to face direction).

3. Mouse interaction:
   - pointermove -> NDC -> raycaster -> intersect a plane facing the camera at z = 0 (a plane through tank center facing the camera). Get world coordinates -> predator position. Also show a subtle ripple/cursor light (a faint glowing sphere or ring) at that point, fading out when idle.
   - If pointer leaves, predator moves far away.
   - Touch support: update predator on touchmove.

4. Environment:
   - Sand floor: plane with vertex displacement noise, sand-colored standard material.
   - Rocks: dodecahedron/icosahedron randomly scaled, darkish colors.
   - Kelp/seaweed: several planes or cylinders? Good approach: use a PlaneGeometry with many vertical segments; sway via shader, or per-frame vertex manipulation? Cheaper: make kelp with a few stacked segments, rotate each slightly over time (group hierarchy). Or use vertex shader. More simply: make a kelp mesh with a custom function that animates vertices every frame via stored base positions (fine for a few dozen plants). Or use a shader material with sin bend. Let's go with a ShaderMaterial kelp: bending based on uv.y and time. Actually even simpler: a group of cones? Let's go with a bent plane updated every frame in JS — kelp has maybe 8 planes × 1x8 segments. Updating ~100 vertices × 10 = 1000 vertices/frame, no problem.
   
   Even better: kelp as ribbons: each kelp = several flattened cones? Make a ribbon geometry (thin PlaneGeometry) and animate a sway of cumulative rotation by height.

   - Bubbles: particle system via Points, or instanced spheres rising with wobble. Use Points with a round sprite texture generated on a canvas (radial gradient). Recycle when reaching top.
   - Light shafts: several transparent, additive-blended planes angled from above, slowly rotating/pulsating opacity. Cheap and beautiful.
   - Caustics on the floor: moving texture? Could use a canvas-generated caustics pattern that scrolls via two overlapping additive planes. Or, simpler: a spotlight with moving texture? Use a big plane just above the sand with a canvas texture having a wavy-cell pattern, additive, low opacity, slowly rotating/scaling — good enough.

5. HUD/UI:
   - Top-left title: "THE FISHBOWL" or something distinctive. Typography: a display face + body face pair. No external fonts allowed ("everything must be inline... no external files, images, fonts" — Google Fonts fonts are network requests; not permitted). So creatively use the system font stack: display could be styled via letter-spacing, weight; e.g., "Georgia"/"Palatino" for the display italic contrast, or a strong sans stack with weight 900? A distinctive display + readable body combo via system fonts: display: "Copperplate", fallback… risky. Let's go with font stacks: display: Futura, "Trebuchet MS", … body: "Segoe UI", system-ui. Use strong size/weight contrast, small caps letter-spacing for labels.
   - Stats: fish count, "agitated" count, a small "proximity sensor" meter that shows how many fish are fleeing. A small panel bottom-left with a legend: "Move your cursor — the school scatters."
   - Depth-meter gradient bar? Could also have a subtle vignette overlay via CSS radial-gradient.
   - Ripple cursor: could also be a custom CSS cursor: none + a div that follows the mouse with a ring. A nice microinteraction: the ring pulses; expands when fish scatter. In 3D I'll do it via a 3D ring, plus also on the DOM side. Simpler: hide the cursor over the canvas and show a DOM ring that follows the pointer; also, the predator point in 3D gets a faint glowing sprite.

6. Fish count: about 24–30 fish, varied sizes/colors/species (3 species: angelfish-like tall and flat fish, tetra small and streamlined, goldfish roundish). Procedural variation.

Performance: keep geometry modest. Use MeshStandardMaterial with modest light count. Shadow: maybe soft shadows off for perf; could enable with a single directional and few casters? Shadows in the aquarium might look good but are costly; skip and use a gradient floor + pseudo-blobs? Let's skip real shadows, use lighting.

Also, subtle camera parallax: camera lerps toward mouse offset a bit, lookAt center. But mouse controls both predator and camera sway — that's fine, predator stays computed from ray.

Post-processing? UnrealBloom via addons would be nice (additive light shafts would bloom). The allowed import map includes three/addons/. EffectComposer, RenderPass, UnrealBloomPass can be imported from 'three/addons/postprocessing/...'. That adds beauty. Perf OK at 1280x800. Add gentle bloom (strength ~0.35, threshold ~0.6). Risk: bloom on everything will blow out; tune threshold.

Alternatively, keep it simple to ensure zero errors. Bloom is well-known; include with guards: if import fails? Import errors would break the module. It's from the same CDN so should be fine. Include composer.

Code structure:

- index.html with import map, module script.
- CSS: overlay UI, vignette, custom cursor ring, HUD panel, animated title shimmer? Elegantly.

Let me write the fish creation:

Species configuration: color palette pairs (body, belly, fins), bodyScale.

Build fish geometry procedurally:
- Body: SphereGeometry(1, 20, 14) scaled (1.6, 0.55, 0.42) → streamlined. For angelfish-like: scale (1.0, 0.9, 0.28). Vertex colors? Keep it simple: uniform color, and a second smaller sphere shifted as a belly? Skip.
- Eyes: two small dark spheres on head sides + white spheres.
- Tail: cone or custom: make tailfin with a shape: use rotated ConeGeometry(radius 0.35, height 0.5, 4)? Better: flat triangle fan via BufferGeometry: ShapeGeometry of a triangle-ish fin, double sided.
- Dorsal fin: shape on top.
- Pectoral fins: two small triangles on sides, flapping slowly.

Tail wag: tailGroup.rotation.y = sin(t*freq)*amp; also slight body wobble: bodyGroup.rotation.y = sin(t*freq + phase)*0.06; plus small roll.

Fins: use Shape + ShapeGeometry, material double-sided with slight transparency.

Behavior constants:
- bounds: box around tank (x±16, y -5..8, z -6..6). Camera at z ~26 looking at origin. Tank is "open water" style, no glass walls (just underwater scene) — fine.

Actually let me consider containing in a glass aquarium with visible walls? A visible tank with glass panels, refractive? That's complicated. Scene = slice of ocean: sand floor, water volume by fog, light shafts. It reads as "aquarium" with kelp and rocks. Title can be "Open Tank". Good.

Flee logic:
- Each fish has stress level 0..1. Distance d to predator; if d < fearRadius (say 6): steer = (pos - predator).normalize * (panicStrength * (1 - d/fearRadius)); stress = max(stress, 1-d/fearRadius). Decay stress over time. Speed = lerp(cruise, maxSpeed, stress). Tail frequency also scales with speed.
- Wander: target a noise point changing per fish: use per-fish random phase; steer toward a slowly moving point = center + orbit; or use "heading wander": add small random angular jitter to velocity. I'll implement: acceleration = seekWander + containment + separation + flee + weak alignment/cohesion.

Containment: if outside soft bound, proportional steering toward center.

Separation: O(n²) fine for 26 fish.

Also vertical constraint: keep y between 0.8 above sand and top.

Bubbles: Points with size attenuation; each bubble has x,z, y rising speed, wobble phase; reset at floor. About 120 bubbles. Use a single Points with per-frame attribute update on positions (float32). Fine.

Light shafts: 5 PlaneGeometry(4, 40) tall planes, material: custom gradient (alpha via canvas texture with vertical gradient) additive, depthWrite false, rotate to slight tilt, positions varied across the top, slow opacity pulse and slight sway in rotation.

Caustic floor texture: generate 256px canvas: draw a wavy line network? Simple approach: draw random circles with strokes? A classic cheap caustics: draw many overlapping arcs with 'lighter'. Then place as a texture on a plane just above the sand, blending Additive, opacity 0.25, slowly rotate and offset-animate (via texture.offset). Two layers counter-rotating = convincing.

Kelp shader material: create ShaderMaterial with uniforms time; vertex: bend x by sin(time + worldY) * uv.y^2 * amp. Plane geometry width 0.8, height ~5, 1x12 segments, positioned root at floor. Double sided, color gradient dark green to bright via uv in fragment. 8–12 kelp. Nice.

Rocks: several IcosahedronGeometry(radius,0) flat-shaded, gray-green color, half-buried.

Small fish food? Skip.

Predator visual: a soft ring sprite (canvas radial) additive, scale pulses; plus a point light (dim, cyan) moving at predator position for drama — a point light with small distance following cursor gives interactive light: good feedback! Use PointLight (cyan, intensity 30, distance 18, decay 2).

DOM cursor ring: div fixed 28px border circle; transform translate; when scattering occurs (stress count > 0), add a class that changes color/scale. Smooth follow with lerp in rAF (reuse main loop).

HUD contents:
- Top-left: "LUMEN REEF" big display, subline "Interactive aquarium — fish scatter from your cursor."
- Top-right: live counters: FISH 26 · CALM 26 · STARTLED 0 with a small bar.
- Bottom-right: hint "move the cursor through the school · click to drop bubbles"? Extra micro-interaction: clicking generates a burst of bubbles + shockwave that strongly scares fish. That's fun and simple: on pointerdown, predator pulse: temporarily raise fearRadius and spawn a bubble burst + ring ripple in 3D. Let's do it!
- Bottom-left: small species legend chips with color dots (3 species names) — decorative richness.

Fonts: display stack: "Palatino Linotype", "Book Antiqua", Palatino, Georgia… Hmm distinctive display: 'Copperplate' is Mac only. Cross-platform approach: for display use "Trebuchet MS"/Verdana bold italic? Rather I want something like: display: Georgia italic? An underwater exhibit feels like a museum; serif italic display + humanist sans body could be elegant. Decision: display: 'Palatino', 'Palatino Linotype', Georgia, serif; style: uppercase, letter-space? Or italic large. Body: 'Segoe UI', 'Helvetica Neue', sans. Strong size contrast. Good.

Colors: deep-sea teal/abyssal-navy background (#03181f→#0a3a44), accents aqua (#4de3c1?), coral (#ff7a59) for startled state. HUD panels: dark translucent teal with 1px pale-cyan border, small radius (6px), no rounded-2xl everywhere.

Vignette overlay + top water light gradient via CSS pointer-events none.

Loading: module scripts load from CDN; show a small "filling the tank…" loader div that is hidden on first frame.

Edge cases: pointer leaves window → predator lerps to far away (set flag active=false; lerp predator position to y +100? Or fade influence). Use predator.active flag; fish ignore when inactive; visual also fades.

Touch: handle touchstart/move mapping to predator; touch creates a pulse too.

Resize handler.

Camera: perspective fov 55, position (0, 3.5, 26), lookAt(0,2,0). Parallax: target camera offset from mouse NDC * (1.5, 0.8). Lerp.

Renderer clear color: use a scene.background gradient? Use a big inverted sphere with a ShaderMaterial vertical gradient (deep navy bottom to teal top, with slight radial). Or scene.background = Color + fog to match. A gradient sphere looks richer. Implementation: SphereGeometry(200) BackSide ShaderMaterial with mix(bottomColor, topColor, pow(smoothstep of normalized y)). Fog color to match middle tone; FogExp2 density 0.016.

Light shafts need additive: fog affects shader? Set material.fog=false for additive stuff to avoid tinting? Keep fog true for the shafts? Simplest: shafts have fog:false.

Fish materials and fog: default true is fine.

Bloom: threshold 0.7, strength 0.45, radius 0.6. Predator light ring sprite being bright will bloom cleanly. Make shafts slightly bright.

OK, let me carefully write the code. Ensure zero console errors: guard things like renderer.outputColorSpace = THREE.SRGBColorSpace (exists in r170). toneMapping = ACESFilmicToneMapping.

UnrealBloomPass import path: 'three/addons/postprocessing/UnrealBloomPass.js', EffectComposer '.../EffectComposer.js', RenderPass, also OutputPass for correct color when using composer with tone mapping? In r170 recommended chain: RenderPass + UnrealBloomPass + OutputPass. OutputPass applies tone mapping and color space. Import OutputPass from 'three/addons/postprocessing/OutputPass.js'. Yes.

Resize composer too: composer.setSize; bloom resolution handled internally via setSize.

Pixel ratio: Math.min(devicePixelRatio, 2).

Fish species parameters:
1. "Neon tetra": small (0.5), body color #26d4c9? Actually tetra: silver-blue with red belly: body #35e0ff-ish, fins semi-transparent #ff5e5b? Simplify: body #4fd8ff, belly not doable; fins orange.
2. "Sunburst" (goldfish-ish): body #ffb64d, fins #ff7a3d, round scale (1.1, 0.8, 0.45).
3. "Royal angelfish": tall-flat: body #7a5cff? Maybe teal with violet: body #b46bff, fins #5ad1ff. Hmm palette: keep an aquatic accent. Let me choose: tetra cyan, clownfish orange, angelfish violet-blue.

Eyes: little black spheres.

Fish construction function makeFish(species, size):
- group root
- inner "rig" group for wiggle? Apply wiggle rotation to body+tail group; root handles translation/orientation.
- Tail group at x = -bodyLen*0.55 (fish faces +X). Rotation.y wiggles.
- Tail fin geometry: Shape: moveTo(0,0) quadratic to (−0.55,0.45), lineTo(−0.7,0), lineTo(−0.55,−0.45), quadratic back → forked-ish. ShapeGeometry, double sided.
- Dorsal: shape on top of mid.
- Pectoral fins: two small shape groups with z offsets ±, each pivoting, rotation.z wiggling slightly out-of-phase.

Orientation: fish forward +X. velocity → direction; compute quaternion via m.lookAt? Build matrix4 lookAt from pos to pos+dir with up (0,1,0) and quaternion.setFromRotationMatrix; but lookAt aligns -Z? Object3D.lookAt points +Z toward target? THREE lookAt is internal forward -Z for cameras; for objects lookAt rotates so +Z? Actually Object3D.lookAt makes local -Z? Docs: lookAt is aimed at point with object's local +Z? THREE.Camera looks down -Z, and object lookAt points +Z toward target? Standard: Object3D.lookAt rotates object such that its local +Z axis points toward target? Hmm: for non-camera objects, lookAt aligns the positive Z axis with the target (cameras use negative Z). Yes: for non-camera objects, +Z toward target. Our fish forward is +X. Options: build fish with forward +Z (rotate geometry) or rotate a wrapper: put fish model group inside root; compute desired quaternion via matrix lookAt with +Z pointing to dir, then model is rotated Y 90° inside so +X→+Z. So: inner.rotation.y = Math.PI/2? +X rotated by rotY(π/2): x→? RotY(π/2) maps +X to −Z. RotY(−π/2) maps +X to +Z. Check: rotY(θ): x' = x cosθ + z sinθ; z' = −x sinθ + z cosθ. For vector (1,0,0) θ=−π/2: x'=cos(−90)=0; z'=−1*sin(−90)= +1. Yes: rotY(−π/2) maps +X→+Z. So inner.rotation.y = -Math.PI/2. Then root.quaternion from lookAt(dir). Smooth via slerp with rate depending on stress.

Also banking: roll on turns is nice but skip complexity; add a small roll = lateral steering component * factor, apply to inner.rotation.z? Can compute signed angle between old and new heading… Simpler: inner.rotation.z = lerp toward clamp(turnRate * k). turnRate = cross(oldDir, newDir).y sign * amount. Let's add subtly.

Speed-based tail wag: freq = 3 + 9*speedNorm; amp = 0.35 + 0.4*speedNorm.

Per fish: phase offset.

Now steering implementation details:

Constants:
cruise = 2.2, maxSpeed = 3.2; panic maxSpeed = 7.5.
Actually speed = mix(cruise, panic, stress). Accelerations:
- wander: target direction = current velocity direction rotated by small random; implement as desired = velocity.clone().add(randVec*0.3), steer = (desired.normalize*speed - vel)*0.5? Classic steer = desired - vel clamped by maxForce.
- per-fish goalPoint: pick a random point within bounds every 4-8 seconds; seek gently (force 0.3). Combined with wander jitter looks organic.
- containment: when pos outside soft box (e.g., |x|>bx etc), force toward center proportional to overshoot *2.
- separation: for neighbors < 1.6: push away accumulate force 1.
- cohesion/alignment mild: compute local center of neighbors within 6, steer to it *0.15, align *0.2. Keep the school loose.
- flee: if predator active and dist < R (6.5 + pulse bonus): strength s = (1 - d/R); force = away.normalize * (14 * s); stress target = max over time; also vertical bias to scatter slightly.

Integration: vel += acc*dt; speed cap = lerp(cruise, panic, stress); also min speed 0.6. pos += vel*dt. Stress decays: stress = max(0, stress - dt*0.5); on flee stress = max(stress, s).

Also click shockwave: set predator.pulse = 1, decay; fearRadius += pulse*6, panic boost; also spawn ripple ring (expanding torus/ring with opacity fade) and a bubble burst at predator position.

Ring ripple: RingGeometry(0.1..) basic material additive; animate scale up to 8, opacity to 0. Pool of a few.

Predator sprite: Sprite of radial-gradient texture, additive, scale ~2.2 pulses; opacity 0.9 when active else 0.

Bubble burst: reuse bubble system? Simpler: separate burst particles array with small points with velocity upward/outward, gravity slightly up (buoyancy), lifetime 1.5s; pool 60 points, spawn 20 per click.

HUD counter updates every ~200ms: startled count = fish with stress > 0.25. Bar width %.

Legend chips with species color.

Title text: Let me name it: "TIDEGLASS AQUARIUM"? Or "THE SCATTER SCHOOL". Display: "Tideglass Aquarium" — hmm, maybe "LUMEN LAGOON". Let's go with "TIDEGLASS / living aquarium". Title: "Tideglass Reef". Subtitle: "Glide your cursor through the water — the school scatters. Click to send a pressure wave."

Small note top-right corner: "three.js r170".

CSS cursor: none over canvas; show ring div. On mobile (touch) hide ring.

Ensure no horizontal scroll: body overflow hidden.

Also iframe keyboard? Not needed; mouse only. "click or key press to start" rule applies to games with keyboard; here interaction is mouse, which works without focus in iframe? Pointer events to the iframe content need… actually pointermove events inside the iframe work without clicking (mouse events don't need focus). So fine, no start gate needed. But auto-screenshot after 3s should show fish swimming (self-playing). Fish swim autonomously (wander). Good. Additionally maybe add an idle attract mode: if no pointer ever, predator occasionally drifts on its own? Not needed; fish are alive on their own.

OK, code. Watch for import + top-level module. Write clean, complete code.

Let me draft the kelp ShaderMaterial:

uniform float uTime; varying vec2 vUv;
vertex:
vUv = uv;
vec3 p = position;
float bend = sin(uTime*1.2 + p.y*0.6 + phase) ... need per-instance phase: use uniform uPhase.
p.x += sin(uTime*1.4 + uv.y*4.0 + uPhase) * pow(uv.y,1.8) * 0.9;
p.z += cos(uTime*1.1 + uv.y*3.0 + uPhase*1.7) * pow(uv.y,1.6)*0.35;
fragment: color mix deep (#0b3f33) → tip (#3fae7a) by vUv.y, alpha 1; slight edge? Just solid, double sided.

Kelp mesh: PlaneGeometry(0.7, height, 1, 12); translate so root at y=0: geometry.translate(0, h/2, 0). Position y at floor y (sand height at that xz ~ maybe -4.8 + local noise). Compute sand height function for placement: reuse same noise function used to displace sand vertices, for kelp/rock/bubble spawn base.

Sand: PlaneGeometry(90, 40, 48, 24) rotated -π/2; displacement: y = n(x,z) using a sum of sines: h = sin(x*0.35)*cos(z*0.5)*0.5 + sin(x*1.1+z*0.7)*0.18 ... make function sandH(x,z). Color #d8c08a? Underwater sand more greenish tan #b7a27a. MeshStandardMaterial roughness 1. Also darken via vertex colors by height? Keep simple; fog handles.

Floor is at y = -4.6 base. Sand plane base y0 with vertex displacement ±0.5. Bounds for fish y min ~ -3.4.

Rocks: positions on sand: y = -4.6 + sandH + radius*0.4.

Light shafts: position y ~ 14 down length 30; planes rotated around Y various angles, tilted z ~ -0.15. Texture: canvas 64x256 vertical gradient white alpha: top alpha 0.9 → bottom 0. Also horizontal fade at edges: draw gradient with 'destination-in'? Simpler: fill a linear gradient top-white bottom-transparent; then create horizontal gradient with globalCompositeOperation 'destination-in' for edge fade. Fine.

Caustics canvas: 512px; background black; ctx strokeStyle rgba(255,255,255,0.5) with many random wavy cells: draw ~140 arcs: random centers, radius 10-40, partial arcs. Composite 'lighter'. Then two meshes with this texture, blending Additive, opacity 0.22, rotateX(-π/2) y=-4.1, scale large 60x30; animate by rotating texture offset/rotate via texture.rotation? Rotating texture about center: set texture.center (0.5,0.5); rotate slowly with rotation.z. Move offset too. Layer B in reverse direction.

Background sphere shader:

varying vec3 vPos; frag: float t = clamp((normalize(vWorld).y + 1.0)*0.5 ... Actually compute from world Y: t = smoothstep(-0.4, 0.9, dir.y); color = mix(#02131c deep, #0d5f6b top?) plus a slight radial glow near top center. Colors: bottom #041418, mid #073541, top #0f6b73. mix of 2 steps.

Water tint fog: scene.fog = new THREE.FogExp2(0x06333d, 0.018).

Lights: hemisphere (skyColor #7fd4d4, ground #0a2a33, 0.9); directional #cfeff0 intensity 1.2 pos (6, 18, 4); also a cyan point for predator, plus subtle warm accent point (#ff9a5c, low) near rocks for contrast? Nice: put small warm point light near a rock cluster, intensity 8, distance 20. Underwater warm light is odd but adds richness, keep subtle.

Bloom strength maybe 0.5, threshold 0.55. With ACES + OutputPass. Renderer: toneMapping set; when using OutputPass, tone mapping is applied inside OutputPass reading renderer.toneMapping automatically? OutputPass performs tone mapping and color-space conversion based on renderer settings. Yes.

Now let me detail DOM HUD:

<div id="hud">
  <header class="brand">
    <div class="eyebrow">EST. TIDEGLASS — TANK Nº 7</div>
    <h1>The Scatter School</h1>
    <p class="lede">Glide your cursor through the water and the shoal breaks apart. Click to send a pressure wave.</p>
  </header>
  <div class="stats"> ... counters ... </div>
  <div class="legend"> species chips </div>
  <footer hint bar bottom center? maybe put hint inside brand lede; bottom-left small "depth 12 m · temp 24 °C · salinity 1.025" flavor line, and bottom-right "three.js r170 · bloom on".
</div>

Counters:
<div class="stat"><span class="num" id="calmNum">24</span><label>calm</label></div>
<div class="stat"><span class="num hot" id="fleeNum">0</span><label>startled</label></div>
Meter bar: width % of startled.

Also a small live "water quality" ticker? overkill.

Micro-interactions: numbers transition color; meter animated with transition width.

Custom cursor ring: #cursor {position:fixed; width:34px;height:34px;border:1.5px solid rgba(160,255,240,.9); border-radius:50%; transform:translate(-50%,-50%); mix-blend-mode: screen; pointer-events:none; transition: width .2s,height .2s, border-color .2s;} .scared state: larger + coral border. Dot center via ::after.

Hide when pointer leaves (opacity 0).

Loader: #loader center text "filling the tank…", fade out after first render frame via class.

Edge case: ensure pointermove on window to also update if HUD is hovered? HUD is pointer-events none, only small elements maybe? Simpler: everything in HUD pointer-events:none so canvas receives events everywhere. Click handled on renderer.domElement or window? Use window 'pointermove', and 'pointerdown' on window. If pointerdown over HUD it still fine.

Raycast plane: use THREE.Plane(normal = camera direction?, through origin?) Use plane facing camera through (0, 1.5, 0): normal = camera forward (negated). Compute each move: raycaster.ray.intersectPlane(plane, out). Set plane.normal.copy(camera.getWorldDirection()) once? Camera moves slightly; keep plane normal fixed (0,0,1), and plane constant such that z=0: new THREE.Plane(new THREE.Vector3(0,0,1), 0). Ray from camera at z=26 hits plane at z=0 — that will map nicely within tank volume (x within ±~14 given fov). Good, cheap.

Predator smoothing: predator.pos.lerp(target, 0.25) per frame; sprite opacity lerp to active?1:0.05.

Idle: after 4s no pointer, active=false.

Touch: pointermove handles pointer type; pointerdown too: on mobile tap = pulse.

Now numbers: N fish: 26 (tetra 12, clown 8, angel 6). Sizes vary.

Let me write the fish builder with materials per species; reuse geometries for perf: cache geometry per species, clone material per fish? Sharing material per species is fine (no individual color variance needed) — but per-fish material needed for panic tinting? Could also tint via material.emissiveIntensity… sharing is fine; skip per-fish tinting; instead show stress via tail speed and maybe a slight scale pulse. OK share species materials.

Eyes per fish geometry is small — create shared eye geometry, material black.

Fish part layout (fish length unit L ~ 1 scaled by size):
body: sphere radius 0.5 → scale (1.5, 0.5, 0.35) * size. Position 0.
head a bit: fine.
tailPivot at x = -0.7*size. Tailfin shape scaled by size: shape points: (0,0) → quad to (-0.5,0.42) → (-0.32,0) → (-0.5,-0.42) → close (forked). Build shape in unit and scale geometry by size.
dorsal: top: shape (−0.25,0)→(0.05,0.35)→(0.35,0) closed; position y=0.4*size? Body half-height 0.5*size*0.5? Body y scale 0.5*0.5 radius? Sphere r=0.5 scaled y 0.5 → half-height 0.25*size… hmm scale numbers: SphereGeometry(0.5,18,12); mesh.scale.set(1.55,0.62,0.38)*size → half axes: x 0.775s, y 0.31s, z 0.19s. So dorsal base y≈0.28s.
Pectoral fins at (0.15s, -0.05s, ±0.2s), rotated.
Eyes at (0.45s, 0.08s, ±0.14s), r 0.045s black; also white ring behind? Keep just dark eyes plus tiny white highlight sphere offset? Keep just dark eyes + small white glint.

Angel species scale (1.05, 1.1, 0.3), bigger dorsal + anal fins: add a mirrored ventral-fin shape.

Clown: scale (1.4,0.62,0.4); add stripes? Stripes via extra torus-like bands? Could add 2 white bands with thin box/ring segments slightly larger than body cross-section: use SphereGeometry slice? Simpler: 3 thin flattened spheres (white) as rings at x offsets intersecting body: use cylinderGeometry thin? Cheap trick: use a torus scaled: TorusGeometry(0.33, 0.035, 8, 20) rotated Y? torus in YZ plane at x offset: rotation.y = π/2, scale y slightly bigger. Torus radius 0.3 vs body half-z 0.2, half-y 0.31 — torus circle in YZ radius 0.3 covers. White material. Good enough visual stripe bands. Add 2.

OK. Fin materials: MeshStandardMaterial({color: finColor, transparent: true, opacity: 0.85, side: DoubleSide, roughness:0.6}).

Body material: MeshStandardMaterial({color, roughness:0.45, metalness:0.15, emissive: color, emissiveIntensity:0.06}).

Fish total meshes ≈ body+tail+2 pects+dorsal(+anal)+2 eyes (+2 bands clown) ≈ 8-10 → 26 fish ≈ 240 draw calls... plus environment. May be heavy but modern GPU fine; could merge but keep simple. Maybe reduce eyes: keep. Fine; bloom pass is heaviest.

Alternative to merge: not needed.

Animation loop with clock; dt clamp 0.033.

Update HUD every 15 frames.

Also camera parallax + subtle idle drift: camera.position.x = lerp to mouseNDC.x*2.2 + slow sine*0.4; y = 3.2 + mouseNDC.y*1.2 + sin*0.2; lookAt(0,1.6,0).

Wait: predator plane is at z=0 fixed; when camera moves, the ray maps the same NDC to slightly different world — fine.

Bubble point texture: canvas radial gradient circle white→transparent. PointsMaterial size 0.35, transparent, opacity .8, blending additive, depthWrite false, map. Update positions array every frame: y += speed*dt; x wobble sin(time+phase)*small: store baseX; reset when y> topY(9): y = floor + rand, new x,z.

Bubble count 140.

Click bubble burst: use another Points of 90 particles inactive (y=-100). On click, activate K=24 from pool at predator position with random velocity (sphere*2 + up 2). Update: vel *= 0.96 damp; vel.y += 1.5*dt buoyancy; life -= dt; deactivate. Manage lifetimes in array. Points size .3, per-particle opacity via material uniform not possible… use single material opacity; fade overall is meh. Or scale size by average? Keep simple: particles just fly and fade by moving out; lifetime 1.6s then park.

Ripple ring: mesh RingGeometry(0.42,0.5,40), basic material additive cyan; pool 5; spawn: scale 0.6→8, opacity .9→0, over 0.9s; oriented facing camera: copy quaternion to camera. Update in loop.

Also click triggers short burst of predator light intensity.

Sound? No.

Let me write the final HTML.

Also include <meta viewport>. Title "Tideglass — The Scatter School".

Check bloom import correctness:

import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';

Good.

renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.05. OutputPass handles.

Fog on background shader: set fog:false in its ShaderMaterial (ShaderMaterial ignores fog unless defined). Background sphere radius 160, camera far 300.

Careful: FogExp2 will tint distant fish; bounds x±14, z±6, camera z 26: distance up to ~40 → fog factor exp(-0.018*40)=0.49 half-faded: moody, good. Maybe density 0.014.

Light shafts positions across top: x from -12..12, slight z variance, rotation.y random, tilt.

Caustics plane additive above sand: y = -4.15. Two mesh layers with same texture; animate: tex.rotation += dt*0.02 (set center 0.5,0.5); layer2 reverse rotate & offset drift; mesh.material.opacity pulse 0.18+0.05*sin.

Sand plane also receive subtle caustics tint via standard material — visually enough with an additive overlay.

Kelp placement: ring of clusters: positions [(-11,-2),(-9,1),(9,-1),(11,2),(-3,-4),(4,-4)...] x,z; heights 4-7.

Small green tufts? Skip.

Rock colors: #3c4f4c, #2f4341 flat shading.

Let me define species list:

const SPECIES = [
 {name:'Neon Tetra', body:0x39d7ff, fin:0xff7a5c, count:12, size:[0.42,0.55], shape:'slim'},
 {name:'Ember Clown', body:0xff8a3d, fin:0xffd9a0, count:8, size:[0.55,0.7], shape:'round', bands:true},
 {name:'Violet Angel', body:0x9a6bff, fin:0x63e6ff, count:6, size:[0.7,0.95], shape:'tall'},
];

Build geometry per shape and share.

Fish class fields: root(Group), rig(Group) inside (rotation.y=-PI/2? Actually apply the mapping: rig contains parts built facing +X; set rig.rotation.y = -Math.PI/2 so root's +Z-facing (lookAt style) equals fish forward. Then orient root via quaternion from Matrix4.lookAt(pos, pos+dir, up). Matrix4.lookAt(eye, target, up) builds a rotation matrix with +Z pointing from target→eye? THREE Matrix4.lookAt(eye,target,up) constructs a rotation matrix from eye to target with Z axis = normalized(eye - target)? In THREE, Matrix4.lookAt builds a rotation matrix where Z axis = normalize(eye - target)? Camera convention: camera looks toward -Z, so z axis = eye-target. That means object's -Z points toward target? Ugh. Object3D.lookAt: "Rotates the object to face a point in world space... assumes local +Z is forward"? Actually per docs: Object3D.lookAt works for objects where +Z is forward except cameras and lights which use -Z? Let me recall source: Object3D.lookAt uses _m1.lookAt(position, target, up) for cameras/lights, else _m1.lookAt(target, position, up) (swapped). Yes! Object3D.lookAt: if isCamera||isLight → m.lookAt(position, target, up), else m.lookAt(target, position, up). So for regular objects, +Z points toward target. And Matrix4.lookAt(eye, target, up) sets z = normalize(eye - target). If we pass (target, position), z = normalize(target - position) = dir. So quaternion from m.lookAt(pos, pos+dir? no we need z=dir → eye=targetPoint=pos+dir, target=pos → m.lookAt(pos.add(dir), pos, up) gives z = dir. Simply: use a temp Object3D? Easiest: dummy.position.copy(pos); dummy.lookAt(pos+dir); quaternion = dummy.quaternion. Use an Object3D dummy.

So root.quaternion.slerp(targetQuat, 1 - exp(-dt*turn)), with turn = lerp(3, 7, stress)? Slerp factor = 1 - Math.exp(-dt * k).

Fish forward after rig rotation: forward (root local) is +Z.

Inner wiggle applies to a "wriggle" subgroup inside rig? rig.rotation.y = -π/2 fixed; wriggleGroup for tail-wag yaw: apply body wobble to bodyGroup.rotation.y = sin*0.08 (inside rig this adds wobble around fish's vertical axis). Tail group rotation.y = sin*amp. But tail is child of bodyGroup? Put tailPivot as child of bodyGroup so body wobble propagates. Pectoral fin pivots are children of bodyGroup.

Hierarchy: root → rig(rotY=-π/2) → body(g) [bodyMesh, dorsal, eyes, bands, pectL, pectR, tailPivot→tailFin].

Compute fish world position via root.position.

Also add slight vertical bob: root.position.y += is handled by physics; fine.

Pectoral wiggle: pectL.rotation.z = sin(t*6+phase)*0.4 - 0.3 (drooping), mirrored side opposite.

Now steering code:

update(dt, time, predator):
 const p = this.pos (root.position), v = this.vel.
 acc.set(0,0,0)
 // goal seeking
 if time > this.retarget: pick new goal inside bounds (maybe biased toward center), retarget = time + 3+rand*4.
 desired = goal - p → normalize*cruise; steer = desired - v; acc += steer*0.35
 // wander jitter
 acc += randVec*0.6 (scaled by dt? no force is independent) use small.
 // containment
 per axis: if p.x > BX → acc.x -= (p.x-BX)*2.5; etc. For y bounds (floorY+0.8, ceilY-1) same.
 // separation & alignment (loop others)
 // flee
 if predator.active:
   d = p.distanceTo(predator.pos); R = predator.fearR (base 5.5 + pulse*4)
   if d < R: s = 1 - d/R; dir = (p - predator.pos).normalize; acc += dir * (22*s*s + 6*s); this.stress = max(this.stress, min(1, s*1.4)); maybe add slight upward dart: acc.y += s*3*randSign? Use this.dartBias assigned at construction.
 // stress decay
 this.stress = max(0, this.stress - dt*0.55)
 // integrate
 v += acc*dt;
 sp = v.length(); maxs = lerp(2.6, 8.5, stress); mins=0.5;
 if sp > maxs v.multiply(maxs/sp); if sp<mins v.setLength(mins) (or push toward goal).
 p += v*dt
 // orient
 dirTmp.copy(v).normalize(); dummy at origin looking along dir: dummy.position.set(0,0,0)? Just compute quaternion: dummy.position.copy(p) irrelevant since quaternion only: dummy.lookAt(p + dir). Use _target.copy(p).add(dirTmp).
 slerpFactor = 1 - exp(-dt*(2.5+6*stress))
 root.quaternion.slerp(q, slerpFactor)
 // wiggle
 speedN = clamp((sp - 0.5)/(8.5-0.5),0,1) better to use maxs relative: speedN = sp/8.5.
 tail.rotation.y = sin(time*freq + phase)*amp; freq = 5+16*speedN? For cruising ~2.6 → speedN .3 → freq ~10 OK.
 body.rotation.y = sin(time*freq*0.85 + phase)*0.09
 pects...

Bounds: BX=13, BZ=5.5, floorY=-3.2, ceilY=8.

Predator fearR base 5.2.

Counter for startled: stress>0.3.

HUD meter width = startled/total.

Now DOM ring cursor update: lerp ringPos toward pointer px. On document pointermove save client coords; on frame update ring transform. Also on fleeCount>0 add .scared class? Toggle by classList with condition, with transition.

Loader removal: after first composer.render, add 'loaded' class to body → CSS fades #loader.

Perf guard: pixelRatio min(1.75).

Also handle visibility: skip.

Double-check kelp ShaderMaterial fog: fog affects standard materials; ShaderMaterial by default no fog — kelp distance ~ up to 35 might stand out sharp against fogged sand. Add manual fog to kelp shader? Simplest: include fog manually: compute fogDepth = -mvPosition.z; factor = 1-exp(-density²·depth²)? FogExp2: factor = 1 - exp(- (density*depth)^2 ). Mix color with fogColor uniform (0x06333d). Implement: uniform vec3 uFog; uniform float uFogD; float f = 1.0 - exp(-pow(uFogD*depth,2.0)); color = mix(color, uFog, f). density 0.014 → at depth 30: (0.42)^2=0.176 → 16% fog. OK.

Light shafts also: additive, fog false — visually acceptable.

Also double check PointsMaterial fog: PointsMaterial supports fog: yes; fine for bubbles.

Background sphere: ensure not affected by fog (ShaderMaterial no fog).

OutputPass tone mapping: uses renderer.toneMapping; set.

Composer passes: renderPass, bloomPass, outputPass.

On resize: camera aspect, renderer size, composer size, bloom.setSize? composer.setSize propagates.

Edge: pointermove before canvas ready fine.

Potential console error sources: none obvious. Avoid optional chain issues — modern Chrome fine.

Let me write CSS carefully:

body { margin:0; overflow:hidden; background:#02141a; font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif; color:#d7f5ef; }
#scene { position:fixed; inset:0; display:block; cursor:none; }
Actually canvas default; set cursor none on html so HUD is also hidden? HUD is pointer-events none so cursor over it = canvas? Cursor is taken from topmost element; since HUD is pointer-events:none, hover target is canvas → cursor:none applies from canvas. Set canvas { cursor:none }.

HUD:
#hud { position:fixed; inset:0; pointer-events:none; z-index:10; }
.brand { position:absolute; top:28px; left:32px; max-width:380px; }
.eyebrow { font-size:11px; letter-spacing:.32em; color:#7fe7d4; text-transform:uppercase; font-weight:600; }
h1 { font-family: Palatino..., serif; font-size: clamp(34px, 4.6vw, 58px); line-height:.95; margin:10px 0 12px; font-weight:400; font-style:italic; color:#eafcf6; text-shadow:0 2px 24px rgba(0,60,60,.5);} Maybe split: "The Scatter" italic + "School" outlined? Keep italic serif; add accent span color #ff8a5c.
.lede { font-size:14px; line-height:1.55; color:#a8d8cf; max-width:34ch; }
.lede b {color:#ffd9c4; font-weight:600}

.stats { position:absolute; top:30px; right:32px; text-align:right; display:flex; flex-direction:column; gap:10px; align-items:flex-end; }
.stats .row { display:flex; gap:18px; align-items:baseline; }
.num { font-family: Palatino serif? tabular numbers better sans; font-size:34px; font-weight:700; letter-spacing:-0.02em; } num.hot color #ff8a5c.
label { font-size:10px; letter-spacing:.28em; text-transform:uppercase; color:#8fc9bf; }
meter: width 190px; height:4px; background rgba(255,255,255,.12); .fill {height:100%; background:linear-gradient(90deg,#3ee0c0,#ff8a5c); width:0%; transition: width .25s;}

.legend { position:absolute; left:32px; bottom:30px; display:flex; gap:10px; }
.chip { display:flex; align-items:center; gap:8px; padding:7px 12px; border:1px solid rgba(127,231,212,.25); border-radius:999px; background:rgba(4,28,33,.45); font-size:11.5px; letter-spacing:.04em; color:#bfe9df; backdrop-filter: blur(4px);}
.chip i { width:9px;height:9px;border-radius:50%; background:var(--c); box-shadow:0 0 8px var(--c); }

.meta { position:absolute; right:32px; bottom:30px; font-size:10.5px; letter-spacing:.22em; text-transform:uppercase; color:#6faea4; text-align:right; line-height:1.9; }

.vignette { position:fixed; inset:0; pointer-events:none; z-index:5; background: radial-gradient(120% 90% at 50% 40%, transparent 55%, rgba(1,10,14,.55) 100%), linear-gradient(180deg, rgba(2,20,26,.35), transparent 18%);}

.cursor { position:fixed; z-index:20; width:36px;height:36px; margin:-18px 0 0 -18px; border:1.5px solid rgba(150,255,238,.85); border-radius:50%; pointer-events:none; transform:translate(-200px,-200px); transition:width .18s, height .18s, margin .18s, border-color .18s, opacity .3s; mix-blend-mode:screen;}
.cursor::after { content:''; position:absolute; inset:50% auto auto 50%; width:4px;height:4px; margin:-2px; border-radius:50%; background:#aef7ea; }
.cursor.scared { width:56px;height:56px; margin:-28px 0 0 -28px; border-color:#ff9a70; }
.cursor.hidden {opacity:0}

#loader { position:fixed; inset:0; display:grid; place-items:center; background:#02141a; z-index:50; letter-spacing:.3em; font-size:11px; color:#7fe7d4; text-transform:uppercase; transition:opacity .8s; } small pulsating animation.
body.loaded #loader {opacity:0; pointer-events:none}

Responsive: @media (max-width:720px): .brand {left:18px;top:18px; max-width:74vw} h1 smaller via clamp already; .stats right:18px top:18px, scale meter smaller; .legend hide? flex-wrap and show 3 chips smaller; .meta hide.

Also h1 font-weight 400 italic; add <em> without accent? Add span.accent color #ff8a5c to part of the word, or skip.

Now JS module writing. Let me be careful writing it.

Key snippet:

import * as THREE from 'three';
import { EffectComposer } ...

const canvas = document.getElementById('scene');
renderer setup; scene; camera.

const state = { pointer: new THREE.Vector2(), px: {x:-100,y:-100}, ring:{x:0,y:0}, hasPointer:false, lastMove:0 };

Predator object: { pos:new THREE.Vector3(0,2,0), target:new THREE.Vector3(), active:false, pulse:0, fearR:5.2 }

Raycaster + plane.

Pointer handlers:
window.addEventListener('pointermove', e => { clientToNDC; hasPointer=true; lastMove=now; compute ray → predator.target; px=e.clientX... });
window.addEventListener('pointerleave' on document? Use document.addEventListener('pointerleave')? For window: 'pointerout' when leaving viewport (with relatedTarget null). Simpler: rely on idle timeout 3.5s → active=false.
window.addEventListener('pointerdown', e => { update coords too; predator.pulse = 1; spawnRipple(predator.pos); burstBubbles(predator.pos); })

Note: pointerdown before move (tap on mobile): set target via ray then set pulse.

Frame:
dt; time=clock elapsed.
active = hasPointer && (performance.now()-lastMove < 3500); predator.active=active.
predator.pos.lerp(target, 1-exp(-dt*8))
predator.pulse = max(0, pulse - dt*1.6)
sprite scale/opacity, light position/intensity.
fish update.
bubbles, kelp time uniform, shafts opacity pulse, caustics rotate.
Camera parallax: camTarget.x = pointer.x*2.4; y=3.1+pointer.y*1.4; lerp; camera.lookAt(0,1.8,0). If !hasPointer, use idle slow drift sin.
Update counters every 12 frames.
Cursor ring DOM update.
composer.render().
if(!loadedFlag) body.classList.add('loaded').

Ensure pointer NDC default (0,0) but hasPointer false → idle drift.

randRange helper; rand sign.

Fish geometry builder:

function buildFishMeshes(shapeKey) returns {bodyGeo? no — group per fish built via shared geometry and material set}.

Let me write makeFish(species, size):

const g = new THREE.Group(); // root
const rig = new THREE.Group(); rig.rotation.y = -Math.PI/2; g.add(rig);
const body = new THREE.Group(); rig.add(body);
bodyMesh = new Mesh(GEOS[shape].body, MATS[species].body); bodyMesh.scale set per shape? Bake shape scale into geometry: GEOS.shape.body already scaled geometry? Since scale differs per fish size: create geometry at unit size and set mesh.scale = size multipliers. Per shape multipliers:
 slim: (1.55,0.55,0.36)
 round:(1.35,0.68,0.44)
 tall: (1.05,1.05,0.30)
bodyMesh.scale.set(...shapeScale.map(s=>s*size));

tailPivot = new Group(); tailPivot.position.x = -0.72*size; body.add(tailPivot);
tail = new Mesh(GEOS.tail, finMat); tail.scale.setScalar(size*1.1); tailPivot.add(tail);
dorsal = Mesh(GEOS[shape].dorsal, finMat); positioned at y=0.26*size (tall higher) scale size.
For tall add anal fin: mirrored dorsal rotation.z=π positioned y=-0.26*size.
pectL/R: Mesh(GEOS.pect, finMat) at (0.18*size, -0.02*size, ±0.19*size); rotation.y = ∓? Set a base rotation later; store references: fish.pectL, etc.
eyes: Mesh(GEOS.eye, MATS.black) at (0.46*size, 0.1*size, ±0.15*size) — but for tall body half z=0.15*size → eye z 0.14 OK; for round 0.22.
Compute eye z as shapeScale[2]*size*0.75.
Bands (clown): two torus meshes at x=0.1*size and x=-0.35*size: torus in YZ plane: geometry TorusGeometry(0.3,0.03,8,24); mesh.rotation.y = Math.PI/2; scale y by (shapeScale[1]/0.62)? Body cross-section ellipse half-y a=shapeScale[1]*0.5*size? Sphere r 0.5 scaled → half-y = 0.5*shapeScale[1]*size. Torus radius should roughly match the average: use radius r = 0.5*max(sy,sz)*size*1.02. Create geometry per band per size... shared geometry with radius 1 and scale: torus scale (s, sy*size*2.05? no — geometry radius 1 → mesh.scale.set(r, r, thickness) hmm torus axis after rotY is x: torus in XY plane; rotY(π/2) → YZ plane. mesh.scale.set(1, ry, rz)? Non-uniform scaling on torus warps tube but acceptably. Simpler: create geometry with radius = 0.31 and tube 0.028 fixed; scale uniformly size and scale.y extra by (shapeScale[1]/shapeScale[2]) for ellipse. After rotY(π/2), the original Y stays Y and original X maps to -Z. So ellipse stretch in y and z: mesh.scale.set(size, size*1.3, size)? Set scale after rotation: local axes of mesh: x thickness? torus axis is z; after rotY, axis x. Circumference in YZ: scale.y and scale.z... but rotation applied before scale (matrix = T*R*S) → scale is local axis pre-rotation: local XY is circle → for ellipse set scale.y; z thickness stays. So mesh.scale.set(size, size*1.25, size). Radius 0.31 vs body half-y 0.5*0.68=0.34*size... radius*? Torus radius 0.31*size ≈ intersecting surface — band appears as a stripe. Half-z 0.5*0.44=0.22*size: band extends beyond body in z (0.31>0.22), ring visible poking out — hmm looks like a life preserver. Better: white stripes via scaled thin cylinders? Alternative: draw stripes with canvas texture! Body texture with clown stripes via canvas → cleaner. Body UV of sphere is standard. Canvas 128x64: fill orange; 2 white vertical bands with black border at u 0.35 and 0.65. Also slight noise. Set as map on clown body material. That's cleaner! Also tetra: body silver-blue with red lower stripe? Keep tetra plain + emissive. Angel: purple with darker vertical bands? Add subtle 3 darker bands. Do canvas textures for each species body — richer.

Canvas texture helper: makeBodyTexture(baseHex, stripes[]) → canvas 256x128; fill base with vertical shading (lighter top → darker bottom via gradient); stripes: each {u, w, color, edge} draw rectangle. Sphere UV: u wraps around y axis... For fish facing +X, the sphere's poles are at ±Y so vertical bands along u appear as rings around body lengthwise? Sphere UV: u is around equator (longitude), v is pole→pole (y). After scaling x-long, longitude lines map to rings around long axis — vertical bands (constant u) = rings around the body lengthwise = like clown bands. Yes! So stripes at constant u = bands around body. Clown bands: u 0.72 (head?) whatever looks good: u 0.62 and 0.8, width .07. But seam at u=0/1: sphere u seam is at -Z? Fine.

Angel bands: thin darker stripes 4.

Tetra: base cyan gradient + red horizontal stripe near bottom (v 0.25-0.35) → draw a rectangle across full width.

Neon eye stripe? enough.

Make textures colorSpace = SRGBColorSpace.

Body material with map.

Good, per-species material also for fins.

Shared GEOS:
bodyGeo = SphereGeometry(0.5, 20, 14)
tailGeo: ShapeGeometry from a forked shape (unit ~0.9 wide), then positioned so it connects at x=0: shape from (0,0).
dorsalGeo: shape; pectGeo small fan shape; analGeo = mirrored dorsal clone with rotation applied? Just reuse and rotate mesh.

Shapes:
tail: s.moveTo(0,0); s.quadraticCurveTo(-0.28,0.16,-0.62,0.46); s.quadraticCurveTo(-0.42,0,-0.62,-0.46); s.quadraticCurveTo(-0.28,-0.16,0,0); → forked with concave middle. ShapeGeometry fine.
dorsal: moveTo(-0.34,0); quadraticCurveTo(-0.1,0.34, 0.3,0.06); close.
pect: moveTo(0,0); quadraticCurveTo(-0.22,-0.04,-0.34,-0.22); quadraticCurveTo(-0.12,-0.16,0,0); hmm just triangle-ish.
eye: SphereGeometry(0.045,8,8); glint sphere 0.015 white offset? Skip glint.

Per species materials:
bodyMat = MeshStandardMaterial({map:tex, roughness:0.5, metalness:0.1, emissive:new Color(base).multiplyScalar(0.12)}).
finMat = ({color:fin, transparent:true, opacity:0.9, side:DoubleSide, roughness:0.7}).
eyeMat black.

Total geometry few.

Write sand height: function sandH(x,z){ return Math.sin(x*0.28)*Math.cos(z*0.45)*0.55 + Math.sin(x*0.9+z*0.6)*0.22 + Math.cos(x*0.13 - z*0.8)*0.3; }

Sand geometry: PlaneGeometry(120, 60, 60, 30) rotX -π/2; loop positions: y = sandH(x,z); computeVertexNormals. Material color #b8a679? Under teal light will appear greenish; choose #c9b48a roughness 1.

Floor base offset: place mesh at y=-4.6.

Rocks: for i<9: icosa radius rand .6-1.6, position x ±(6..13), z -3..3, y = -4.6+sandH - r*0.15, random rotation; flat shading; color pick from palette; also 2 big anchor rocks.

Kelp cluster: define positions: [-12,-2],[-10,2.5],[10.5,-1.5],[12.5,2],[-4,-4.5],[5,-4.2],[-7,-3.8],[8,3.2] plus 3 more random. Each cluster: 2-4 blades, offsets. Blade: height 3.5-7, Plane(0.55,h,1,14) translate(0,h/2,0); ShaderMaterial with uniforms uTime, uPhase, uFog color, uFogD, uColA (#0a4a37), uColB (#57c98a). Double-sided. Position y=-4.6+sandH(x,z)-0.1; random rotation.y.

Collect kelpMats to update uTime.

Shafts: makeTexture for gradient; 5 planes width rand 2.5-4.5, height 34; positions x -10..10, z -2..1, y 11; rotation.z = -0.18; rotation.y random; opacity base random 0.10-0.2 stored; material additive blending, depthWrite false, transparent, fog false? MeshBasicMaterial has fog param true default; set fog:false. Animate: mat.opacity = base*(0.75+0.25*sin(t*0.6+i)); mesh.rotation.z = -0.18+ sin(t*0.1+i)*0.02.

Caustics texture canvas:
const c=document.createElement('canvas'); c.width=c.height=512; ctx=c.getContext('2d'); ctx.fillStyle='#000'; fillRect; ctx.globalCompositeOperation='lighter';
For i<180: ctx.strokeStyle=`rgba(180,255,240,${0.05+Math.random()*0.12})`; lineWidth 1+rand*2; arc random center, r 8+rand*34, start rand*π2, length 1+rand*3; stroke.
texture wrap Repeat (3,2)? Set repeat 2,1.5; center 0.5,0.5.
Two planes (90,40) rotX -π/2 y=-4.35 and -4.34? Z-fighting → use polygonOffset or y -4.3 and -4.25. Blending Additive, depthWrite false, transparent, opacity ~0.16. Animate: texA.rotation.z += dt*0.03; texB.rotation.z -= dt*0.022; offset slight drift.

Material.fog false.

Also add subtle floating particles (marine snow): small Points ~200, size .08, opacity .35, drift slow with small velocity + time-based sin; easy win. Implement: positions random in box; velocities small; wrap. Merge conceptually with bubbles system but a separate Points with normal blending white low alpha. Let's add.

OK let me finalize the code. Also handle no WebGL? skip.

Now HUD update code: document.getElementById... cache.

Startled definition: stress > 0.35.

Meter fill style width%.

Also show "wave cooldown"? no.

Flavor meta lines: "TANK Nº 7 — DEPTH 12 M" / "24 °C · PH 8.1 · SALINITY 1.024" / "CURSOR = PREDATOR SHADOW". Cute: "curator note: cursor reads as predator".

Time to write the final file. Keep code organized with sections/comments. Double-check no `alert` etc.

One more thing: iframe keyboard — not used. No "click to start" message needed; but per rule, if the demo uses keyboard, need a message. We don't use. Fine. But click triggers shockwave — hint in lede text. Also pointerdown works without prior focus? Click inside iframe gives focus anyway.

Also verify: first paint has meaningful content: fish swim immediately.

Potential pitfalls: THREE.Matrix4 lookAt not used; dummy Object3D.

Compute slerp target: dummy.position.set(0,0,0); dummy.up is (0,1,0); dummy.lookAt(dirTmp) → forward +Z toward dirTmp (target point = dirTmp since dummy at origin). Good: quaternion copy.

If velocity near zero guard normalize.

Bubble Points: create BufferGeometry with position attribute setUsage(DynamicDrawUsage). Update positions array each frame, attribute.needsUpdate = true.

Marine snow similar, but cheaper: rotate whole point group slowly + drift via time offset in shader? Just update positions every frame (200) fine.

Burst particles: Points 90; each with pos, vel, life array; park positions at (0,-999,0).

Ripple pool: 5 meshes hidden (visible=false); spawn sets visible, t=0. Update scale = 0.6 + t*9; opacity (1-t)*0.8; t += dt/0.9; done → hidden. Ring geometry in XY plane; orient: mesh.quaternion.copy(camera.quaternion).

Predator sprite: Sprite with radial texture (soft white core). scale 2.6+pulse*2; material opacity = active? 0.5+pulse*0.5 : 0; color #9df5e6. Point light child? Separate light: predatorLight = PointLight(0x66ffe0, 0, 16, 2); intensity = active? 26+pulse*70:0 lerp.

Bloom threshold: 0.55 strength 0.55 radius 0.5? Careful with washing out bright HUD? HUD is DOM, unaffected. ACES will darken; fine, exposure 1.1.

Fish emissiveIntensity maybe 0.18 to pop under bloom.

Check UnrealBloomPass constructor: (resolution Vector2, strength, radius, threshold).

Composer with OutputPass: order render, bloom, output.

renderer.setClearColor handled by background sphere anyway.

Also `renderer.toneMapping = THREE.ACESFilmicToneMapping` set. When OutputPass is used, tonemapping is applied inside OutputPass reading renderer.toneMapping? OutputPass performs tone mapping and color-space conversion based on renderer settings. Yes.

Resize camera also update.

Also `renderer.setClearColor`? Background sphere covers it.

Let me write. Also HUD numbers tabular-nums: font-variant-numeric: tabular-nums.

Let me write CSS responsive:

@media (max-width: 760px){
 .brand{ left:16px; top:16px; max-width: 78vw; }
 .lede{ display:none; } // reduce clutter? Maybe keep short. Hide lede on small screens to avoid overlap with fish.
 .stats{ right:16px; top:16px; }
 .stats .num{ font-size:26px }
 .meter{ width:130px }
 .legend{ left:16px; bottom:16px; flex-wrap:wrap; gap:6px }
 .chip{ padding:5px 9px; font-size:10px }
 .meta{ display:none }
}

Stats on small may collide with brand — brand top-left, stats top-right; brand max-width 78vw may overlap at 375: h1 clamp 34px, width ~200px OK; stats column ~120px → at 375 might barely touch. Reduce brand max-width to 62vw at small. Fine.

Now finalize code. Note: pointermove uses e.clientX etc.; save state.pointerNDC for camera parallax.

Camera parallax target uses pointerNDC if hasPointer else slow sin drift.

camera.position.lerp(targetV, 1-exp(-dt*3)). lookAt(0,1.7,0).

Also small camera z 26 fixed? Slight z breathing: 26 + sin(t*0.2)*0.4.

Fish vertical bounds: FLOOR=-3.1 (above sand peaks -4.6+~1.1≈-3.5, add margin), CEIL=8.5.

Bounds: BX=12.5, BZ=5.2.

Goal pick: x rand ±10, y rand [-2,7], z ±4.

OK let me write it.

Also remember: reply with a single code block, no extra text.