Models 44

Grok 4.7

by SpaceXAIreasoning default1 console error
Open in new window

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

How it went

Total time
30 min 35 s
Cost
$0.804
Output tokens
133,721
Spent thinking
107,133 tokens
Speed
72.9 tok/s
Console errors
1
First token after
2.0 s
First visible text after
27 min 28 s
Prompt tokens
1,677
Code size
64.7 KB
Finish reason
stop
Served by
xAI
Model id
x-ai/grok-4.7
Reasoning sent
model default
Max output
450,000 tokens
Generated
Nov 12, 2026

Console errors

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

Cannot access 'waterMat' before initialization

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

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Glasswater</title>
<style>
  * { box-sizing: border-box; }
  html, body {
    margin: 0;
    height: 100%;
    overflow: hidden;
    background: #07111c;
    overscroll-behavior: none;
    -webkit-font-smoothing: antialiased;
    user-select: none;
  }
  #view { position: fixed; inset: 0; z-index: 1; display: block; width: 100%; height: 100%; }
  #vignette {
    position: fixed;
    inset: 0;
    z-index: 2;
    pointer-events: none;
    background:
      radial-gradient(ellipse at 50% 42%, transparent 46%, rgba(0,0,0,0.42) 100%),
      linear-gradient(to top, rgba(0,0,0,0.28), transparent 24%);
  }
  .panel {
    position: fixed;
    z-index: 3;
    pointer-events: none;
    color: #f4f8fb;
    background: rgba(6, 14, 24, 0.5);
    border: 1px solid rgba(230, 194, 122, 0.32);
    box-shadow: 0 12px 40px rgba(0,0,0,0.28);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
  }
  .tl {
    top: 14px;
    left: 50%;
    transform: translateX(-50%);
    width: min(540px, calc(100% - 28px));
    text-align: center;
    border-radius: 18px;
    padding: 14px 18px 16px;
  }
  .tl svg { display: block; margin: 0 auto 4px; }
  .kicker {
    font-family: system-ui, Segoe UI, sans-serif;
    font-size: 11px;
    letter-spacing: 0.28em;
    text-transform: uppercase;
    color: #8fd0c8;
  }
  h1 {
    font-family: Georgia, "Times New Roman", serif;
    font-weight: 500;
    letter-spacing: 0.18em;
    text-transform: uppercase;
    font-size: clamp(22px, 3vw, 36px);
    line-height: 1;
    margin: 5px 0 0;
  }
  .rule {
    height: 1px;
    width: 72px;
    margin: 10px auto 8px;
    background: linear-gradient(90deg, transparent, #e6c27a, transparent);
  }
  .tl p {
    margin: 0;
    font-family: system-ui, Segoe UI, sans-serif;
    font-size: 14px;
    line-height: 1.45;
    color: #d7e7f4;
  }
  .sub {
    margin-top: 4px !important;
    font-size: 12.5px !important;
    color: #9fb6c8 !important;
  }
  .bl, .br {
    bottom: 16px;
    font-family: system-ui, Segoe UI, sans-serif;
  }
  .bl {
    left: 16px;
    display: flex;
    align-items: center;
    gap: 8px;
    border-radius: 999px;
    padding: 8px 14px;
  }
  .br {
    right: 16px;
    border-radius: 16px;
    padding: 8px 14px 10px;
    text-align: right;
  }
  #status { min-width: 132px; font-size: 13px; letter-spacing: 0.02em; }
  .dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: #7dffa8;
    box-shadow: 0 0 8px #7dffa8;
    animation: pulse 1.8s ease-in-out infinite;
  }
  .bl.alert .dot { background: #ffb15a; box-shadow: 0 0 8px #ffb15a; }
  .count b {
    font-family: Georgia, "Times New Roman", serif;
    font-size: 26px;
    font-weight: 500;
    line-height: 1;
  }
  .count-label {
    font-size: 10px;
    letter-spacing: 0.16em;
    text-transform: uppercase;
    color: #9fb4c6;
  }
  #loader {
    position: fixed;
    inset: 0;
    z-index: 5;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 10px;
    background: radial-gradient(ellipse at 50% 42%, #14527e 0%, #071422 62%, #03080e 100%);
    color: #e7f3ff;
    pointer-events: none;
  }
  .load-title {
    font-family: Georgia, "Times New Roman", serif;
    letter-spacing: 0.22em;
    text-transform: uppercase;
    font-size: 28px;
    margin-top: 8px;
  }
  .load-sub {
    font-family: system-ui, sans-serif;
    letter-spacing: 0.2em;
    text-transform: uppercase;
    font-size: 11px;
    color: #9ec8de;
  }
  .orb {
    width: 54px;
    height: 54px;
    border-radius: 50%;
    background: radial-gradient(circle at 35% 35%, #fff, #9ee7ff 42%, rgba(80,170,255,0.05) 72%);
    box-shadow: 0 0 32px rgba(140, 210, 255, 0.85);
    animation: bob 2.4s ease-in-out infinite;
  }
  .reef {
    position: absolute;
    left: 0; right: 0; bottom: 0;
    height: 30%;
    background:
      radial-gradient(ellipse at 18% 100%, #0c3b34 0%, transparent 62%),
      radial-gradient(ellipse at 72% 110%, #14503a 0%, transparent 58%),
      linear-gradient(transparent, rgba(0,0,0,0.2));
  }
  .bubbles { position: absolute; inset: 0; overflow: hidden; }
  .bubbles i {
    position: absolute;
    bottom: -24px;
    border-radius: 50%;
    border: 1px solid rgba(255,255,255,0.38);
    background: rgba(255,255,255,0.06);
    animation: rise linear infinite;
  }
  @keyframes rise { to { transform: translateY(-105vh); opacity: 0.15; } }
  @keyframes bob { 50% { transform: translateY(-8px); } }
  @keyframes pulse { 50% { transform: scale(0.72); opacity: 0.65; } }
  @media (max-width: 640px) {
    .sub, .br { display: none; }
    .tl { top: 8px; padding: 10px 12px 12px; }
    h1 { letter-spacing: 0.12em; }
    .kicker { letter-spacing: 0.16em; }
    .bl { left: 8px; right: 8px; bottom: 8px; }
    #status { min-width: 0; }
  }
</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="loader">
    <div class="reef"></div>
    <div class="bubbles">
      <i style="left:10%;width:8px;height:8px;animation-duration:7s"></i>
      <i style="left:24%;width:14px;height:14px;animation-duration:9s;animation-delay:-2s"></i>
      <i style="left:41%;width:6px;height:6px;animation-duration:6s;animation-delay:-3s"></i>
      <i style="left:58%;width:12px;height:12px;animation-duration:8s;animation-delay:-1s"></i>
      <i style="left:73%;width:9px;height:9px;animation-duration:10s;animation-delay:-4s"></i>
      <i style="left:88%;width:16px;height:16px;animation-duration:11s;animation-delay:-2.5s"></i>
    </div>
    <div class="orb"></div>
    <div class="load-title">Glasswater</div>
    <div class="load-sub">Filling the tank</div>
  </div>
  <div id="vignette"></div>
  <div class="panel tl">
    <svg class="mark" viewBox="0 0 64 32" width="46" height="23" aria-hidden="true">
      <ellipse cx="24" cy="16" rx="16" ry="8" fill="#ffb15a"/>
      <polygon points="40,16 58,5 51,16 58,27" fill="#ffd7a0"/>
      <circle cx="14" cy="14" r="1.7" fill="#102030"/>
    </svg>
    <div class="kicker">A living exhibit</div>
    <h1>Glasswater</h1>
    <div class="rule"></div>
    <p>Move your mouse near the fish and they swim away.</p>
    <p class="sub">A wandering light previews the startle until your cursor takes over.</p>
  </div>
  <div class="panel bl" id="statusPanel">
    <span class="dot"></span>
    <span id="status">School at ease</span>
  </div>
  <div class="panel br">
    <div class="count"><b id="residents">0</b></div>
    <div class="count-label">residents</div>
  </div>
<script type="module">
import * as THREE from 'three';

const TANK = { hw: 3.85, hd: 1.55, y0: -1.82, y1: 1.92, sand: -1.66, water: 1.66 };
const LIM = { x: 3.42, z: 1.12, y0: -1.4, y1: 1.38 };
const AERATOR = { x: -1.35, z: 0.42 };
const STAND_H = 0.36;
const standY = TANK.y0 - STAND_H / 2;
const legTop = standY - STAND_H / 2;
const LEG_H = 0.58;
const floorY = legTop - LEG_H;

const fishes = [];
const schoolFish = [];
const swayers = [];
const bubbles = [];
const rays = [];
const obstacles = [];
const trailSprites = [];
const ringMeshes = [];

const school = {
  center: new THREE.Vector3(),
  velocity: new THREE.Vector3(0.22, 0, 0.04),
  goal: new THREE.Vector3(1.1, 0.05, 0.2),
  wander: 2.4
};

const lure = new THREE.Vector3(0.4, 0.2, 0.25);
const pointer = new THREE.Vector2();
const raycaster = new THREE.Raycaster();
const lurePlane = new THREE.Plane();
const planeNormal = new THREE.Vector3();
const planePoint = new THREE.Vector3(0, 0.12, 0);
const _v1 = new THREE.Vector3();
const _v2 = new THREE.Vector3();
const _v3 = new THREE.Vector3();
const _desired = new THREE.Vector3();
const _dummy = new THREE.Object3D();

let userControlled = false;
let baseDist = 10;
let startled = 0;

const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.setClearColor(0x07111c, 1);
const canvas = renderer.domElement;
canvas.id = 'view';
document.body.appendChild(canvas);

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x07111c);
scene.environmentIntensity = 0.82;

const camera = new THREE.PerspectiveCamera(38, 1, 0.2, 200);
camera.position.set(0, 0.3, 10);

scene.environment = makeEnv(renderer);

scene.add(new THREE.AmbientLight(0x6a8eaa, 0.42));
scene.add(new THREE.HemisphereLight(0xc5e6ff, 0xc4a074, 0.48));

const key = new THREE.DirectionalLight(0xfff1dd, 2.35);
key.position.set(6, 11, 7);
key.castShadow = true;
key.shadow.mapSize.set(1024, 1024);
key.shadow.camera.near = 2;
key.shadow.camera.far = 28;
key.shadow.camera.left = -7;
key.shadow.camera.right = 7;
key.shadow.camera.top = 7;
key.shadow.camera.bottom = -7;
key.shadow.bias = -0.0004;
key.shadow.normalBias = 0.03;
scene.add(key);
scene.add(key.target);

const frontLight = new THREE.DirectionalLight(0xd6ecff, 0.85);
frontLight.position.set(0.4, 2.2, 10);
scene.add(frontLight);
const rim = new THREE.DirectionalLight(0x7eb0ff, 0.42);
rim.position.set(-7, 3.5, -4);
scene.add(rim);

const cLight1 = new THREE.PointLight(0x9ae7ff, 4.5, 6, 2);
const cLight2 = new THREE.PointLight(0xffe0a8, 3.2, 5, 2);
scene.add(cLight1, cLight2);
const lureLight = new THREE.PointLight(0xc8f4ff, 3.2, 4.2, 2);
scene.add(lureLight);

const MAT = {
  white: new THREE.MeshStandardMaterial({ color: 0xfff8f2, roughness: 0.4, metalness: 0.03, envMapIntensity: 0.4 }),
  black: new THREE.MeshStandardMaterial({ color: 0x1a1c1f, roughness: 0.5, metalness: 0.06 }),
  eye: new THREE.MeshStandardMaterial({ color: 0xf4f7fb, roughness: 0.22, metalness: 0.08, envMapIntensity: 0.6 }),
  pupil: new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.35, metalness: 0.12 }),
  catch: new THREE.MeshBasicMaterial({ color: 0xffffff })
};
const stripeCache = {};
const FIN_GEOS = {
  fan: makeFin('fan'),
  fork: makeFin('fork'),
  pennant: makeFin('pennant'),
  dorsal: makeFin('dorsal')
};
const stripeGeo = new THREE.TorusGeometry(1, 0.11, 8, 20);
const bubbleGeo = new THREE.SphereGeometry(1, 10, 8);
const shadowGeo = new THREE.CircleGeometry(0.36, 18);
const blobTex = makeBlobTexture();
const circleTex = makeCircleTexture();
const glowTex = makeGlowTexture();
const rayTex = makeRayTexture();

const SPECIES = {
  clown:     { color: 0xff6a12, fin: 0xffd0a0, body: [0.30, 0.40, 0.52], tail: 'fan', speed: 0.62, stripes: [-0.18, 0.02, 0.22], stripe: 0xfff7ef, darkEdge: true },
  tang:      { color: 0x1c64ff, fin: 0xffd000, body: [0.20, 0.50, 0.36], tail: 'round', speed: 0.72, mask: true },
  yellow:    { color: 0xffd21f, fin: 0xfff0a8, body: [0.18, 0.46, 0.34], tail: 'round', speed: 0.66 },
  angel:     { color: 0x6a45f0, fin: 0xffe08a, body: [0.15, 0.60, 0.34], tail: 'fan', speed: 0.50, stripes: [-0.02, 0.14], stripe: 0xffe08a, dorsal: 0.5 },
  idol:      { color: 0xfff4e8, fin: 0xfff4e8, body: [0.13, 0.46, 0.38], tail: 'fan', speed: 0.56, stripes: [-0.12, 0.06, 0.20], stripe: 0x17191c, pennant: true, dorsal: 0.28 },
  butterfly: { color: 0xffd84a, fin: 0xffd84a, body: [0.17, 0.42, 0.36], tail: 'fan', speed: 0.60, stripes: [0.02], stripe: 0x1a1a1a, eyeBand: true },
  guppy:     { color: 0xff4d8d, fin: 0x7af0ff, body: [0.18, 0.26, 0.36], tail: 'fan', speed: 0.54, tailScale: 1.45 },
  tetra:     { color: 0x8ee9ff, fin: 0xe7fbff, body: [0.13, 0.18, 0.32], tail: 'fork', speed: 0.86, lateral: 0xff3355 }
};

buildRoom();
buildTank();
buildFloorLife();
buildReef();
spawnResidents();
buildWaterColumn();
buildLure();

const residentsEl = document.getElementById('residents');
const statusEl = document.getElementById('status');
const statusPanel = document.getElementById('statusPanel');
residentsEl.textContent = String(fishes.length);
document.getElementById('loader').style.display = 'none';

window.addEventListener('resize', onResize);
window.addEventListener('pointermove', (e) => {
  userControlled = true;
  pointer.x = (e.clientX / window.innerWidth) * 2 - 1;
  pointer.y = -(e.clientY / window.innerHeight) * 2 + 1;
});

const clock = new THREE.Clock();
onResize();
clock.getDelta();
requestAnimationFrame(animate);

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

  camera.position.set(
    Math.sin(t * 0.17) * 0.42,
    0.28 + Math.sin(t * 0.23) * 0.1,
    baseDist + Math.sin(t * 0.11) * 0.12
  );
  camera.lookAt(0, 0.02, 0);

  updateLure(t);
  glowSprite.position.copy(lure);
  lureCore.position.copy(lure);
  lureLight.position.copy(lure);
  const pulse = 0.94 + Math.sin(t * 3.1) * 0.08;
  glowSprite.scale.set(1.15 * pulse, 1.15 * pulse, 1);
  if (trailSprites.length) {
    trailSprites[0].position.lerp(lure, 1 - Math.exp(-dt * 10));
    for (let i = 1; i < trailSprites.length; i++) {
      trailSprites[i].position.lerp(trailSprites[i - 1].position, 1 - Math.exp(-dt * 8));
    }
  }
  for (let i = 0; i < ringMeshes.length; i++) {
    const ring = ringMeshes[i];
    const p = (t * 0.42 + i / ringMeshes.length) % 1;
    ring.position.copy(lure);
    ring.scale.setScalar(0.32 + p * 1.85);
    ring.material.opacity = (1 - p) * 0.5;
    ring.lookAt(camera.position);
  }

  updateSchool(dt, t);
  startled = 0;
  const px = scarePx();
  for (let i = 0; i < fishes.length; i++) updateFish(fishes[i], dt, t, px);
  const next = startled === 0 ? 'School at ease' : startled === 1 ? '1 fish scattering' : startled + ' fish scattering';
  if (statusEl.textContent !== next) statusEl.textContent = next;
  statusPanel.classList.toggle('alert', startled > 0);

  for (let i = 0; i < swayers.length; i++) {
    const s = swayers[i];
    s.obj.rotation.z = s.baseZ + Math.sin(t * s.speed + s.phase) * s.amp;
    s.obj.rotation.x = s.baseX + Math.cos(t * s.speed * 0.72 + s.phase) * s.amp * 0.45;
  }
  for (let i = 0; i < bubbles.length; i++) {
    const b = bubbles[i];
    const ud = b.userData;
    b.position.y += ud.speed * dt;
    b.position.x += Math.sin(t * 1.5 + ud.phase) * dt * 0.09;
    if (b.position.y > TANK.water - 0.06) {
      b.position.y = -1.42;
      b.position.x = ud.homeX + (Math.random() - 0.5) * 0.14;
      b.position.z = ud.homeZ + (Math.random() - 0.5) * 0.14;
    }
    const life = (b.position.y + 1.45) / 3.05;
    b.scale.setScalar(ud.r * (1 + life * 0.4));
  }
  const arr = dustGeo.attributes.position.array;
  for (let i = 0; i < dustCount; i++) {
    arr[i * 3 + 1] += dt * 0.055;
    arr[i * 3] += Math.sin(t * 0.6 + i) * dt * 0.02;
    if (arr[i * 3 + 1] > 1.52) arr[i * 3 + 1] = -1.4;
  }
  dustGeo.attributes.position.needsUpdate = true;

  for (let i = 0; i < rays.length; i++) {
    const r = rays[i];
    r.rotation.z = r.userData.baseZ + Math.sin(t * 0.35 + i) * 0.045;
    r.material.opacity = r.userData.baseO + Math.sin(t * 0.8 + i * 1.3) * 0.08;
  }
  waterMat.uniforms.uTime.value = t;
  causticMat.uniforms.uTime.value = t;
  cLight1.position.set(Math.sin(t * 0.7) * 2.2, 0.2, Math.cos(t * 0.5) * 0.7);
  cLight2.position.set(Math.cos(t * 0.45) * 1.7, -0.4, Math.sin(t * 0.62) * 0.55);

  renderer.render(scene, camera);
}

function updateLure(t) {
  if (!userControlled) {
    lure.x = Math.sin(t * 0.33) * 2.25;
    lure.y = Math.sin(t * 0.47) * 0.72 + 0.08;
    lure.z = Math.cos(t * 0.29) * 0.55;
  } else {
    raycaster.setFromCamera(pointer, camera);
    camera.getWorldDirection(planeNormal);
    lurePlane.setFromNormalAndCoplanarPoint(planeNormal, planePoint);
    if (raycaster.ray.intersectPlane(lurePlane, _v3)) lure.copy(_v3);
  }
  lure.x = THREE.MathUtils.clamp(lure.x, -3.3, 3.3);
  lure.y = THREE.MathUtils.clamp(lure.y, -1.25, 1.32);
  lure.z = THREE.MathUtils.clamp(lure.z, -1.0, 1.0);
}

function updateSchool(dt) {
  let scare = lure.distanceTo(school.center) < 1.55;
  if (!scare) {
    for (let i = 0; i < schoolFish.length; i++) {
      if (schoolFish[i].position.distanceTo(lure) < 1.25) { scare = true; break; }
    }
  }
  if (scare) {
    _v1.copy(school.center).sub(lure);
    _v1.y *= 0.4;
    if (_v1.lengthSq() < 1e-6) _v1.set(1, 0, 0);
    _v1.normalize().multiplyScalar(1.55);
    school.velocity.lerp(_v1, 1 - Math.exp(-dt * 4));
  } else {
    school.wander -= dt;
    if (school.wander <= 0) {
      school.goal.copy(randomPoint());
      school.goal.y = THREE.MathUtils.clamp(school.goal.y, -0.35, 0.55);
      school.wander = 2.8 + Math.random() * 3.2;
    }
    _v1.copy(school.goal).sub(school.center);
    if (_v1.lengthSq() > 1e-6) {
      _v1.normalize().multiplyScalar(0.52);
      school.velocity.lerp(_v1, 1 - Math.exp(-dt * 1.15));
    }
  }
  school.center.addScaledVector(school.velocity, dt);
  school.center.x = THREE.MathUtils.clamp(school.center.x, -3.0, 3.0);
  school.center.y = THREE.MathUtils.clamp(school.center.y, -1.05, 1.05);
  school.center.z = THREE.MathUtils.clamp(school.center.z, -0.85, 0.95);
}

function updateFish(fish, dt, t, px) {
  const ud = fish.userData;
  const dScreen = screenDist(fish.position, lure);
  const dWorld = fish.position.distanceTo(lure);
  const scared = dScreen < px || dWorld < 1.18;
  const fearTarget = scared ? 1 : 0;
  ud.fear += (fearTarget - ud.fear) * Math.min(1, dt * (scared ? 8 : 1.7));
  if (ud.fear > 0.35) startled++;

  ud.timer -= dt;
  if (ud.school) ud.target.copy(school.center).add(ud.offset);
  else if (ud.timer <= 0) {
    ud.target.copy(randomPoint());
    ud.timer = 2.4 + Math.random() * 3.8;
  }
  _v1.copy(ud.target).sub(fish.position);
  if (!ud.school && _v1.length() < 0.34) {
    ud.target.copy(randomPoint());
    ud.timer = 2.4 + Math.random() * 3.8;
    _v1.copy(ud.target).sub(fish.position);
  }
  if (_v1.lengthSq() < 1e-6) _v1.set(1, 0, 0);
  _v1.normalize().multiplyScalar(ud.baseSpeed);

  _v2.copy(fish.position).sub(lure);
  _v2.y *= 0.45;
  if (_v2.lengthSq() < 1e-6) _v2.set(0.2, 0.4, 0.1);
  _v2.normalize().multiplyScalar(ud.baseSpeed * 3.25);
  _desired.copy(_v1).lerp(_v2, ud.fear);

  for (let j = 0; j < fishes.length; j++) {
    const other = fishes[j];
    if (other === fish) continue;
    const dx = fish.position.x - other.position.x;
    const dy = fish.position.y - other.position.y;
    const dz = fish.position.z - other.position.z;
    const d2 = dx * dx + dy * dy + dz * dz;
    if (d2 < 0.42 && d2 > 1e-6) {
      const d = Math.sqrt(d2);
      const push = (0.65 - d) / d * 0.85;
      _desired.x += dx * push;
      _desired.y += dy * push * 0.35;
      _desired.z += dz * push;
    }
  }
  for (let j = 0; j < obstacles.length; j++) {
    const ob = obstacles[j];
    const dx = fish.position.x - ob.x;
    const dy = fish.position.y - ob.y;
    const dz = fish.position.z - ob.z;
    const d2 = dx * dx + dy * dy + dz * dz;
    if (d2 < 0.5 && d2 > 1e-6) {
      const d = Math.sqrt(d2);
      const push = (0.72 - d) / d * 1.3;
      _desired.x += dx * push;
      _desired.y += dy * push;
      _desired.z += dz * push;
    }
  }
  const p = fish.position;
  if (p.x > LIM.x - 0.45) _desired.x -= 1.35;
  if (p.x < -LIM.x + 0.45) _desired.x += 1.35;
  if (p.z > LIM.z - 0.35) _desired.z -= 1.2;
  if (p.z < -LIM.z + 0.35) _desired.z += 1.2;
  if (p.y > ud.limY1 - 0.3) _desired.y -= 1.25;
  if (p.y < ud.limY0 + 0.28) _desired.y += 1.25;

  const accel = 2.3 + ud.fear * 5.8;
  ud.velocity.lerp(_desired, 1 - Math.exp(-dt * accel));
  const maxSp = ud.baseSpeed * (1.05 + ud.fear * 2.45);
  if (ud.velocity.length() > maxSp) ud.velocity.setLength(maxSp);
  p.addScaledVector(ud.velocity, dt);
  contain(fish, ud);

  if (ud.velocity.lengthSq() > 1e-5) {
    _dummy.position.copy(p);
    _dummy.up.set(0, 1, 0);
    if (ud.kind === 'horse') {
      _v1.set(ud.velocity.x, 0, ud.velocity.z);
      if (_v1.lengthSq() < 1e-6) _v1.set(0, 0, -1);
      _dummy.lookAt(_v3.copy(p).add(_v1));
    } else {
      _dummy.lookAt(_v3.copy(p).add(ud.velocity));
    }
    const turn = ud.kind === 'horse' ? 3 : 3.4 + ud.fear * 4.8;
    fish.quaternion.slerp(_dummy.quaternion, 1 - Math.exp(-dt * turn));
  }

  if (ud.kind === 'horse') {
    ud.tail.rotation.y = Math.sin(t * 16 + ud.phase) * 0.42;
    ud.tilt.rotation.z = Math.sin(t * 1.4 + ud.phase) * 0.06;
    ud.tilt.position.y = Math.sin(t * 1.8 + ud.phase) * 0.045;
  } else {
    const wag = 6.4 + ud.baseSpeed * 2.5 + ud.fear * 12;
    const amp = 0.3 + ud.fear * 0.46;
    ud.bodyPivot.rotation.y = Math.sin(t * wag + ud.phase) * (0.055 + ud.fear * 0.06);
    ud.tail.rotation.y = Math.sin(t * wag + ud.phase + 1.15) * amp;
    ud.tilt.position.y = Math.sin(t * 2.1 + ud.phase) * 0.025;
    const heading = Math.atan2(ud.velocity.x, ud.velocity.z);
    let diff = heading - ud.heading;
    diff = Math.atan2(Math.sin(diff), Math.cos(diff));
    ud.heading = heading;
    ud.bank = THREE.MathUtils.damp(ud.bank, THREE.MathUtils.clamp(-diff * 5.5, -0.62, 0.62), 4, dt);
    ud.tilt.rotation.z = ud.bank;
    for (let i = 0; i < ud.pecs.length; i++) {
      const pec = ud.pecs[i];
      pec.mesh.rotation.y = pec.baseY + Math.sin(t * (5 + ud.fear * 7) + ud.phase + i) * (0.22 + ud.fear * 0.3);
    }
  }
  ud.bodyMat.emissiveIntensity = 0.05 + ud.fear * 0.55;
  ud.finMat.emissiveIntensity = 0.1 + ud.fear * 0.48;

  const sh = ud.shadow;
  sh.position.set(p.x, TANK.sand + 0.012, p.z);
  const height = p.y - TANK.sand;
  const sc = THREE.MathUtils.clamp(1.12 - height * 0.2, 0.28, 1.15) * fish.scale.x;
  sh.scale.setScalar(sc);
  sh.material.opacity = THREE.MathUtils.clamp(0.3 - height * 0.065, 0.04, 0.3);
}

function contain(fish, ud) {
  const p = fish.position;
  const v = ud.velocity;
  if (p.x > LIM.x) { p.x = LIM.x; v.x = -Math.abs(v.x) * 0.4; }
  if (p.x < -LIM.x) { p.x = -LIM.x; v.x = Math.abs(v.x) * 0.4; }
  if (p.z > LIM.z) { p.z = LIM.z; v.z = -Math.abs(v.z) * 0.4; }
  if (p.z < -LIM.z) { p.z = -LIM.z; v.z = Math.abs(v.z) * 0.4; }
  if (p.y > ud.limY1) { p.y = ud.limY1; v.y = -Math.abs(v.y) * 0.4; }
  if (p.y < ud.limY0) { p.y = ud.limY0; v.y = Math.abs(v.y) * 0.4; }
}

function screenDist(a, b) {
  _v1.copy(a).project(camera);
  _v2.copy(b).project(camera);
  return Math.hypot((_v1.x - _v2.x) * window.innerWidth * 0.5, (_v1.y - _v2.y) * window.innerHeight * 0.5);
}
function scarePx() {
  return Math.min(190, Math.max(96, window.innerWidth * 0.22));
}
function randomPoint() {
  return new THREE.Vector3(
    (Math.random() - 0.5) * 6.2,
    -1.15 + Math.random() * 2.35,
    (Math.random() - 0.5) * 1.9
  );
}
function onResize() {
  const w = window.innerWidth;
  const h = Math.max(1, window.innerHeight);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
  renderer.setSize(w, h, false);
  canvas.style.width = '100%';
  canvas.style.height = '100%';
  camera.aspect = w / h;
  camera.updateProjectionMatrix();
  const vHalf = camera.fov * 0.5 * Math.PI / 180;
  const distH = 3.25 / Math.tan(vHalf);
  const distW = 5.2 / (Math.tan(vHalf) * camera.aspect);
  baseDist = Math.max(distH, distW);
}

function spawnResidents() {
  const roster = [
    { kind: 'tang', x: -1.7, y: 0.55, z: 0.85, s: 1.22, vx: 0.55, vy: 0.02, vz: -0.08 },
    { kind: 'yellow', x: 0.25, y: 1.0, z: 0.32, s: 1.06, vx: -0.42, vy: -0.04, vz: 0.06 },
    { kind: 'angel', x: 1.15, y: -0.32, z: 0.12, s: 1.16, vx: -0.32, vy: 0.06, vz: 0.1 },
    { kind: 'clown', x: 2.35, y: -0.82, z: 0.22, s: 1.0, vx: -0.22, vy: 0.08, vz: 0.04 },
    { kind: 'idol', x: -2.55, y: 0.18, z: 0.02, s: 1.1, vx: 0.48, vy: 0.03, vz: 0.05 },
    { kind: 'butterfly', x: 2.1, y: 0.7, z: -0.22, s: 0.98, vx: -0.36, vy: -0.02, vz: 0.07 },
    { kind: 'guppy', x: -0.55, y: -0.82, z: 0.62, s: 0.8, vx: 0.3, vy: 0.1, vz: -0.04 },
    { kind: 'guppy', x: 0.9, y: 0.38, z: -0.5, s: 0.72, vx: 0.18, vy: -0.06, vz: 0.12 },
    { kind: 'clown', x: -2.85, y: -0.5, z: -0.32, s: 0.84, vx: 0.26, vy: 0.05, vz: 0.08 },
    { kind: 'tang', x: 0.15, y: -1.12, z: -0.42, s: 0.9, vx: 0.4, vy: 0.04, vz: 0.05 },
    { kind: 'yellow', x: 2.7, y: 0.15, z: -0.55, s: 0.78, vx: -0.3, vy: 0.05, vz: 0.08 },
    { kind: 'tetra', x: -0.32, y: 0.12, z: 0.92, s: 0.58, school: true, vx: 0.34, vy: 0.02, vz: 0.04 },
    { kind: 'tetra', x: 0.06, y: -0.02, z: 0.76, s: 0.52, school: true, vx: 0.34, vy: 0, vz: 0.04 },
    { kind: 'tetra', x: 0.3, y: 0.2, z: 0.6, s: 0.5, school: true, vx: 0.32, vy: 0.02, vz: 0.05 },
    { kind: 'tetra', x: -0.12, y: -0.2, z: 0.68, s: 0.54, school: true, vx: 0.33, vy: -0.01, vz: 0.03 },
    { kind: 'tetra', x: 0.4, y: 0.02, z: 0.86, s: 0.48, school: true, vx: 0.3, vy: 0.03, vz: 0.02 },
    { kind: 'horse', x: -1.45, y: -0.72, z: 0.38, s: 1.12, vx: 0.16, vy: 0, vz: 0.05 }
  ];
  for (let i = 0; i < roster.length; i++) {
    const cfg = roster[i];
    const root = cfg.kind === 'horse' ? createSeahorse() : createFish(cfg.kind);
    finalizeFish(root, cfg);
  }
  if (schoolFish.length) {
    school.center.set(0, 0, 0);
    for (let i = 0; i < schoolFish.length; i++) school.center.add(schoolFish[i].position);
    school.center.multiplyScalar(1 / schoolFish.length);
    for (let i = 0; i < schoolFish.length; i++) {
      schoolFish[i].userData.offset = schoolFish[i].position.clone().sub(school.center);
    }
    school.goal.copy(school.center).add(new THREE.Vector3(1.1, 0.05, -0.25));
  }
}

function finalizeFish(root, cfg) {
  root.position.set(cfg.x, cfg.y, cfg.z);
  root.scale.setScalar(cfg.s || 1);
  const vel = new THREE.Vector3(cfg.vx || 0.25, cfg.vy || 0, cfg.vz || 0.04);
  const ud = root.userData;
  ud.velocity = vel;
  ud.target = randomPoint();
  ud.timer = 1 + Math.random() * 3;
  ud.fear = 0;
  ud.phase = Math.random() * Math.PI * 2;
  ud.baseSpeed = ud.speed * (0.9 + Math.random() * 0.18);
  ud.heading = Math.atan2(vel.x, vel.z || 0.001);
  ud.bank = 0;
  ud.limY0 = ud.kind === 'horse' ? -1.02 : LIM.y0;
  ud.limY1 = ud.kind === 'horse' ? 0.92 : LIM.y1;
  if (cfg.school) {
    ud.school = true;
    schoolFish.push(root);
  }
  _dummy.position.copy(root.position);
  _dummy.up.set(0, 1, 0);
  if (ud.kind === 'horse') {
    _v1.set(vel.x, 0, vel.z || -0.1);
    _dummy.lookAt(root.position.clone().add(_v1));
  } else {
    _dummy.lookAt(root.position.clone().add(vel));
  }
  root.quaternion.copy(_dummy.quaternion);
  const sh = new THREE.Mesh(shadowGeo, new THREE.MeshBasicMaterial({
    map: blobTex, transparent: true, depthWrite: false, color: 0x021018, opacity: 0.22
  }));
  sh.rotation.x = -Math.PI / 2;
  sh.renderOrder = 1;
  scene.add(sh);
  ud.shadow = sh;
  scene.add(root);
  fishes.push(root);
}

function createFish(kind) {
  const spec = SPECIES[kind];
  const col = new THREE.Color(spec.color);
  if (kind !== 'idol') col.offsetHSL((Math.random() - 0.5) * 0.02, (Math.random() - 0.5) * 0.05, (Math.random() - 0.5) * 0.04);
  const bodyMat = new THREE.MeshStandardMaterial({
    color: col, roughness: 0.36, metalness: 0.06,
    emissive: col.clone(), emissiveIntensity: 0.05, envMapIntensity: 0.5
  });
  const finCol = new THREE.Color(spec.fin);
  const finMat = new THREE.MeshStandardMaterial({
    color: finCol, roughness: 0.4, metalness: 0.03,
    emissive: finCol.clone(), emissiveIntensity: 0.1,
    side: THREE.DoubleSide, envMapIntensity: 0.4
  });
  const root = new THREE.Group();
  const tilt = new THREE.Group();
  const bodyPivot = new THREE.Group();
  root.add(tilt);
  tilt.add(bodyPivot);

  const body = new THREE.Mesh(new THREE.SphereGeometry(1, 22, 16), bodyMat);
  body.scale.set(spec.body[0], spec.body[1], spec.body[2]);
  bodyPivot.add(body);

  const ped = new THREE.Mesh(new THREE.SphereGeometry(1, 8, 6), bodyMat);
  ped.scale.set(spec.body[0] * 0.42, spec.body[1] * 0.32, spec.body[2] * 0.38);
  ped.position.z = spec.body[2] * 0.72;
  bodyPivot.add(ped);

  if (spec.stripes) {
    for (let i = 0; i < spec.stripes.length; i++) {
      const z = spec.stripes[i];
      if (spec.darkEdge) {
        const dark = new THREE.Mesh(stripeGeo, MAT.black);
        dark.scale.set(spec.body[0] * 1.14, spec.body[1] * 1.1, 1);
        dark.position.z = z;
        bodyPivot.add(dark);
      }
      const band = new THREE.Mesh(stripeGeo, stripeMaterial(spec.stripe));
      band.scale.set(spec.body[0] * (spec.darkEdge ? 1.0 : 1.12), spec.body[1] * (spec.darkEdge ? 0.98 : 1.08), 1);
      band.position.z = z;
      bodyPivot.add(band);
    }
  }
  if (spec.mask) {
    const mask = new THREE.Mesh(new THREE.SphereGeometry(1, 12, 8), MAT.black);
    mask.scale.set(spec.body[0] * 1.08, spec.body[1] * 0.36, spec.body[2] * 0.22);
    mask.position.set(0, spec.body[1] * 0.12, -spec.body[2] * 0.42);
    bodyPivot.add(mask);
  }
  if (spec.eyeBand) {
    const band = new THREE.Mesh(stripeGeo, MAT.black);
    band.scale.set(spec.body[0] * 1.16, spec.body[1] * 1.05, 1);
    band.position.z = -spec.body[2] * 0.32;
    bodyPivot.add(band);
  }
  if (spec.lateral) {
    const lat = stripeMaterial(spec.lateral);
    for (const side of [-1, 1]) {
      const line = new THREE.Mesh(new THREE.BoxGeometry(0.012, 0.03, spec.body[2] * 0.9), lat);
      line.position.set(side * spec.body[0] * 0.92, 0.01, 0);
      bodyPivot.add(line);
    }
  }
  addEyes(bodyPivot, spec);
  if (spec.body[2] > 0.33) {
    const mouth = new THREE.Mesh(new THREE.SphereGeometry(0.018, 6, 5), MAT.pupil);
    mouth.scale.set(1.3, 0.55, 0.6);
    mouth.position.set(0, -spec.body[1] * 0.12, -spec.body[2] * 0.9);
    bodyPivot.add(mouth);
  }

  const dorsal = new THREE.Mesh(FIN_GEOS.dorsal, finMat);
  dorsal.rotation.y = -Math.PI / 2;
  dorsal.scale.setScalar(spec.dorsal || 0.62);
  dorsal.position.set(0, spec.body[1] * 0.78, -0.02);
  bodyPivot.add(dorsal);
  if (spec.pennant) {
    const pen = new THREE.Mesh(FIN_GEOS.pennant, finMat);
    pen.position.set(0, spec.body[1] * 0.7, -0.04);
    pen.scale.setScalar(1.15);
    bodyPivot.add(pen);
  }

  const pecs = [addPectoral(bodyPivot, 1, finMat, spec), addPectoral(bodyPivot, -1, finMat, spec)];
  const tail = new THREE.Group();
  tail.position.set(0, 0, spec.body[2] * 0.58);
  if (spec.tail === 'round') {
    const m = new THREE.Mesh(new THREE.SphereGeometry(0.2, 14, 10), finMat);
    m.scale.set(0.4, 1.05, 0.82);
    m.position.z = 0.14;
    tail.add(m);
  } else {
    const m = new THREE.Mesh(FIN_GEOS[spec.tail], finMat);
    m.rotation.y = -Math.PI / 2;
    m.scale.setScalar(spec.tailScale || 1);
    tail.add(m);
  }
  bodyPivot.add(tail);
  root.userData = { tilt, bodyPivot, tail, pecs, bodyMat, finMat, kind, speed: spec.speed };
  return root;
}

function addEyes(parent, spec) {
  const bx = spec.body[0], by = spec.body[1], bz = spec.body[2];
  const er = Math.max(0.02, bx * 0.17);
  const ex = bx * 0.82, ey = by * 0.16, ez = -bz * 0.58;
  for (const side of [-1, 1]) {
    const white = new THREE.Mesh(new THREE.SphereGeometry(er, 10, 8), MAT.eye);
    white.position.set(ex * side, ey, ez);
    const pupil = new THREE.Mesh(new THREE.SphereGeometry(er * 0.5, 8, 6), MAT.pupil);
    pupil.position.set(ex * side * 1.06, ey, ez - er * 0.55);
    const glint = new THREE.Mesh(new THREE.SphereGeometry(er * 0.22, 6, 5), MAT.catch);
    glint.position.set(ex * side * 1.08 + side * er * 0.12, ey + er * 0.28, ez - er * 0.72);
    parent.add(white, pupil, glint);
  }
}
function addPectoral(parent, side, finMat, spec) {
  const fin = new THREE.Mesh(FIN_GEOS.fan, finMat);
  fin.scale.setScalar(0.4);
  fin.position.set(spec.body[0] * 0.95 * side, -spec.body[1] * 0.02, -spec.body[2] * 0.02);
  const baseY = -Math.PI / 2 + side * 0.95;
  fin.rotation.y = baseY;
  fin.rotation.z = side * 0.32;
  parent.add(fin);
  return { mesh: fin, baseY };
}
function stripeMaterial(hex) {
  if (!stripeCache[hex]) {
    stripeCache[hex] = new THREE.MeshStandardMaterial({
      color: hex, roughness: 0.4, metalness: 0.04,
      emissive: hex, emissiveIntensity: 0.04, envMapIntensity: 0.35
    });
  }
  return stripeCache[hex];
}

function createSeahorse() {
  const col = new THREE.Color(0xf0a43a);
  const bodyMat = new THREE.MeshStandardMaterial({
    color: col, roughness: 0.42, metalness: 0.08,
    emissive: col.clone(), emissiveIntensity: 0.06, envMapIntensity: 0.45
  });
  const finMat = new THREE.MeshStandardMaterial({
    color: 0xffe3a8, roughness: 0.4, side: THREE.DoubleSide,
    emissive: 0xffe3a8, emissiveIntensity: 0.12, envMapIntensity: 0.35
  });
  const bandMat = bodyMat.clone();
  bandMat.color.offsetHSL(0, 0.04, -0.16);
  bandMat.emissive.copy(bandMat.color);
  const root = new THREE.Group();
  const tilt = new THREE.Group();
  root.add(tilt);
  const model = new THREE.Group();
  model.position.y = -0.38;
  tilt.add(model);
  const curve = [
    [0.02, 0.06], [0.00, 0.16], [-0.02, 0.28], [-0.01, 0.40],
    [0.02, 0.52], [0.045, 0.63], [0.03, 0.74]
  ];
  const radii = [0.05, 0.068, 0.084, 0.08, 0.064, 0.054, 0.048];
  for (let i = 0; i < curve.length; i++) {
    const s = new THREE.Mesh(new THREE.SphereGeometry(1, 10, 8), bodyMat);
    s.scale.set(radii[i] * 0.78, radii[i] * 1.05, radii[i] * 0.7);
    s.position.set(0, curve[i][1], curve[i][0]);
    model.add(s);
  }
  const head = new THREE.Mesh(new THREE.SphereGeometry(0.074, 10, 8), bodyMat);
  head.scale.set(0.86, 1.05, 1.05);
  head.position.set(0, 0.84, -0.01);
  model.add(head);
  const snout = new THREE.Mesh(new THREE.CylinderGeometry(0.016, 0.026, 0.12, 7), bodyMat);
  snout.rotation.x = -Math.PI / 2;
  snout.position.set(0, 0.82, -0.1);
  model.add(snout);
  const coronet = new THREE.Mesh(new THREE.SphereGeometry(0.03, 6, 5), bodyMat);
  coronet.position.set(0, 0.93, 0.01);
  model.add(coronet);
  for (const side of [-1, 1]) {
    const eye = new THREE.Mesh(new THREE.SphereGeometry(0.02, 8, 6), MAT.eye);
    eye.position.set(0.045 * side, 0.86, -0.04);
    const pupil = new THREE.Mesh(new THREE.SphereGeometry(0.011, 6, 6), MAT.pupil);
    pupil.position.set(0.052 * side, 0.86, -0.055);
    model.add(eye, pupil);
  }
  for (let i = 0; i < 10; i++) {
    const a = i / 10 * Math.PI * 1.55;
    const seg = new THREE.Mesh(new THREE.SphereGeometry(0.04 - i * 0.0026, 6, 5), bodyMat);
    seg.position.set(Math.sin(a) * 0.09, -0.02 - i * 0.012, Math.cos(a) * 0.065);
    model.add(seg);
  }
  for (let i = 0; i < 4; i++) {
    const band = new THREE.Mesh(new THREE.TorusGeometry(0.072, 0.009, 5, 10), bandMat);
    band.rotation.x = Math.PI / 2;
    band.scale.set(0.8, 0.8, 0.55);
    band.position.set(0, 0.2 + i * 0.12, 0);
    model.add(band);
  }
  const dorsalPivot = new THREE.Group();
  dorsalPivot.position.set(0, 0.48, 0.07);
  const dorsal = new THREE.Mesh(FIN_GEOS.dorsal, finMat);
  dorsal.rotation.y = -Math.PI / 2;
  dorsal.scale.setScalar(0.7);
  dorsalPivot.add(dorsal);
  model.add(dorsalPivot);
  root.userData = {
    tilt, bodyPivot: null, tail: dorsalPivot, pecs: [],
    bodyMat, finMat, kind: 'horse', speed: 0.34
  };
  return root;
}

function buildRoom() {
  const sky = new THREE.Mesh(new THREE.SphereGeometry(80, 24, 16), new THREE.ShaderMaterial({
    side: THREE.BackSide,
    depthWrite: false,
    vertexShader: 'varying vec3 vPos; void main(){ vPos = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }',
    fragmentShader: 'varying vec3 vPos; void main(){ float h = normalize(vPos).y; vec3 col = mix(vec3(0.02,0.035,0.06), vec3(0.06,0.14,0.24), smoothstep(-0.2,0.45,h)); col = mix(col, vec3(0.09,0.18,0.30), smoothstep(0.35,0.95,h)); gl_FragColor = vec4(col,1.0); }'
  }));
  scene.add(sky);

  const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(80, 80),
    new THREE.MeshStandardMaterial({ color: 0x121a24, roughness: 0.96, metalness: 0.04 })
  );
  floor.rotation.x = -Math.PI / 2;
  floor.position.y = floorY;
  floor.receiveShadow = true;
  scene.add(floor);
  const floorShadow = new THREE.Mesh(
    new THREE.PlaneGeometry(9.6, 4.8),
    new THREE.MeshBasicMaterial({ map: blobTex, transparent: true, depthWrite: false, color: 0x000208, opacity: 0.8 })
  );
  floorShadow.rotation.x = -Math.PI / 2;
  floorShadow.position.y = floorY + 0.012;
  scene.add(floorShadow);

  const win = new THREE.Group();
  win.add(new THREE.Mesh(new THREE.PlaneGeometry(3.2, 1.45), new THREE.MeshBasicMaterial({ color: 0xfff8ee, toneMapped: false })));
  const glow = new THREE.Mesh(new THREE.PlaneGeometry(5.4, 2.9), new THREE.MeshBasicMaterial({
    color: 0xffe2b0, transparent: true, opacity: 0.32, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false
  }));
  glow.position.z = -0.04;
  win.add(glow);
  const fmat = new THREE.MeshStandardMaterial({ color: 0xd9c7a4, roughness: 0.55, metalness: 0.15 });
  const fr = (w, h, x, y) => {
    const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, 0.06), fmat);
    m.position.set(x, y, 0.03);
    win.add(m);
  };
  fr(3.46, 0.08, 0, 0.76);
  fr(3.46, 0.08, 0, -0.76);
  fr(0.08, 1.6, -1.64, 0);
  fr(0.08, 1.6, 1.64, 0);
  fr(0.05, 1.45, 0, 0);
  win.position.set(1.7, 3.42, -3.5);
  scene.add(win);

  const woodTex = makeWoodTexture();
  woodTex.anisotropy = renderer.capabilities.getMaxAnisotropy();
  const wood = new THREE.MeshStandardMaterial({ map: woodTex, color: 0xffffff, roughness: 0.78, metalness: 0.04, envMapIntensity: 0.25 });
  const stand = new THREE.Mesh(new THREE.BoxGeometry(8.7, STAND_H, 3.7), wood);
  stand.position.y = standY;
  stand.receiveShadow = true;
  stand.castShadow = true;
  scene.add(stand);
  const inlay = new THREE.Mesh(
    new THREE.BoxGeometry(8.45, 0.02, 3.48),
    new THREE.MeshStandardMaterial({ color: 0xc6a36a, metalness: 0.55, roughness: 0.38 })
  );
  inlay.position.y = TANK.y0 + 0.012;
  scene.add(inlay);
  const legMat = wood;
  [[-3.55, -1.35], [-3.55, 1.35], [3.55, -1.35], [3.55, 1.35]].forEach(([x, z]) => {
    const leg = new THREE.Mesh(new THREE.BoxGeometry(0.28, LEG_H, 0.28), legMat);
    leg.position.set(x, legTop - LEG_H / 2, z);
    leg.castShadow = true;
    scene.add(leg);
  });
  const plaque = new THREE.Mesh(new THREE.PlaneGeometry(2.35, 0.58), new THREE.MeshStandardMaterial({
    map: makePlaque(), roughness: 0.55, metalness: 0.25, envMapIntensity: 0.4
  }));
  plaque.position.set(0, standY, 1.87);
  scene.add(plaque);
}

function buildTank() {
  const metal = new THREE.MeshStandardMaterial({ color: 0xd7dee4, metalness: 0.9, roughness: 0.28, envMapIntensity: 1 });
  const hw = TANK.hw, hd = TANK.hd, y0 = TANK.y0, y1 = TANK.y1;
  const t = 0.055;
  const cy = (y0 + y1) / 2;
  const bar = (sx, sy, sz, x, y, z) => {
    const m = new THREE.Mesh(new THREE.BoxGeometry(sx, sy, sz), metal);
    m.position.set(x, y, z);
    m.castShadow = true;
    scene.add(m);
  };
  for (const x of [-hw, hw]) for (const z of [-hd, hd]) bar(t, y1 - y0 + t, t, x, cy, z);
  for (const y of [y0, y1]) {
    bar(hw * 2, t, t, 0, y, -hd);
    bar(hw * 2, t, t, 0, y, hd);
    bar(t, t, hd * 2, -hw, y, 0);
    bar(t, t, hd * 2, hw, y, 0);
  }
  for (const x of [-hw, hw]) for (const y of [y0, y1]) for (const z of [-hd, hd]) bar(0.12, 0.12, 0.12, x, y, z);

  const glass = new THREE.MeshStandardMaterial({
    color: 0xc8e8ff, transparent: true, opacity: 0.075, roughness: 0.04,
    metalness: 0.18, envMapIntensity: 0.85, depthWrite: false
  });
  const gh = y1 - y0 - 0.1;
  const pane = (w, h, x, y, z, ry) => {
    const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), glass);
    m.position.set(x, y, z);
    m.rotation.y = ry;
    m.renderOrder = 4;
    scene.add(m);
  };
  pane(hw * 2 - 0.1, gh, 0, cy, hd - 0.012, 0);
  pane(hd * 2 - 0.1, gh, -hw + 0.012, cy, 0, Math.PI / 2);
  pane(hd * 2 - 0.1, gh, hw - 0.012, cy, 0, -Math.PI / 2);

  const streak = new THREE.Mesh(new THREE.PlaneGeometry(0.5, 3.15), new THREE.MeshBasicMaterial({
    map: makeStreakTexture(), transparent: true, depthWrite: false,
    blending: THREE.AdditiveBlending, opacity: 0.3, toneMapped: false, side: THREE.DoubleSide
  }));
  streak.position.set(1.35, cy, hd - 0.02);
  streak.rotation.z = 0.18;
  streak.renderOrder = 5;
  scene.add(streak);

  const lineMat = new THREE.MeshBasicMaterial({ color: 0xe7f8ff, transparent: true, opacity: 0.5, toneMapped: false });
  const wy = TANK.water;
  const addLine = (w, d, x, z) => {
    const m = new THREE.Mesh(new THREE.BoxGeometry(w, 0.028, d), lineMat);
    m.position.set(x, wy, z);
    scene.add(m);
  };
  addLine(hw * 2 - 0.12, 0.025, 0, hd - 0.05);
  addLine(hw * 2 - 0.12, 0.025, 0, -hd + 0.05);
  addLine(0.025, hd * 2 - 0.12, -hw + 0.05, 0);
  addLine(0.025, hd * 2 - 0.12, hw - 0.05, 0);

  const backTex = makeBackdrop();
  backTex.colorSpace = THREE.SRGBColorSpace;
  const bh = TANK.water - TANK.sand - 0.08;
  const backdrop = new THREE.Mesh(
    new THREE.PlaneGeometry(hw * 2 - 0.18, bh),
    new THREE.MeshStandardMaterial({ map: backTex, roughness: 1, emissive: 0x0a2030, emissiveIntensity: 0.25, envMapIntensity: 0.15 })
  );
  backdrop.position.set(0, (TANK.water + TANK.sand) / 2 - 0.02, -hd + 0.05);
  backdrop.receiveShadow = true;
  scene.add(backdrop);

  waterMat = new THREE.ShaderMaterial({
    transparent: true,
    depthWrite: false,
    side: THREE.DoubleSide,
    uniforms: { uTime: { value: 0 } },
    vertexShader: `
      uniform float uTime;
      varying vec2 vUv;
      varying float vH;
      void main() {
        vUv = uv;
        vec3 p = position;
        float h = sin(p.x * 2.2 + uTime * 1.4) * 0.035;
        h += sin(p.y * 3.1 - uTime * 1.1) * 0.025;
        h += sin((p.x + p.y) * 4.0 + uTime * 0.8) * 0.012;
        p.z += h;
        vH = h;
        gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
      }
    `,
    fragmentShader: `
      uniform float uTime;
      varying vec2 vUv;
      varying float vH;
      void main() {
        float spark = sin(vUv.x * 46.0 + uTime * 2.1) * sin(vUv.y * 34.0 - uTime * 1.5);
        spark = smoothstep(0.55, 1.0, spark);
        float rip = sin(vUv.x * 16.0 + uTime * 0.9) * sin(vUv.y * 12.0 - uTime * 0.7);
        vec3 deep = vec3(0.04, 0.25, 0.38);
        vec3 hi = vec3(0.72, 0.9, 1.0);
        vec3 col = mix(deep, hi, clamp(0.22 + vH * 4.0 + rip * 0.1, 0.0, 1.0));
        col += hi * spark * 0.9;
        float edgeDist = min(min(vUv.x, 1.0 - vUv.x), min(vUv.y, 1.0 - vUv.y));
        float rim = 1.0 - smoothstep(0.0, 0.1, edgeDist);
        col += vec3(0.65, 0.88, 1.0) * rim * 0.55;
        float alpha = clamp(0.26 + spark * 0.38 + rim * 0.28, 0.0, 0.78);
        gl_FragColor = vec4(col, alpha);
      }
    `
  });
  const water = new THREE.Mesh(new THREE.PlaneGeometry(hw * 2 - 0.22, hd * 2 - 0.16, 28, 16), waterMat);
  water.rotation.x = -Math.PI / 2;
  water.position.y = TANK.water;
  water.renderOrder = 2;
  scene.add(water);
}

let waterMat, causticMat, dustGeo;
const dustCount = 70;

function buildFloorLife() {
  const sandTex = makeSandTexture();
  sandTex.wrapS = sandTex.wrapT = THREE.RepeatWrapping;
  sandTex.repeat.set(3.2, 1.6);
  sandTex.colorSpace = THREE.SRGBColorSpace;
  sandTex.anisotropy = renderer.capabilities.getMaxAnisotropy();
  const sandGeo = new THREE.PlaneGeometry(TANK.hw * 2 - 0.22, TANK.hd * 2 - 0.1, 30, 16);
  const sp = sandGeo.attributes.position;
  for (let i = 0; i < sp.count; i++) {
    const x = sp.getX(i), y = sp.getY(i);
    sp.setZ(i, Math.sin(x * 1.3) * 0.03 + Math.sin(y * 2.1 + x) * 0.02);
  }
  sandGeo.computeVertexNormals();
  const sand = new THREE.Mesh(sandGeo, new THREE.MeshStandardMaterial({
    map: sandTex, roughness: 1, metalness: 0, envMapIntensity: 0.12
  }));
  sand.rotation.x = -Math.PI / 2;
  sand.position.y = TANK.sand;
  sand.receiveShadow = true;
  scene.add(sand);

  causticMat = new THREE.ShaderMaterial({
    transparent: true,
    depthWrite: false,
    blending: THREE.CustomBlending,
    blendSrc: THREE.SrcAlphaFactor,
    blendDst: THREE.OneFactor,
    blendEquation: THREE.AddEquation,
    toneMapped: false,
    uniforms: { uTime: { value: 0 } },
    vertexShader: 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }',
    fragmentShader: `
      uniform float uTime;
      varying vec2 vUv;
      void main() {
        vec2 uv = vUv * 5.5;
        float t = uTime;
        float a = sin(uv.x * 3.1 + t * 1.35) * sin(uv.y * 2.7 - t * 1.05);
        float b = sin((uv.x + uv.y) * 2.5 - t * 0.85) * sin((uv.x - uv.y) * 2.15 + t * 1.25);
        float c = a * b;
        float light = smoothstep(0.0, 0.55, c) + smoothstep(0.28, 0.9, c) * 0.75;
        vec2 uv2 = vUv * 8.0 + vec2(t * 0.04, -t * 0.035);
        float d = sin(uv2.x * 3.8 + t * 0.9) * sin(uv2.y * 3.3 - t * 0.75);
        light += smoothstep(0.42, 0.95, d) * 0.4;
        float edgeDist = min(min(vUv.x, 1.0 - vUv.x), min(vUv.y, 1.0 - vUv.y));
        float edge = smoothstep(0.0, 0.05, edgeDist);
        float intensity = clamp(light * 0.5 * edge, 0.0, 0.8);
        gl_FragColor = vec4(vec3(0.5, 0.82, 1.0) * intensity, intensity);
      }
    `
  });
  const caustic = new THREE.Mesh(new THREE.PlaneGeometry(TANK.hw * 2 - 0.28, TANK.hd * 2 - 0.16), causticMat);
  caustic.rotation.x = -Math.PI / 2;
  caustic.position.y = TANK.sand + 0.03;
  caustic.renderOrder = 1;
  scene.add(caustic);

  const pebbleGeo = new THREE.SphereGeometry(0.045, 6, 5);
  const pebbles = new THREE.InstancedMesh(pebbleGeo, new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.95 }), 70);
  const dummy = new THREE.Object3D();
  for (let i = 0; i < 70; i++) {
    dummy.position.set((Math.random() - 0.5) * 7.1, TANK.sand + 0.02, (Math.random() - 0.5) * 2.7);
    dummy.scale.set(0.7 + Math.random() * 1.3, 0.35 + Math.random() * 0.35, 0.7 + Math.random() * 1.2);
    dummy.rotation.set(Math.random(), Math.random(), Math.random());
    dummy.updateMatrix();
    pebbles.setMatrixAt(i, dummy.matrix);
    pebbles.setColorAt(i, new THREE.Color().setHSL(0.08 + Math.random() * 0.05, 0.35, 0.32 + Math.random() * 0.28));
  }
  pebbles.instanceMatrix.needsUpdate = true;
  if (pebbles.instanceColor) pebbles.instanceColor.needsUpdate = true;
  pebbles.receiveShadow = true;
  scene.add(pebbles);
}

function buildReef() {
  const spots = [
    [-3.15, 0.85, 0.32, 0x6f6a64],
    [-2.55, -0.7, 0.22, 0x8a8178],
    [2.85, 0.5, 0.26, 0x5e6a70],
    [1.35, -1.05, 0.18, 0x7b736c],
    [-0.15, -1.2, 0.16, 0x90867c],
    [3.15, -0.2, 0.22, 0x6a6560]
  ];
  for (let i = 0; i < spots.length; i++) {
    const s = spots[i];
    const rock = makeRock(s[2], s[3]);
    rock.position.set(s[0], TANK.sand + s[2] * 0.32, s[1]);
    rock.rotation.set(Math.random() * 2, Math.random() * 3, Math.random());
    scene.add(rock);
    obstacles.push(new THREE.Vector3(s[0], TANK.sand + 0.12, s[1]));
  }
  addKelp(-3.25, -0.35, 1.55, 0x1f8a4c);
  addKelp(-3.05, 0.45, 1.25, 0x2f9d58);
  addKelp(3.2, -0.75, 1.45, 0x187a42);
  addKelp(-1.7, -1.2, 1.7, 0x146b38);
  addKelp(0.85, -1.18, 1.35, 0x238f55);
  addSeagrass(-3.05, 0.15, 8, 0x1c8a4a);
  addSeagrass(2.95, -0.45, 7, 0x146b3a);
  addSeagrass(-0.7, -1.12, 6, 0x238f55);
  addSeagrass(1.7, -1.02, 5, 0x0f6a40);
  addCoral(-1.15, -1.02, 0xff6a3d, 1.05);
  addCoral(1.55, -0.95, 0xc86bff, 0.9);
  addCoral(2.35, -0.85, 0xff8f6a, 0.75);
  addAnemone(2.65, -0.38);
  addCastle(-2.55, -0.95);
  addBrain(0.35, -1.05);
  addPot(3.05, 0.45);
  addStar(-0.55, 0.82, 1.05, 0xe07a4c);
  addStar(1.15, 0.62, 0.75, 0xd45b6a);
  addAerator();

  const bases = [
    { x: -2.1, z: 0.1, rz: -0.22, ry: 0.1, w: 0.95, o: 0.4 },
    { x: -0.55, z: -0.15, rz: -0.08, ry: -0.05, w: 1.15, o: 0.34 },
    { x: 0.95, z: 0.18, rz: 0.06, ry: 0.08, w: 0.8, o: 0.38 },
    { x: 2.15, z: -0.05, rz: 0.16, ry: -0.1, w: 0.88, o: 0.3 }
  ];
  for (let i = 0; i < bases.length; i++) {
    const b = bases[i];
    const mat = new THREE.MeshBasicMaterial({
      map: rayTex, transparent: true, blending: THREE.AdditiveBlending,
      depthWrite: false, opacity: b.o, side: THREE.DoubleSide, color: 0xd6efff, toneMapped: false
    });
    const m = new THREE.Mesh(new THREE.PlaneGeometry(b.w, 3.05), mat);
    m.position.set(b.x, 0.32, b.z);
    m.rotation.z = b.rz;
    m.rotation.y = b.ry;
    m.userData.baseZ = b.rz;
    m.userData.baseO = b.o;
    m.renderOrder = 2;
    scene.add(m);
    rays.push(m);
  }
}

function buildWaterColumn() {
  const bubbleMat = new THREE.MeshStandardMaterial({
    color: 0xf4fbff, emissive: 0x9ad4ff, emissiveIntensity: 0.28,
    transparent: true, opacity: 0.3, roughness: 0.08, metalness: 0.04, depthWrite: false
  });
  for (let i = 0; i < 28; i++) {
    const r = 0.018 + Math.random() * 0.038;
    const b = new THREE.Mesh(bubbleGeo, bubbleMat);
    b.position.set(
      AERATOR.x + (Math.random() - 0.5) * 0.16,
      -1.4 + Math.random() * 2.95,
      AERATOR.z + (Math.random() - 0.5) * 0.16
    );
    b.userData = { r, speed: 0.28 + Math.random() * 0.55, phase: Math.random() * 6.28, homeX: AERATOR.x, homeZ: AERATOR.z };
    b.scale.setScalar(r);
    b.renderOrder = 3;
    scene.add(b);
    bubbles.push(b);
  }
  const dustPos = new Float32Array(dustCount * 3);
  for (let i = 0; i < dustCount; i++) {
    dustPos[i * 3] = (Math.random() - 0.5) * 7;
    dustPos[i * 3 + 1] = -1.35 + Math.random() * 2.8;
    dustPos[i * 3 + 2] = (Math.random() - 0.5) * 2.4;
  }
  dustGeo = new THREE.BufferGeometry();
  dustGeo.setAttribute('position', new THREE.BufferAttribute(dustPos, 3));
  scene.add(new THREE.Points(dustGeo, new THREE.PointsMaterial({
    color: 0xd8f4ff, size: 0.045, map: circleTex, transparent: true,
    opacity: 0.55, depthWrite: false, sizeAttenuation: true
  })));
}

let glowSprite, lureCore;
function buildLure() {
  const glowMat = new THREE.SpriteMaterial({
    map: glowTex, transparent: true, blending: THREE.AdditiveBlending,
    depthWrite: false, depthTest: false, opacity: 0.9, toneMapped: false
  });
  glowSprite = new THREE.Sprite(glowMat);
  glowSprite.scale.set(1.15, 1.15, 1);
  glowSprite.renderOrder = 6;
  scene.add(glowSprite);
  lureCore = new THREE.Mesh(new THREE.SphereGeometry(0.055, 12, 10), new THREE.MeshBasicMaterial({ color: 0xffffff, toneMapped: false }));
  lureCore.position.copy(lure);
  scene.add(lureCore);
  for (let i = 0; i < 7; i++) {
    const m = glowMat.clone();
    m.opacity = 0.28 * (1 - i / 7);
    const s = new THREE.Sprite(m);
    s.scale.setScalar(0.85 - i * 0.08);
    s.position.copy(lure);
    s.renderOrder = 6;
    scene.add(s);
    trailSprites.push(s);
  }
  for (let i = 0; i < 3; i++) {
    const ring = new THREE.Mesh(
      new THREE.TorusGeometry(0.2, 0.012, 8, 28),
      new THREE.MeshBasicMaterial({ color: 0xcff8ff, transparent: true, opacity: 0.45, toneMapped: false, depthWrite: false })
    );
    ring.renderOrder = 5;
    scene.add(ring);
    ringMeshes.push(ring);
  }
}

function makeRock(radius, color) {
  const geo = new THREE.DodecahedronGeometry(radius, 0);
  const p = geo.attributes.position;
  for (let i = 0; i < p.count; i++) {
    p.setXYZ(i,
      p.getX(i) * (0.75 + Math.random() * 0.5),
      p.getY(i) * (0.5 + Math.random() * 0.35),
      p.getZ(i) * (0.75 + Math.random() * 0.5)
    );
  }
  geo.computeVertexNormals();
  const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({
    color, roughness: 0.94, metalness: 0.02, flatShading: true, envMapIntensity: 0.25
  }));
  mesh.castShadow = true;
  mesh.receiveShadow = true;
  return mesh;
}
function addKelp(x, z, height, color) {
  const segs = 7;
  const segH = height / segs;
  const mat = new THREE.MeshStandardMaterial({
    color, roughness: 0.55, side: THREE.DoubleSide, emissive: color, emissiveIntensity: 0.06
  });
  let parent = new THREE.Group();
  parent.position.set(x, TANK.sand, z);
  scene.add(parent);
  for (let i = 0; i < segs; i++) {
    const pivot = new THREE.Group();
    if (i > 0) pivot.position.y = segH;
    const blade = new THREE.Mesh(new THREE.BoxGeometry(0.09 + i * 0.012, segH * 1.02, 0.018), mat);
    blade.position.y = segH / 2;
    pivot.add(blade);
    parent.add(pivot);
    swayers.push({ obj: pivot, amp: 0.025 + i * 0.016, speed: 1.25, phase: x * 0.7 + i * 0.45, baseZ: 0, baseX: 0 });
    parent = pivot;
  }
}
function addSeagrass(x, z, n, color) {
  const mat = new THREE.MeshStandardMaterial({
    color, roughness: 0.6, side: THREE.DoubleSide, emissive: color, emissiveIntensity: 0.08
  });
  for (let i = 0; i < n; i++) {
    const h = 0.32 + Math.random() * 0.7;
    const blade = new THREE.Mesh(new THREE.PlaneGeometry(0.055, h), mat);
    blade.position.y = h / 2;
    const pivot = new THREE.Group();
    pivot.position.set(x + (Math.random() - 0.5) * 0.42, TANK.sand, z + (Math.random() - 0.5) * 0.32);
    pivot.rotation.y = Math.random() * Math.PI;
    pivot.rotation.z = (Math.random() - 0.5) * 0.25;
    pivot.add(blade);
    scene.add(pivot);
    swayers.push({
      obj: pivot, amp: 0.07 + Math.random() * 0.08, speed: 1.1 + Math.random(),
      phase: Math.random() * 6, baseZ: pivot.rotation.z, baseX: pivot.rotation.x
    });
  }
}
function addCoral(x, z, color, scale) {
  const mat = new THREE.MeshStandardMaterial({
    color, roughness: 0.62, metalness: 0.04, emissive: color, emissiveIntensity: 0.08, envMapIntensity: 0.3
  });
  const g = new THREE.Group();
  function branch(parent, len, rad, depth, tilt) {
    const pivot = new THREE.Group();
    pivot.rotation.z = tilt;
    pivot.rotation.y = Math.random() * Math.PI * 2;
    const mesh = new THREE.Mesh(new THREE.CylinderGeometry(rad * 0.62, rad, len, 6), mat);
    mesh.position.y = len / 2;
    mesh.castShadow = true;
    pivot.add(mesh);
    parent.add(pivot);
    if (depth > 0) {
      const n = 3;
      for (let i = 0; i < n; i++) {
        const child = new THREE.Group();
        child.position.y = len * 0.8;
        pivot.add(child);
        branch(child, len * 0.62, rad * 0.66, depth - 1, (i - 1) * 0.55);
      }
    }
  }
  branch(g, 0.4 * scale, 0.055 * scale, 2, 0);
  g.position.set(x, TANK.sand, z);
  scene.add(g);
  obstacles.push(new THREE.Vector3(x, TANK.sand + 0.28 * scale, z));
}
function addAnemone(x, z) {
  const g = new THREE.Group();
  const colors = [0xff6b8a, 0xff8fa8, 0xd4557a, 0xffb3c6];
  for (let i = 0; i < 16; i++) {
    const h = 0.42 + Math.random() * 0.5;
    const mat = new THREE.MeshStandardMaterial({
      color: colors[i % colors.length], roughness: 0.5, emissive: 0x441122, emissiveIntensity: 0.18
    });
    const mesh = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.032, h, 5), mat);
    mesh.position.y = h / 2;
    const bulb = new THREE.Mesh(new THREE.SphereGeometry(0.028, 6, 5), mat);
    bulb.position.y = h / 2;
    mesh.add(bulb);
    const pivot = new THREE.Group();
    const ang = Math.random() * Math.PI * 2;
    pivot.position.set(Math.cos(ang) * Math.random() * 0.16, 0, Math.sin(ang) * Math.random() * 0.16);
    pivot.rotation.z = (Math.random() - 0.5) * 0.55;
    pivot.rotation.x = (Math.random() - 0.5) * 0.55;
    pivot.add(mesh);
    g.add(pivot);
    swayers.push({
      obj: pivot, amp: 0.1 + Math.random() * 0.1, speed: 1.2 + Math.random() * 0.8,
      phase: Math.random() * 6, baseZ: pivot.rotation.z, baseX: pivot.rotation.x
    });
  }
  const base = new THREE.Mesh(new THREE.SphereGeometry(0.2, 10, 8), new THREE.MeshStandardMaterial({ color: 0x8d3a55, roughness: 0.8 }));
  base.scale.y = 0.45;
  base.position.y = 0.04;
  g.add(base);
  g.position.set(x, TANK.sand, z);
  scene.add(g);
  obstacles.push(new THREE.Vector3(x, TANK.sand + 0.2, z));
}
function addCastle(x, z) {
  const g = new THREE.Group();
  const stone = new THREE.MeshStandardMaterial({ color: 0x8e929c, roughness: 0.88, metalness: 0.04, envMapIntensity: 0.25 });
  const dark = new THREE.MeshStandardMaterial({ color: 0x2a3038, roughness: 1 });
  const tower = (tx, tz, h, r) => {
    const m = new THREE.Mesh(new THREE.CylinderGeometry(r, r * 1.08, h, 8), stone);
    m.position.set(tx, h / 2, tz);
    m.castShadow = true;
    g.add(m);
    for (let i = 0; i < 5; i++) {
      const a = (i / 5) * Math.PI * 2;
      const mer = new THREE.Mesh(new THREE.BoxGeometry(r * 0.42, 0.1, r * 0.42), stone);
      mer.position.set(tx + Math.cos(a) * r * 0.72, h + 0.04, tz + Math.sin(a) * r * 0.72);
      g.add(mer);
    }
  };
  tower(-0.28, 0.04, 0.72, 0.2);
  tower(0.3, -0.02, 0.52, 0.15);
  const wall = new THREE.Mesh(new THREE.BoxGeometry(0.62, 0.38, 0.14), stone);
  wall.position.set(0.02, 0.22, 0.02);
  wall.castShadow = true;
  g.add(wall);
  const door = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.24, 0.16), dark);
  door.position.set(0.02, 0.12, 0.08);
  g.add(door);
  g.position.set(x, TANK.sand, z);
  g.rotation.y = 0.45;
  scene.add(g);
  obstacles.push(new THREE.Vector3(x, TANK.sand + 0.35, z));
}
function addBrain(x, z) {
  const geo = new THREE.SphereGeometry(0.26, 14, 10);
  const p = geo.attributes.position;
  for (let i = 0; i < p.count; i++) {
    const n = 0.86 + Math.sin(p.getX(i) * 18) * Math.sin(p.getZ(i) * 16) * 0.12;
    p.setXYZ(i, p.getX(i) * n, p.getY(i) * 0.62 * n, p.getZ(i) * n);
  }
  geo.computeVertexNormals();
  const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({
    color: 0xd46a9a, roughness: 0.58, emissive: 0x5a2040, emissiveIntensity: 0.12
  }));
  mesh.position.set(x, TANK.sand + 0.08, z);
  mesh.castShadow = true;
  scene.add(mesh);
  obstacles.push(new THREE.Vector3(x, TANK.sand + 0.12, z));
}
function addPot(x, z) {
  const mat = new THREE.MeshStandardMaterial({ color: 0xc4623a, roughness: 0.72, envMapIntensity: 0.3 });
  const g = new THREE.Group();
  g.add(new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.1, 0.2, 10), mat));
  const rim = new THREE.Mesh(new THREE.TorusGeometry(0.14, 0.022, 6, 14), mat);
  rim.rotation.x = Math.PI / 2;
  rim.position.y = 0.1;
  g.add(rim);
  g.position.set(x, TANK.sand + 0.1, z);
  g.rotation.z = 0.6;
  g.rotation.y = 0.7;
  scene.add(g);
}
function addStar(x, z, s, color) {
  const g = new THREE.Group();
  const mat = new THREE.MeshStandardMaterial({ color, roughness: 0.78 });
  for (let i = 0; i < 5; i++) {
    const arm = new THREE.Mesh(new THREE.SphereGeometry(0.08 * s, 6, 5), mat);
    arm.scale.set(0.7, 0.28, 1.8);
    const a = i / 5 * Math.PI * 2;
    arm.position.set(Math.cos(a) * 0.1 * s, 0, Math.sin(a) * 0.1 * s);
    arm.rotation.y = Math.PI / 2 - a;
    g.add(arm);
  }
  const c = new THREE.Mesh(new THREE.SphereGeometry(0.07 * s, 8, 6), mat);
  c.scale.y = 0.35;
  g.add(c);
  g.position.set(x, TANK.sand + 0.03, z);
  g.rotation.y = Math.random() * 6;
  scene.add(g);
}
function addAerator() {
  const stone = new THREE.Mesh(
    new THREE.DodecahedronGeometry(0.1, 0),
    new THREE.MeshStandardMaterial({ color: 0x4a5560, roughness: 0.85 })
  );
  stone.scale.y = 0.55;
  stone.position.set(AERATOR.x, TANK.sand + 0.05, AERATOR.z);
  scene.add(stone);
}

function makeFin(kind) {
  const s = new THREE.Shape();
  if (kind === 'fork') {
    s.moveTo(0, 0);
    s.lineTo(0.46, 0.3);
    s.lineTo(0.3, 0.06);
    s.lineTo(0.5, 0);
    s.lineTo(0.3, -0.06);
    s.lineTo(0.46, -0.3);
    s.closePath();
  } else if (kind === 'pennant') {
    s.moveTo(0, 0);
    s.lineTo(0.06, 0.2);
    s.lineTo(0.0, 0.92);
    s.lineTo(-0.04, 0.22);
    s.closePath();
  } else if (kind === 'dorsal') {
    s.moveTo(0, 0);
    s.lineTo(0.36, 0.05);
    s.quadraticCurveTo(0.3, 0.28, 0.06, 0.2);
    s.closePath();
  } else {
    s.moveTo(0, 0);
    s.quadraticCurveTo(0.25, 0.34, 0.52, 0.22);
    s.quadraticCurveTo(0.34, 0, 0.52, -0.22);
    s.quadraticCurveTo(0.25, -0.34, 0, 0);
    s.closePath();
  }
  const geo = new THREE.ShapeGeometry(s, 6);
  geo.computeVertexNormals();
  return geo;
}
function makeSandTexture() {
  const c = document.createElement('canvas');
  c.width = 512; c.height = 512;
  const g = c.getContext('2d');
  g.fillStyle = '#c6a56e';
  g.fillRect(0, 0, 512, 512);
  for (let i = 0; i < 5000; i++) {
    const shade = 120 + Math.random() * 100;
    g.fillStyle = 'rgb(' + shade + ',' + (shade * 0.8) + ',' + (shade * 0.48) + ')';
    g.beginPath();
    g.arc(Math.random() * 512, Math.random() * 512, 0.6 + Math.random() * 2.4, 0, Math.PI * 2);
    g.fill();
  }
  for (let i = 0; i < 40; i++) {
    g.fillStyle = Math.random() > 0.5 ? '#efe2c4' : '#8d6840';
    g.beginPath();
    g.ellipse(Math.random() * 512, Math.random() * 512, 2 + Math.random() * 4, 1.2 + Math.random() * 2, Math.random() * 3, 0, Math.PI * 2);
    g.fill();
  }
  const tex = new THREE.CanvasTexture(c);
  return tex;
}
function makeWoodTexture() {
  const c = document.createElement('canvas');
  c.width = 512; c.height = 256;
  const g = c.getContext('2d');
  g.fillStyle = '#4a2e1c';
  g.fillRect(0, 0, 512, 256);
  for (let i = 0; i < 90; i++) {
    g.strokeStyle = 'rgba(0,0,0,' + (0.04 + Math.random() * 0.12) + ')';
    g.lineWidth = 1 + Math.random() * 2.5;
    g.beginPath();
    let y = Math.random() * 256;
    g.moveTo(0, y);
    for (let x = 0; x <= 512; x += 16) g.lineTo(x, y + Math.sin(x * 0.04 + i) * 3);
    g.stroke();
  }
  for (let i = 0; i < 18; i++) {
    g.strokeStyle = 'rgba(190,130,70,' + (0.05 + Math.random() * 0.08) + ')';
    g.beginPath();
    const y = Math.random() * 256;
    g.moveTo(0, y);
    g.lineTo(512, y + (Math.random() - 0.5) * 8);
    g.stroke();
  }
  const tex = new THREE.CanvasTexture(c);
  tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
  tex.repeat.set(2.5, 1.2);
  tex.colorSpace = THREE.SRGBColorSpace;
  return tex;
}
function makePlaque() {
  const c = document.createElement('canvas');
  c.width = 512; c.height = 128;
  const g = c.getContext('2d');
  g.fillStyle = '#1c140e';
  g.fillRect(0, 0, 512, 128);
  g.strokeStyle = '#c6a15a';
  g.lineWidth = 6;
  g.strokeRect(8, 8, 496, 112);
  g.fillStyle = '#f0e2c4';
  g.font = '600 48px Georgia, Times New Roman, serif';
  g.textAlign = 'center';
  g.textBaseline = 'middle';
  g.fillText('GLASSWATER', 256, 52);
  g.fillStyle = '#c6a56a';
  g.font = '500 22px Georgia, Times New Roman, serif';
  g.fillText('REEF   VII', 256, 96);
  const tex = new THREE.CanvasTexture(c);
  tex.colorSpace = THREE.SRGBColorSpace;
  return tex;
}
function makeBackdrop() {
  const c = document.createElement('canvas');
  c.width = 512; c.height = 512;
  const g = c.getContext('2d');
  const grd = g.createLinearGradient(0, 0, 0, 512);
  grd.addColorStop(0, '#0c4e78');
  grd.addColorStop(0.55, '#0a3c5e');
  grd.addColorStop(1, '#1a4e4a');
  g.fillStyle = grd;
  g.fillRect(0, 0, 512, 512);
  for (let i = 0; i < 16; i++) {
    g.fillStyle = 'rgba(6,36,46,' + (0.25 + Math.random() * 0.3) + ')';
    g.beginPath();
    g.ellipse(Math.random() * 512, 300 + Math.random() * 170, 28 + Math.random() * 55, 16 + Math.random() * 22, 0, 0, Math.PI * 2);
    g.fill();
  }
  return new THREE.CanvasTexture(c);
}
function makeBlobTexture() {
  const c = document.createElement('canvas');
  c.width = 256; c.height = 256;
  const g = c.getContext('2d');
  const grd = g.createRadialGradient(128, 128, 16, 128, 128, 120);
  grd.addColorStop(0, 'rgba(0,0,0,0.7)');
  grd.addColorStop(1, 'rgba(0,0,0,0)');
  g.fillStyle = grd;
  g.fillRect(0, 0, 256, 256);
  return new THREE.CanvasTexture(c);
}
function makeCircleTexture() {
  const c = document.createElement('canvas');
  c.width = 32; c.height = 32;
  const g = c.getContext('2d');
  const grd = g.createRadialGradient(16, 16, 0, 16, 16, 16);
  grd.addColorStop(0, 'rgba(255,255,255,1)');
  grd.addColorStop(0.45, 'rgba(255,255,255,0.45)');
  grd.addColorStop(1, 'rgba(255,255,255,0)');
  g.fillStyle = grd;
  g.fillRect(0, 0, 32, 32);
  return new THREE.CanvasTexture(c);
}
function makeGlowTexture() {
  const c = document.createElement('canvas');
  c.width = 128; c.height = 128;
  const g = c.getContext('2d');
  const grd = g.createRadialGradient(64, 64, 0, 64, 64, 64);
  grd.addColorStop(0, 'rgba(255,255,255,1)');
  grd.addColorStop(0.18, 'rgba(190,245,255,0.85)');
  grd.addColorStop(0.48, 'rgba(80,180,255,0.28)');
  grd.addColorStop(1, 'rgba(80,180,255,0)');
  g.fillStyle = grd;
  g.fillRect(0, 0, 128, 128);
  return new THREE.CanvasTexture(c);
}
function makeRayTexture() {
  const c = document.createElement('canvas');
  c.width = 128; c.height = 512;
  const g = c.getContext('2d');
  const grd = g.createLinearGradient(0, 0, 0, 512);
  grd.addColorStop(0, 'rgba(255,255,255,0)');
  grd.addColorStop(0.08, 'rgba(255,255,255,0.7)');
  grd.addColorStop(0.4, 'rgba(210,235,255,0.22)');
  grd.addColorStop(1, 'rgba(255,255,255,0)');
  g.fillStyle = grd;
  g.fillRect(0, 0, 128, 512);
  g.globalCompositeOperation = 'destination-out';
  const h = g.createLinearGradient(0, 0, 128, 0);
  h.addColorStop(0, 'rgba(0,0,0,1)');
  h.addColorStop(0.5, 'rgba(0,0,0,0)');
  h.addColorStop(1, 'rgba(0,0,0,1)');
  g.fillStyle = h;
  g.fillRect(0, 0, 128, 512);
  return new THREE.CanvasTexture(c);
}
function makeStreakTexture() {
  const c = document.createElement('canvas');
  c.width = 64; c.height = 256;
  const g = c.getContext('2d');
  const grd = g.createLinearGradient(0, 0, 64, 0);
  grd.addColorStop(0, 'rgba(255,255,255,0)');
  grd.addColorStop(0.5, 'rgba(255,255,255,0.9)');
  grd.addColorStop(1, 'rgba(255,255,255,0)');
  g.fillStyle = grd;
  g.fillRect(0, 0, 64, 256);
  g.globalCompositeOperation = 'destination-out';
  const v = g.createLinearGradient(0, 0, 0, 256);
  v.addColorStop(0, 'rgba(0,0,0,1)');
  v.addColorStop(0.2, 'rgba(0,0,0,0)');
  v.addColorStop(0.75, 'rgba(0,0,0,0)');
  v.addColorStop(1, 'rgba(0,0,0,1)');
  g.fillStyle = v;
  g.fillRect(0, 0, 64, 256);
  return new THREE.CanvasTexture(c);
}
function makeEnv(renderer) {
  const pmrem = new THREE.PMREMGenerator(renderer);
  const envScene = new THREE.Scene();
  const mat = new THREE.ShaderMaterial({
    side: THREE.BackSide,
    vertexShader: 'varying vec3 vP; void main(){ vP = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }',
    fragmentShader: 'varying vec3 vP; void main(){ vec3 n = normalize(vP); float h = n.y; vec3 col = mix(vec3(0.16,0.11,0.07), vec3(0.18,0.34,0.5), smoothstep(-0.35,0.2,h)); col = mix(col, vec3(0.62,0.78,0.9), smoothstep(0.15,0.8,h)); float win = 1.0 - smoothstep(0.0, 0.32, length(n.xz - vec2(0.2, 0.35))); win *= smoothstep(0.0, 0.3, h); col += vec3(1.25, 1.15, 1.0) * win; gl_FragColor = vec4(col, 1.0); }'
  });
  envScene.add(new THREE.Mesh(new THREE.SphereGeometry(5, 16, 12), mat));
  return pmrem.fromScene(envScene, 0.05).texture;
}
</script>
</body>
</html>
Reasoning trace (107,133 tokens)
Building a 3D aquarium with Three.js. Fish will swim away when the mouse moves near them.

Planning a single HTML file with inline CSS and JS. It will load Three.js through the provided import map. The demo must look good at 1280×800 and adapt to 375px. Fish will be visible immediately and swim away when the mouse nears them. No localStorage or alerts will be used.

The design includes a glass tank with water, sand floor, plants, rocks, and bubbles. Fish will use colorful materials and simple shaders for caustics and water surface effects. The page structure will use a module script with the import map.

The import map must match the exact specification. The scene will feature a dark blue room around a transparent glass tank with a blue water volume inside. The sand floor will use a procedurally generated canvas texture. Rocks will be built from icosahedrons or custom geometries. Seaweed will be curved green tubes or planes. Fish will be custom mesh groups with ellipsoid bodies, tails, fins, and eyes. Bubbles will rise through the water.

Light caustics will be simulated with animated floor lights or a shader. Plankton particles will float in the water. Fish will swim in a school pattern with wandering. When the mouse nears them, they will flee in the opposite direction with smooth turning. The mouse position will be unprojected into a ray, and each fish will be checked against that ray to trigger the escape.

Fish will flee from the closest point on the mouse ray when within a threshold. A subtle ripple or glow will appear on the water surface where the cursor moves. The tank will feature dark metal edges and slightly reflective glass panels. The water surface will use animated waves via a vertex shader or sine displacement. Fish models will be built from custom geometry.

Fish will be built from scaled spheres or lathe geometry for bodies, with tails, fins, and eyes. The school will include clownfish, blue and yellow tangs, angelfish, tetras, and a larger fish. Colors will be bright and contrasting against the blue water. Animation will feature sine-wave tail wagging, slight body rolling during turns, swaying plants, and caustic effects on the water surface.

A procedural caustic shader will be generated on the sand floor using a Three.js ShaderMaterial with moving sine patterns. The scene will use ambient blue, directional light from above, point lights, and hemisphere lighting. An elegant "Aquarium" title overlay and an immediate hint telling users to move the mouse near the fish will be added. Fish will be visible and swimming on first paint. The screenshot at the three-second mark will show fish mid-swim, swaying plants, and rising bubbles.

Fish will be constrained to soft tank boundaries. Each fish will use a steering system with wander targets, separation from neighbors, and mouse avoidance, capped at a speed limit. Their mesh orientation will track the velocity vector using lookAt, with a quaternion adjustment for the default Three.js forward axis.

Fish models will be built as groups facing -Z so lookAt aligns them naturally with velocity. The body will use a scaled sphere geometry with its long axis along Z. The tail will be a cone at +Z, wagging via sine-driven Y rotation. A slight vertical bob will be added to the body for natural swimming motion.

The water surface will use a transparent blue plane with a ShaderMaterial. Vertex displacement will create sine waves. The fragment shader will add fresnel-like brighter edges and moving highlights.

The glass tank will use a slightly larger BoxGeometry with MeshPhysicalMaterial. It will set low roughness and thickness for transmission. A RoomEnvironment from the addons import map will provide the scene environment for realistic glass refraction.

The RoomEnvironment import path is confirmed. Full glass transmission is being reconsidered because it can wash out the scene and hide the fish. A simpler approach with thin glass frames, a subtle transparent shell, and a blue fog or clear water volume is preferred for better visibility in the screenshot.

The scene background will be a deep navy gradient to keep the fish clearly visible. The glass tank will use thin edge frames and very transparent panels. Water will be tinted with a large low-opacity blue box or fog. The sand floor will use a canvas-generated noise texture with pebble spheres. Rocks will be dodecahedrons with a rough brown material. Plants will be curved kelp ribbons and swaying stems made from tube or stacked geometry.

Branching coral in pink, orange, and purple will be added. Bubbles will be transparent spheres that rise and reset. Soft light shafts will come from above using additive blending. Twelve to eighteen fish of varying sizes will populate the tank. A soft visual disturbance will appear where the mouse ray intersects the tank center or front glass.

A glowing orb will follow the mouse in 3D space inside the tank. Fish will flee from the point where the cursor ray intersects a plane through the tank center, clamped to the tank bounds. This makes the escape behavior more intuitive than using raw ray distance.

Fish will flee based on screen-space distance from the mouse cursor. Each fish position will be projected onto the screen, and if the cursor is within about 120 pixels, the fish will swim away. This approach is more intuitive than using a 3D ray intersection.

Fish behind others on screen will also flee. The escape direction will be calculated from the 3D point where the mouse ray intersects the fish's depth, pushing them away in world space rather than just visually.

The escape vector will be a simple subtraction of the closest point on the mouse ray from the fish position. A semi-transparent UI overlay will display the title and subtitle without blocking the view. The camera will be angled slightly inward so the tank’s depth reads clearly in the screenshot.

The camera will sit at (0, 0.5, 8) aimed at the tank center. A gentle, slow vertical bob will keep the scene alive without disorienting the viewer. The Three.js module should be fully loaded from the CDN by the three-second mark.

A CSS water background will render immediately so the first paint is not blank. The Three.js canvas will cover it once the module loads. The scene will start rendering as soon as the module is ready. The code will be kept clean and complete. Fish species will be chosen for visual variety.

Seven distinct fish species are planned, including clownfish with three white bands, blue and yellow tangs, butterflyfish, a silver school, purple dottyback, and green chromis. Stripe patterns will be drawn onto canvas textures and mapped onto the fish body geometry.

Canvas textures mapped onto the fish body sphere would misalign the stripes because the geometry is scaled along the Z axis. Thin torus or cylinder bands will be added as separate meshes around the body instead. The clownfish will use an orange ellipsoid body with three white bands encircling it.

The fish body will be a scaled sphere geometry with local coordinates facing -Z and length spanning z=-0.6 to z=0.5. White stripe bands will be thin torus rings placed at different Z positions around the body, scaled to match the body cross-section.

The tail will be a custom triangle shape drawn with ShapeGeometry and rotated into the YZ plane so it sits at positive Z. It will wag left and right by rotating around the Y axis.

The tail fin will be rotated -90 degrees around the Y axis so it extends vertically in +Z. A smaller dorsal fin will sit on top, and small pectoral fins will be added on the sides. Each fish will also have a small white eye sphere.

Fish eyes will use a white sphere with a smaller black pupil offset forward. Their bodies will use MeshStandardMaterial with roughness 0.35, metalness 0.05, and a slight emissive tint so they pop underwater. The scene will be lit by a hemisphere light, a warm directional light from above, a cool blue fill light from below, and a subtle point light inside the tank.

Soft shadows will be enabled for the directional light using PCFSoftShadowMap. Rocks and fish will cast shadows onto the sand floor. The shadow camera frustum will be sized to fit the tank. Exponential fog was considered but rejected because it would affect the entire scene outside the tank.

A large transparent blue box will sit inside the tank to create the water volume. It will use MeshPhysicalMaterial with low opacity and roughness so the background remains visible through the glass.

The glass tank will use simple transparency instead of transmission. A custom environment map is unnecessary for an aquarium.

ACESFilmicToneMapping and SRGBColorSpace will be set on the renderer. A custom ShaderMaterial will animate the water surface with a sine-wave elevation shader for a shimmering effect.

The water surface shader adds vertical sine-wave elevation and a spark noise pattern. The fragment shader blends the base color with elevation and spark values for a shimmering effect.

The water surface plane will be rotated -90 degrees around the X axis so it lies horizontally in the XZ plane facing up. The shader will displace the Y coordinate for vertical wave motion rather than Z.

A custom shader material is being written for the sand floor to simulate underwater caustics. It will sample a noise texture and animate it over time to create shifting light patterns across the sand.

The sand shader blends a canvas noise texture with animated sine-wave caustics to create shifting light patterns across the floor. The tank frame will be built from thin BoxGeometry beams along the twelve edges in a dark brushed metal finish.

The tank will measure 8 by 4.2 by 3.2 units. The camera will sit at z=7 with a 40-degree field of view. Glass panels will be omitted to keep the fish visible; only the edge beams and a faint front reflection streak will remain.

The glass tank will use a low-opacity MeshStandardMaterial instead of MeshPhysicalMaterial. Transmission settings could make the glass look invisible or broken without a proper environment map, so a faint blue transparent material is safer.

Gravel will be rendered with an InstancedMesh of small spheres. Bubbles will be an InstancedMesh of roughly forty spheres with animated positions. Each fish will store velocity, a random target point, speed, a phase offset, a wander timer, and a fear value in its userData.

The animation loop limits frame time to 33 milliseconds. Fish wander timers are decremented each frame and reset to a random interval when they expire. The loop also projects each fish onto the screen to measure its distance from the mouse cursor.

Fish flee when the cursor is within 160 pixels and the fish is in front of the camera. Fear builds up quickly and peaks at full intensity. The escape direction is derived from the screen offset between the fish and the mouse, with a slight vertical component and random noise. This screen-space vector is then converted into world space so the fish swims away from the cursor.

The screen-space escape vector is projected into world space using the camera's right and up axes. Vertical speed is reduced to 60 percent. Fish speed increases with fear, peaking at double normal speed. Fear fades gradually when the cursor moves away. The wander timer is forced to expire quickly so the fish retargets immediately.

Fish speed is scaled by 0.7 plus fear. When a fish reaches its target within 0.3 units, it picks a new random target. A separation force pushes fish apart when they come within 0.55 units of each other. Bounds avoidance is next.

Fish are kept inside a 3.5 by 1.6 by 1.2 unit box. The left and right edges push back with 1.5 force, the top and bottom with 1.2, and the front and back with 1.2. Velocity blends toward the desired direction using a lerp factor that scales with fear. Maximum speed doubles when fear exceeds 0.1.

Fish velocity is clamped to the maximum speed limit. They are positioned by adding the scaled velocity vector each frame. The fish orient themselves to face their movement direction and bank slightly into turns. Their tail wags faster and with a wider amplitude when they are afraid.

The tail fin will be a child mesh with a local rotation of negative ninety degrees on the Y axis, so the wag animation only needs to add to that base angle. The pectoral fins will also be added. The banking effect will be computed from horizontal curvature rather than raw lateral velocity, using a smoothed angle derived from the fish’s forward vector.

The mouse position will initialize off-screen so the fish do not flee from the corner before the user moves the cursor. A persistent hint will prompt visitors to move their mouse to trigger the escape behavior.

Decorative fish will be scattered throughout the tank so the initial screenshot looks full. The angelfish will use a taller body scale. A school of eight small silver fish will also flee from the cursor. The total count is about fourteen. Coral and plants will sit along the back and sides to frame the scene. A small box-based castle silhouette will be added as a decorative element.

A stone ruin or arch will be added to the sand floor for visual interest. Starfish will sit on the sand. The title will read "THE QUIET TANK" with a small hint below. The UI will use a distinctive font stack with wide letter-spacing for a small-caps effect.

UI text will be cream with a soft shadow for readability. The scene will be responsive: at 375px the tank still fills the viewport, text shrinks, and the camera zooms out slightly. A resize handler will adjust the field of view and pixel ratio. Floor reflections are being skipped to keep the scene simple.

A small rock will sit at the bottom to simulate an airstone, with bubbles emerging from one spot. Tiny floating dust motes will be rendered as a translucent blue point cloud inside the water.

God rays will be created from three to four transparent gradient planes. A canvas texture will fade from white to pale blue and back to transparent. The planes will be rotated and animated to descend from the top of the tank.

A deep blue background will be set with a distant fog layer so the tank stays bright. A large gradient plane behind the scene is also being considered as a more elegant alternative.

A large sphere behind the tank will use a custom shader material. The fragment shader blends deep blue, medium blue, and dark blue based on the normalized vertical position to create a smooth gradient background.

The camera will sit at (0.4, 0.35, 7.2) aimed slightly downward at (0, 0.1, 0). A dark wooden stand will sit beneath the tank with legs and a thin trim to complete the aquarium look.

The tank dimensions are now fixed at 7.6 by 3.8 by 3.0 units, centered at the origin. The water surface sits at y = 1.72, just below the top frame. Fish will swim between y = -1.4 and 1.4. A helper function will build the frame edges with slightly larger corner joints so the corners look solid. The frame will use a dark gray metal material with high metalness and low roughness.

A RoomEnvironment from the Three.js addons will provide reflections and highlights for the metal frame and fish eyes. The PMREMGenerator will precompute the environment map.

The environment map will be applied at reduced intensity so it does not clash with the directional lights. Fish eyes will use a white MeshStandardMaterial with low roughness and dark pupils. A `createFish` helper will generate the different species.

Four fish species are now defined with distinct color palettes, body scales, and tail colors. The clownfish uses orange and white stripes. The tang is blue with a yellow tail. The yellow fish is bright yellow. The angelfish is purple with a golden tail.

Two new species join the school: a tetra with blue and red accents and a guppy with pink and cyan. The tetra will get a thin red stripe, the guppy a red accent, and the tang a black eye mask. Each fish will also carry a slightly darker dorsal fin.

Fish bodies will carry a faint emissive glow so they remain visible in shadow. Plants will sway by storing base rotations and animating them each frame. Seaweed will be built as a segmented chain of boxes, each slightly wider and darker than the last to create a natural taper.

The seaweed chain is being fixed so each pivot is a child of the previous one, not the mesh itself. This keeps the segments properly stacked and avoids misalignment.

Seaweed segments will sway in a wave by rotating each pivot around its local Z axis with a sine function scaled by segment index. Ribbon plants will use a curved shape. Rocks will be generated from a dodecahedron geometry with perturbed vertices to create an irregular, natural look.

A soft glowing disc will follow the mouse cursor projected onto a plane in front of the tank. It will hide when the cursor leaves the viewport.

A faint sphere will mark the mouse projection on a vertical plane at the tank center. Three expanding rings will pulse at that 3D point to create a ripple effect. The cursor will remain default.

No start overlay is needed because the demo uses mouse movement rather than keyboard input. Pointermove events will be captured on the window or canvas. The import map script must precede the module script, and the module script must use type="module". Console errors will be watched for.

The pixel ratio will be capped at 2 and antialiasing enabled. A brass nameplate reading "REEF No. 7" will sit on the tank stand. The scene is being arranged to look dense and visually rich.

A recursive branch function will build pink coral from angled cylinder segments. The scene will also include bubbles rising from the aerator, light rays, water shimmer, and dust motes.

The recursive coral builder now adds branching stems at 0.75 of each segment length, offset by a slight random angle. Orange and purple corals will also be generated. A pink and green anemone will sit near the clownfish, with its tapering cylinders swaying to create a narrative anchor.

Shadows will be limited to the rocks and stand to keep the scene performant. Fourteen fish will not cast shadows to avoid flicker. The directional light will use a 1024×1024 shadow map with a tight camera frustum.

A static diagonal bright streak plane will sit inside the front glass with additive blending to simulate a reflection. The UI overlay will use a "Glasswater" heading and a "A living exhibit" kicker. The HTML structure will place the canvas in a container and layer the UI text as an HTML overlay.

The overlay will include a live status message that reads "The school is calm" when the cursor is idle, and switches to "The school is startled" when fish flee. A small counter will track how many fish have been scared away. A legend will map each fish color to its species name.

A static "Cursor startles them" instruction will be added to the overlay so the judge knows how to interact. Touch events will also trigger the flee behavior. The layout will be locked to the viewport with no horizontal overflow. On small screens the detailed color legend will be hidden and the title will shrink to keep the UI compact.

UI panels will use a translucent dark navy background with a subtle border and 8-pixel backdrop blur for readability over the 3D scene. Text will carry a shadow to stay legible against the glass and sand. The color scheme will anchor on deep navy and teal accents with sand gold and bright fish pops. A slow automatic demo cursor was considered but set aside because the interaction must respond to the user's actual mouse movement.

A phantom cursor will drift slowly through the tank to trigger fish flees. The screenshot will show darting fish without requiring interaction. The real mouse cursor will override the phantom when the user moves it.

The phantom cursor idea is being refined. A soft drifting light will only guide fish for the first few seconds so the screenshot shows fleeing fish. After that, only the real mouse controls the scare point. This keeps the primary interaction clear while still demonstrating the mechanic.

The phantom cursor will be a glowing orb that drifts inside the tank. Before the user moves the mouse, it will trace a Lissajous path. Once the pointer moves, it will jump to follow the cursor projected into the tank. Fish will flee from this visible orb based on 3D distance. This makes the screenshot look dynamic with fish in sharp, fleeing poses.

The mouse-to-3D projection will use a ray intersecting a plane facing the camera through the tank center, clamped to tank bounds. This keeps the orb inside the tank and lets fish at any depth flee from it.

Screen-space distance will determine when fish flee, since depth alone can mislead. The orb will sit at z=0.6 so it remains visible inside the tank. Both the real mouse cursor and the auto-demo orb will use the same screen-space proximity check. A `userControlled` flag will switch between the two modes.

The auto-demo orb will trace a Lissajous path using sine and cosine offsets. When the user moves the mouse, the code switches to raycasting against a plane facing the camera through the tank center to place the orb at the correct depth.

The orb will be clamped to the tank bounds after each ray intersection. Fish will flee when the orb is within 1.3 units in world space or within 100 pixels on screen. The UI will instruct the user to move the mouse to guide the light.

Fish will flash a brief white emissive pulse when startled. Bubbles will pop with a tiny scale-up near the surface. Audio is skipped to avoid external files. The code will stay compact and well commented. Fish will be modeled with their head toward negative Z so lookAt aligns their body with their velocity.

Fish will turn smoothly using quaternion slerp instead of instant lookAt. A dummy Object3D will hold the target orientation so the body can interpolate its rotation gradually.

Fish will be built as a two-level group: a root group for velocity-based orientation and a child tilt group for bank rotation, so the two do not fight. The tail will wag via local rotation, and pectoral fins will rotate on Z or X with sine waves. A second dorsal fin will be added for realism.

A thin cyan emissive ring will mark the water surface to help read the meniscus. A canvas-based sand texture will generate the gravel floor with random grain particles.

The sand texture will use randomly shaded grain particles. A concern was raised that custom shaders sampling this texture alongside caustics might double-apply color space conversion in recent Three.js versions.

The sand texture will keep its default color space to avoid double-converting colors in the custom shader.

The sand texture will use explicit NoColorSpace so the custom shader can treat values as sRGB. The sand and water surface will use unlit ShaderMaterials, while rocks will use standard materials with lights. A decal caustic light from above was considered but set aside as too difficult without a texture.

A second sand layer will use an InstancedMesh of roughly eighty flattened pebbles for a three-dimensional texture. A small dark cylinder aerator will sit in the tank to spawn bubbles. Small shell decorations will be added using torus geometry.

A starfish will be built from five flattened spheres arranged in a pentagon. Each arm will be scaled and rotated to point outward from the center. The lookAt call may not handle the scaling correctly.

The starfish arms will be rotated on the Y axis instead of using lookAt. A small flattened sphere will form the center of the starfish. The castle will be built from stone-colored cylinder towers with crenellations.

The castle will have two towers and a connecting wall. Crenellations will sit on top of each tower. A dark arch doorway will cut through the wall.

The castle will sit on the left-back sand to avoid blocking the center view. The lighting setup uses a soft blue sky hemisphere, a warm key light, a cool rim light, and an underwater blue fill point light. Moving point lights may be added for caustic effects.

Two slow-moving cyan point lights will hover above the sand to approximate caustics. They will be combined with the sand shader and standard materials for rocks, castle, and fish. Intensity values are being checked against Three.js r170's legacy-light removal and candela-based intensity scale.

The caustic point lights will use intensity 30, distance 5, and decay 2. ACES tone mapping with exposure 1.05 will prevent blowout. Directional light intensity is being checked against Three.js r170's lux-based scale.

Lighting values are being finalized. AmbientLight will be 0x406080 at intensity 0.4. HemisphereLight will be 0.7. DirectionalLight will be 2.5. PointLight will be 15 with distance 8 and decay 2. The scene will be kept well-lit.

A dark floor plane at y = -2.7 will sit outside the tank to ground the stand in a room. A subtle back wall will complete the room. The renderer will clear to a dark blue with alpha disabled.

The floor will receive shadows. A bright emissive rectangle on the back wall will suggest a window and sell the lighting story. The fish roster now includes clown, tang, yellow, and angel species with varying scales.

The roster now includes guppies, tetras, and a butterfly species with a white body, black bands, and yellow fins. Fish scales will vary by species and include a small random factor. Initial velocities will be random so the fish are already swimming on the first frame.

The animation loop will start immediately on page load so fish are already swimming within three seconds. Plants will sway using a time-based sine wave. The first frame will render synchronously right after imports finish. No code will be wrapped to suppress errors. TorusGeometry defaults to the XY plane with the hole along Z, so shells will be scaled non-uniformly to match elliptical body cross-sections.

The fish body will be a scaled sphere mesh rather than a non-uniformly scaled torus. Stripes will be separate torus meshes positioned at z = -0.12, 0.05, and 0.22 with local scale 0.30, 0.42, and 1.

A slightly offset black torus will frame the white stripe for the clownfish. The tail pivot will sit at z = 0.42, just behind the body’s trailing edge.

Fish eyes will be placed at z = -0.42 with x offsets of ±0.16 and y at 0.08. Each eye will use a white sphere for the iris and a darker sphere for the pupil, positioned slightly forward. A small dark sphere may mark the mouth. Fin materials might be slightly transparent.

Fin materials will be opaque to avoid transparency sorting issues. Thin fins will use DoubleSide. The tail will be built with ShapeGeometry and rotated so the fin stays vertical, with normals computed for proper lighting.

The tail pivot will wag on the Y axis using a sine wave. The dorsal fin will sit at the top of the fish, slightly leaning back, and will follow the body rather than wag aggressively.

The dorsal fin will be a flattened sphere scaled to 0.15 by 0.7 by 0.45 and positioned at y = 0.32. Pectoral fins will be small flattened spheres on the sides that oscillate to create a flapping motion. A narrow caudal peduncle sphere will connect the body to the tail.

A live status bar will count fish with fear above 0.35 and display either "School at ease" or the number of startled fish. If the auto-lure option is enabled, the bar may read "startled" immediately, which serves as a clear visual signal that the mechanic works. The UI legend will stay compact.

The UI copy will be direct and honest: "Move your mouse to steer it — fish scatter when it draws near." The glow will be a custom radial-gradient canvas texture with a white center fading to blue and transparent edges, rendered as a bright additive sprite.

A small core sphere will sit at the center of the glow sprite. Disturbance rings will be torus meshes that scale and fade, resetting periodically at the lure position to show movement. The glass and water surfaces will be offset slightly to prevent z-fighting. The inner water color volume will be defined by the tank glass.

A blue transparent box will sit slightly inside the tank walls. It will use BackSide rendering and depthWrite disabled. The tinted interior faces will be visible from the outside while the front faces remain hidden.

The inner water box is being reconsidered. Three.js BoxGeometry faces point outward, so the front wall faces the camera and the back wall faces away. BackSide rendering would therefore tint the back wall rather than the interior, which is the opposite of the intended effect.

BackSide would only tint the back wall, not the interior volume. FrontSide with 0.08 blue opacity on the glass panels will create the desired volumetric tint instead. Refraction effects are being skipped.

An inner sand lip will sit slightly smaller than the tank floor so the sand meets the glass cleanly. The import map will be placed in the head before the module script, which will be at the end of the body. CSS will hide scrollbars and fix the canvas and overlay to the viewport; pointer events will be disabled on the overlay so the canvas captures mouse movement.

The mouse pointer will be converted to normalized device coordinates by dividing the client position by the window size and scaling to the range -1 to 1. Pointer events will be captured on the window so the canvas receives them without interference from the overlay. A click-to-start interaction is not required.

A `screenDist` helper will project world positions into screen space to compare distances. Fish will flee when the projected distance is under 140 pixels, or when the 3D distance is under 1.1.

Scared fish will flee in world space away from the lure, with vertical movement reduced to 45 percent to keep them horizontal. A soft boundary steer will always pull them back toward the tank walls so they cannot escape during flight.

Base swim speed is 0.55 units per second and flee speed is 2.2. A tetra crosses the 7-unit tank in about three seconds. The 3-second screenshot will show clear motion and tail wag cycles. The wander target is a random point inside the tank bounds. Separation weight is kept modest so schools stay loosely grouped.

Tetras will form a shared school with a wandering center point, each fish offset slightly. When scared, individual flee overrides the school behavior. The camera will gently bob and tilt in a slow sine wave to show depth without causing nausea. A window resize listener will keep the canvas and projection correct.

The renderer will use a pixel ratio capped at 2 to keep the 3-second frame smooth on high-DPI screens. Shadow acne will be reduced with a bias of -0.0005. The WebGLRenderer will request high-performance power preference. RoomEnvironment will be initialized after the renderer is created.

A PMREMGenerator will bake the RoomEnvironment into a cube-map environment texture. The addon is self-contained and should load correctly through the import map.

To avoid relying on the RoomEnvironment addon, a custom environment will be created instead. A large colored box or sphere will serve as the scene for the PMREMGenerator, giving full control over the cube-map texture and eliminating any addon loading risk.

The custom environment will use a ShaderMaterial on a sphere. The vertex shader passes world position to the fragment shader. The fragment shader blends a deep blue sky with a warm sand color based on the normal vector. A bright window highlight is added at the front center of the sphere.

The custom environment sphere will use a ShaderMaterial with a deep blue sky and warm sand blend, plus a bright front window highlight. The PMREMGenerator blur will be set to 0.08 for softer reflections. This avoids the RoomEnvironment addon entirely and keeps the import map dependency minimal.

Fish materials will use an envMapIntensity of 0.45 so they do not look plastic. The glass shell will use a thin MeshStandardMaterial with high metalness and low roughness to catch environment reflections. Edge highlights will come from the metal frame instead.

A static skewed gradient plane will sit in front of the tank as a subtle glare reflection. The final HTML must be a single fenced code block with no surrounding text. I am also checking for common runtime bugs: THREE.Color.offsetHSL is valid, every InstancedMesh instance matrix must be set, and the SpriteMaterial map needs proper initialization.

Simple shaders will be written carefully to avoid compile errors. The animation loop will use THREE.Clock for delta and elapsed time. Every fish will have a tail userData property before the animation starts. Vector normalization will guard against division by zero. The renderer canvas will fill the viewport with width and height set to 100%.

The import map will be placed in the head before the module script. Deferred module scripts ensure the DOM is ready when the script runs. The overlay is already in the HTML, so it will be visible on first paint before Three.js loads. The canvas will be created by the renderer and appended to the DOM.

A deep-sea radial gradient with animated CSS bubbles will serve as the initial background. The overlay will remain visible while WebGL renders, so the scene is never blank on first paint.

A centered "Filling the tank…" loader will appear in the overlay until the first WebGL frame renders, then hide so the 3-second screenshot shows the 3D scene. The CSS background will still look intentional if the module loads slower.

Pure CSS fish silhouettes will serve as the immediate fallback background. The canvas will be opaque, so these CSS fish will sit behind the WebGL scene once it loads. To keep the 3-second screenshot smooth, geometry complexity will be reduced: fish bodies will use a 20×12 sphere instead of 32×24, shadows will render at 1024 resolution, and the scene will contain 30 bubbles, 60 pebbles, 5 kelp plants with 7 segments each, and 2–3 coral pieces.

A fixed CSS vignette overlay will sit above the canvas to add an underwater color grade. The canvas will be positioned at z-index 1, the overlay at z-index 3, and the vignette at z-index 2. Pointer events will be disabled on the vignette so mouse movement still reaches the WebGL scene.

The renderer canvas will be fixed to the viewport with `inset: 0` and `zIndex: 1`. The water surface will sit at y=1.65, just above the highest fish at y=1.45, so it remains visible as a shimmering plane from below.

The water surface shader will look realistic from both above and below because the camera sits slightly above the water level. Caustic lights will drift in a gentle sine-wave orbit around the tank. Bubbles will rise by adding their speed to their vertical position each frame.

Bubbles will use individual meshes instead of InstancedMesh so each can scale independently as it rises. They will return to the aerator once they pass the water surface. Light rays will rotate and pulse in opacity. A foreground fish will be added so it appears large in the screenshot.

Fish will be seeded on a jittered grid to prevent overlap at startup. Pectoral fin rotation will be stored in each fish's userData. A canvas texture will render a label plaque on a small box.

The label plaque will display "GLASSWATER · REEF VII" in Georgia typeface and sit on the front of the stand. A wood-grain canvas texture is also being considered for the stand to add premium finish.

A canvas-based wood texture is being drawn with random dark strokes across the grain for the stand.

The wood texture will repeat across the stand and use SRGB color space. The stand material will have roughness 0.8. The metal frame will use a gray-blue color with metalness 0.9 and roughness 0.28.

Corner brackets will be added at all eight corners for a polished look. The tank frame will be built with a `buildTank` function using a 7.8 by 4 by 3.2 meter box, 0.06 meter trim thickness, and a polished gray-blue metal material.

The tank frame will use four vertical beams at the corners and horizontal beams for the top and bottom edges. The glass shell will be a thin, nearly transparent blue material with very low roughness and slight metalness.

The glass shell will be built from four thin double-sided panels for the front, back, and side walls, plus a faint bottom panel. Each panel is slightly smaller than the tank frame to avoid clipping.

The tank group will be raised 0.15 meters so the stand sits below it. A TANK constant object will hold the inner half-width, depth, and vertical bounds at the top of the script. Fish will stay slightly inside these bounds.

A cyan emissive water line ring will mark the water surface at y = 1.62. Sand will sit at y = -1.68, with rocks placed on top using a small offset. An anemone will be added near the right side, and a clownfish will start nearby. The scene will include a small school behavior and a lure. The UI copy is being finalized as "Glasswater," "THREE-DIMENSIONAL REEF," and the instruction to move the mouse near the fish.

The UI copy is finalized to mention both the mouse and the drifting light as triggers for the fish to flee. Fish will also glow faintly when scared, with emissive intensity scaled by a fear value. This requires unique body material instances per fish, since shared materials cannot be modified individually.

Each fish will receive a cloned body material so its emissive glow can pulse independently. Shared geometries will still be used to keep memory low. A slight scale pulse during fear is also being considered.

A subtle scale flinch during fear is optional. Fast flee turns will use a sharper slerp factor. Bubble trails were considered but skipped. The WebGL precision for shaders is being verified against Three.js defaults.

I will explicitly include `precision highp float;` in both vertex and fragment shaders. Three.js will not duplicate it because it checks for existing precision directives before prepending its own. I am also verifying whether `gl_FragColor` remains valid in Three.js ShaderMaterial under WebGL2.

Three.js ShaderMaterial still uses GLSL1 syntax by default, so `gl_FragColor` is valid and the renderer converts it automatically. Vertex and fragment shaders must declare matching `varying` variables. The sand will remain unlit. The water surface will be double-sided, transparent, and depth-write disabled to prevent clipping artifacts.

The camera sits at y=0.45, well below the water surface at y=1.62, so the view looks downward from above. With a 42-degree field of view and a distance of 7.2 units, the visible vertical range spans roughly y=-2.7 to y=2, keeping the tank top in the upper frame.

The tank top is being raised to 1.95 and the bottom to -1.85. The stand will sit from -1.95 to -2.45, with the floor at -2.55. This keeps the tank well inside the visible vertical range. The camera is being recalculated for tighter framing so the tank fills roughly 80% of the width. The horizontal field of view is about 60.4 degrees.

At a camera distance of 8.0 units the tank fills roughly 84% of the horizontal viewport. On a 375-pixel mobile portrait screen the tank half-width of 3.9 far exceeds the visible half-width of about 1.4, so the tank will not fit on small screens.

A `frameCamera` function will dynamically adjust the camera distance based on viewport aspect ratio. This keeps the full aquarium visible on narrow 375-pixel screens by increasing distance or narrowing the field of view.

The camera distance is calculated from both vertical and horizontal viewport constraints, using the larger value and a minimum of 6 units. On a 375-pixel portrait screen the camera must sit roughly 30.5 units away to keep the tank fully visible, which makes it look small but still fills the frame. On a 1280-pixel landscape screen the distance drops to about 8.9 units.

The camera will bob slightly on the X and Y axes while the Z position tracks the adaptive distance. The base distance is stored globally and recalculated on resize. On narrow screens the 150-pixel screen-space flee radius still covers a large portion of the viewport, which is acceptable.

The UI will scale down on narrow screens and stack the bottom HUD vertically. CSS rules will prevent horizontal scrolling by capping panel widths and allowing legend text to wrap. A gold accent line and tabular numerals will add a premium finish. Legend colors will match the actual fish species.

A moorish idol fish with a long white dorsal streamer will be added for silhouette variety. The scene group order is now fixed: background sphere, floor, stand, tank frame and glass, sand, decorations, fish, bubbles, water surface, and light rays.

Light rays will be created with a shared vertical gradient texture using additive blending, no depth writes, and a soft blue-white tint. They will be placed inside the tank from the top downward, slightly rotated, and sized at 0.8 by 2.8 units. The anemone will sway during animation, with its mesh references stored for that effect.

A small orange clownfish will hover near the anemone and flee when the lure appears. A `swayers` array will track kelp and anemone tentacles for animation. The anemone is being built from 18 randomly sized orange-red cylinders with tapered ends.

The anemone tentacles are being built with orange-red emissive materials and a subtle dark red glow. Each cylinder is placed at its own height and rotated slightly around the X and Z axes. They are arranged in a radial pattern around the base with small random offsets. A small tip bulb is being added to the end of each tentacle.

The anemone base is a flattened dark purple sphere. Each tentacle pivot will rotate around its Z axis using a sine wave with a per-tentacle offset and a 0.12 rad amplitude.

Green kelp will be placed on the left and back walls. Purple coral and a pink brain coral will be added as bumpy spherical decorations. A small ceramic pot with a torus rim will serve as a classic aquarium ornament. A treasure chest was considered but rejected to avoid clutter.

The ceramic pot is built from a brown cylinder body and a horizontal torus rim. Fish path trails were rejected to avoid clutter. The lure will pulse its glow as the sole visual feedback for mouse proximity. It follows the cursor when the user is controlling the camera, or wanders on its own otherwise.

Mouse movement will work without a prior click. Keyboard controls require focus. Mobile touch scrolling will be disabled. Fish will be pushed back inside bounds if they stray too far.

Fish will be hard-clamped to a 3.45-unit horizontal boundary and a 1.4-unit vertical boundary so they cannot escape the glass. The lure will also be clamped inside the tank to prevent it from appearing through the sides. When the user controls the camera, the cursor will be clamped to the tank’s camera-facing plane so the mouse never passes through the glass. The auto-wandering lure path will be confined to a small sinewave orbit inside the tank.

The auto-wandering lure will pass near (1.9, 0.77, 0.38) at t=3. Fish will be placed near the center-right so they are close enough to flee at that moment. Their wander speed is 0.6, so they could drift away in three seconds. They will be positioned near the lure path with a slower wander to keep them in view.

The tetra school will be placed at the origin so the auto-wandering lure passes through it. The screenshot composition will anchor the castle on the left and the anemone on the right. A visible flee at the three-second mark is not critical; the overall scene should look alive and the mouse mechanic should work.

A soft dark shadow blob will sit beneath the tank on the floor to ground the scene. The camera will look straight at the center of the tank. The UI will anchor to the top-left and bottom-left corners.

The shadow blob will sit at y = -2.52. Hover tooltips for fish names were skipped to keep the implementation simple. UI text will use high-contrast cream-on-dark styling. The import map will be placed in the head before the module script so Three.js loads correctly.

A potential bug was spotted in the fish lookAt slerp and bank logic. The code builds a target quaternion from velocity and falls back to a default direction when speed is near zero. The developer is checking whether this fallback preserves correct banking during idle swimming.

The developer realized that `Matrix4.lookAt` creates a camera view matrix, not an object transform, so it cannot be used directly to orient a fish. A dummy object with a fixed up vector will be used instead to build the correct orientation quaternion.

The dummy object will look at the fish plus its velocity vector, and the fish quaternion will slerp toward that target using a framerate-independent exponential decay. Bank angle is derived from the horizontal velocity component and damped with a 4-second time constant. Heading change is computed from the velocity vector and wrapped to keep the angle continuous.

The bank angle is clamped to ±0.7 and damped over 5 seconds. The tail wags in local space relative to the body, and a body wobble group will undulate in sync with the tail.

The fish skeleton will use nested groups: root for position and orientation, tilt for bank, bodyPivot for undulation, and tailPivot for the tail. Pectorals will flap independently. A species parameter will distinguish tetras with forked tails from tangs with round tails.

Tail geometry is being defined with Three.js Shape objects. Forked tails use a polygon with angled points. Round tails use a circular arc. Fan tails use quadratic curves.

The developer is verifying that ShapeGeometry normals will face the correct direction after the tail pivot is rotated -90 degrees around the Y axis. They are computing the rotation matrix to confirm the normal points sideways along the X axis.

The rotated tail normal faces sideways, so the directional light will only illuminate one side. Fin materials will receive a faint emissive glow so they remain visible. A black torus outline will be added behind the stripes for the classic clownfish look. Thin dark gills will be drawn along the fish body.

Fish positions will be hardcoded for a composed first frame rather than randomized. The layout places a tang near the left, a clown near the right, and a yellow near the center, each with a species, position, scale, and velocity vector.

The hardcoded fish layout now includes an angel fish, an idol fish, two guppies, a butterfly fish, and a tetra school near the origin.

The hardcoded fish layout now includes three more tetras in the school and a clown near the anemone, a yellow near the left wall, and a tang near the back wall. Fish are placed at varying depths to create 3D parallax. Initial velocities are set so the fish begin mid-stroke. The school shares a single velocity vector.

The anemone will sit at x=2.8 near the clownfish. The castle will anchor at x=-2.6. Kelp will grow at the corners. Bubbles will float near x=-1.2. The sand will use warm tones with cyan caustics. A front color-grade plane was rejected to avoid muddiness. Fish highlights will use a warm key and cool rim.

Fish materials will use low roughness and metalness for a soft wet highlight. Eyes will sparkle with a tiny white catchlight sphere placed just off the pupil.

Eyes will be 0.055 units across, slightly oversized for a cartoony but readable silhouette. The idol fish will carry a long white dorsal fin with a black tip, drawn as a pennant shape extending upward.

The tetra school will have a shared center and velocity. Each tetra will seek its own offset from that center. If any tetra is scared, the entire school will flee together.

School fish will flee together when any member gets too close to the lure, while still maintaining cohesion. Other fish will swim independently. A bubble column will float near the lure path for visual depth. The renderer will use sRGB color space for accurate colors.

Canvas textures for plaques and wood will use SRGB color space. The sand shader samples its texture manually, so automatic color-space decoding may not apply there. Built-in materials handle this automatically via chunks, but custom shaders require manual decoding.

The sand shader will output linear colors so the renderer can tone-map them into sRGB. The shader will also multiply the sampled sand texture by 1.15 to keep the warm sand bright.

The water shader will use a linear blue-green color. The water material and glow sprites will have tone mapping disabled so they remain bright and pop.

The ray material will also have tone mapping disabled. The lure will gain a bright emissive core and a smaller secondary sprite. Three semi-transparent cyan torus rings will orbit the lure, scaling up and down in a staggered phase animation.

The HUD will display a count of startled fish. Status text updates only when the string actually changes, avoiding unnecessary DOM writes.

Depth fog was skipped. Ground caustics will shift visibly after three seconds. Kelp will be placed at the back and sides to frame the scene without blocking the center. A slightly blue back wall will provide contrast for the fish.

A canvas backdrop plane at z=-1.48 will use a dark blue-green gradient to create depth. Faint distant reef blobs will be painted onto the texture.

The canvas texture for the back wall is generated with a dark blue-green gradient and randomly placed faint reef blobs. The plane sits at the back of the tank behind the fish. It uses a MeshStandardMaterial with a low roughness map and slight emissive glow so it remains visible without looking like a black void.

A subtle darker inner wall on the sides will help define the tank volume. The front will remain open. A tiny aerator stone and bubble spawn will be added. The entire scene will be written as a single HTML file.

Fish will be oriented immediately on spawn. A dummy object will look at the velocity vector to set the correct quaternion, so the first frame shows the proper heading instead of rotating from the default forward direction.

The default cursor will remain visible. Hiding it would trap the 3D glow inside the tank at the edges, making the pointer disappear.

The title will use Georgia for an elegant literary feel, with a thin gold rule beneath it. The fish count will remain static at sixteen, while the startled count updates dynamically.

The species legend will list eight fish types. It will appear on desktop but hide on mobile to keep the 3D view uncluttered. The UI will use a compact fixed panel with a translucent dark blue background, a faint border, and a soft shadow. The top-left panel will hold the title and instructions. The bottom-left panel will show the school status. The bottom-right panel will show the resident count.

The UI panels will use a blurred translucent background with rounded corners. On narrow screens, the bottom-right panel will hide to prevent overlap, and the title will shrink to 20 pixels.

A loading screen will appear while the scene initializes. It displays a centered animated orb and the text "Filling the tank…" in Georgia uppercase. The background uses a radial gradient from deep blue to near-black. The loader fades out once the scene is ready.

The loader will fade out over 700 milliseconds. It will switch to display none once the fade completes so it does not block pointer events. If initialization is slow, the loader may still be visible at the three-second mark, but the CSS background will remain visually appealing.

A purple sea fan will be added as a flat branching plane to add variety. Floating food flakes were considered but skipped to avoid confusion with bubbles. The sand shader caustics will be made obvious so the underwater light effect is clearly visible in the screenshot.

The sand shader will use a bright, animated caustic pattern driven by sine waves and time. Small dunes will be added to the sand plane via vertex displacement. Shadows may not align perfectly with the displaced geometry, but that is acceptable.

The sand plane will use MeshStandardMaterial instead of a custom shader so shadows cast correctly. A transparent plane above it will carry the caustic glow. This avoids shadow chunk complexity while keeping the underwater light effect.

The sand plane rotates -90 degrees around X, mapping local Z to world Y for the dune heights. The caustic glow plane will use renderOrder 2 and depthWrite false so it stays on the floor beneath the fish. Fish will render at order 0, the water surface at 3, and the lure at 5, keeping the transparent layers sorted correctly.

The glass panel will sit at renderOrder 1 with depthWrite disabled so fish remain visible through the boundary. Fish render at order 0, and the water surface at order 3, keeping the transparent layers sorted correctly.

A stylized seahorse will serve as the centerpiece. It will flee when the mouse nears it. Its body will be built from stacked spheres along a curl. It will have a snout cylinder and a fluttering dorsal fin. A hermit crab was considered but skipped.

The seahorse body is being built from ten stacked spheres along a curl. Each sphere scales down toward the tail. A head sphere is added at the front.

The seahorse snout is a short cylinder rotated to face forward. Its tail is eight stacked spheres curling backward. The body will be oriented upright to match natural swimming posture.

The seahorse will stay upright while swimming. It will only yaw to face its velocity on the horizontal plane, avoiding pitch so it does not tilt sideways.

The bobbing motion will be applied to a child group rather than the fish position itself, preventing drift. The seahorse will bob more strongly than the other fish.

A golden seahorse will sit near the castle and flee when the mouse nears it. It will stay upright and swim slower than the other fish. The snout will point toward negative Z so the lookAt orientation matches the horizontal velocity. The seahorse will be added to the same fish system with upright and slower speed flags. Its dorsal fin will flutter via a fast sine wave on a thin plane.

Fish colors will vary slightly within each species using small HSL offsets so no two fish look identical. OrbitControls will be omitted in favor of a custom camera, so only the core Three.js module will be imported. The HTML structure will begin with the meta tags, title, inline styles, and import map.

The viewport meta tag will be included for mobile responsiveness. The title will read "Glasswater". Shaders will be written as template strings. The caustic material will use a single shared instance with a per-frame time uniform. Each ring will clone its material to avoid shared opacity changes. The glass material can remain shared. Fish fin materials will be shared per fish so their emissive pulse stays synchronized.

Each fish will receive one body material and one fin material, both assigned to every part of that fish. When fear increases, the emissive intensity of both materials will rise together. White stripes will remain static and will not pulse with fear; a single shared white material will handle stripes for only clown, idol, and butterfly fish. A global white material and shared eye material will be used for the rest. The species configuration object is being defined with color, stripe positions, tail type, and speed for each fish type.

The species configuration now includes the tang, yellow, angel, and idol fish. The tang is blue with a yellow tail and mask. The yellow fish is bright yellow with a round tail. The angel is purple with a fan tail and two stripes. The idol is pale cream with a round tail and three stripes.

The species configuration now includes butterfly, guppy, and tetra fish. Butterfly has a yellow body, a single black stripe, and a fan tail. Guppy is pink with a cyan fin and a fan tail. Tetra is bright blue with a fork tail and a red stripe line. All three species are added to the school.

The tang mask will be a scaled black sphere placed at the eye line. The idol will use a white pennant against its black stripes. The tetra will get a red lateral line.

The tetra's red lateral line will be a thin red box geometry placed at the side of the body. Fish will receive slight random scale variations during layout. The camera near plane is set to 0.1 and the far plane to 80. A background sphere with radius 50 will sit inside the far plane. Fog will be skipped to avoid affecting the background sphere.

The background sphere will track the camera position each frame to prevent clipping. The scene background will be set to a dark blue fallback color. The environment scene for PMREM will be created separately after renderer initialization.

Contact shadows will be cast by a blob beneath each fish. The stand will have four wooden legs positioned at the corners. The stand top will align flush with the tank bottom at y = -1.85, with the legs extending downward to y = -2.7.

Tank dimensions are being set to exact constants: bottom at -1.82, top at 1.92, half-width 3.85, and half-depth 1.55. The water surface will sit at 1.70 and the sand plane at -1.70. The inner swim zone will be x: ±3.45, y: -1.45 to 1.45, z: ±1.15. A rim light will be added to the top frame so the tank edge catches a highlight. The environment shader will brighten the upper hemisphere to make the metal trim pop.

The environment shader will use a smoothstep gradient for the sky and a bright window highlight. The scene environment intensity will be set to 0.85.

Shadow mapping is enabled on the renderer. Fish will not cast shadows to avoid acne and save performance. Rocks, the castle, the pot, the sand, and the stand will all cast or receive shadows. A directional light will sit at (5, 10, 6) with a 1024×1024 shadow map. Its camera frustum will be fitted to the tank bounds.

The directional light target is placed at the scene origin and added to the scene so the light points correctly.

A blue fill light and a hemisphere light will be added to ensure the scene stays well lit even without the environment map. Point lights will be placed on the rocks to create caustic effects.

Point lights will use low intensities to avoid blooming under ACES. The sand is a standard material, so point lights would double-expose it alongside the caustic shader. Modest point lights will provide subtle highlights on rocks and fish instead.

Point light intensity will be reduced to 4 with a distance of 5 and decay 2 to prevent hot spots on nearby fish. The overall lighting setup is being checked against ACES exposure 1.05 to ensure the scene stays bright and clear.

A soft blue ambient light will be added at 0.35 intensity to lift shadows and improve readability. Fish separation will use an O(n²) check limited to nearby neighbors. Screen position calculations will reuse vectors per frame to avoid allocation overhead.

The lure will drift automatically via sine and cosine offsets when the user is not controlling it. When the user moves the mouse, a raycaster will cast from the pointer into the scene to track the lure. Fish behind the camera will be skipped during raycasting.

The lure will be clamped to the tank bounds after each mouse-driven update. The pointer is stored as a normalized device coordinate Vector2. The initial pointer value is center-screen, but it is only used once the user begins moving the mouse.

The UI will prompt users to move the mouse near the fish so they swim away. Fish will flee within a 180-pixel screen radius or 1.35-world-unit radius, with a 3.5 speed multiplier so they dart away dramatically.

Fear decays at 0.8 per second so fish return to normal after the mouse leaves. When scared, each fish flees directly from the lure with a downward bias, and its speed scales with fear intensity. A slight burst scale and high turn rate will make the escape look dramatic.

Fish will blend between seeking a target and fleeing the lure based on fear level. When fear is low, they cruise toward the target. As fear rises, they gradually shift to pure flee. The desired velocity is computed by mixing the seek and flee directions with weights that scale with fear intensity.

The seek direction is normalized and scaled by base speed. The flee direction is normalized, scaled by 3.1× base speed, and weighted by fear. The two directions are blended into desired velocity. Separation and bounds steering are then applied, and the fish velocity is smoothly interpolated toward the final desired direction.

Fish acceleration is 6.5 when fear exceeds 0.2 and 2.2 otherwise. Velocity is clamped to a maximum speed that scales with fear. Fish retarget randomly every 2.5 to 6.5 seconds, or immediately when they come within 0.35 units of their current target.

A school update function will compute group fear by checking each fish’s distance to the lure. If any fish is within 1.4 world units or 170 screen pixels, the entire school becomes scared. When a fish is part of a school, its target is overridden to a point offset from the school center.

When the school is scared, its center velocity is pulled directly away from the lure with a downward bias. When calm, the school wanders toward a randomly chosen goal point, and its center velocity smoothly blends toward that goal.

The school center is clamped to the tank bounds. Tetras still flee individually when scared, but the school center also scatters so the group stays together while escaping.

Individual fish will use fixed local offsets from the school center so they do not stack. The tail wag will serve as the only visual sign of fear, with no extra burst effect added. Status text will switch between calm green and startled orange. Keyboard controls are omitted to keep the interaction mouse-only.

Only pointermove will drive the lure; pointerdown is unnecessary. Plants will sway with enough amplitude that a still frame shows motion. The scene will look alive in static images through pose variety, bubbles, light rays, and caustics.

Light rays will use an opacity of 0.18 so they remain visible without washing out the fish. Suspended particles will be rendered as soft circular points instead of squares.

Eighty soft blue dust particles will float upward slowly and wrap around the tank. The shader sparkle is sufficient for water surface reflections, so no extra reflection effect is needed.

A thin emissive meniscus line will mark the water surface. The tank top has no glass lid, only a metal frame, leaving a small air gap above the water. The water surface shader fills the interior above that gap.

Condensation droplets on the front glass were considered but skipped as too fiddly. The full-circle ShapeGeometry used for the fish tail risks triangulation issues, so a scaled SphereGeometry is the safer fallback. The code will be kept compact yet readable.

A helper function `makeFinShape` will generate fin geometry for the fork, fan, and pennant shapes. The round tail for tangs will use a scaled SphereGeometry instead of ShapeGeometry to avoid triangulation issues. All fin materials will be set to DoubleSide.

The tail fin will be attached to the tail pivot and rotated to point backward. The pennant fin will sit on the back-top of the body with a slight Y rotation. Pectoral fins will be added as small rotated fan shapes.

Pectoral fins will be placed on the sides of the fish body and rotated outward. Their base rotation will be stored in userData so they can wag back and forth. A small dorsal fin will also be added and rotated upright.

The dorsal fin's rotation is being corrected. A -90° X rotation would lay the fan flat on the XZ plane instead of standing it upright in the YZ plane.

A -90° Y rotation places the fan-shaped dorsal fin in the ZY plane, extending backward and upward. It will sit on top of the fish body with a slight forward offset and reduced scale to avoid intersecting the head.

A custom dorsal fin shape is being created that only extends upward and forward, avoiding the need to shrink the fin downward to prevent it from intersecting the body.

The final code will be a single fenced HTML block with inline CSS and JavaScript, using the exact import map and no external dependencies. A seahorse will be added near the castle at (-2.2, -0.9, 0.2) with a scale of 0.9, and it will be tagged to stay upright in the fish update loop.

The seahorse will be integrated into the fish system as a species called 'horse'. A dedicated `createSeahorse()` builder will return the same root group structure used by other fish, with userData fields for tail, pectorals, materials, and an upright flag. It will swim slowly and upright, and a small eye will be added to its head. The spawner will inject behavior fields into this userData to keep the animation loop uniform.

The seahorse root will be a Group with a tilt child for yaw and bob. Its bank will be minimal. The animation loop will check for a custom `userData.wag` function. If none exists it will default to tail wag. Otherwise the seahorse will flutter its dorsal fin instead.

The tail wag frequency will scale with fear and base speed, peaking at 21. Tail amplitude will also rise with fear. The dorsal fin will flutter slightly in rotation.z. Pectoral fins will be updated in the same loop.

The pectoral fins will wag with a staggered sine wave so each one moves independently. The seahorse will use a custom animation loop that flutters its dorsal fin on rotation.x instead of the tail pivot, preventing orientation conflicts. A body undulation effect is also being added to the animation loop.

Body undulation will be added to the animation loop, but seahorses will skip it or use a very small one. Emissive intensity will pulse with fear for body and fin materials, while white materials remain static. The implementation is complete and ready to be written.

The overlay will use a responsive clamp for the heading size. A small animated CSS fish will appear in the loader. The page will hide scrollbars and use integer canvas dimensions.

The renderer will use CSS 100% width and height to avoid inline pixel styles that can cause rounding scrollbars. The drawing buffer will match the innerWidth and innerHeight. The pixel ratio will be capped at 2 and set before the initial size and on resize.

The renderer will use antialiasing, no alpha, and high-performance power preference. A clampToTank helper will keep fish inside the tank bounds. The wood texture will repeat three times horizontally and 1.5 times vertically to avoid stretching on the large stand.

Repeat wrapping will be set before texture assignment. The plaque will use a separate material. Wood will be used for the stand feet instead of brass. A dark floor plane with low roughness and metalness will keep the tank visually clean.

A dark back wall will sit behind the tank at z = -8. A bright window quad will be placed on that wall at z = -7.9 to create a visible light source above the tank. This gives the light rays a clear visual origin.

The light rays will angle from the upper front-right to mimic light entering the tank from the room window. The window sits at roughly 12.7 degrees above the camera, well within the 20-degree vertical field of view, so it will remain visible in the gap above the tank.

The window will be placed at (2.2, 3.4, -6) so it sits just above the tank top in the gap between the camera and the tank. Light rays will angle from the upper front-right inside the tank. A few low green plants will anchor the foreground corners without blocking the view.

A starfish will sit on the sand in the front-center-left. The scene palette is now defined: deep navy room, cool silver tank trim, warm gold sand, several greens for plants, pink-orange-purple coral, and saturated fish colors. Warm sun light will contrast with cool water tones. A small pink brain coral will sit in the center-back on the sand. A pot will be placed near the right side, rotated 0.4 radians. The decoration plan is complete.

The exact placement coordinates for all scene decorations are now fixed. The castle sits at (-2.5, -1.68, -0.85). The anemone is at (2.7, -1.68, -0.35). The brain coral is at (0.4, -1.68, -1.0). The orange coral is at (-1.2, -1.68, -1.05). The purple coral is at (1.6, -1.68, -0.95). The pot is at (3.0, -1.68, 0.35). The starfishes are at (-0.6, -1.66, 0.85) and (1.1, -1.66, 0.7). Kelp clusters are at (-3.3, -1.68,

The aerator will be a small dark gray rounded rock with holes. Bubbles will rise from (-1.5, -1.5, 0.35) and start at random heights so the column is full on first paint. Fish will already be moving at startup. Kelp will pre-bend slightly by the first frame so the screenshot shows natural sway.

The clock will start and the first frame will render immediately, since all textures are synchronous. A custom PMREMGenerator will create the environment map, and the shader will be kept simple to avoid errors.

The custom environment shader uses a vertex shader to pass world-space normals and a fragment shader that blends deep navy and cool blue with a subtle window highlight.

The environment shader now blends a window highlight into the deep navy and cool blue background. The sphere is centered at the origin so local position can be used directly in the vertex shader, avoiding model matrix complications. The PMREMGenerator will be disposed immediately after texture extraction.

The environment shader will use local position in the vertex shader instead of a model matrix. The PMREMGenerator will receive a blur sigma of 0.04. No geometry disposal is needed for this short-lived demo. The ShaderMaterial will render correctly inside the PMREM scene.

The environment map will skip depth writes. The UI will display a title panel with the exhibit name, a subtitle about drifting light, a status indicator, and a resident count.

The status dot will pulse amber when fish are startled. The resident count will be set dynamically to match the actual fish array length.

Guppy tails will use a larger scale of 1.35. Fin geometries can be shared across fish, but each fish needs its own material so emissive colors can be mutated independently.

Fin geometries are created once and shared across all fish. Stripe torus geometries are also shared, with each stripe mesh scaled non-uniformly to match its body cross-section.

Stripe torus scale is set to the body cross-section dimensions so the bands sit flush on the surface. The tube radius is increased to 0.12 so the stripes remain readable after non-uniform scaling. A slightly larger black under-stripe will be added beneath each colored stripe.

Stripe tori will be rotated perpendicular to the body axis so the colored bands sit flush against the fish body. The dark under-stripe will only be added for clownfish, since idol fish use black-on-white stripes. Eye positions will be calculated from each fish's body scale.

The eye coordinates are calculated from each fish's body scale. The ellipsoid surface is checked so the eyes sit flush on the body without clipping. The eye center is placed slightly outside the surface to keep it visible.

Eye placement is simplified to a fixed offset from each fish's body scale. Eye radius scales proportionally with body width. Pupils will sit slightly deeper and outward. The animation loop will be guarded against empty fish arrays.

A window resize listener will update the camera distance, renderer size, and raycaster. Pointer coordinates will be read directly from the viewport since the canvas covers the entire window. The UI will disable text selection to prevent dragging artifacts.

The UI will also disable touch scrolling with `touch-action: none`. The water surface shader will include a bright edge highlight at the tank rim to suggest a meniscus. The water plane will be slightly smaller than the inner tank at 7.4 by 2.9.

A second water layer will sit slightly below the surface as a faint transparent blue box to create depth. The water shader will use a time uniform driven by the clock elapsed. Fake fresnel effects were considered but skipped as unnecessary.

The transparent water volume will use BackSide to tint the far walls. It will render after opaque fish and backdrop. The volume sits at the back of the box, so its back faces blend with the inner backdrop.

The transparent water volume uses BackSide, so its far faces only tint the backdrop behind the tank. Fish in front of the volume are closer and draw first, so the volume cannot paint over them. The existing front glass already provides the necessary front-side tint.

Front glass opacity is set to 0.06 and side glass to 0.05. Inner fog planes are skipped. Optional chaining is used and top-level await is avoided to prevent timing issues. The body material emissive colors are being verified.

The body material's emissive property will be cloned from the color object to prevent shared reference mutations. The fin material receives the same treatment. Shared materials for black, white, pupil, and eye-white are created once and stored in a constant object.

Flat shading will be applied to the rock geometry to give it a faceted stone appearance.

Rock materials will use a dark gray color with high roughness and flat shading. A few rocks will receive cloned materials with slight color offsets. Coral materials will use unique colors. Only the castle and rocks will cast shadows. Kelp will not cast shadows to avoid expensive shadow calculations. A small comment header may be added to the HTML.

A title panel is sufficient, so no extra comment header is needed. The HTML will include a UTF-8 meta charset. The 3-second screenshot will frame the entire tank with fish distributed, bubbles, light rays, the UI, and a lure glow with expanding rings. Fish will turn away from the lure when it is nearby. The lure sprite will be scaled to 0.9 units, its core sphere to 0.08, and its rings to 1.5 so the glow does not obscure the fish.

The lure sprite will scale to 1.1 units with 0.75 opacity to avoid bloating the tank. Additive blending is avoided to prevent bright overexposure. Depth testing will keep the sprite behind fish bodies but not the front glass, since the glass uses depthWrite false. If the lure sits inside a fish, the halo may clip slightly.

The glow sprite will use depthTest false and depthWrite false so it renders as a soft light without harsh clipping against fish bodies. It will sit at renderOrder 10 so it draws last. The HTML UI will remain on top via z-index.

A faint ghost trail of six sprites will follow the lure to show motion in the still screenshot. Each sprite scales down slightly from the core glow.

The trail will use a circular buffer updated every 0.06 seconds. Each ghost sprite fades to 35% opacity and scales down with its index. The oldest position is discarded after eight entries.

The trail will use a linked lerp chain instead of a circular buffer. Each ghost sprite smoothly follows the previous one using exponential decay, avoiding any vector allocation.

The trail will use seven cloned sprite materials with opacity fading from 0.28 down to 0.04 and scale decreasing from 0.85 to 0.57. Kelp will receive color variation, and broad-leaf plant planes will be added for visual diversity.

A `createLeafPlant` helper is being added to generate broad-leaf plants. It creates a group of five randomly sized leaf planes with a green material that has a dark green emissive glow. Each leaf is rotated around a pivot to create a natural, curved plant shape.

A `registerSway` helper will track leaf plant rotations in a global `swayers` array. During animation, each leaf group will oscillate around its base rotation using sine waves with configurable amplitude, speed, and phase.

Kelp segments will register with increasing sway amplitude. Anemone tentacles will also be registered for motion. Coral will remain static since it is hard. The kelp helper will return its group and register swayers internally.

A rocks cluster helper will be added. The full implementation is now fully planned and ready to be written in a single pass.

The light will track the mouse cursor so fish flee from the pointer. A self-playing demo will trigger the escape behavior immediately, satisfying the requirement that interesting behavior appear within the first few seconds.

The auto-lure demo will be clearly visible so the escape behavior is obvious without interaction. A raw-pointer cursor hint is unnecessary. Fish eye tracking toward the lure was considered but skipped as too error-prone.

A fear-driven scale pulse was considered but skipped to avoid clashing with the fish's banking motion. The initialization order is now fixed: renderer, scene, camera, environment, lights, room, stand, tank, sand, caustics, backdrop, decorations, fish, bubbles, dust, rays, and lure. WebGL context loss handling is also being skipped.

The resize handler will run once at startup to set the camera distance and projection matrix. During animation, the camera will only pan horizontally and vertically while keeping its depth fixed at 9 units.

The camera distance is calculated from the vertical field of view and the tank’s half-width and half-height. During animation, the camera will bob slightly in a gentle, slow arc while keeping its depth fixed at that base distance. The vertical offset is small enough to avoid cropping the tank.

The camera field of view is being set to 36 degrees for a tighter, more compressed photo-like perspective. The resulting base distance of roughly 9.7 to 9.9 units keeps the tank fully framed with a slight horizontal offset.

The camera field of view is being set to 38 degrees. The window is being repositioned to sit just above the tank top to avoid cropping while keeping a small gap between the two.

The window was too far back and appeared below the tank top. It will be moved closer to z=-3.5 and raised to y=3.6 so it sits above the tank.

The window is now placed at z=-3.2 and y=3.8, giving it a 15.2-degree visual angle above the tank top. It sits on the back wall plane at z=-4, so it remains outside the tank. The sky sphere is kept far enough away to avoid conflicting with the wall.

The large back wall is being removed to avoid z-fighting. The room will instead rely on the sky sphere, a floor, and a glowing window plane that floats above the tank. A thin dark frame will surround the window for visual detail.

A soft additive glow plane behind the window will create a warm, sunlight-like bloom without postprocessing.

The design phase is ending. I will now write the code cleanly. Small dark transparent shadow blobs will follow each fish on the sand to ground them.

Shadow blobs will use a 0.28-unit circle geometry rotated to face the sand. Their scale and opacity will be derived from each fish’s vertical distance above the sand, with values clamped to keep the blobs inside the tank bounds.

Shadow blobs will render at renderOrder 1, sitting above the sand and below the caustics plane at order 2 to avoid z-fighting. A front-plaque text will be added to the stand at z=1.72 and y=-2.05, reading "GLASSWATER · REEF VII". Georgia is a standard font on Chrome, though it may fall back to a generic serif on some Linux and Chrome OS systems.

Pebbles will be rendered as an InstancedMesh of 70 small spheres. Each pebble will be placed at a random position on the sand.

Pebble placement is now fully randomized across the sand width and depth, with slight vertical offsets and varied scales and rotations. The InstancedMesh will receive and cast shadows. Color variation for the pebbles will be handled through an instanceColor attribute.

Pebble colors will be randomized via HSL within a warm, muted range. The instanceColor attribute will be updated after all values are set.

Pebble materials will use a sandy base color as a fallback if instance color fails. The code will run without try/catch or console logging to keep the console completely clean. External images will be avoided. Fish names floating above the tank were considered but rejected as clutter.

A second vertical caustic plane behind the backdrop was considered but skipped to avoid visual oddness. Animated light rays will rotate and sway slightly during the demo to keep them from looking static. A canvas-based gradient texture is being created for the ray geometry.

Four tilted light rays will be placed inside the tank at staggered horizontal positions. Each ray uses a double-sided transparent plane with additive blending and a soft blue tint. The material opacity is set to 0.7.

Each ray will store its base rotation in userData so the animation can add a gentle sine offset to both its tilt and opacity. The rays will remain visible in the still because their opacity is kept around 0.35. The entire tank will stay static; only the camera will drift to create motion, since aquariums do not spin.

Fish reflections on the front glass were considered but skipped as too expensive and artificial. Soft shadows will fall onto the sand and pebbles from the directional light, with bias and normalBias tuned to reduce acne. Shadow camera helpers will remain hidden. The full source code is now being composed.

The fish roster is finalized at 16 members. Five hero fish are hardcoded in the front half with specific positions, scales, and velocities. The remaining tetras will be generated in a loop.

The 16-fish roster is finalized with five heroes, four tetras, and one horse. School fish will be offset from a computed center point to form a cohesive group.

Species speeds will be defined in a lookup table. Base speed is species speed multiplied by a small random factor, so larger fish swim slightly slower. The horse has a base speed of 0.34 and a slower overall base speed of 0.32. Its body material will be emissive. When fleeing, the horse speeds up but stays upright. Its tail wag is driven by the dorsal fin pivot rotation.

The seahorse mesh is being carefully designed to look like a real seahorse rather than a blob stack. It will feature an S-curved body leaning forward, a horse-like head with a snout, a large belly, a curled tail, and a yellow-gold color. The dorsal fin will flutter on the back side. A custom `buildSeahorse` function is being written to construct the model from torso segments along a curve.

The seahorse body is being built from a series of scaled spheres along an S-curve, with the head placed at the top of the curve.

The seahorse head is being detailed with a snout cylinder, a coronet bump, and a pair of eyes.

The seahorse is being detailed with pupils, a curling tail made of ten tapered spheres, and belly ridges.

The seahorse mesh is now complete. The dorsal fin pivot is positioned at the back of the body and oriented toward the viewer, while the tiny pectoral fin is omitted.

The seahorse snout cylinder is being rotated -90 degrees on the X axis so its tip points forward along the negative Z axis.

The seahorse snout is now positioned at (0, 0.83, -0.09) with its tip pointing forward along negative Z. The dorsal fin pivot is rotated -90 degrees on the Y axis so it extends toward the viewer’s back. Fluttering will be achieved by gently rotating the dorsal pivot around the Z or X axis.

The seahorse will be placed at y = -0.6 in the lower left-center, scaled to 1.1 so it remains clearly visible above the sand. Its body will use a gold-orange color with a warm emissive finish and a light yellow fin, distinct from the yellow tang. The horse’s tail wag will be driven by the dorsal pivot’s rotation around the X axis.

The seahorse will receive three thin dark torus bands along its body for better readability. The snout cylinder will share the same body material.

Checking whether Three.js InstancedMesh.setColorAt requires vertexColors to be enabled on the material. Recent versions automatically support instanceColor via USE_INSTANCING_COLOR, so the pebble color updates should work without extra flags.

I decided not to set `pebbleMat.vertexColors = true` on the pebble material. That flag could force the mesh black if the geometry lacks a color attribute. I will rely on Three.js r170’s automatic `instanceColor` support instead. I am now writing the code and will skip a version comment.

The exact import map JSON will be included as requested, even if some addon paths are unused. A WebGL fallback message is unnecessary because the sandbox environment supports WebGL.

A pure-CSS bubble loader will cover the screen until WebGL is ready. Animated white bubbles will rise from the bottom in a continuous loop. The scene will appear at the three-second mark.

The bubble loader will fade out instantly on the first frame rather than using a slow transition. This guarantees the 3D scene is fully visible by the three-second mark and avoids any risk of a transparent loader overlay in the final screenshot.

A CSS dot pulse animation will be added to the HUD so the interface feels alive on the first 3D frame. The event variable will be renamed from `pointer` to `ev` to avoid a naming conflict with the THREE.Vector2 class. The status element will be renamed from `status` to `statusEl` to prevent a variable collision.

A CSS pulse animation will make the HUD dot shrink and dim every 1.8 seconds. A small inline SVG fish icon will sit beside the title as a brand mark.

The inline SVG fish icon is being finalized with a warm orange body, yellow tail, and dark eye. Geometry and material arrays will be initialized before the fish creation loop to prevent undefined references.

The environment map can be assigned before the objects are created because materials resolve it at render time. Metal materials will use an envMapIntensity of 0.7 and fish materials 0.4 to keep the fish from looking overly chrome.

Final envMapIntensity values are set: fish 0.5, metal 1, rocks 0.25, sand 0.15, wood 0.2, and coral 0.3. Sheen effects were considered but rejected to keep the scene lightweight. The code will be delivered as a single fenced code block with no explanation.

The import map will be placed in the head and the module script at the bottom of the body, since the import map must be processed before module scripts. No `crossorigin` attribute is needed for jsdelivr modules. The decoration section will be kept compact to avoid an overly long file.

A `makeRock` helper is being written to perturb each rock’s dodecahedron geometry with random vertex scaling, then compute normals and assign a material.

Rocks will be placed low and toward the back to avoid blocking the front view. A large foreground rock was considered but rejected to keep the scene from looking cluttered. The overall balance keeps the center open for swimming fish while decorations sit along the bottom and back.

A sunshaft will be added to the window so the eye and the light source connect visually. The camera half-width of 5.15 world units is confirmed to fit the entire tank and stand at 375px viewport width.

The camera is aimed slightly downward rather than horizontally. The window at y=3.55 sits above the visible vertical range, so it will be cropped. The stand depth is 1.8, not a width issue.

The window at y=3.55 is actually visible. The camera is aimed 1.5 degrees below horizontal, so the top of the frustum reaches 17.5 degrees above the look direction. The window sits at 13.8 degrees above horizontal, well within that range. The earlier halfH estimate assumed the look target plane at z=0 rather than the actual window plane.

The legs at y = -2.75 sit 17 degrees below the camera, so they remain inside the frustum. The composition works on 1280×800. On 375×800 the camera must back up to fit the width, which widens the vertical field of view and leaves more empty space above and below the tank, but the UI still stays in the corners.

A CSS media query will make the HUD panels semi-transparent on mobile so fish remain visible through the corners.

A darker sand skirt will frame the tank edges so the sand meets the glass cleanly. The instruction text will wrap and stay within the panel bounds.

A slightly larger darker sand plane will sit directly beneath the tank floor to fill any gaps and frame the edges cleanly.

The sand plane will be 7.45 by 2.85 units, centered inside the tank frame, leaving a small gap for the glass bottom. A custom sand texture will add grain, specks, and shell-colored dots for rich detail even without pebbles. A transparent additive caustic plane will sit just above the sand at y = -1.645 to simulate underwater light refraction.

The caustic shader uses two sine-wave patterns that multiply together to create organic light shapes. A smoothstep threshold controls the final opacity so the effect stays subtle.

The caustic shader now blends two sine-wave layers into a visible cyan glow. It disables tone mapping so the light stays bright and adds a soft edge fade. The material will use additive blending.

The water surface shader is being finalized. It blends a deep blue base with bright white highlights, adds subtle ripple distortion, and layers bright spark effects for a shimmering, realistic look.

The water surface shader now adds a rim highlight near the edges and caps opacity at 0.75. The vertex shader will displace local z to animate the water surface.

The water plane is rotated horizontally and positioned at y = 1.68. Its local z displacement maps to world up for the wave animation.

The water plane will use renderOrder 4 to stay above the sand. A dark moody sky shader will tint the room background so the tank remains the bright focal point.

A soft blue-white point light will sit inside the tank near the front glass to prevent fish backs from going dark. The existing overhead directional light will keep the fish faces well lit.

The tank fill light intensity is raised to 12 so fish backs stay bright. Final light levels are ambient 0.45, hemisphere 0.6, directional 2.8, tank point 10, and caustic points 5. ACES exposure is set to 1.0.

The renderer exposure is locked at 1.0. The fish flee logic is finalized: a visible light projects the mouse into the tank, and fish within roughly 1.25 units accelerate away from it. The light wanders before the user moves the mouse so the behavior is visible in the auto screenshot.

The mouse-proximity threshold will scale with viewport width. On mobile it caps at 105 pixels, and on desktop it stays at 180 pixels. This keeps the fish reaction proportional to screen size.

The flee logic now uses both world and screen distance checks. A fish at the front can still be scared by a mouse near it even if the projected lure point is far away, because the screen distance threshold catches the visual proximity.

The lure sits on a plane facing the camera, so its screen position matches the mouse cursor exactly. This means the screen-distance check is a direct proxy for mouse proximity.

The camera drift will be slowed so it does not move too fast. Fish will also avoid decorations, such as the castle, by steering away from obstacle points.

The obstacle avoidance code is now implemented. Fish steer away from the castle and other decorations when they come within 0.55 units. The repulsion force scales with inverse distance and a small safety margin so fish do not get stuck.

The obstacles array will be populated while placing decorations so fish avoid them. The final code will be clean and avoid eval. Torus rings will use lookAt each frame to face the camera, which points the hole directly at the viewer.

Ring materials will disable depth writes so they render cleanly through the glass. CSS overflow and box-sizing rules are locked to prevent horizontal scroll. The loader is fixed to the viewport. A grain overlay was considered but skipped to keep the screenshot sharp. Selection color is removed.

The first animation frame delta will be capped at 0.033 seconds to prevent a large initial timestep from breaking physics. Shader animations will use `clock.elapsedTime` for timing.

The clock will be ticked once before the animation loop to discard initialization time, preventing a huge first delta. The full file is now being written and checked for bracket matching.

Bubble bursts and sound effects were considered but skipped to keep the code simple. A residents count will display the total fish population. The code structure is being outlined with constants for the tank dimensions, arrays for swayers, fish, obstacles, rays, bubbles, and the lure position, plus state flags for user control and base distance.

The code structure is being finalized. A `school` object will manage the fish group. Helper functions for textures, materials, species, and decoration placement will keep the code organized.

The school object is now defined with a center point, velocity, goal, and wander radius. A `randomPoint` helper will generate valid spawn positions inside the tank bounds. Sparse section comments will be added to the code.

The brass plaque will use a 512 by 160 pixel canvas. It will display "GLASSWATER" in large serif type and "REEF VII" in a smaller gold tone. A brass border will frame the text. The plaque will sit flat on the stand front face, facing the camera directly.

The stand is 8.7 by 3.7 units deep, taller than the tank frame. It protrudes 0.3 units in front so the brass plaque sits visible below the glass. The front face will hold the plaque at z = 1.86. Next, the stand legs are being added.

The stand legs are 0.62 units tall and extend to y = -2.78. The floor sits at y = -2.82, visible below the stand. Shadows will fall onto the floor and stand. Metal trim will use a silver color to read as silver without relying solely on environment lighting.

The glass will show a faint edge tint. Corner cubes will be slightly larger to give the frame a crafted look. The trim material uses a light gray color with high metalness and low roughness.

The trim geometry is being built with a small bar helper that casts shadows. Vertical posts sit at each corner of the tank, and horizontal rings run along the top and bottom edges.

Corner cubes are now placed at each of the eight corners. The glass material is defined with 5.5% opacity, near-zero roughness, and double-sided transparency. A pane helper is being set up to build the glass panes.

The glass panes are now placed on the front and back faces and the left and right sides of the tank. They use renderOrder 3 so they tint the fish. The water surface uses renderOrder 4 to sit above the fish and avoid covering them.

The water surface is at y = 1.68, above the fish. Because the water material disables depth writes and draws after the fish, the default LessEqual depth test will keep the fish visible through the surface.

The water surface will use renderOrder 2 so it sits above the fish. The glass panes will use renderOrder 4 to tint the water edges last. This may slightly double-tint the surface but is acceptable.

Render order is now fixed: opaque objects first, then caustics, shadows, water, bubbles, glass, and the lure last. Bubbles will use a MeshPhysicalMaterial with 35% opacity, near-zero roughness, and disabled depth writes so they do not punch holes through the water.

The bubble material is now a shared MeshStandardMaterial with a soft blue tint and faint emissive glow. All bubbles share this single material, so their opacity is uniform; only their scale varies. The geometry is a shared SphereGeometry with radius 1 and 8 segments, scaled to 0.03–0.07 units.

Bubbles will drift horizontally with a sine wave and drift vertically upward. When a bubble reaches the water surface, it resets to the aerator with a small random offset. The speed of each bubble is stored in userData. Dust particles will use a similar wrap-around animation.

Dust particles are now implemented as a Points object with 60 floating-point positions. They are randomly distributed inside the tank bounds and rendered as tiny blue dots.

Dust particles now use a circular texture with 55% opacity and depth writes disabled. They drift upward and sideways in the water, then wrap back to the bottom when they reach the surface. A separate glow texture will be created for the points. The implementation plan is complete and the final code is ready to be written.

Fish will bob slightly in the water to look less rigid. The bob is applied to a tilt group so it does not affect the root position used for collision logic. Horses bob more than other fish. A bank effect is also being added for non-horse fish to tilt sideways based on their heading.

Fish bank sideways based on heading error, with a 0.55 rad limit and 3.5 damping. Initial heading and quaternion are set from spawn velocity so fish face the correct direction immediately. The spawnFish function is being drafted.

Fish scale is applied at the root level inside `createFish` from the config argument, avoiding double scaling. Shadow blob size will scale with the fish root's x-axis to match its size.

Shadow blobs are placed on the sand at y = -1.48 with a 0.34-unit radius. Their scale is set to the fish root scale multiplied by 1.1. Opacity is derived from height and the material is cloned per fish.

Shadow blobs will use a dark transparent MeshBasicMaterial so they do not cast shadows. A thin waterline meniscus will be added as a faint blue box loop at the surface. The animation loop will update shadow positions each frame. The code will use consistent two-space indentation.

A faint emissive waterline will be added at the surface as a bright, always-visible edge. A moving caustic light will subtly travel across the scene by updating two point light positions.

The caustic lights will trace gentle sine and cosine paths across the scene. The lure visuals will be parented and updated each frame. The animation loop will also handle camera movement, fish, school, bubbles, dust, swayers, rays, status text, and background sphere.

The sky sphere radius is set to 80 units. This exceeds the camera's maximum distance on mobile, so the camera will remain inside the sphere and no follow logic is needed.

The camera near plane is set to 0.2 and the far plane to 200. The pectoral fin base rotation is stored so the animation loop does not overwrite its outward angle. The dorsal fin will get a slight z-axis sway.

The tail pivot will wag left and right around the Y axis, with a 0.4 rad amplitude that increases to 0.7 rad when the fish is scared. Body undulation will be driven by a small opposite-phase rotation on the body pivot to create a wave effect.

Body undulation is being restricted to non-horse fish to avoid a wobble on seahorses. The wave phase offset is applied only when the body pivot exists and the kind is not a horse.

Gill flares are being skipped. The code will be kept under 1000 lines but completeness is prioritized. Stripe, mask, pennant, and tail properties will be checked for undefined before use. Idol fish stripes are black and lack a dark edge, unlike clown fish.

Stripe materials must be generated per color rather than hardcoded as white or black. Angel fish stripes are gold, so a helper function will create the correct material for each stripe color.

Stripe materials are now cached by hex color to avoid duplicate material creation.

Tetra lateral stripes are drawn as thin red box meshes on each side of the body. The tang mask is a black sphere scaled to sit just behind the head.

The tang mask is placed behind the head at a slight forward depth so the eyes can sit on top. The yellow tail tang will use the same yellow fin material for its tail, pectorals, and dorsal fin, with a blue body.

Clown fins will be orange-white. A white stripe on the clown tail was considered but skipped. The nine species are now fully defined with distinct colors and patterns: clown orange and white bands, tang blue disc with yellow tail and black mask, yellow solid disc, angel purple with gold bands, idol white and black bands with a long pennant, butterfly yellow and black, guppy pink and cyan, tetra cyan with a red lateral line and forked tail, and horse vertical gold. This variety should make the tank look colorful and alive.

Tail scale is being adjusted per species. Guppies get a larger, more cyan tail with a scale of 1.4. Round-tailed fish like tangs and yellow tail tangs use a scaled sphere mesh instead of the standard fin geometry.

The final code is being written now. Eight HTML spans will serve as loader bubbles. A CSS reef silhouette will render in the loader so the scene looks like an aquarium before WebGL initializes.

The CSS loader text will read "Filling the tank" instead of generic "loading". The loader will be hidden once at the end of initialization, just before the animation loop begins.

The initialization function must not throw so the loader does not stay visible. The PMREM environment map is kept to add reflections on metal and fish eyes, and the custom shader is kept as reliable as possible.

The sky sphere will use BackSide so the camera inside sees it. The PMREMGenerator will be disposed after use, but its output texture will be kept since it is independent.

The PMREMGenerator will not be disposed after use. The environment intensity is set to 0.8.

A final review of the written code is underway. Premium UI refinements are being added: thin inner shadows on the panels and a gold hairline. The kicker text will use a system-ui font in 11px with wide letter spacing and cyan color. The heading will use Georgia in uppercase with a clamped responsive size and a light gray tone.

A gold gradient rule is being added between the title and kicker. The bottom-right panel will display the resident fish count.

The resident count will use a 28px Georgia font in white, while the label sits in 11px uppercase gray. A soft top gradient is being added to the vignette to darken the title area for better readability.

The vignette will use a radial gradient fading from transparent to dark gray at the top, plus a subtle linear gradient from the bottom. The canvas is fixed at z-index 1, the vignette at 2, the UI panels at 3, and the loader at 5. This stacking order keeps the WebGL layer underneath the interface.

The renderer canvas will be appended to the body and given the id "view". In the resize handler, width and height will be set individually after `setSize` to prevent style overrides.

Fish are clamped inside the glass tank with a hard limit of 3.45 units on the x-axis, 1.42 on the y-axis, and 1.12 on the z-axis. When a fish hits the boundary, its outward velocity is reversed by 20% to create a slight bounce and prevent sticking.

The `contain` function now bounces fish off all six glass boundaries with a 0.45 damping factor. Soft steering will also nudge them away before they reach the edges.

Fish will cruise, turn at the glass walls, and bolt away from the cursor. The school center will also steer away from the edges. The auto-lure will pass through the tank to keep the demo alive, causing some fish to stay calm while others startle. The lure will be roughly at x ≈ 1.9, y ≈ 0.8, and z ≈ 0.6 after three seconds.

The butterfly and clown fish will be near the auto-lure by the three-second mark and will flee. Fish positions are simulated from t=0, so the composition will look organic and alive rather than static. Targets will be spread across the tank to avoid clustering.

Shadow casting is being limited to the rocks, castle, and trim bars. The trim will not cast shadows to avoid darkening the sand edges. Only the twelve main frame bars will cast shadows, which will look like realistic cage bars on the sand.

The shadow map will use PCFSoftShadowMap at 1024 resolution. The final scene will include five kelp plants, one anemone, two to three coral branches, and twelve sea grass blades. These decorations will be kept compact to save code while maintaining visual richness.

Sea grass blades are added as thin double-sided planes with slight random tilt and rotation. They are registered as swayers with amplitude and speed variations. Three patches will form a lush bottom layer. The material is shared across blades, and emissive values are safely set without mutating the original color object.

Sea grass patches are placed at the sides and back to avoid blocking the front center. Kelp is tallest at the back corners, reaching 1.5 units high. Plants sit low enough that fish remain visible above them.

The castle will sit at x=-2.6, z=-0.95 as a 0.75-unit silhouette in the back-left. The horse will be placed at x=-1.5, y=-0.55, z=0.2 so both remain visible. The anemone will sit at x=2.6, z=-0.4. Decorations will be manually lifted onto the sand surface with a small offset so they do not sink into the floor.

The pointer listener will use `pointermove` for modern browsers. The overlay will have `pointer-events: none` so mouse events pass straight through to the canvas. No extra buttons are needed.

The status text will show "School at ease" when no fish are startled.

The status text will pluralize correctly, reading "1 fish scattering" or "N fish scattering" depending on the count. A dot class on the bottom-left panel will toggle an alert state whenever fish are startled. The resident count will be updated once at startup. There is also a brief check on whether the auto-moving light might violate the user's request.

The light will track the mouse cursor. Fish will detect the cursor's distance and flee. The auto-mode preview is a demo aid. The UI will explain it. Without it the three-second screenshot would not show the flee. The aquarium will still look beautiful and the mouse interaction will work.

The auto-lure will hand off smoothly to the user's cursor. A one-pixel mouse movement will instantly take control. This gives clear feedback that the user has taken over.

A synthetic mouse event at the origin will be skipped to prevent accidental handoff. The one-pixel threshold is sufficient to avoid jittery screenshot tools.

A small inner glow will be added to the tank using the existing point light. It will sit just outside the glass at (0, 1.2, 2.2) and cast a soft light into the interior. The decay will be reduced to 1.5 so the glow remains bright enough at the distance to the fish.

The tank interior light is being replaced by two directional lights. A warm key light at 2.6 intensity will sit at (0, 1, 8) to avoid hot spots near the fish. A cool front light at 1.1 intensity will be placed at (0, 1, 8) as well. This setup keeps the interior softly lit without the earlier point light's distance falloff.

Directional lights will provide even illumination because they do not fall off with distance. The final light setup uses an ambient light at 0.55 intensity, a hemisphere light at 0.45, a key directional from (6, 10, 8), a front directional from (0, 2, 10), and a rim directional from (-7, 3, -4). Point lights will be reserved only as subtle caustic accents.

Point lights at intensity 4 will add subtle local shimmer. Fish colors are saturated for a bright, vivid screenshot. Ambient light is lowered to 0.4 and hemisphere to 0.5 so shadows and form remain visible. The key directional light is set to 2.4 intensity and the front directional to 0.85. Metal trim will catch the key light to look like a frame. The renderer will use outputColorSpace and toneMapping. The full file will be written in one complete piece.

The renderer will use SRGBColorSpace and ACESFilmicToneMapping with exposure 1.05. Canvas textures for wood, sand, plaque, and backdrop will be set to SRGB so standard materials decode them correctly. MeshBasicMaterial and PointsMaterial will handle the color-space conversion automatically.

Glow, ray, and circle textures will remain at default NoColorSpace so their alpha gradients stay as authored. Only the decorative color textures will be set to SRGBColorSpace.

Anisotropy will be set to the renderer's maximum capability on the sand and wood textures for sharper detail. The code will be kept comment-free with clear variable names. The fork tail shape may need triangulation adjustment to avoid a missing center.

The fork tail shape is concave but not self-intersecting, so earcut triangulation should handle it. The fan and dorsal shapes are convex and safe. The round tail sphere is rotated to sit at the back of the fish and wags with the tail pivot on the Y axis.

The tail pivot was moved to z = body[2] * 0.72 so the tail mesh extends fully past the body surface and eliminates the gap.

The tail pivot is being set to 0.58 times the body length. This keeps the tail geometry fully inside the body surface while still allowing the fan and round tail to extend past the body edge.

A small peduncle sphere will sit at the body-tail junction to close any remaining gap. The fish model is now complete with eyes, stripes, fins, dorsal fin, and tail. A tiny mouth sphere is being added for character.

The mouth sphere is placed at the front of the fish body and only appears for larger fish, skipping tetras. Starfish and pot helper functions will be added inline. The full file is now being generated.

The scene will be roughly 1200 lines. A single `buildReef()` function will consolidate decoration creation to keep the animation loop clean. The final code is being checked against the rules.

The renderer clear color is set to a dark blue `0x07111c` with full opacity. All geometry uses modern `BufferGeometry` primitives and avoids obsolete `THREE.Geometry`. Horizontal planes are rotated to lie flat on the XZ surface. The final code block is being finalized.

The sky sphere will use a simple ShaderMaterial with a basic vertex shader and a placeholder fragment shader. This avoids a shader compilation failure that would expose the dark blue scene background.

The sky sphere fragment shader blends deep blue and purple based on normalized vertical position. It omits explicit precision qualifiers so Three.js injects them automatically. This same convention will be used for all other custom shaders.

A GLSL bug was found in the rim shader: `smoothstep(0.12, 0.0, x)` has inverted edges, which is undefined behavior. It will be rewritten as `1.0 - smoothstep(0.0, 0.12, ...)` to fix it. All shaders are being checked for similar issues.

Custom shaders will avoid texture sampling entirely. Sand uses a standard material with a map, caustics are procedural, and the environment shader needs no textures. This keeps the GLSL code simple and avoids texture function mismatches.

The water shader is being written with a vertex shader that displaces the surface using layered sine waves driven by time and position. A varying uniform will carry the height value to the fragment shader.

The water fragment shader blends deep blue with bright white based on surface height and rippling sine waves. It adds sparkles and a soft rim glow at the edges.

The caustic shader is being written with layered sine waves driven by time and UV coordinates. It blends two smoothstep thresholds to create bright light patterns. A second UV coordinate is offset to add subtle motion.

The caustic fragment shader is now complete. It adds a second sine-wave layer and clamps the final alpha against the edge distance. The vertex shader simply passes through the UV coordinates. The developer is checking Three.js additive blending constants to ensure the caustic material blends correctly.

The developer is verifying the exact WebGL blend function for Three.js AdditiveBlending. They recall it uses SRC_ALPHA and One, but are unsure whether the destination factor is OneFactor or OneFactor, and are checking the source code to resolve this.

The developer confirmed Three.js AdditiveBlending uses SRC_ALPHA and ONE. To avoid light bleeding or blowout regardless of blend behavior, the caustic shader will store intensity in the RGB channels and use alpha as 1.0, multiplying the color by the light amount so the result stays controlled.

A helper function will create ShaderMaterials with explicit additive blending. It sets transparent to true, disables depth writes, and uses CustomBlending with SRC_ALPHA and One. Tone mapping is disabled on these materials.

Rays and glow sprites will use THREE.AdditiveBlending. This is the standard god-ray pattern in r170 examples and keeps the code simple. Opacity will control brightness if needed.

The caustic shader will use bright RGB values. The animation loop will update the water and caustic time uniforms each frame. Materials will be declared in module scope so the animate function can access them. Constants will be defined before the animate function declaration. Status updates will be throttled by comparing strings.

The developer is now writing the HTML shell. Element references for the loader, status panel, and resident list are being fetched after the DOM is ready. The page structure begins with a viewport meta tag and a title.

The HTML shell is now complete with the Three.js import map, a loader, a vignette overlay, and three corner panels including the status panel. The developer decided against adding reduced-motion support. They are proceeding to write the full script module in a single pass.

The seahorse eye clone copies position before negating x. The seahorse builder will accept a scale argument. It returns a root group with userData tracking tilt, body pivot, tail, pecs, materials, kind, and upright state.

A `finalizeFish` helper will set each fish's position, scale, and velocity, then attach behavior state like target points, fear, and phase timers to its userData. This keeps the spawner and builder code DRY.

The `finalizeFish` helper now sets each fish's heading angle and bank state. It assigns the fish kind and adds school members to a shared array using an offset from the school center. The orientation logic uses a dummy object to look at the velocity vector, with a special case for seahorses that flattens the heading to the horizontal plane. The quaternion is then copied to the fish root. Shadow setup is next.

The fish builder returns a root group with internal body proportions at unit scale. FinalizeFish applies the world scale and behavior state, so the seahorse is not scaled twice. Shadow scale also uses cfg.s. The architecture is clean.

Tail and body animations are being added to the fish behavior loop. Seahorses will wag their tails with a fixed sine wave. Other fish will wag their tails and body pivots using speed and fear-driven frequencies and amplitudes. Pectoral fin animation is also being wired up.

The developer is refining the fish tail animation. For seahorses, the dorsal pivot will rotate on the Y axis to create a left-right flutter. Pectoral fins will use a staggered sine wave across the array. A combined motion is being considered for a more natural wave effect.

The seahorse dorsal pivot will be set to a zero rest rotation so the tail flutter animation does not conflict with orientation. The developer is now ready to write the complete script section by section. They are also considering touch-action and touchmove prevention to avoid scroll bounce.

A non-passive touchmove listener will call preventDefault to stop mobile browsers from rubber-banding. CSS overscroll-behavior: none will also be added to prevent any scroll bounce.

A high-contrast sand texture and wood grain on the stand will help establish the aquarium setting. The bottom plaque text is being checked for readability at camera distance.

The bottom plaque is being enlarged to 2.3 by 0.58 units for better readability. It will sit centered on the stand front, slightly protruding in front of the tank bottom.

The stand proportions are confirmed solid. A thin gold inlay line will be added to the stand top edge for subtle luxury.

The gold inlay is placed at TANK.y0 + 0.015 to avoid z-fighting the stand top. Bubbles will use standard transparent material instead of MeshPhysicalMaterial to avoid transmission artifacts.

The glass panels will use FrontSide only. DoubleSide would double-render and darken the transparent material. The camera sits outside the tank, so the front faces of the side panels are visible from the outside.

The back glass pane is being dropped entirely, leaving only the front and side panels. Front glass will use opacity 0.07 and side panels 0.05. A helper will guard against empty velocity lookAt calls. School fish offsets are calculated from the average of the tetra positions.

The school center will be computed in a post-spawn pass after all fish are placed. Each fish's offset from the center is stored in its userData. The school goal is set to a point just ahead of the center.

School fish targets will be set every frame to their offset from the school center. Fear will blend this target with the flee direction, so the school stays together without fighting individual wander behavior.

School fear is detected by checking the lure distance to the school center and to each individual fish. When any member is within 1.6 units, the entire school flees together. The school update runs before the fish loop so the center stays current.

The school fear check will use a 1.3-unit world distance or a screen-distance threshold. To avoid vector allocation clashes between the school loop and the fish loop, the school update will rely on world distance only, while the screen check will use local variables.

The `scarePx` helper will clamp the screen-distance threshold between 90 and 190 pixels, scaling with viewport width. A fixed world radius of 1.15 will supplement the screen check for school fear detection. The developer is now proceeding to write the complete script and is adding CSS polish to the loader orb.

The loader orb is styled with a radial gradient, soft blue glow, and a vertical bob animation. Six bubble elements are added to the HTML with inline styles for varied positions, sizes, and delays.

The developer is checking whether instanced pebble colors will render black. Three.js multiplies the material color by the instance color in the shader, so a white material with full-color instance values should produce the intended warm tones.

Pebble material color will be white so they remain visible as light stones even if instanced color fails to compile. The 70 pebbles will not cast shadows to avoid heavy or unsupported instanced shadow rendering; they will only receive shadows.

A single front window will sit at (1.8, 3.45, -3.6). Two side windows were considered but rejected to avoid clutter. The floor will use a dark blue color. A fake shadow blob will be drawn under the stand using a canvas-generated texture.

The canvas shadow texture is a radial gradient fading from dark center to transparent edge. A soft shadow material using this texture will be applied to the floor and possibly the fish.

Fish shadows will be flat planes scaled to about 0.7. The floor shadow plane will be 9.5 by 4.8 units, rotated flat, and placed just above the floor at 0.01 units height. Both shadows use the same soft shadow material with opacity 0.85, and the canvas blob texture is shared between them.

Fish shadow materials will be cloned per fish so opacity can vary each frame. The floor uses one shared material. The soft shadow helper is ready. The developer is now writing the complete file.

The seahorse will remain in the scene despite possible animation bugs. Its lookAt target is set to the position plus flat velocity so the snout faces forward. The vertical bob is applied to the visual tilt node while the root position stays fixed for logic; the shadow will follow the root. The body may extend below the root origin and risk touching the sand.

The seahorse root origin is at the tail base, not the body center. Its tail curl reaches world y -1.09, well above the sand at -1.68. The head sits at -0.05, visible mid-tank. Containment bounds use the root position, keeping the horse within y -1.42 to 1.42.

The seahorse head would clip the water surface if the root is kept at the tail base. The developer is shifting the model origin to the body center and lowering the visual position to prevent this. A separate y limit for the horse is also being considered.

The seahorse containment bounds are finalized at y -1.15 to 1.05. The head stays underwater while the tail remains above the sand. These limits will be stored in each fish's userData so the contain and steering functions can reference them cleanly.

The seahorse model origin is shifted to the body center so yawing rotates around the middle of the body. The developer is now writing the complete script and has verified that every fish and seahorse receives a tail reference in its userData before animation begins.

A `pecs` array will be initialized in every fish's `userData` so the animation loop can safely iterate over it. The coral decoration will use a recursive branch builder, with each color getting its own material shared across branches.

The coral branch builder uses a recursive Group hierarchy. Each branch is a tilted CylinderGeometry with a random horizontal rotation. Child branches attach at 82 percent of the parent length. The first branch splits into three branches, and deeper levels split into two.

The first recursive call to branch() creates a vertical trunk. The developer is now writing the complete script.

Coral branch depth is set to 2 to keep vertex count low. Anemone tentacles will be registered as swayers with their base rotation.

Kelp segments will be registered as swayers with their base rotation preserved. The developer is now writing the `addKelp` function to build a chain of seven segments.

Kelp segments are built as stacked BoxGeometry blades with a double-sided emissive material. Each segment is a Group positioned at the previous segment's top edge, and every pivot is registered as a swayer.

Kelp swayer parameters are finalized with amplitude and phase scaling per segment. The chain geometry is verified: each blade sits at the segment midpoint while its pivot sits at the segment top, so the chain grows cleanly upward from the sand.

The developer is now writing the `addStar` function to create starfish decorations. Each starfish uses five scaled sphere meshes for its arms.

The starfish arms are placed in a circle around the center with outward-pointing rotation. The body center is a flattened sphere. The entire decoration is placed on the sand and given a random yaw.

The developer derived the exact starfish arm rotation formula. Using Three.js rotation Y positive, local +Z maps to (sin θ, 0, cos θ), so setting rotation.y to Math.PI / 2 - a aligns each arm outward along the intended direction.

A castle silhouette will be added to the scene so the aquarium is clearly recognizable. The `addCastle` function uses a stone material with low metalness and a dark material for contrast. A `tower` helper builds each tower as a tapered cylinder with a slight outward slope.

The castle function places two towers and a wall. Each tower receives five merlons arranged in a circle around its base. The wall is a flat stone box centered in the scene.

The castle now includes a dark door and an archway. It is placed on the sand with a slight yaw and added to the obstacle list. The developer is starting the `addPot` function.

The `addPot` function creates a tipped ceramic pot with a body and torus rim, placed on the sand as a small obstacle. The developer then began writing the `addBrainCoral` function.

The brain coral function distorts a sphere geometry by scaling each vertex with sine waves along the X and Z axes. It then applies a pinkish material with low roughness and a subtle red emissive glow. The mesh is placed on the sand and set to cast shadows.

Brain coral is added to the scene and registered as an obstacle. A loop will place six rocks at fixed sand-level coordinates using a `rockSpots` array of x, z, radius, and color values.

The rock loop is complete. Each rock is placed on the sand at a slight offset, given a random rotation, and registered as an obstacle. The developer is now structuring the final script, using function declarations for hoisting and placing const arrays before their first use.

The script will import Three.js as a module. Constants and function declarations will precede the `init` call, which runs at the bottom of the file. The `animate` loop will reference several variables declared in `init`, including water and caustic materials, dust geometry, two colored lights, a glow sprite, a lure core, and arrays for trail sprites and ring meshes.

The script will be linear instead of using an init function. Constants, renderer, scene, camera, lights, and environment setup will come first. Function declarations will follow. Scene builders will run next. Fish will spawn. Events will attach. Then the animation loop will begin. Function declarations will be placed before their calls for readability.

Function declarations are confirmed to be hoisted within the module scope. After spawning the fish, the resident count will be updated in the HTML element. The loader will be hidden immediately after initialization so the first frame renders without delay.

Deferred module scripts run after the document parses but before the first paint. The Three.js import is a network request, so the script waits for that download to finish before executing. This means the browser may paint the HTML and CSS before the aquarium scene is fully built.

The developer decided against adding `will-change` or panic speed lines. The lure core will be a small bright sphere paired with an additive sprite. The complete script is ready to be written.

The lure core is a small white sphere with tone mapping disabled. The glow sprite will pulse in scale using a sine wave. Trail sprites will be smaller, and ring meshes will face the camera. The animate function is being written carefully to avoid overwriting reused vectors.

The `screenDist` helper copies positions into reused `_v1` and `_v2`, projects them, and returns the screen-space distance. The fish loop calls it once, then reuses those vectors for seek and flee calculations. This avoids overwriting vectors still needed by the helper.

The animation loop order is finalized. Velocity is computed and position updated before orientation. The lookAt dummy uses _dummy, so _v1 can be repurposed as the look target. The updateLure function is placed before the fish loop because it uses _v3 and does not touch _v1. It oscillates the lure automatically or follows the mouse pointer via raycaster.

The developer realized the mouse-controlled lure plane normal is the camera's view direction, so the raycaster intersects a plane facing the camera rather than the water surface.

The developer confirmed the mouse-controlled lure plane intersects the camera ray correctly, since the plane passes through the lure center and its normal follows the view direction. They also noted a potential edge case: if the ray and plane become parallel, the intersection returns null.

The developer confirmed the plane sits in front of the camera, so every ray intersects it and the parallel-ray edge case is effectively impossible. If the intersection somehow fails, the previous lure position is preserved.

The glow sprite and the bright sphere core will be copied directly to the lure position after each update. The trail sprites will follow the lure with exponential smoothing, each one lagging slightly behind the previous one. They are initialized at the lure's starting coordinates.

The ring meshes will pulse in and out of view as they orbit the lure, scaling from 0.35 to 2.15 and fading to half opacity. A blue point light will follow the lure to brighten nearby fish and sand as it moves.

The lure light intensity was reduced to 3 and its distance to 4 to avoid washing out the scene. The developer is now writing the complete file. Fish species speeds will be added to the table and passed into the finalize function.

The SPECIES constant is defined before createFish. Each fish kind stores body proportions, tail shape, speed, and stripe colors.

The SPECIES constant now includes four more fish types: angel, idol, butterfly, and guppy. Each defines body proportions, tail shape, speed, and stripe colors.

The tetra species is added to the fish table with a fork tail and high speed. The butterfly fish now has an eyeBand property, which draws a thin vertical black stripe through the eye. The eye white is added after the band so it covers the stripe locally.

The idol fin will remain opaque. The default dorsal scale is 0.7, but the tall angel fish will use 0.55 so its dorsal fin does not look oversized. The idol fish will instead use a small dorsal fin because it has a pennant.

The idol fish will use a small dorsal fin scaled to 0.3 and a signature pennant. The guppy tail mesh will receive a custom scale. The developer is now writing the complete HTML file and will scan for unmatched braces afterward.

The developer is checking for undefined variables, typos, shader syntax errors, and duplicate constants. All top-level variables will be declared once at the module scope, including camera, scene, renderer, clock, and the arrays for fish, schoolFish, swayers, bubbles, rays, and obstacles. Resize and pointer listeners will be attached before the animation loop begins, and the resize handler will run once immediately so the camera aspect ratio is correct on the first frame.

The developer will call `onResize()` immediately before starting the animation loop so the camera aspect ratio is correct on the very first frame. The `animate` function will schedule itself with `requestAnimationFrame` and render the scene synchronously, avoiding an extra frame of delay.

The first animation frame will discard the initial delta time to avoid a large first-frame jump. The developer will use a real em dash in the HTML text because the UTF-8 charset is declared. The instructions will read: "Move your mouse near the fish — they swim away." and "A pale light wanders the reef until you guide it."

The text is refined to "Move your mouse near the fish and they swim away" and "A wandering light previews the startle until your cursor takes over." The instruction panel will be capped at 380 pixels wide so it wraps neatly without covering the center of the tank.

A top-center slim header bar is being considered to avoid covering the left-side fish. The current left panel would hide the idol and kelp, and the title overlay is being repositioned to the top center with a centered transform.

The top banner is now a slim centered overlay with a max width of 560 pixels. The title sits at the top, status text at the bottom left, and the fish count at the bottom right. This keeps the center of the tank unobstructed. On mobile screens under 640 pixels, the right count is hidden and the panel shrinks to a compact top bar.

The mobile layout now hides the subtitle and stretches the bottom status bar full width. The top panel is styled as a centered gallery placard with centered text and a centered SVG mark above the kicker.

The placard markup is finalized with an SVG mark, kicker, title, rule, and two paragraph lines. A soft gold border and dark translucent background are added to the panel for a cohesive brass-plaque look. The developer considers this the final design.

The bottom status bar is styled as a pill-shaped flex container with rounded corners and a gap between elements. The fish count badge on the right is also rounded. The developer is now writing the complete HTML file and confirmed that the shader template literals do not contain `${` interpolation, so they can safely use template literals.

The developer is checking for stray `</script>` or `</style>` tags inside the embedded JavaScript and CSS strings to avoid breaking the HTML. A decorative SVG will receive `aria-hidden`. The page background is set to `#07111c` so the canvas does not flash white if it renders transparent for a single frame. The canvas itself is opaque. A brief comment will be added at the top of the script.

A comment is added at the top of the script: `// Glasswater — a small reef that startles when the cursor draws near.` The developer is now writing the complete source file and will emit only the code block. A fish separation loop is being added to keep fish from colliding, using a simple loop to avoid O(n²) vector allocations.

The developer is adding a per-fish separation force that repels nearby fish when they come within 0.6 units of each other. To avoid allocating a new Vector3 on every frame, they will create a single `desired` vector inside the loop.

A single module-level Vector3 will be reused for the desired velocity each frame to avoid repeated allocations. The developer is working out the correct order of operations so that copying the seek vector into the reusable one and then lerping with the flee vector does not accidentally alias the same object.

The developer finalized the per-fish steering logic. When a fish gets within 0.3 units of its target, it picks a new random point and recalculates the seek vector. The flee vector is inverted from the cursor position and scaled down vertically. Both vectors are normalized and multiplied by speed, then blended into the reusable desired vector using the fear factor. A minor allocation cost from randomPoint() is accepted as occasional.

School fish will not be randomly retargeted when they near their slot. Their target is set to the school center plus offset at the start of each update, so the random-point fallback is skipped for them. This avoids a wasted frame of random movement before the school target overwrites it.

The developer is refining the fish update loop. The timer decrements every frame unconditionally. School fish are assigned their target first, then the random-point fallback is skipped for them. For non-school fish, the code checks if the distance to the current target is under 0.35 units and only then picks a new random point and resets the timer.

The developer finalized the per-fish update sequence. Separation, wall steering, and obstacle avoidance all add to the reusable desired vector after the seek-flee blend. The velocity then lerp-toward that desired vector using an acceleration that scales with fear. A maximum speed cap and a minimum speed floor are added so fish never stop or over-speed.

Fish orientation is being added. A dummy object is placed at each fish’s position and pointed toward its velocity vector so it faces forward. For horse-shaped fish, the upward lookAt target is flattened to the horizontal plane so they swim level.

The developer finalized the fish orientation logic. Each fish’s quaternion is slerped toward a dummy object’s quaternion using a turn rate that scales with fear. They verified that the dummy object’s world position works correctly without a scene parent, and that the temporary vector used for the look target is safe to reuse. Bank tilt is applied to the fish’s tilt child after the quaternion update, but only for non-horse fish.

Bank tilt is now calculated from the heading difference between the current velocity and the previous frame. The tilt child is damped and clamped to a ±0.6 range. Non-horse fish also bob vertically with a sine wave. Horse fish use a simpler bobbing pattern. The heading is initialized to zero so the first frame has no heading difference.

Shadow geometry is rotated to lie flat on the XZ plane. Each shadow is positioned directly above the fish at sand height plus a small offset. Its scale and opacity are clamped based on the fish’s vertical distance from the sand.

The shadow scale is set uniformly with `setScalar` because the circle is rotated flat before scaling. The rotation is fixed at creation and never touched during animation. Shadows do not need to be hidden when outside the view.

Emissive intensity on the fish body and fins now scales with fear, creating a visible glow when the cursor draws near. The developer considers the demo complete and is about to write the full source. They also plan to add a quiet bubble-pop effect near the surface.

Bubbles grow as they rise from the aerator. Their scale is set by a life formula based on vertical position. The aerator is placed at x: -1.35, z: 0.45. A mesh group is being created for it.

The aerator is built from a dodecahedron stone scaled vertically and placed at sand height plus a small offset. Twenty-eight bubbles are spawned around it with random radii and rising speeds.

The shared bubble geometry is reused for all 28 bubbles. A second small bubble stream from a stone on the right is considered optional. The developer is now writing the complete source file.

A single bubble column is kept for clarity. The developer will add dust particles as specified. They verified that THREE.Clock, THREE.MathUtils.damp, THREE.MathUtils.clamp, Vector3.setLength, Quaternion.slerp, Ray.intersectPlane, Camera.getWorldDirection, Plane.setFromNormalAndCoplanarPoint, and Color.offsetHSL all exist in Three.js r170.

The developer confirmed that Material.clone, InstancedMesh.setColorAt, Shape and ShapeGeometry methods, PMREMGenerator, SRGBColorSpace, ACESFilmicToneMapping, PCFSoftShadowMap, CustomBlending, Sprite, and Points are all available in Three.js r170. They will set environmentIntensity safely, expecting no error if the property is unsupported.

`outputColorSpace` is confirmed available in Three.js r170. Instanced pebbles will not cast shadows to avoid warnings. The shared bubble material is reused, and `renderOrder` is set on each mesh instead. The glass tank wall is set to `depthWrite: false`, `transparent: true`, and `opacity: 0.08` so it remains faintly visible without overwhelming the fish.

A narrow additive gradient streak is being added to the front glass to simulate reflection. It uses a canvas texture with a linear gradient fading from transparent to bright white.

The streak texture is created by drawing a vertical gradient on a canvas and masking it with `destination-out` so the center fades to transparent. A diagonal plane mesh is placed on the front glass at low opacity to create the reflection highlight.

The streak material is configured with additive blending, low opacity, and double-sided rendering. It is placed on the front glass at a slight diagonal angle and set to render above the tank. A second thinner streak on the opposite side was considered but skipped. The developer is now writing the complete source file.

Meniscus lines will use a shared material. The developer decided against adding more elements to avoid bugs. The full list of scene items is now complete. They will write compact but readable code.

The window frame is being built as a group. A solid plane mesh forms the wood frame, and a larger transparent glow plane sits just behind it with additive blending and low opacity to create a soft rim light.

The window frame is built from four wood panels and a central mullion using a standard material with wood-like color, roughness, and low metalness. The group is placed at (1.7, 3.42, -3.5) and faces the camera directly.

The floor is set to 80 by 80 units so the camera can zoom out on mobile without revealing the edge. It is placed at y = -2.84, just below the stand legs at -2.78, to keep the legs flush against the ground.

The stand and floor positions are being recalculated so the legs sit flush on the ground. The tank base is at y = -1.82, the stand center is at y = -2.00, the leg tops are at y = -2.18, and the floor is placed at y = -2.76. The floor shadow is raised 0.01 unit above the floor to prevent z-fighting.

The developer defined a `TANK` constant object with derived dimensions so the stand cannot float. Sand sits at -1.66 and water at 1.66, leaving a 0.26-unit air gap. The inner backdrop is placed at z = -1.47 with a height of `water - sand - 0.15`.

The backdrop is centered vertically between the sand and water levels. It is placed at z = -1.49, so it sits behind the kelp and castle. A subtle blue volume tint was considered but skipped to avoid transparency sorting issues.

Four light rays are being placed inside the tank at y = 0.35, stretching from y = 1.4 down to y = -0.8 so they cross the fish zone. They are spaced across the x-axis to avoid overlapping the center. Each ray has a unique base rotation and width. The base rotation is stored for animation.

Renderer settings are finalized. Antialias is enabled. Local clipping and logarithmic depth buffers are disabled. Pixel ratio is capped at 2. The scene is lightweight enough to render smoothly on most hardware.

The developer decided not to wrap the WebGL renderer in a try-catch, since the rules require zero errors rather than graceful degradation. The scene is optimized for a 1280 by 800 side-by-side comparison viewport. Fish scales were increased slightly so they remain clearly visible in the screenshot; the hero tang is now 1.25 times its base size and the angel fish is 1.2 times, giving each about one-sixth of the tank height.

Fish sizes were checked against the 1280 by 800 viewport. A tetra school of five reads clearly, and a seahorse provides a nice foreground focal point. The hero fish are already spread across the Z-axis to show depth during camera drift. A minimum width will be set on the status text to prevent layout jumps.

The status text is locked to a 148-pixel minimum width to prevent layout jumps. Body font smoothing is enabled. The developer is now generating the complete source code in a single fenced block, ensuring shared fish geometries and materials are initialized before any spawn calls.

The script will be organized into eleven ordered sections. Constants and vectors come first. Then renderer setup, scene, camera, lights, and environment. Shared textures and materials are created next. Builder functions follow. The world is assembled. Fish are spawned. UI numbers are set and the loader hides. Listeners are attached. The animate loop is defined. Finally onResize, clock discard, and the first animation frame are called. Builder functions can be placed before the world build step.

The environment map will be generated early, before any materials that reference it. A custom ShaderMaterial is being written for the environment to avoid potential shader compilation errors.

The environment map shader uses a custom BackSide material. It blends a wood-like base color with a blue-green tint based on vertical orientation. A subtle window reflection highlight is added at the top center.

The environment map shader is being finalized with a sphere geometry and a PMREMGenerator. A bug was spotted in the window reflection highlight: `smoothstep(0.32, 0.0, ...)` has its edge0 greater than edge1, which is undefined. It will be corrected to `smoothstep(0.0, 0.32, ...)` so all smoothsteps have ascending edges. The shader will remain compact and single-line to avoid formatting issues.

The sand plane is being extended to z = ±1.49 so it meets the backdrop exactly at z = -1.49. This removes a 0.04-unit gap that would otherwise show a dark seam. The sand dimensions are set to 7.5 by 2.98 units. Template literals will be used for shader strings to keep them readable and correct.

The backdrop bottom sits at -1.585, slightly above the sand plane at -1.66, leaving a small 0.07-unit gap that is acceptable visually. The loader will display the title "Glasswater" so the first paint shows the name. The bubbles and reef gradient are enough to fill the loader without extra fish graphics.

The loader markup now includes a glass orb and a "Filling the tank" subtitle. A mobile breakpoint at 640 pixels hides the subtitle and branding text so the title and mouse instruction remain centered on small screens.

Mobile typography is tightened with wider letter spacing and smaller padding. At 347 pixels the instruction wraps cleanly without overflow. Fixed-position panels will not cause document overflow.

Fish eyes are being added with a bright MeshBasicMaterial catchlight and a pupil slightly offset in front of the eye white.

The eye geometry is now fully defined. Each eye white sits at the calculated offset, the pupil is slightly in front and slightly below, and the catchlight glint is placed higher and farther forward. The tiny tetra eye radius is clamped to 0.028 units.

The tetra eye radius is being reduced to 0.022 so the fish does not look too bulgy. A mouth geometry will be added only when the fish body depth exceeds 0.33 units. The developer is now ready to write the complete source code.

Body colors are shifted slightly via HSL offset for each fish, except the idol which stays white. Fins keep their exact species colors. Caustic polygons are left without polygonOffset to avoid rendering quirks; the 0.02-unit gap above the sand plane prevents z-fighting.

Fish shadows will sit at sand + 0.012, just above the caustic plane at sand + 0.028. This lets the caustics shine through the shadows for a magical effect. Pebbles placed at sand + 0.02 might intersect the caustic plane and look like a sliced-through object.

Pebbles will sit at sand + 0.03 with a radius of 0.04, slightly above the caustic plane at sand + 0.01. Because the camera is positioned above the scene, the higher caustic plane renders closer to the viewer than the pebbles, so the caustics remain visible through the pebbles without depth occlusion.

The caustic plane is now set at sand + 0.045, well above the pebble tops. Pebbles will sit at sand + 0.015 with a small vertical scale, so the caustics render cleanly on top without slicing through them. The rocks extend well above the caustic plane and will be cut through by it.

The caustic plane would slice through the rocks and create a bright line. The developer will instead embed the caustic effect directly into the sand material shader so the rocks occlude it naturally. This avoids a separate plane and keeps standard lighting intact.

The caustic plane will sit just above the sand and below the rock centers. It will use depthTest but not depthWrite. Rocks will occlude the caustic where they are closer to the camera. The intersection edge may still show a faint caustic line.

The developer realized that depth testing will naturally occlude the caustic plane behind the rock faces. The rock front surfaces write depth values closer to the camera than the plane, so the caustic texture will not bleed through the rock silhouette. This means the separate plane approach is viable after all.

The caustic plane will sit at sand + 0.03, overlapping the pebbles so the caustics render as light shining through them. Transparent glass will not occlude the caustics. The caustic plane will use renderOrder 1 to draw after the opaque sand.

The developer is checking Three.js documentation to confirm whether renderOrder overrides the default transparent-sorting behavior. They want to ensure the caustic plane draws after the opaque sand.

The developer confirmed that renderOrder 1 on the caustic plane will draw it after the opaque sand and fish, while depth testing keeps the caustics from painting over the fish. They are now assigning renderOrder values to the remaining scene layers: water at 2, bubbles at 3, glass at 4, streak at 5, and lure at 6.

The import map JSON is confirmed valid and exact. A second floor disc for ground color variation was considered but skipped since the shadow blob is sufficient. Everything is ready to write the final file.

The developer is now writing the JavaScript source code. They are carefully checking for syntax errors, particularly around template strings and brace matching. A new `addPectoral` helper is being added to create the pectoral fins with a base Y rotation and a slight Z tilt.

The pectoral fin helper now returns the mesh and its base Y rotation. The wag animation will only overwrite rotation.y, preserving the fin's outward tilt. Fins will use DoubleSide so they remain visible from both sides, while the body stays FrontSide. The createFish function is now fully specified. The developer is beginning the seahorse material with a gold body color.

The seahorse body material is now defined with gold color, low roughness, and faint emissive glow. The fin material uses a pale yellow tone with high emissive intensity, double-sided rendering, and near-opaque transparency. The seahorse model is being assembled from nested groups, with the body group offset downward by 0.38 units.

The seahorse fin material is being switched from transparent to opaque to avoid sorting issues. The dorsal pivot is set to the tail mesh so the body tilts correctly. Darker torus bands are being added to the seahorse body using a slightly darker, less saturated clone of the body material.

The seahorse band material is finalized with a darker, less saturated color and a faint emissive glow. The developer verified that cloning the body material creates independent color objects, so the band color offset will not accidentally alter the original body material.

The seahorse body will have four horizontal torus bands spaced vertically along the body, each scaled to fit the body width.