Interactive 3D aquariumOne-line
Claude Haiku 5.5
by AnthropicReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 8 min 23 s
- Cost
- $0.062
- Output tokens
- 123,014
- Spent thinking
- 108,360 tokens
- Speed
- 245.3 tok/s
- Console errors
- 0
- First token after
- 1.9 s
- First visible text after
- 7 min 44 s
- Prompt tokens
- 649
- Code size
- 27.8 KB
- Finish reason
- stop
- Served by
- Claude Platform on AWS
- Model id
anthropic/claude-haiku-5.5- Reasoning sent
{"effort":"xhigh"}- Max output
- 128,000 tokens
- Generated
- Nov 10, 2026
What it was asked
The prompt
“a 3D aquarium with Three.js where the fish swim away when I move the mouse near them (Load Three.js only through this import map: <script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}</script>)”
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task. Output rules: - Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block. - Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task. - Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw. - The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar. - Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction. - Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session. - Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing. - The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message. - The code must run with zero errors in the console of the latest Chrome.
Code run-1.html · 781 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>3D Aquarium · fish flee from your cursor</title>
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}</script>
<style>
html, body {
margin: 0; height: 100%; overflow: hidden;
background: #021a2a; color: #e3f7ff;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
touch-action: none; -webkit-user-select: none; user-select: none;
}
#app { position: fixed; inset: 0; }
#app canvas { display: block; width: 100%; height: 100%; }
.title {
position: fixed; top: 18px; left: 22px; pointer-events: none;
text-shadow: 0 2px 14px rgba(0,0,0,.55);
}
.title h1 { margin: 0; font-size: 20px; font-weight: 650; letter-spacing: .14em; text-transform: uppercase; }
.title p { margin: 5px 0 0; font-size: 13px; opacity: .85; }
.glow {
position: fixed; left: 0; top: 0; width: 240px; height: 240px; border-radius: 50%;
pointer-events: none; opacity: .45; mix-blend-mode: screen; will-change: transform;
background: radial-gradient(circle closest-side,
rgba(170,240,255,.07) 0%, rgba(170,240,255,.05) 70%,
rgba(200,250,255,.32) 93%, rgba(200,250,255,0) 100%);
transition: opacity .5s;
}
.hint {
position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%);
width: max-content; max-width: calc(100vw - 28px); box-sizing: border-box;
padding: 9px 18px; border-radius: 999px; font-size: 14px; line-height: 1.35; text-align: center;
background: rgba(2, 22, 36, .6); border: 1px solid rgba(170, 235, 255, .25);
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
pointer-events: none;
}
@media (max-width: 480px) {
.title { top: 12px; left: 14px; }
.title h1 { font-size: 16px; }
.title p { font-size: 12px; }
.hint { bottom: 10px; font-size: 12px; padding: 8px 14px; }
}
</style>
</head>
<body>
<div id="app"></div>
<div class="title">
<h1>Aquarium</h1>
<p>Fish scatter wherever your cursor passes</p>
</div>
<div class="glow" id="glow"></div>
<div class="hint" id="hint">Move your mouse near the fish · click for a splash</div>
<script type="module">
import * as THREE from 'three';
/* ================================================================
Config
================================================================ */
const FLEE_RADIUS = 2.1; // reaction radius around the pointer's line of sight (world units)
const FISH_PER_SCHOOL = 7;
const SCHOOLS = [
{ body: 0xff7a2e, fin: 0xffc35c }, // orange
{ body: 0x2f80e0, fin: 0x12418f }, // blue
{ body: 0xf7d646, fin: 0xe0901c }, // yellow
{ body: 0xdfe9f0, fin: 0x7f9cb4 }, // silver
{ body: 0xe9485f, fin: 0x8c1c33 }, // red
{ body: 0x3fd3b1, fin: 0x1d7f6f }, // teal
];
const N_PLANKTON = 160;
const N_BUBBLES = 70;
const FOV = 40;
const TAN_HALF = Math.tan(THREE.MathUtils.degToRad(FOV / 2));
const TANK = { w: 7.6, h: 4.3, d: 2.4 }; // half-extents of the glass box
const B = { x: 6.8, yMin: -3.5, yMax: 3.7, z: 1.9 }; // swim box for fish (set in layout)
let FLEE_R = FLEE_RADIUS;
let fishK = 1;
let T = 0;
let lastT = performance.now();
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const lerp = (a, b, t) => a + (b - a) * t;
const rand = (a, b) => a + Math.random() * (b - a);
/* ================================================================
Renderer, scene, camera, lights
================================================================ */
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(0x021a2a, 1);
renderer.toneMapping = THREE.NeutralToneMapping;
document.getElementById('app').appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x0c4d6b, 0.022);
const camera = new THREE.PerspectiveCamera(FOV, window.innerWidth / window.innerHeight, 0.1, 300);
scene.add(new THREE.HemisphereLight(0xcdf7ff, 0x1c5566, 1.8));
const sun = new THREE.DirectionalLight(0xffffff, 2.0);
sun.position.set(2.5, 12, 7);
scene.add(sun);
const raycaster = new THREE.Raycaster();
const ray = raycaster.ray;
// scratch objects (avoid per-frame allocations)
const acc = new THREE.Vector3(), sepV = new THREE.Vector3(), aliV = new THREE.Vector3(), cohV = new THREE.Vector3();
const _a = new THREE.Vector3(), _b = new THREE.Vector3(), _c = new THREE.Vector3();
const UP = new THREE.Vector3(0, 1, 0), AX = new THREE.Vector3(), AY = new THREE.Vector3(), AZ = new THREE.Vector3();
const basisM = new THREE.Matrix4(), qT = new THREE.Quaternion();
/* ================================================================
Procedural textures
================================================================ */
function canvasTexture(w, h, draw) {
const c = document.createElement('canvas');
c.width = w; c.height = h;
draw(c.getContext('2d'), w, h);
return new THREE.CanvasTexture(c);
}
const sandTex = canvasTexture(256, 256, (g, w, h) => {
const img = g.createImageData(w, h), d = img.data;
for (let i = 0; i < w * h; i++) {
const n = (Math.random() - 0.5) * 34;
const speck = Math.random() < 0.05 ? -45 : 0;
d[i * 4] = clamp(226 + n + speck, 0, 255);
d[i * 4 + 1] = clamp(202 + n * 0.9 + speck, 0, 255);
d[i * 4 + 2] = clamp(158 + n * 0.6 + speck, 0, 255);
d[i * 4 + 3] = 255;
}
g.putImageData(img, 0, 0);
});
sandTex.colorSpace = THREE.SRGBColorSpace;
sandTex.wrapS = sandTex.wrapT = THREE.RepeatWrapping;
sandTex.repeat.set(3, 2);
// tileable caustic pattern (integer frequencies keep it seamless)
const causticTex = canvasTexture(512, 512, (g, w, h) => {
const img = g.createImageData(w, h), d = img.data;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const u = (x / w) * Math.PI * 2, v = (y / h) * Math.PI * 2;
const c = Math.sin(2 * u + Math.sin(3 * v + 1.1) * 1.7)
+ Math.sin(3 * v + Math.sin(2 * u + 0.7) * 1.3)
+ 0.5 * Math.sin(5 * u + 5 * v);
const b = Math.pow(Math.max(0, 1 - Math.abs(Math.sin(c * 2.2))), 6);
const k = (y * w + x) * 4;
d[k] = d[k + 1] = d[k + 2] = b * 255;
d[k + 3] = 255;
}
}
g.putImageData(img, 0, 0);
});
causticTex.wrapS = causticTex.wrapT = THREE.RepeatWrapping;
causticTex.repeat.set(2.5, 1.4);
const bubbleTex = canvasTexture(64, 64, (g) => {
const grd = g.createRadialGradient(32, 32, 16, 32, 32, 31);
grd.addColorStop(0, 'rgba(200,245,255,0)');
grd.addColorStop(0.72, 'rgba(200,245,255,0.14)');
grd.addColorStop(0.92, 'rgba(240,255,255,0.9)');
grd.addColorStop(1, 'rgba(255,255,255,0)');
g.fillStyle = grd;
g.fillRect(0, 0, 64, 64);
});
const dotTex = canvasTexture(64, 64, (g) => {
const grd = g.createRadialGradient(32, 32, 0, 32, 32, 32);
grd.addColorStop(0, 'rgba(255,255,255,1)');
grd.addColorStop(0.4, 'rgba(255,255,255,0.5)');
grd.addColorStop(1, 'rgba(255,255,255,0)');
g.fillStyle = grd;
g.fillRect(0, 0, 64, 64);
});
/* ================================================================
Tank: floor, caustics, back wall, glass
================================================================ */
const WALL_VS = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`;
const WALL_FS = `
uniform float uTime;
varying vec2 vUv;
void main() {
float y = vUv.y;
vec3 deep = vec3(0.015, 0.19, 0.32);
vec3 mid = vec3(0.03, 0.42, 0.58);
vec3 top = vec3(0.40, 0.86, 0.93);
vec3 col = mix(deep, mid, smoothstep(0.0, 0.7, y));
col = mix(col, top, smoothstep(0.6, 1.0, y) * 0.55);
// soft light shafts coming down from the surface
float s1 = sin(vUv.x * 9.0 + vUv.y * 2.4 + sin(uTime * 0.25) * 0.8);
float s2 = sin(vUv.x * 4.1 - vUv.y * 1.7 + uTime * 0.17 + 1.7);
float shaft = smoothstep(0.35, 1.0, s1) * 0.6 + smoothstep(0.55, 1.0, s2) * 0.4;
shaft *= pow(y, 1.8);
col += shaft * vec3(0.5, 0.9, 0.95) * 0.22;
// rippling water surface line
float wave = 0.012 * sin(vUv.x * 38.0 + uTime * 2.1) + 0.007 * sin(vUv.x * 71.0 - uTime * 3.3);
float line = 1.0 - smoothstep(0.0, 0.02, abs(y - (0.975 + wave)));
col += line * vec3(0.8, 1.0, 1.0) * 0.5;
col = mix(col, vec3(0.75, 0.97, 1.0), smoothstep(0.975 + wave, 1.0, y) * 0.5);
float vig = 1.0 - smoothstep(0.4, 1.1, length((vUv - 0.5) * vec2(1.0, 1.2)));
col *= 0.75 + 0.25 * vig;
gl_FragColor = vec4(col, 1.0);
}`;
const planeGeo = new THREE.PlaneGeometry(1, 1);
planeGeo.rotateX(-Math.PI / 2);
const floor = new THREE.Mesh(planeGeo, new THREE.MeshStandardMaterial({ map: sandTex, roughness: 1, metalness: 0 }));
const causticMat = new THREE.MeshBasicMaterial({
map: causticTex, color: 0xa8f0ff, transparent: true, opacity: 0.3,
blending: THREE.AdditiveBlending, depthWrite: false
});
const caustics = new THREE.Mesh(planeGeo, causticMat);
scene.add(floor, caustics);
const wallUniforms = { uTime: { value: 0 } };
const wall = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.ShaderMaterial({
uniforms: wallUniforms, vertexShader: WALL_VS, fragmentShader: WALL_FS
}));
scene.add(wall);
const edges = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(1, 1, 1)),
new THREE.LineBasicMaterial({ color: 0xc4f6ff, transparent: true, opacity: 0.6, fog: false })
);
const pane = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({
color: 0xc8f5ff, transparent: true, opacity: 0.05, depthWrite: false, fog: false
}));
scene.add(edges, pane);
/* ---------- rocks (obstacles the fish swim around) ---------- */
const rocks = [];
const ROCK_COLORS = [0x6b7378, 0x7e7468, 0x5b6669, 0x8a8076];
for (let i = 0; i < 8; i++) {
const geo = new THREE.IcosahedronGeometry(1, 2);
const seed = rand(0, 10);
const pa = geo.attributes.position;
for (let k = 0; k < pa.count; k++) {
const x = pa.getX(k), y = pa.getY(k), z = pa.getZ(k);
// position-based noise keeps shared vertices consistent (no cracks)
const f = 1 + 0.17 * Math.sin(x * 3.1 + seed) * Math.cos(y * 2.7 + z * 1.9 + seed * 0.5)
+ 0.08 * Math.sin(z * 5.3 - seed);
pa.setXYZ(k, x * f, y * f, z * f);
}
geo.computeVertexNormals();
const s = rand(0.45, 0.9);
const rock = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({
color: ROCK_COLORS[i % ROCK_COLORS.length], roughness: 0.95, flatShading: true
}));
rock.scale.set(s * rand(1.0, 1.5), s * rand(0.55, 0.85), s * rand(0.8, 1.1));
rock.userData.fx = -0.9 + 1.8 * (i + rand(0.15, 0.85)) / 8;
rock.userData.fz = rand(-0.85, 0.85);
rock.userData.r = s * 1.25;
rocks.push(rock);
scene.add(rock);
}
/* ---------- swaying seaweed (CPU-animated ribbons) ---------- */
const WEED_MATS = [0x2c9b58, 0x3fb36a, 0x1f7a4a].map(c =>
new THREE.MeshStandardMaterial({ color: c, roughness: 0.7, side: THREE.DoubleSide }));
const weeds = [];
for (let i = 0; i < 14; i++) {
const height = rand(2.2, 4.0);
const geo = new THREE.PlaneGeometry(0.46, height, 1, 12);
geo.translate(0, height / 2, 0);
const mat = WEED_MATS[i % WEED_MATS.length];
const grp = new THREE.Group();
const m1 = new THREE.Mesh(geo, mat);
const m2 = new THREE.Mesh(geo, mat);
m2.rotation.y = Math.PI / 2;
m1.frustumCulled = m2.frustumCulled = false;
grp.add(m1, m2);
grp.userData = {
fx: rand(-0.95, 0.95), fz: rand(-0.85, 0.85), height, geo,
base: Float32Array.from(geo.attributes.position.array),
phase: rand(0, 6.28), speed: rand(0.6, 1.1), amp: rand(0.25, 0.45)
};
weeds.push(grp);
scene.add(grp);
}
/* ---------- bubbles & plankton (Points) ---------- */
const bubbleArr = new Float32Array(N_BUBBLES * 3);
const bubbleGeo = new THREE.BufferGeometry();
bubbleGeo.setAttribute('position', new THREE.BufferAttribute(bubbleArr, 3));
const bubbleMat = new THREE.PointsMaterial({ map: bubbleTex, size: 0.5, transparent: true, opacity: 0.9, depthWrite: false });
const bubblePts = new THREE.Points(bubbleGeo, bubbleMat);
bubblePts.frustumCulled = false;
scene.add(bubblePts);
const bubbles = Array.from({ length: N_BUBBLES }, () => ({ x: 0, y: 0, z: 0, v: 1, ph: 0 }));
const plankArr = new Float32Array(N_PLANKTON * 3);
const plankGeo = new THREE.BufferGeometry();
plankGeo.setAttribute('position', new THREE.BufferAttribute(plankArr, 3));
const plankMat = new THREE.PointsMaterial({ map: dotTex, color: 0xe8fbff, size: 0.2, transparent: true, opacity: 0.6, depthWrite: false });
const plankPts = new THREE.Points(plankGeo, plankMat);
plankPts.frustumCulled = false;
scene.add(plankPts);
const plank = Array.from({ length: N_PLANKTON }, () => ({ x: 0, y: 0, z: 0, ph: rand(0, 6.28) }));
plank.forEach(p => {
p.x = rand(-TANK.w, TANK.w); p.y = rand(-TANK.h, TANK.h); p.z = rand(-TANK.d, TANK.d);
});
/* ================================================================
Fish geometry (shared) and materials
================================================================ */
function buildBodyGeometry() {
const U = 30, V = 20;
const pos = [];
for (let i = 0; i <= U; i++) {
const t = i / U;
const x = 0.5 - t; // nose at +X
const r = Math.max(0.0008, Math.pow(Math.sin(Math.PI * Math.pow(t, 0.62)), 0.85));
for (let j = 0; j <= V; j++) {
const a = (j / V) * Math.PI * 2;
pos.push(x, Math.cos(a) * r * 0.27, Math.sin(a) * r * 0.105); // laterally compressed
}
}
const idx = [];
for (let i = 0; i < U; i++) {
for (let j = 0; j < V; j++) {
const a = i * (V + 1) + j, b = (i + 1) * (V + 1) + j;
idx.push(a, b, a + 1, b, b + 1, a + 1);
}
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
g.setIndex(idx);
g.computeVertexNormals();
return g;
}
function tailShape() {
const s = new THREE.Shape();
s.moveTo(0.04, 0.05);
s.quadraticCurveTo(-0.14, 0.13, -0.34, 0.31);
s.quadraticCurveTo(-0.26, 0.0, -0.34, -0.31);
s.quadraticCurveTo(-0.14, -0.13, 0.04, -0.05);
s.closePath();
return s;
}
function dorsalShape() {
const s = new THREE.Shape();
s.moveTo(0.24, 0.12);
s.quadraticCurveTo(0.02, 0.44, -0.3, 0.12);
s.closePath();
return s;
}
function extrude(shape, depth) {
const g = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false, curveSegments: 12 });
g.translate(0, 0, -depth / 2);
return g;
}
const MAT = {
eye: new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.2 }),
pupil: new THREE.MeshStandardMaterial({ color: 0x101418, roughness: 0.3 }),
};
const GEO = {
body: buildBodyGeometry(),
tail: extrude(tailShape(), 0.025),
dorsal: extrude(dorsalShape(), 0.02),
pec: new THREE.SphereGeometry(1, 10, 8),
eye: new THREE.SphereGeometry(0.05, 12, 10),
pupil: new THREE.SphereGeometry(0.025, 8, 6),
};
/* ================================================================
Fish, schools, state
================================================================ */
const fishes = [];
const schools = [];
function clampFish(f) {
const p = f.root.position;
p.x = clamp(p.x, -B.x, B.x);
p.y = clamp(p.y, B.yMin, B.yMax);
p.z = clamp(p.z, -B.z, B.z);
}
function pickHome(sc) {
sc.home.set(rand(-B.x * 0.7, B.x * 0.7), rand(B.yMin + 1.0, B.yMax - 1.0), rand(-B.z * 0.6, B.z * 0.6));
sc.homeT = rand(7, 11);
}
function pickTarget(f) {
const h = f.school.home;
f.target.set(
clamp(h.x + rand(-1.7, 1.7), -B.x, B.x),
clamp(h.y + rand(-0.7, 0.7), B.yMin, B.yMax),
clamp(h.z + rand(-1.0, 1.0), -B.z, B.z)
);
f.tTimer = rand(3, 6);
}
function makeFish(school) {
const root = new THREE.Group();
const model = new THREE.Group();
root.add(model);
model.add(new THREE.Mesh(GEO.body, school.body));
const tailPivot = new THREE.Group();
tailPivot.position.x = -0.46;
tailPivot.add(new THREE.Mesh(GEO.tail, school.fin));
model.add(tailPivot);
model.add(new THREE.Mesh(GEO.dorsal, school.fin));
const anal = new THREE.Mesh(GEO.dorsal, school.fin);
anal.position.set(-0.08, -0.13, 0);
anal.scale.set(0.75, -0.6, 1);
model.add(anal);
for (const sz of [-1, 1]) {
const pec = new THREE.Mesh(GEO.pec, school.fin);
pec.position.set(0.12, -0.05, sz * 0.1);
pec.scale.set(0.16, 0.03, 0.11);
pec.rotation.x = sz * 0.35;
model.add(pec);
const eye = new THREE.Mesh(GEO.eye, MAT.eye);
eye.position.set(0.32, 0.05, sz * 0.09);
model.add(eye);
const pupil = new THREE.Mesh(GEO.pupil, MAT.pupil);
pupil.position.set(0.345, 0.05, sz * 0.13);
model.add(pupil);
}
const tailPiv = tailPivot;
const fish = {
root, model, tailPivot: tailPiv, school,
size: rand(0.9, 1.3),
cruise: rand(1.0, 1.5),
vel: new THREE.Vector3(rand(-1, 1), rand(-0.2, 0.2), rand(-0.4, 0.4)),
target: new THREE.Vector3(),
tTimer: 0, scare: 0, wig: rand(0, 6.28),
};
fish.vel.normalize().multiplyScalar(fish.cruise);
model.scale.setScalar(fish.size * fishK);
root.position.copy(school.home).add(new THREE.Vector3(rand(-1.5, 1.5), rand(-0.6, 0.6), rand(-0.9, 0.9)));
clampFish(fish);
pickTarget(fish);
scene.add(root);
return fish;
}
/* ================================================================
Pointer: real mouse/touch, or a virtual cursor for the demo
================================================================ */
const ptr = { real: false, x: 0, y: 0 };
const ndcVec = new THREE.Vector2();
function setRealPointer(e) {
ptr.x = (e.clientX / window.innerWidth) * 2 - 1;
ptr.y = -(e.clientY / window.innerHeight) * 2 + 1;
ptr.real = true;
raycaster.setFromCamera(ndcVec.set(ptr.x, ptr.y), camera);
}
function pointerNDC(time) {
if (ptr.real) return ndcVec.set(ptr.x, ptr.y);
// demo mode: a virtual cursor wanders through the tank so fish keep reacting
return ndcVec.set(0.8 * Math.sin(time * 0.21 + 0.6), 0.42 * Math.sin(time * 0.34 + 1.1));
}
function splash() {
for (const f of fishes) {
ray.closestPointToPoint(f.root.position, _c);
_a.subVectors(f.root.position, _c);
const d = _a.length();
if (d < 3.2 * fishK) {
const k = 1 - d / (3.2 * fishK);
if (d < 1e-3) _a.set(rand(-1, 1), 0.3, rand(-1, 1));
_a.normalize();
f.vel.addScaledVector(_a, 3.5 * k + 0.5);
f.scare = Math.max(f.scare, 0.6 + 0.4 * k);
}
}
}
/* ================================================================
Simulation
================================================================ */
function stepFish(dt) {
for (const sc of schools) {
sc.homeT -= dt;
if (sc.homeT <= 0) pickHome(sc);
}
for (const f of fishes) {
const p = f.root.position;
acc.set(0, 0, 0); sepV.set(0, 0, 0); aliV.set(0, 0, 0); cohV.set(0, 0, 0);
let n = 0;
// boids: separation from everyone, alignment + cohesion within the school
for (const o of fishes) {
if (o === f) continue;
const q = o.root.position;
_a.subVectors(p, q);
const d2 = _a.lengthSq();
if (d2 < 1.3) {
const d = Math.sqrt(d2) + 1e-4;
sepV.addScaledVector(_a, (1.15 - d) * 2 / d);
}
if (o.school === f.school && d2 < 14) {
aliV.add(o.vel);
cohV.add(q);
n++;
}
}
if (n > 0) {
_a.copy(aliV).multiplyScalar(1 / n).sub(f.vel);
acc.add(_a);
_b.copy(cohV).multiplyScalar(1 / n).sub(p).multiplyScalar(0.35);
acc.add(_b);
}
acc.addScaledVector(sepV, 2.6);
// wander toward the school's roaming home
f.tTimer -= dt;
_a.subVectors(f.target, p);
if (f.tTimer <= 0 || _a.lengthSq() < 0.8) {
pickTarget(f);
_a.subVectors(f.target, p);
}
if (_a.lengthSq() > 1e-6) {
_a.normalize().multiplyScalar(f.cruise).sub(f.vel);
acc.addScaledVector(_a, 0.9);
}
// FLEE: push away from the cursor's line of sight
ray.closestPointToPoint(p, _c);
_a.subVectors(p, _c);
const dRay = _a.length();
if (dRay < FLEE_R) {
const k = 1 - dRay / FLEE_R;
const s = k * k * (3 - 2 * k);
if (dRay < 1e-3) _a.set(rand(-1, 1), 0.4, rand(-1, 1));
_a.normalize();
acc.addScaledVector(_a, 36 * s);
f.scare = Math.min(1, f.scare + s * dt * 5);
}
f.scare = Math.max(0, f.scare - dt * 0.28);
// soft walls of the tank
const MX = 1.1, MY = 0.7, MZ = 0.5, K = 14;
if (p.x > B.x - MX) acc.x -= (p.x - (B.x - MX)) * K;
if (p.x < -B.x + MX) acc.x += (-B.x + MX - p.x) * K;
if (p.y > B.yMax - MY) acc.y -= (p.y - (B.yMax - MY)) * K;
if (p.y < B.yMin + MY) acc.y += (B.yMin + MY - p.y) * K;
if (p.z > B.z - MZ) acc.z -= (p.z - (B.z - MZ)) * K;
if (p.z < -B.z + MZ) acc.z += (-B.z + MZ - p.z) * K;
// rocks
for (const r of rocks) {
_a.subVectors(p, r.position);
const dd = _a.length();
const R = r.userData.r + 0.55 * fishK;
if (dd < R) {
_a.multiplyScalar(1 / (dd + 1e-3));
acc.addScaledVector(_a, (R - dd) * 40);
}
}
// integrate
f.vel.addScaledVector(acc, dt);
f.vel.multiplyScalar(Math.exp(-0.25 * dt));
const minS = f.cruise * 0.6;
const maxS = f.cruise * (1 + 2.6 * f.scare);
let sp = f.vel.length();
if (sp < 1e-4) {
f.vel.set(rand(-1, 1), rand(-0.2, 0.2), rand(-0.3, 0.3)).normalize().multiplyScalar(minS);
sp = minS;
}
if (sp > maxS) f.vel.multiplyScalar(maxS / sp);
else if (sp < minS) f.vel.multiplyScalar(minS / sp);
p.addScaledVector(f.vel, dt);
clampFish(f);
// orientation: nose along velocity, fish stays upright
AX.copy(f.vel).normalize();
AZ.crossVectors(AX, UP);
if (AZ.lengthSq() < 1e-4) AZ.set(0, 0, 1); else AZ.normalize();
AY.crossVectors(AZ, AX);
basisM.makeBasis(AX, AY, AZ);
qT.setFromRotationMatrix(basisM);
f.root.quaternion.slerp(qT, 1 - Math.exp(-dt * 6));
// tail beat: faster and wider when scared
f.wig += dt * (7 + 4 * f.cruise + 14 * f.scare);
f.tailPivot.rotation.y = Math.sin(f.wig) * (0.28 + 0.4 * f.scare);
}
}
function respawnBubble(b, initial) {
b.x = rand(-TANK.w * 0.92, TANK.w * 0.92);
b.z = rand(-TANK.d * 0.7, TANK.d * 0.7);
b.y = initial ? rand(-TANK.h, TANK.h) : -TANK.h + rand(0, 0.25);
b.v = rand(0.5, 1.2);
b.ph = rand(0, 6.28);
}
function updateBubbles(dt) {
for (let i = 0; i < N_BUBBLES; i++) {
const b = bubbles[i];
b.y += b.v * dt;
b.x = clamp(b.x + Math.sin(T * 2.1 + b.ph) * 0.2 * dt, -TANK.w, TANK.w);
if (b.y > TANK.h - 0.05) respawnBubble(b, false);
bubbleArr[i * 3] = b.x;
bubbleArr[i * 3 + 1] = b.y;
bubbleArr[i * 3 + 2] = b.z;
}
bubbleGeo.attributes.position.needsUpdate = true;
}
function updatePlankton(dt) {
for (let i = 0; i < N_PLANKTON; i++) {
const p = plank[i];
p.x = clamp(p.x + Math.sin(T * 0.35 + p.ph) * 0.12 * dt, -TANK.w, TANK.w);
p.y = clamp(p.y + Math.cos(T * 0.27 + p.ph * 1.7) * 0.1 * dt, -TANK.h, TANK.h);
p.z = clamp(p.z + Math.sin(T * 0.21 + p.ph * 0.6) * 0.06 * dt, -TANK.d * 0.9, TANK.d * 0.9);
plankArr[i * 3] = p.x;
plankArr[i * 3 + 1] = p.y;
plankArr[i * 3 + 2] = p.z;
}
plankGeo.attributes.position.needsUpdate = true;
}
function swayWeed(g) {
const u = g.userData, pa = u.geo.attributes.position, base = u.base;
for (let i = 0; i < pa.count; i++) {
const f = base[i * 3 + 1] / u.height;
const off = (Math.sin(T * u.speed + u.phase + f * 2.4) * u.amp
+ 0.06 * Math.sin(T * 1.7 + u.phase * 2 + f * 6)) * f * f;
pa.setX(i, base[i * 3] + off);
}
pa.needsUpdate = true;
}
/* ================================================================
Layout (responsive camera + tank proportions)
================================================================ */
const glowEl = document.getElementById('glow');
const hintEl = document.getElementById('hint');
let glowPx = 240, gx = 0, gy = 0, glowInit = false, mode = '';
function layout() {
const W = window.innerWidth, H = window.innerHeight, aspect = W / H;
renderer.setSize(W, H);
// narrower, taller tank on portrait screens; classic wide tank on landscape
const t = clamp((aspect - 0.6) / 0.9, 0, 1);
TANK.w = lerp(4.6, 7.6, t);
TANK.h = lerp(6.2, 4.3, t);
camera.aspect = aspect;
const dist = Math.max((TANK.h + 1) / TAN_HALF, (TANK.w + 1) / (TAN_HALF * aspect));
camera.position.set(0, 0.6, TANK.d + dist);
camera.lookAt(0, 0, 0);
camera.updateProjectionMatrix();
camera.updateMatrixWorld();
const { w, h, d } = TANK;
edges.scale.set(2 * w, 2 * h, 2 * d);
pane.position.set(0, 0, d); pane.scale.set(2 * w, 2 * h, 1);
wall.position.set(0, 0, -d); wall.scale.set(2 * w, 2 * h, 1);
floor.position.set(0, -h, 0); floor.scale.set(2 * w, 1, 2 * d);
caustics.position.set(0, -h + 0.02, 0); caustics.scale.set(2 * w, 1, 2 * d);
B.x = w - 0.8; B.yMin = -h + 0.8; B.yMax = h - 0.6; B.z = d - 0.5;
// keep fish a readable size on tall/narrow screens
const D = camera.position.z;
fishK = clamp(D / 15, 1, 1.5);
FLEE_R = FLEE_RADIUS * fishK;
for (const r of rocks) r.position.set(r.userData.fx * w, -h + r.scale.y * 0.4, r.userData.fz * d * 0.7);
for (const g of weeds) g.position.set(g.userData.fx * w * 0.95, -h, g.userData.fz * d * 0.8);
for (const f of fishes) {
f.model.scale.setScalar(f.size * fishK);
clampFish(f);
}
// on-screen reaction ring matches FLEE_R at tank depth
const pxPerUnit = (H / 2) / (D * TAN_HALF);
glowPx = 2 * FLEE_R * pxPerUnit;
glowEl.style.width = glowPx.toFixed(0) + 'px';
glowEl.style.height = glowPx.toFixed(0) + 'px';
bubbleMat.size = 13 * D / (H / 2);
plankMat.size = 4 * D / (H / 2);
}
layout();
/* ================================================================
Spawn
================================================================ */
for (const sc of SCHOOLS) {
const school = {
home: new THREE.Vector3(),
homeT: 0,
body: new THREE.MeshStandardMaterial({ color: sc.body, roughness: 0.35, metalness: 0.08 }),
fin: new THREE.MeshStandardMaterial({ color: sc.fin, roughness: 0.5, side: THREE.DoubleSide }),
};
pickHome(school);
schools.push(school);
for (let k = 0; k < FISH_PER_SCHOOL; k++) fishes.push(makeFish(school));
}
bubbles.forEach(b => respawnBubble(b, true));
/* ================================================================
Input
================================================================ */
window.addEventListener('pointermove', setRealPointer, { passive: true });
window.addEventListener('pointerdown', (e) => { setRealPointer(e); splash(); }, { passive: true });
window.addEventListener('pointerup', (e) => { if (e.pointerType === 'touch') ptr.real = false; }, { passive: true });
window.addEventListener('blur', () => { ptr.real = false; });
document.addEventListener('pointerout', (e) => { if (!e.relatedTarget) ptr.real = false; });
window.addEventListener('resize', layout);
/* ================================================================
Loop
================================================================ */
function frame(now) {
const dt = clamp((now - lastT) / 1000, 0, 0.05);
lastT = now;
T += dt;
const ndc = pointerNDC(T);
raycaster.setFromCamera(ndc, camera);
stepFish(dt);
updateBubbles(dt);
updatePlankton(dt);
for (const w of weeds) swayWeed(w);
causticTex.offset.set(T * 0.012, T * 0.008);
wallUniforms.uTime.value = T;
// reaction ring follows the active pointer (smoothed)
const px = (ndc.x + 1) / 2 * window.innerWidth;
const py = (1 - ndc.y) / 2 * window.innerHeight;
if (!glowInit) { gx = px; gy = py; glowInit = true; }
gx += (px - gx) * 0.2;
gy += (py - gy) * 0.2;
glowEl.style.transform = `translate3d(${(gx - glowPx / 2).toFixed(1)}px, ${(gy - glowPx / 2).toFixed(1)}px, 0)`;
glowEl.style.opacity = ptr.real ? '1' : '0.45';
const m = ptr.real ? 'real' : 'demo';
if (m !== mode) {
mode = m;
hintEl.textContent = ptr.real
? 'Fish swim away from your cursor · click for a splash'
: 'Demo: a virtual cursor is swimming through the tank · move your mouse to take over';
}
renderer.render(scene, camera);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>Reasoning trace (108,360 tokens)
I'm weighing whether the first-paint and demo-mode requirements mean I need a self-playing start screen before any interaction, since an aquarium is interactive but not really a game. I'm also considering whether keyboard controls would need their own on-screen hint. I'm going to let the mouse drive the interaction. The start-screen rule seems aimed at games, so the aquarium can run its fish from the first frame. To make it visibly responsive even without a mouse, I'll add an idle mode where a virtual pointer drifts around and occasionally scatters the fish, which also shows up well in a screenshot. Still writing the fish body... Still writing the fish body... Still working on the fish body geometry, shaping the profile so the nose and tail taper and the body is taller than it is wide. Still working on the fish body profile: I'm using a radius curve that tapers toward the nose and tail and peaks roughly a third of the way along the length. Still writing the fish fins: a forked tail shape, extruded thin and hung on a pivot at the back of the body for the wiggle, plus a triangular dorsal fin on top and small flattened pectoral fins on the sides. I'm settling on the fish look: each one gets a random color from a palette of orange, blue, yellow, and silver, with the fins in a darker shade to give some two-tone contrast. I'll skip stripes to keep it simple. For the behavior, I'm planning around forty-five fish that flock with separation, alignment, and cohesion, wander toward targets, and steer away from the pointer. I'm working out how the mouse ray should drive the fish: each fish gets pushed away based on its distance from the ray from the camera through the pointer, so depth is handled correctly. I'm using the line of sight from the camera through the pointer as the repulsor, since that keeps the effect correct in 3D: a fish that is near the cursor on screen but far away in depth should only react if it sits close to that ray. For each fish I'll find the closest point on the ray, push it away from that point, and scale the push by how near it is within a radius of about two world units. Points at other depths on the same screen line all sit at zero distance from the ray, so the fish behind the cursor would scatter too, which is roughly right visually. I'm considering whether the flee radius should scale with depth, but for now I'll keep it in world units at around 2.2, so fish anywhere along the ray's path react. I'm sketching the demo mode next: if the mouse hasn't moved for a few seconds, a virtual pointer will wander through the tank and scatter the fish, so the screenshot shows them reacting. I'll also have the fish speed up and wiggle harder when they flee, and maybe add a faint caustic glow at the pointer's position. I'm settling on the touch-and-pointer handling and the idle behavior: after about two and a half seconds without mouse movement, a virtual pointer will drift slowly across the tank, scattering fish and letting them regroup, and it hands control back as soon as a real mouse or touch moves. I'll show a short hint at the bottom reading "Move the mouse near the fish" and skip any start-screen gate, since the aquarium isn't a game. Now I'm laying out the tank itself: a dark blue background with fog for depth, and a transparent glass box with edges to frame the water. Now I'm laying out the tank's structure: glass edges and a sand floor, with a water surface shimmering along the top and soft light shafts angled down through it. I'm setting up the floor now: a canvas-generated caustic texture on a transparent overlay that scrolls with additive blending, sitting over a speckled sand plane. Lighting will be ambient, hemisphere, and a directional light, and I'm weighing whether casting fish shadows onto the floor is worth the cost. Next I'm setting up the tank's rocks and kelp: a few lumpy, flat-shaded grey rocks, and a row of swaying seaweed strands that I'll bend on the CPU each frame. Now I'm adding the seaweed sway, which I'll handle by shifting the ribbon vertices each frame, and I'm making it double-sided. Next I'm planning the bubbles as a Points cloud with a soft sprite texture, and then setting up about forty-six fish with a boids-style flocking behavior. I'm writing out the per-frame fish update: flocking with separation, alignment, and cohesion, a wander target that refreshes every few seconds, soft steering away from the tank walls, and a mouse-ray repulsion that fades with distance. Still writing the fish update: I'm applying the scare amount so a fleeing fish speeds up toward a higher top speed, then integrating velocity with damping and clamping it. Next I'm turning each fish to face its direction of travel, using a smoothed rotation toward the velocity. I'm settling on the fish's orientation: rather than a pure rotation from the x-axis, I'm weighing a lookAt with an up vector so the fish stays upright, though a plain rotation toward the velocity would also work and only risks some rolling. I'm working out how to orient the fish: the simplest route is to wrap each fish in a container group and rotate an inner mesh so its nose lines up with the container's local Z axis, which makes the usual lookAt direction math fit cleanly. I'm working out the orientation math now: the fish's nose should point along its velocity, with its up direction kept roughly vertical. I'm weighing a basis built from the forward direction, world up, and the cross product for the side axis, with a fallback for when the fish swims straight up or down. Still working out the orientation basis: the forward direction becomes the fish's nose, and the side axis comes from crossing forward with world up, with the vertical axis recovered from the cross product so the matrix stays right-handed. I'm settling the fish's facing: the nose follows the velocity direction, and the side axis is built from the cross product with world up so the fish stays roughly upright. I'm still deciding whether the fish's flat sides end up facing the camera. Still working out the fish's orientation: I'm weighing whether the body's thin axis should be Z, so the broad side faces the camera, and I'm checking whether a thinner Z radius would keep the profile visible. Checking the fish's facing: with forward along X and a fixed up reference, the side axis comes out as Z, so the flat side faces the camera when the fish swims right. Swimming left flips Z while Y stays up, so the fish keeps its upright orientation in both directions. Still working out the fish's orientation: the tail is a flat fin, so its side-to-side wag should come from rotating it about the vertical axis. Still working out the fish's tail motion: a side-to-side wag around the vertical axis should read as foreshortening from the camera, which is the right look. I'm weighing a CPU-side body bend against a vertex shader displacement along the length of the fish, and leaning toward the shader approach. Still working out the fish's body: I'm weighing whether to add a per-fish sway or a vertex-shader bend, but a shared material would need per-fish uniforms, which adds complexity. The simpler route is a tail wiggle plus a small yaw sway per fish group, and at about 46 fish with roughly six meshes each, the draw-call count is still manageable. Still working on the fish's look: I'm settling on a body and fin pair per fish, with a small palette of orange and blue tones. Still writing the fish palette: I'm listing body and fin colors for blue, yellow, silver, red, teal, and purple variants. I'm skipping a clownfish stripe for now and setting per-fish size variation. Next I'm setting the tank's bounds: the fish will be kept inside a box about 7.5 units wide, 4.2 high, and 2.8 deep, with a floor below and the water surface above, and I'm planning to push them gently back when they approach the edges. I'm sizing the camera so the whole tank fits: working out the distance needed to cover the tank's height, then checking the width against the aspect ratio and taking whichever is larger, with some margin. I also need to remember the glass front face sits at the tank's depth edge, while the fish stay a bit inside it. I'm placing the camera at a distance that frames the tank, with the glass front face at the tank's depth edge. For the glass itself, I'm planning edge lines drawn in light cyan, plus a very faint, nearly transparent tinted box so the tank reads as glass without blocking the fish. I'm setting up the glass: a faint, depth-neutral tinted box so the fish stay visible through it, drawn after the opaque fish. Then I'm considering tilting the camera slightly upward so the water surface shows as a band at the top, possibly with a shimmering shader. I'm weighing a ShaderMaterial for the water, or a simple CSS gradient behind a transparent canvas, but a CSS background would cover the whole page rather than just the tank interior. A surface plane with a gently displaced, light teal color looks like the better option for the top of the tank. Now I'm setting up the backdrop: a large plane behind the fish with a vertical gradient from bright cyan at the top to deep blue at the bottom, plus slowly moving light rays. I'm setting up the scene's surroundings now: a gradient back wall with drifting light rays, a sandy floor with a caustic overlay, and a faint glass front outlined with edge lines. I'm also considering a thin animated water-surface band along the top of the tank, though that part may be skipped. I'm working out the back wall's look now: a shader with a vertical gradient, a shimmering ripple band along the top edge, and animated diagonal light shafts that drift with time. I'm also planning the foreground elements after that. I'm adding a few hundred faint drifting particles through the water as depth cues, then bubbles that rise from the floor with a slight wobble and wrap back to the bottom. Next I'm placing about fourteen kelp-like ribbons on the floor at varied heights, built from a plane with its base moved to the bottom so they grow upward. Still working on the seaweed: each ribbon's vertices will sway by row, with the top moving more than the base, and the geometry is a simple flat plane. I'm also planning to add some rocks on the floor next. I'm placing a few rocks on the floor, a mix of irregular shapes with flat shading in gray-brown tones, and I'm skipping coral for now. For lighting I'm combining a cool ambient light, a bright directional light from above, and a hemisphere light so the fish materials pick up the highlights. Next I'm turning to the mouse: I'll convert pointer movement into normalized device coordinates and cast a ray from the camera, storing it so the fish can react to where the cursor is. I'm setting up the pointer as a fallback: pointer events feed a ray into the scene, and when the pointer goes idle or leaves, the fish switch to an automatic sweep. The virtual pointer will trace a slow Lissajous path across the screen, and I want it to start near a group of fish so the first few seconds visibly scatter them. I'm settling on the virtual pointer's path so it sits near the fish at the start, and I'm adding a soft halo that marks the pointer's influence area, dimmer when it's the automatic one. I'm setting the influence radius to about 2 world units. I'm checking the camera's view height at this distance, which works out to roughly 14 units across the screen, so a 2.2-unit radius comes to about 125 pixels. Since the ray runs through the whole tank, I'm noting that a far fish can sit on screen right next to the cursor while being much farther in depth, so the influence will only be approximately right. I'm working out how the fish should flee: each one gets pushed directly away from the pointer's ray, so the scatter reads as a screen-space motion. The push gets stronger the closer a fish is to the ray, and it also raises the fish's scare level, which lifts its top speed toward about three times its base. I'm setting the speed limits next: a scared fish's top speed scales up with its scare level, and the flee push is strong. Then I'm starting on the boids flocking rules, with separation across all fish and alignment and cohesion within each species. Next I'm writing the boids rules: separation pushes each fish away from any neighbor closer than about 1.1 units, while alignment and cohesion only use same-species fish within roughly 3 units. The steering forces get weighted and summed, then I'll integrate them with a timestep and clamp each fish's speed. Still setting the tank's bounds: I'm defining soft limits so fish steer back inside the walls before reaching them, and the floor sits at the bottom of the tank with rocks partly sunk into it. I'm working out the camera placement: it sits a little above the center and looks at the middle of the tank, so the sandy floor shows as a strip along the bottom. Now I'm figuring out the field-of-view fit so the whole tank width fits the screen. I'm working out the camera fit: the view height needs to cover the tank height plus a margin, and the view width needs to cover the tank width, so I take whichever distance is larger. The camera then goes back by the tank depth so the front glass sits within the frame. I'm working out the camera distance now: the front face of the tank has to fit inside the frame, so the camera sits far enough back to cover whichever is larger, the height or the width scaled by the aspect ratio. On a narrow portrait screen the width is the limit, so the tank fills the width and leaves empty space above and below. I'm checking how the empty bands above and below the tank will look on a narrow screen, and whether a dark background around the glass would make it read as a window in a dark room. I'm weighing whether to make the tank taller on narrow screens, so it fills the portrait view better instead of leaving wide empty bands above and below. I'm considering tying the tank height to the aspect ratio and letting the fish bounds follow that change. I'm settling the tank's dimensions for narrow screens: on a portrait view the width is the limit, so I'm considering making the tank narrower and taller, to cut down the empty bands above and below it. I'm working through the tank's size on narrow screens: fitting the width leaves a lot of empty space above and below, so I'm checking how many visible units a narrower tank would cover at that distance. Because the tank would still only fill about a quarter of the view height, I'm weighing whether the field of view or the tank proportions themselves need to change. I'm checking the window math again: the visible half-width should match the tank's half-width plus margin, not the smaller number I had. Working it through with the narrower, taller tank, the visible height at that distance covers about 23 units, so the 12-unit-tall tank fills roughly half the view. I'm setting the narrow-screen tank to a half-width of 4.6 and a half-height of 6.5, which fills most of the portrait view. On resize the fish bounds will update too, and I'll clamp any fish that end up outside the new box so they don't bunch against the edge. I'm checking the landscape case at 1280x800: the aspect is 1.6, so the width is the binding limit and the camera ends up about 14.4 units back from the front face, which leaves the tank filling roughly 80% of the height and 89% of the width. I'm thinking about switching to a narrower, taller tank on portrait screens, with the dimensions shifting smoothly with aspect ratio so the landscape fit stays the same. I'm checking the endpoints: at a 1.6 aspect the tank stays at its wide size, and narrow screens get a smaller width and a taller height. I'm checking the narrow-screen tank at aspect 0.56 and at the halfway point, and both look reasonable. Next I'm settling the camera distance formula: the camera sits the tank depth plus whichever fit distance, height or width, is larger. I'm settling the camera distance from the tank depth plus the fit distance, which gives about 16.8 units back. I'm also thinking the back wall needs to be large enough to cover the whole view behind the tank, with an outer background color so the glass edges still read as a tank. Now I'm making the back wall exactly the size of the tank and scaling it to match on resize, so the outside of the glass shows the page background. Now I'm making the tank's edges and back wall track the new size on resize, and placing the rocks and seaweed relative to the tank's bounds so they stay inside when it narrows. Still working out how the decorations stay inside the tank: I'm storing each rock and seaweed with a fractional position so a resize can recompute their coordinates from the new width and height. The floor and caustic overlay will be unit planes scaled to the tank size, and I'm sorting out the sand texture next. Now I'm working out how to generate the caustic texture for the floor overlay, using a few sine layers with integer frequencies so the pattern tiles cleanly. Writing caustic texture code... Still writing the caustic shader code... Writing shader code... Still working out the bubbles: I'm setting up about 90 of them as a point cloud, each with its own speed, wobble phase, and size, drifting upward and resetting at the top. Now I'm setting up the bubble texture as a canvas circle with a light rim, and sizing the point sprites so they read at about 19 pixels at this distance. Next comes a small set of plankton particles as points. Writing fish body geometry... Still shaping the fish body: I'm sizing the cross-section so the body reads as a fish, with a blunt nose and a fairly quick rounding at the front. Still shaping the body: I'm laying it out as a grid of rings along its length, with the thickness tapering toward the tail, and I'm checking whether the winding puts the normals outward so the lighting reads correctly. I'm going over the fish body's winding order and normals, and I've settled that DoubleSide handles it, since the shader flips the normal on back faces, so the lighting comes out right either way. I'm checking the body's winding and normals, and since the material is double-sided the lighting comes out right either way. Next I'm placing the eyes near the nose, at about x 0.36 and y 0.08, with the body's thickness there setting how far out they sit on the z axis. I'm placing the eyes near the nose at about t=0.18, where the body is still fairly wide, so they sit just outside the surface on either side. I'm keeping the eyeballs a bit smaller than the body thickness there, with the pupils set slightly further out. Still working out the eyes: I'm placing them just off the body surface near the nose, with white eyeballs and smaller black pupils. Next I'm turning to the tail shape, with its origin at the body's rear end and extending toward the back. Still writing the tail shape: I'm tracing a forked outline from the attachment point, with an outer top lobe, an inner notch, and a flat base. Still writing the tail: I'm extruding the forked outline a little for thickness and centering it on the body's midline, with the pivot set at the rear of the body. Still writing the tail: I'm settling the tail's pivot and its gentle side-to-side sway, then turning to the dorsal fin's outline along the top of the body. Still writing the dorsal fin: I'm shaping it as a curved outline with its base sitting just inside the body's top edge. Still shaping the fish's pectoral fins as small squashed ellipses placed beside the body, with a light flap if it's cheap to add. Still working on the fish's tail wiggle and the group's orientation, setting the tail to sway faster when the fish is scared. Still settling the fish's scale: I'm setting it so the body reads at roughly 1 to 1.35 units long, which should come out around 90 pixels on screen against the tank width. Still writing the fish update loop: I'm setting up per-fish steering, with separation, alignment and cohesion, plus a wander target that gets picked again once it's reached or the timer runs out. Still writing the fish steering: I'm wiring the flee response so fish within range of the pointer steer away and get more scared the closer they are. Still working out the fish's steering and bounds: I'm combining the flee push with a damped velocity, clamping speed to a max that rises when the fish is scared, and keeping every fish moving. I'm setting the pointer's hit radius to about 2.2 world units, and computing the pointer ray each frame from the normalized coordinates so the same code path handles the cursor. Next I'm checking the fleeing acceleration. Still writing the boid steering: I'm weighing each neighbor's separation push by how close it is, so fish that get too near each other push apart harder. Still writing the boid separation: I'm weighting neighbors that get too close so they push apart harder, with the push tapering off as they move away. Still working out the separation push: I'm weighting each close neighbor by how near it is, so fish that get too close push apart harder. Still working out the boundary forces: I'm pushing fish back from the walls with a force that grows the closer they get to the edge, and I'm considering whether a hard position clamp is needed as a backstop. Still writing the fish's wander and orientation code: I'm bounding the random targets inside the tank and setting the body's heading from its velocity, with a smooth turn toward it. Now I'm working out the quaternion for the fish's orientation: the forward axis comes from the velocity, and I'm building an orthonormal basis around it using a cross product with up, falling back to a fixed axis if the fish is heading straight up or down. Then I convert that basis matrix into a quaternion. I'm checking the basis handedness: with X as (1,0,0) the cross products give Z as (0,0,1) and Y as (0,1,0), so the matrix is the identity and the orientation math holds. Next I'm adding a cursor glow as an absolutely positioned div with a radial gradient and screen blending, shown only while the pointer is active. I'm adding a short title in the top-left and a hint at the bottom that fades out, with a status line saying a virtual cursor is swimming around when the demo is running. I'm keeping the UI minimal so nothing covers the fish, and I'm weighing a click that scatters the fish in a burst with a ripple. Still writing the click handler: I'm scattering nearby fish with an impulse and flashing the scene when the tank is clicked. Next I'm setting up the renderer with antialiasing and a capped pixel ratio, plus a resize handler. Still working on the scene lighting: I'm setting up fog to fade distant fish into the blue, then a hemisphere light, a directional key light, and a soft ambient fill for the fish materials. Still working on the scene: I'm setting the clear color, then a sand floor with a light tan tint, and a caustic overlay sitting just above it that drifts over time. Still working on the rocks and caustics: I'm jittering a detailed icosahedron into flat-shaded boulders, scattered across the floor at varied sizes, with the caustic overlay drifting above the sand. Still writing the seaweed: I'm making each ribbon a subdivided plane whose vertices sway more toward the tip, with two shades of green. Still working on the seaweed: I'm adding a second ribbon rotated 90 degrees so each strand has volume, and sharing one geometry between both planes so the sway only has to be computed once. Next I'm adding the bubbles rising from the floor as a point cloud that gets updated every frame, then a plankton layer of about 180 small, faint points drifting slowly through the tank, with a gentle sinusoidal motion and wrap-around at the edges. Still writing the page styling: a full-screen canvas, a small title top-left, and a hint pill at the bottom that wraps on narrow screens. Still working on the virtual pointer: I'm driving its position with a couple of slow sine waves so it sweeps across the tank and fish keep fleeing from it. I'm also treating the real pointer as active only if it moved in the last few seconds and is still inside the window, falling back to the virtual one otherwise. I'm working out the virtual pointer's path as a Lissajous curve, so it drifts across the screen at a moderate pace and passes over visible fish. Fish move around the tank at roughly a unit per second with a 2.2-unit influence radius, so the pointer will scatter them wherever it lands. Still writing the virtual pointer's path: a slow Lissajous sweep that passes over visible fish, so the scatter happens wherever it lands. Still writing the HTML page: I'm setting up the full-screen canvas, the body styling that stops the page from scrolling, and the fixed app container. Still writing the CSS: the title block is fixed in the top-left and ignores pointer input, and the hint pill sits centered at the bottom with a translucent backdrop and a thin border. Still writing the stylesheet: the glow follows the cursor with a soft blend, and the hint pill wraps on narrow phones. Writing the HTML body markup next: a full-screen container, a title block, and a hint pill, with the glow element moved by transform. Still writing the page markup: a glow element that follows the pointer, a hint pill at the bottom, and an import map so the three.js module can load. Still writing the renderer setup: I'm setting the pixel ratio, size, and background color, then switching the output color space to sRGB. Still working on the scene: I'm setting up the fog and the tank dimensions, then the lights for the fish. Still working on the lighting: I'm adding a hemisphere light and a directional sun light to the scene. Still writing the sand texture: I'm filling each pixel with a warm tan base, a little random variation, and occasional dark specks. Still working on the sand texture: I'm writing per-pixel noise with warm tan values and occasional dark specks, then drawing that data to the canvas. Now I'm adding some low-frequency blotches to the sand so it doesn't look uniformly noisy: a few dozen soft, low-alpha tan circles drawn before the speckles, to break up the pattern. Still writing the caustic texture: I'm computing sine-based interference patterns per pixel on a 512 by 512 canvas to make the light ripples. Still working on the caustic texture: I'm finishing the per-pixel interference math, with the sine terms wrapping cleanly so the pattern tiles. Still working on the caustic overlay: a MeshBasicMaterial with the caustic texture, additive blending, and low opacity, so the black areas add nothing. Still writing the floor: the sand texture goes on a standard material, and the caustic overlay shares that same plane geometry so it sits just above the sand. Still writing the back wall: a ShaderMaterial on a plane scaled to the tank size, placed just behind the far edge, with fish kept inside the depth range. Still writing the back wall's fragment shader: I'm setting a deep-to-light blue gradient from bottom to top, and adding light shafts across the wall. Still writing the back wall's shader: I'm adding wave-driven light shafts over the gradient, and I'm about to add a bright surface band near the top. Still writing the back wall's fragment shader: I'm adding a surface band near the top with a soft wave-driven edge, then a subtle vignette to darken the corners. Still writing the back wall's fragment shader: I'm adding a vignette that darkens the corners, and I'm checking that gl_FragColor is still valid in this three.js ShaderMaterial setup. Still writing the glass: I'm settling on a faint front pane and thin edges around the tank, since a fully closed box would show its back faces through the wall. Still writing the glass: I'm adding the edge lines and front pane, scaling them with the tank on resize, and leaving out a base plinth or lid for simplicity. Still writing the rock geometry: I'm jittering an icosahedron's vertices using a position-based noise function, so duplicated corners get the same offset and the shape doesn't crack at the seams. Still writing the rock jitter: each vertex gets scaled by a position-based function, so duplicated corners move together and the shape stays closed. Still writing the rock placement: each rock sits at its spot on the floor, scaled to be irregular, and I'm storing its position on the floor so resize can update it. Next are the seaweed strands, about fourteen of them, each a tall thin ribbon anchored to the sand. Still writing the swaying function: each vertex gets an offset based on its height along the strand, so the top moves more than the base. Still writing the weeds: two shared green materials, double-sided, so each ribbon shows from both faces. Still writing the bubbles: a point cloud of sixty particles with a shared size, and a ring-shaped bubble texture drawn on a small canvas. Still writing the bubbles: I'm finishing the bubble texture's radial gradient, then setting up the points material with the bubble map. Still writing the fish: I'm defining a set of species with body, fin, and stripe colors, then building each fish with its own shape. Still writing the fish: I'm defining the species palette and shared geometry for the bodies, tails, fins, and eyes, then building each fish from those parts. Still writing the fish: I'm building each one from a body, a tail on a pivot so it can swing, a dorsal fin, and the other fins and eyes. Still writing the fish: I'm attaching the anal fin, flipped and placed low on the body, and checking that the flat fin plates face the camera. Still writing the fish: I'm placing the pectoral fins on each side and setting the eyes and pupils near the front of the head. Still writing the fish: I'm adding the fish objects, with a per-fish state for position, velocity, and tail wiggle, and planning seven of each of the six species. Still writing the physics setup: I'm placing the fish with a random center and offset, and planning the bounds so they stay above the rocks and seaweed. Still writing the fish: I'm adding a repulsion from the rocks, so the fish steer away from each rock's center and radius instead of passing through it. Still writing the update loop: I'm setting up the fish steering vectors and an orientation basis so each fish turns toward its heading. Still writing the fish update: I'm looping over each fish and checking its neighbors, pushing apart any that get too close and averaging the headings of same-species fish nearby. Still writing the fish update: I'm making the separation push stronger for very close neighbors, then adding alignment and cohesion steering for same-species fish, which I'm still settling. Still writing the fish steering: I'm adding a wandering target that gets re-picked on a timer, so each fish drifts instead of moving in a straight line. Still writing the pointer flee: I'm finding the closest point on the pointer ray to each fish and computing an offset away from it. Still writing the pointer flee: I'm computing how strongly each fish should be pushed away from the pointer, with a falloff over the flee radius, and making it scare more the closer it gets. Still writing the fish steering: I'm adding soft bounds that push each fish back inside the tank walls, and planning rock avoidance next. Still writing the fish steering: I'm making rocks push the fish out whenever they come within reach of a rock's edge, using each rock's stored radius. Still writing the fish physics: I'm adding the acceleration to velocity each step, then damping it so the fish keep a steady cruising speed. Still writing the fish physics: I'm clamping each fish's speed between a minimum cruise pace and a maximum that rises when it's scared, with a fallback for near-zero velocity. Still writing the fish physics: I'm setting the orientation basis from the velocity and up vector, so each fish's body lines up with its heading. Still writing the orientation: I'm building the fish's basis from its velocity and an up vector, then slerping its quaternion toward that heading so the body turns smoothly. Still writing the fish orientation: I'm setting the tail pivot to swing faster when the fish is scared, and tying the body's sideways sway to the same wiggle phase. Still writing the pick-target function: I'm placing each fish's next wander point at a random spot inside the tank bounds. Still writing the fish steering: I'm making each fish's target a shared species home point plus a small per-fish offset, so schools travel together. Still writing the fish: I'm setting up a shared home point for each species, re-picked on a timer, with each fish aiming for a spot near it. Now I'm wiring up the pointer state for the fish, where I'm tracking the normalized mouse position and when it last moved. Now I'm working out the pointer fallback: if the real mouse moved in the last couple of seconds while it's over the window, I use its position; otherwise a slow virtual pointer drifts around the tank so the fish keep reacting. Still working through the pointer logic: I'm deciding that once the real mouse has entered the frame, it stays in control, and the virtual pointer only takes over when the mouse leaves or never moved. Now I'm settling the pointer behavior: the glow follows the real mouse while it's over the page, and drops back to a virtual sweep once the pointer leaves or a touch lifts. I'll smooth the glow's position in JS so the switch between sources doesn't jump. Still writing the pointer glow: I'm converting the normalized pointer position into pixel coordinates and smoothing the glow's movement so switches between the real and virtual pointer don't jump. Still writing the pointer glow: I'm setting its opacity to full when the real mouse is driving it and dimmer when the virtual pointer takes over. Still writing the resize handler: I'm setting the renderer and camera to the window's aspect ratio and deriving a blend factor from it, so the tank layout adapts to narrow and wide screens. Still writing the resize: I'm scaling the tank to fit the window's aspect ratio, then pulling the camera back far enough that the whole tank stays in view. Still writing the resize: I'm scaling the glass, wall, floor, and caustic layers to the new tank size, repositioning the rocks and seaweed, and clamping the fish back inside the bounds. Still writing the resize: I'm checking that the glass edges and the front pane scale with the tank, and that the wall and caustic layers stay aligned to the new depth. Still writing the resize: I'm giving each rock a fixed scale and an approximate radius, then placing it relative to the tank's new width, height and depth. Still writing the resize: I'm placing each rock and seaweed patch relative to the tank's new size, then keeping the fish's bounds in a shared object so the steering stays inside the walls. Still writing the animation loop: I'm setting up a timer for frame deltas, capping each step at 50 ms, and wiring the pointer and fish updates into the per-frame function. Still writing the animation loop: I'm stepping the fish, weeds, bubbles, plankton, caustics and glow each frame, then rendering and requesting the next frame. Still writing the bubbles: I'm moving each one upward with a slight sideways wobble, and when it reaches the top I reset it near the floor with a new speed. Still writing the animation: I'm setting the bubbles' size, keeping their x positions inside the tank bounds, and holding the plankton count, with each one wrapping to the far side when it leaves. Still writing the pointer: I'm defining a helper that takes the client coordinates, converts them to normalized device coordinates, and sets the raycaster from them, so a click's ray is computed right away rather than waiting for the next frame. Still writing the code: I'm settling the ray and pointer helpers, and checking that the renderer, color space and resize handling all work without console errors. Still writing the lighting: I'm adding an ambient light so the fish aren't too dark under the physically based lighting, alongside the hemisphere and directional lights. Now I'm settling the lighting levels, since three.js uses physical units and the intensities need to be chosen with that in mind. Still working through the lighting: I'm weighing how three.js's physical light units treat hemisphere and ambient light, and whether the intensities I picked will come out too dark on the fish. Still writing the lighting: I'm choosing moderate intensities for the hemisphere, directional and ambient lights, since three.js's physical units make exact brightness hard to predict without seeing it. Still working on the lighting: I'm settling on a tone mapping that keeps the fish colors saturated rather than washing them out. I'm weighing the tone mapping choice now. Neutral tone mapping is built to keep PBR colors from shifting, so I'm leaning toward it over ACES, which would turn the oranges yellowish. Now I'm setting up the page title and the overlay text, with the subtitle saying fish flee from wherever the cursor passes and a hint at the bottom. Still writing the module script: I'm setting up the three.js constants, starting with the flee radius around the pointer ray. Still writing the scene setup: I'm defining the school colors and tank dimensions as constants. Still writing the setup: I'm defining the tank dimensions, a bounds object for the fish, and small helpers for clamping, interpolating and random ranges. Still writing the setup: I'm configuring the renderer's pixel ratio, size, clear color, neutral tone mapping and exposure, then attaching the canvas to the page and creating the scene. Still writing the scene: I'm setting up the fog, the camera and its field-of-view constant, and a hemisphere light plus a directional sun light. Still writing the sand texture: I'm generating a noisy canvas pixel by pixel, with a few darker specks, so the floor looks like real sand. Still writing the caustic texture: I'm generating its pixels from overlapping sine waves, so it reads as rippling light across the floor. Still writing the caustic texture: I'm computing each pixel from overlapping sine waves and turning them into thin bright lines, so the floor shows rippling light. Still writing the caustic texture: I'm weighing whether 512 pixels will keep the thin light lines crisp across the repeated floor, since 256 might turn them into blur. Still writing the caustic material: I'm making it additive and slightly transparent, so the light lines brighten the sand without hiding it. Still writing the scene: I'm placing the floor and its caustic overlay, both sharing one plane geometry, and adding them to the scene. Still writing the wall and glass: I'm adding the back wall to the scene, then setting up the glass edges and a transparent pane in front of the tank. Still writing the glass: I'm finishing the transparent pane's setup, keeping it in front of the tank so the scene reads through it, and planning how the glass edges line up with the back wall. Still writing the rocks: I'm generating a set of icosahedron shapes and jittering their vertices with a random seed so each one looks irregular. Still writing the rock shapes: I'm displacing each icosahedron vertex by a smooth function of its position so every rock comes out lumpy and irregular. Still writing the rocks: I'm giving each one a random base size, stretching it unevenly across the axes, and storing an obstacle radius for the fish to steer around. Still writing the rocks: I'm placing each one along the floor at an evenly spread position with a bit of random jitter, and giving each a random depth so they don't line up. Still writing the rocks: I'm setting each one's horizontal spot along the floor, with depth jittered so they don't line up. Still writing the seaweed: I'm building each strand from a tall plane with subdivisions, crossing a second copy at right angles, and storing its sway parameters. Still writing the bubbles: I'm setting up a buffer geometry for seventy of them, storing each one's position in a flat array. Still writing the bubbles: I'm filling the flat position array for seventy particles and wiring it into a point cloud with a soft bubble texture. Still writing the bubble texture: I'm building it as a radial gradient with a faint rim, so each bubble reads as a soft ring. Still writing the plankton: I'm setting up the particle count and a point cloud with its own velocity array, using a soft dot texture. Still writing the body: I'm building the fish's bulk from a parametric surface, looping over its length and cross-section. Still writing the body: I'm finishing the grid of cross-section points along the fish's length, tapering the radius toward both ends, then stitching them into triangles and creating the buffer geometry. Still writing the body: I'm finishing the normals and returning the geometry, then checking that the nose-to-tail taper puts the widest section near the front third of the fish. Still writing the body: I'm checking how the nose ring collapses to a point and whether the zero-length normals there could produce NaN values. Still writing the body: I'm checking that the zero-length normals at the nose can't produce NaN, since the tiny minimum radius keeps every triangle there non-degenerate. Still writing the tail: I'm defining the fin's shape with curves, then extruding it into a thin piece with the same centering as the other parts. Still writing the tail: I'm checking that the tail's attachment point sits inside the body, so the fin joins cleanly. Still writing the tail: I'm checking the tail's flare, with tips at ±0.31, against where it joins the body. Still writing the dorsal fin: I'm shaping it as a curve rising from the back, then closing it along the body so it joins cleanly. Still writing the dorsal and pectoral fins: I'm checking that the dorsal base sits inside the body, then setting the pectoral fins as flat ellipses at the sides. Still writing the fish: I'm setting up the materials for each school, with a body color and a fin color that uses double-sided rendering. Still writing the fish: I'm setting up the root group and body mesh for each one, and starting on the tail pivot. Still writing the fish: I'm assembling the tail pivot, dorsal fin, and anal fin, then attaching a pair of pectoral fins at the sides. Still writing the fish: I'm setting each pectoral fin's scale and splay, then adding the eyes and pupils on both sides. Still writing the fish: I'm sizing each one with a random scale and setting up the fish record with its velocity, wander timer, and target. Still writing the school layout: I'm picking each school's home spot inside the tank, then giving every fish a random wander target around it. Still writing the school behavior: I'm setting up the flocking accumulators for separation, alignment, and cohesion. Still writing the school behavior: I'm setting up the per-fish steering accumulators, then looping over the other fish in the school. Still writing the separation and alignment checks: I'm measuring each neighbour's distance, pushing apart any fish that gets too close, and collecting velocities and positions from the same school. Still writing the flocking: I'm summing the alignment and cohesion steering, then weighting separation more heavily so fish don't crowd each other. Still writing the wander: I'm picking a fresh target when the timer runs out or the fish gets close to its current one, then steering toward it at cruising speed. Still writing the pointer flee: I'm measuring how far each fish sits from the pointer's ray, and pushing it away with a smooth falloff inside the flee radius. Still writing the flee falloff: I'm scaling the push with a smooth curve, and raising each fish's scare level the closer it gets to the pointer. Still writing the bounds: I'm pushing fish back inward with a steering force as they near each wall of the tank. Still writing the bounds: I'm setting the wall margins, with x inset about 1.1, y about 0.7, and z about 0.5, so fish start turning before they reach the glass. Still writing the rock avoidance: I'm checking each rock's distance from the fish and pushing it away when it gets too close. Still writing the motion: I'm integrating velocity with damping, clamping speed between a minimum and a maximum that rises when the fish is scared. Still writing the motion: I'm clamping the fish inside the tank walls and setting up its orientation from the velocity direction. Still writing the orientation: I'm building a basis from the velocity direction, checking it's right-handed, then slerping the fish's rotation toward it. Still writing the tail: I'm setting the tail's wiggle to speed up with cruise and scare, and swinging the tail pivot side to side with a bigger swing when the fish is scared. Still writing the pointer: I'm making the virtual pointer sweep across the screen with slow sine motions, and switching to the real cursor position whenever it's inside the canvas. Still writing the pointer: I'm setting up a single NDC position that uses the real cursor when it's over the canvas and a slow sine sweep otherwise, then casting the ray from the camera through it. Still writing the pointer: I'm wiring up a real-pointer handler that converts the cursor position into normalized device coordinates and marks the pointer as being over the canvas. Still writing the pointer listeners: I'm wiring the pointer move and press handlers, and clearing the real pointer when it leaves the window. Still writing the pointer splash: I'm making the click ray come from the pointer's current position, so the ripple lands where the cursor actually is. Still writing the splash: I'm finding each fish's closest point on the ray and pushing it away from the ray with a force that fades out with distance. Still writing the hint: I'm setting a mode switch so the on-screen text changes between the real cursor and the virtual demo cursor, and I'm wondering whether the longer wording will wrap cleanly on a narrow phone screen. Still writing the resize handler: I'm reading the window size, computing the aspect ratio, and sizing the renderer to fill the screen. Still writing the camera: I'm sizing the view distance so the whole tank fits the frame at this aspect ratio, then pointing the camera at the center. Still writing the camera: I'm checking the framing numbers for a wide screen, where the tank fills about 80% of the height and most of the width. Still writing the camera: I'm checking the framing numbers for a narrow phone screen, where the tank fills about 60% of the height and most of the width. Still writing the layout: I'm placing the glass pane, back wall, floor, and caustics at the tank's edges, and updating the bounds the fish steer within. Still writing the layout: I'm wiring up the wall, floor, and caustics to the tank bounds, and finishing the bounds for the fish, rocks, and weeds before the animation loop starts. Still writing the bubbles: I'm advancing each bubble upward with a slight sideways drift, respawning any that rise past the top, and pushing the updated positions to the geometry. Still writing the bubbles: I'm respawning each bubble at a random spot inside the tank, with a random depth, and keeping the particle positions updated. Still writing the plankton drift: I'm moving each particle with a slow sine wobble and wrapping it back to the other side of the tank when it leaves. Still writing the weed sway: I'm offsetting each vertex sideways by its height, so the tips move most and the base stays put. Still writing the weeds: I'm checking their tops against the tank's midline so fish can swim among them, and I'm setting the back wall shader's time uniform every frame. Still writing the glow: I'm working out how big the cursor glow should be in pixels, so it roughly matches the flee radius at the front of the tank. Still writing the glow: I'm working out its pixel size from the flee radius and the camera distance, so the glow matches the fish's scare zone at any window height. Still writing the glow sizing: I'm making its diameter match the flee radius at the tank's depth, and softening the gradient so the reaction zone is visible without being too strong. Now I'm setting up the frame loop, starting with the delta time and reading the current pointer position. Still writing the frame loop: I'm stepping the fish and rendering each frame, and I'm clamping the time delta so a stalled tab can't send negative or huge steps. Still writing the raycaster: I'm checking that the camera's world matrix is current before casting the pointer ray, since the camera moved during layout. Still writing the camera: I'm making sure its world matrix is current before the raycast, and calling it in the layout step too, since the lookAt call only updates the quaternion. Still writing the materials and geometry: I'm counting the meshes per fish and checking that the shared fin and eye materials won't cause problems. Still writing the materials: I'm setting up the eye and pupil geometry and two shared materials for them. Still writing the materials: I'm skipping stripes on the fish bodies to keep the geometry simple, and I'm keeping the fin and eye setup as it is. Still writing the spawn loop: I'm placing each school's fish at their home positions before the animation starts. Still writing the spawn code: I'm building the fish from each school's home position with small random offsets, then clamping them inside the tank. Still writing the wall shader: I'm setting the gradient colors from deep to bright at the top, with the water surface line near the edge. Still writing the wall shader: I'm adding soft light shafts from the surface by layering two sine waves across the wall's UVs, so the glow ripples slightly over time. Still writing the wall shader: I'm blending the back wall's color bands, layering in the light shafts, and adding a rippling surface line near the top. Still writing the wall shader: I'm fixing the vignette, since its smoothstep edges are reversed and need to be flipped. Still writing the wall shader: I'm checking the vertex shader's varying and transform before finishing the fragment shader. Still writing the wall shader: I'm fixing the vignette's smoothstep so its edges run in the right order, and checking the vertex shader before wrapping up the fragment code. Still writing the textures: I'm checking the caustic overlay's color space and making sure the sand texture is treated as sRGB. Next I'm composing the hint line, something like "Move your mouse near the fish · click for a splash," and confirming the tone mapping and geometry helpers exist in this three.js version. Still writing the event handling: I'm making sure touch pointers count as real input, and setting touch-action to none on the page so dragging doesn't scroll it. I'm checking the pointer-out handling on the document, since leaving the page should count as no longer having the pointer. Once the mouse has moved over the frame, I'll treat the pointer as real rather than virtual. I also want the fish to react quickly to the cursor, so I'm keeping the acceleration high enough for that. Still writing the fish: I'm deciding how close they can get to the cursor before fleeing, and checking whether the flee push and the tank bounds fight each other at the edges. Still writing the HTML shell: I'm setting up the page's styles, and I'm making sure the body and html fill the window with no scrolling. Still writing the page styles: I'm setting the body and html to fill the window with no scrolling, a dark blue background, and touch handling turned off so dragging doesn't scroll the page. Next I'm styling the canvas container and the title block. Still writing the page styles: I'm laying out the floating title and the glow that follows the cursor, with a soft radial gradient in screen blend mode. Still writing the media query styles: I'm shrinking the title and hint text for narrow screens. Still writing the page styles: I'm making the hint pill centered at the bottom with a width that fits its text, capped so it wraps on narrow screens. Now I'm finishing the page's styling for the hint pill, so it sits centered at the bottom and wraps cleanly on narrow screens. Still writing the scene constants: I'm setting the fish school colors and the plankton and bubble counts, then sizing the tank bounds. Still writing the constants: I'm setting the fish movement box and the clamp, lerp, and random helpers the simulation will use. Still writing the scene setup: I'm creating the renderer, fog, and camera, and computing the camera's half field-of-view tangent for later scale calculations. Setting up the lighting next: a hemisphere light for the overall glow and a directional sun from above and to the side. Then I'm building a helper to make canvas-based textures for the sand and caustic overlays. Still writing the sand texture: I'm adding per-pixel grain and a few darker specks, then setting it to sRGB and tiling it across the floor. Next up are the caustics. Still writing the caustic texture: I'm checking its falloff curve so the bright lines look right, and estimating the cost of looping over 512×512 pixels. Still writing the floor and wall: I'm adding the sand plane, the caustic overlay, and the back wall's uniforms. Still writing the wall material: I'm setting up the glass pane's basic material so the back wall shows through it. Still writing the glass: I'm making the pane's material transparent with depth writes off, and setting fog off on both the pane and the edge lines so they don't pick up a tint. Still writing the rock geometry: I'm looping over each icosahedron's vertices and perturbing them with a noise factor so every rock comes out a little different. Still writing the rock geometry: I'm displacing each vertex with a noise factor, then setting up the flat-shaded material and the rock's scale and drift position. Still writing the rock geometry: I'm setting each rock's radius from its scale, so the fish keep clear of the rocks' wider edges. Still writing the weeds: I'm generating a set of seaweed fronds in a few greens, each a tall plane with its base at the floor. Still writing the weeds: I'm giving each frond a random sway phase, speed, and amplitude, then storing them on the group so the animation can move them. Still writing the bubbles: I'm setting up a soft round sprite texture and a point cloud for them, then looping over each bubble to give it a starting position. Still writing the bubbles: I'm giving each one a starting position, then setting up the plankton dots with a soft round texture. Still writing the plankton: I'm setting up the soft dot texture and the point cloud for them, then planning the fish setup and the raycaster that the fish steering will use. Still writing the layout: I'm making sure the fish array is defined before the layout function runs, since it clamps against that array. The order will be config, renderer, scene, textures, objects, layout, the first layout call, fish spawning, listeners, and then the loop. I'll also need the rocks and weeds in place before that. Now I'm setting up the layout function: it reads the window size, resizes the renderer, and picks the tank's width and height from the aspect ratio. Then I'm positioning the camera far enough back that the whole tank fits on screen. Still writing the layout: I'm sizing the walls, floor, and caustics to the tank bounds, then keeping the rocks, weeds, and fish inside the tank. Still writing the layout: I'm working out the glow radius in pixels from the camera distance, and checking how the rocks and weeds sit on the floor after a resize. Still writing the glow and the fish setup: I'm sizing the glow element from its pixel radius, then adding a clamp helper that keeps each fish inside the tank bounds. Next I'm building the school groups with their materials. Still writing the fish: I'm building each fish's body mesh from the school's material, then starting on its tail pivot. Still writing the fish: I'm adding the tail pivot, dorsal and anal fins, and the paired pectoral fins on each side of the body. Still writing the eyes and pupils for each side of the fish, then I'm placing the fish at a random spot near its school's home point and clamping it inside the tank. Still writing the fish: I'm finishing the fish's data record with its school, velocity, wiggle phase, and size, then picking a first swimming target. Still writing the geometry: I'm building the fish body mesh and the tail shape, extruded for a little thickness. Still writing the materials and shapes: I'm making the eye and pupil materials separate from the geometry, then describing the body, tail, and dorsal fin shapes. Still writing the pointer handling: I'm storing the pointer's normalized coordinates and flagging that a real pointer is in use, then wiring the raycaster into the pointerdown handler so it triggers a splash. Still writing the pointer: I'm defining a helper that returns the normalized pointer position, using the real pointer when one is present and otherwise a slow sweeping virtual pointer. Still writing the render loop: I'm setting up the timer, the glow and hint elements, and a frame function that clamps the time step, converts the pointer to a ray, then moves the fish, bubbles, plankton, and weeds. Still writing the frame update: I'm nudging the caustic texture offset over time, pushing the time uniform to the walls, then mapping the pointer's ray to screen pixels and easing the glow element toward that spot. Still writing the fallback: I'm finishing the frame loop's HUD update, setting the glow's opacity and the hint text by pointer mode, and starting the render call. I'm checking the first-frame timing, since the time step gets clamped to zero if the first animation frame arrives before the timer starts. Then I'm adding a resize listener so the glow size gets recalculated when the window changes. Still writing the splash: I'm finding each fish's distance from the ray, and for any fish within a set radius I'm computing a strength that falls off with distance. Still writing the splash: I'm applying a push to any fish inside the radius, with a stronger push closer in, and I'm raising its scare value so the speed clamp lets it dart away. Still writing the bubble update: I'm giving each bubble a respawn point near the bottom of the tank, with a random rise speed and wobble phase. Still writing the bubble update: I'm moving each bubble upward with a slight sideways wobble, and respawning any bubble that reaches the top of the tank. Still writing the bubble update: I'm clamping each bubble's sideways position so it stays inside the tank after a resize, and spreading the bubbles through the tank's volume at the start. Still writing the plankton: I'm giving each speck a random starting position and a slow drift speed, then updating their positions over time. Still writing the plankton drift: I'm nudging each speck's position with slow sine-based motion, then wrapping any speck that leaves the tank back to the opposite side. Still writing the demo pointer: I'm checking the virtual pointer's starting position near the upper right of the screen, where fish will scatter when the first frame runs. Still writing the splash and bubble logic: I'm checking the fish's wander targets and how they stay within the tank. Still writing the fish: I'm checking the spawn and target bounds so the schools stay inside the tank, then looking at how the rocks and the front and back glass walls limit their movement. Still writing the fish: I'm checking the scale of the fish and weeds against the tank, and whether the density of forty-two fish looks right. Still writing the body: I'm generating the body's vertex positions along its length, with each cross-section's radius shaped to taper toward the head and tail. Still writing the body geometry: I'm placing each cross-section's vertices around the body's circumference, then stitching them into triangles. Still writing the tail: I'm defining a function that builds the tail's outline as a shape, starting from the base and curving out to the fin edges. Still writing the tail: I'm finishing the tail's curves back to the base, then defining the dorsal fin outline that curves up from the top of the body and closes flat against it. Checking the fin's base against the body's top edge, the fin should stay hidden at its root and only show above the body. Still writing the extrusion helper: I'm building the geometry from the shape with a fixed depth and no bevel, then centering it on the z axis. Still working out the point size: I'm checking how three.js scales point sprites with distance, comparing the projected pixel size against the actual world-space radius. Still working on the point size: I'm checking how three.js scales point sprites with distance, comparing the projected pixel size against the actual world-space radius. Checking how three.js sizes point sprites: the shader multiplies the material size by a factor based on viewport height and distance, so the projected pixel size depends on the camera's focal length rather than matching the world-space radius directly. I'm working out the point size for the bubbles and plankton, since three.js scales point sprites by distance: a size of about 0.6 should give bubbles roughly 16px at this camera distance, and about 0.2 should give plankton around 5px. Still working out the bubble sizing: I'm scaling the bubble and plankton point sizes so they look right at the camera distance, and considering whether the size should change with the viewport. Still working on the point sizes: I'm converting the desired pixel diameter for the bubbles into a material size that accounts for the camera distance and the viewport height. Sizing the bubble and plankton points now: I'll scale their sizes by camera distance, with bubbles around 0.6 and plankton around 0.2 at the desktop reference distance, so they stay visible on smaller screens too. Still working out the bubble sizing: I'm setting the bubble point size from the camera distance so it stays about 14 pixels across at any screen size. I'm settling the bubble and plankton point sizes in the layout, scaling each by camera distance so that bubbles come out around 13 pixels and plankton around 4.5 pixels on screen. I'm working out the glow's screen size now: the radius in pixels is the flee radius times half the viewport height, divided by the camera distance times the half-field tangent, so I'll double that for the diameter. I'm working out the glow's diameter in pixels: twice the flee radius, scaled by half the viewport height and divided by the camera distance times the half-field tangent. I'm also weighing whether a ring-shaped gradient would read better than a solid blob. Still working on the glow's look: I'm shaping it as a soft ring with a faint fill, using a radial gradient sized to the glow's own edge so the outer rim marks the flee radius. Still tuning the glow: I'm settling on a soft ring with a faint fill, sized so the outer rim marks the flee radius. I'm also setting the fish's cruise speed to around 1.0 to 1.5 units per second so the tank feels lively without being hurried. Still writing the fish flocking: I'm computing separation from nearby fish and averaging the velocities and positions of the school for alignment and cohesion. Still writing the flee logic: I'm computing how far each fish sits from the pointer's line and easing it away from that line when it gets too close. Still working on the fish flee logic: I'm computing each fish's distance from the cursor's line of sight and pushing it away once it gets too close, with the push easing in smoothly. Still working on the fish flee logic: I'm checking how the pointer's ray is used to find each fish's closest point on the line of sight, and I'm confirming that the ray reference stays valid across frames. Still working on the target picking: I'm placing each fish's next target around its school's home point, clamping it inside the tank bounds, and giving it a fresh timer. Still working out the fish's home picks and the bounds for each school. Still laying out the scene order: the renderer, camera and lights, then the textures and shared geometries and materials, followed by the decor and the fish schools. Still working through the final ordering: after the layout call, I'm going to finish the frame-loop wiring, the event listeners, and then start the loop. Still writing the aquarium's main setup: I'm organizing the config, then the renderer and camera, before the frame loop and event handling come together at the end. Still writing the shader setup: I'm defining the wall's vertex and fragment shaders, passing the UV coordinates through and driving the wall's look with a time uniform. Still writing the wall shader: I'm setting up a few blue-to-cyan colour bands for the water, then adding shifting light shafts that brighten toward the top of the wall. Still writing the wall shader: I'm blending the water colours toward a brighter tone near the surface and adding a shimmering surface line that bends with a small wave. Still writing the wall shader: I'm finishing the vignette, darkening the corners so the tank edges fall off softly. Still checking the wall's vignette and the texture repeat settings, making sure the sand tiling and the caustic brightness look right. Still wrapping up the wall shader's ending: I'm checking the vignette and texture repeat settings so the sand tiling and caustic brightness come out right. Still writing the page's HTML head and the module setup, with the import map and styles in place before the script block. Still writing the config block: I'm setting the school colours, the plankton and bubble counts, and a field-of-view constant from which I'll derive the tangent for the on-screen sizing. Still writing the code: I'm setting up the scene's renderer, scene, and camera, and adding the hemisphere light. Still writing the aquarium's scene setup: I'm adding the hemisphere and sun lights, then moving on to the textures, shared geometry, and decor. Now I'm sorting out how the layout scales the tank on narrow screens: the width and height both shrink toward portrait proportions. Still writing the camera and layout code: I'm placing the camera from the tank size and field of view, then resizing the glass, floor, wall and caustics to match. Still writing the layout: I'm sizing the glow and the reaction ring from the tank depth, then setting the bubble and plankton point sizes to match the camera. Checking that the bubble and plankton sizes come out right at the camera distance, and that the glow and point sizes match the tank scale. I'm also making sure the layout reruns on resize, with the DOM references declared before it's called. Still wiring the camera and scene to match the resized tank, and I'm making sure the layout's DOM references and tank bounds are set before it runs. Still writing the shared state: I'm declaring the renderer, scene and camera handles alongside the sand, caustic and wall materials, the rocks, weeds and particle arrays, and the raycaster and glow-element references. Still writing the sand texture: I'm filling each pixel with a noisy beige tone and scattering darker specks across the canvas. Still writing the canvas texture for the caustics: I'm filling the pixels row by row to build the light pattern. Still filling the caustic texture pixels: I'm computing a wave pattern from sine terms and shaping it into bright bands. Still filling the caustic texture: I'm finishing the pixel brightness from the wave pattern and wrapping the texture so it tiles across the floor. Still writing the caustic material setup, with the texture wired in for the floor. Still wiring up the materials: I'm defining the eye, pupil and other basic materials the fish and decor will use. Still wiring up the materials: I'm adding the fish's body, tail, fin and eye geometries, with the pupils and pectoral fins as spheres. Still tuning the fish scale for narrow screens, so they stay visible on mobile. I'm settling the fish scaling: the fish will grow with the camera distance so they stay readable on narrow screens, and that gets applied inside the layout function. Still settling the fish scaling: I'm making the fish grow with camera distance, clamped between 1.0 and 1.6, so they stay readable on narrow screens. Still settling the fish scaling: I'm checking that the weeds still fit a portrait tank, since they're shorter relative to the taller height. Now I'm working out whether the flee radius needs to scale too: the fish get bigger with the camera distance, so a fixed world-unit radius would feel too small on narrow screens. Still writing the fish-scaling logic: I'm making the flee radius grow with the same factor as the fish, so the reaction zone stays in proportion on narrow screens. Still writing the wall shader setup: I'm defining the time uniform and creating the wall plane that will carry it. Still writing the wall material: I'm defining the shader materials and the glass edges and pane that frame the tank. Still making the bubble and plankton points: I'm checking whether the Points bounding sphere, computed once from the initial positions, would cause them to be culled as they move. Checking whether the points' bounding sphere will cause culling. I'm going to turn off frustum culling for the bubbles and plankton, and probably for the weeds too. Next I'm checking the fish's flee radius against the tank scale, and I'm keeping the weeds and points from being culled. Still checking the Three.js API names as I go: the tone mapping, fog, colour space, geometry and point-material calls all look right for this version. Still checking the Three.js calls as I go: the shape and extrude setup, the raycaster and its ray-point test, and the matrix-to-quaternion conversion all look right. I'm also watching for a possible NaN if a vector gets normalized while it's still zero. Still checking the fish-bounds logic: the rock tops sit near the tank floor, so I'm making sure the fish's lower clamp keeps them clear of the obstacles. Checking the obstacle logic now: a fish pushed out of a rock's sphere can end up slightly inside the rock's top, but the push-out handles it. The clamp at the tank floor keeps fish from being pushed through the bottom. I also need to confirm that the rock's collision radius, which is based on its base size, still covers its wider horizontal extent. Still settling the obstacle logic: the fish will be pushed out of each rock by a radius that also scales with the fish size, so they keep clear of the rocks as they grow. Still writing the fish: I'm building each one as a group with its body, tail and fins, and attaching them to a school. Still writing the fish: I'm assembling each body from its main shape, tail, dorsal and anal fins, and pectoral fins, then adding the eyes on each side. Still writing the fish: I'm finishing each one's pupils and building its model group with a randomized size, cruising speed and starting velocity, then placing it near its school's home and scaling it. Still settling the layout: I'm working out the camera distance first, then deriving the fish scale from it before the fish get resized. Still wiring up the event listeners: I'm defining the pointer state and a helper that turns a mouse position into normalized coordinates for the raycaster, then finishing the loop setup. Still wiring the pointer: I'm attaching move, press and release listeners to the window, and clearing the real-pointer flag when a touch lifts or the pointer leaves the page. Still wiring up the frame loop: I'm stepping the time and fish, bubbles, plankton and weeds each frame, and then working out the cursor ring's screen position. Still wiring the frame loop: I'm easing the glow ring toward the pointer, setting its transform and opacity, and switching the hint text when the pointer mode changes. Still writing the animation loop: I'm updating each plankton particle's position with a gentle sway. Still writing the animation loop: I'm moving each plankton particle with a slow drift and wrapping it when it leaves the tank. Still wiring the plankton: I'm clamping each particle to the tank bounds so the drift stays gentle and doesn't jump across the screen. Still writing the fish-scaling logic and checking the Three.js calls as I go, so the pickHome call and the homeT countdown in the fish step line up. Still writing the page styles: I'm setting the full-screen layout, dark background and text colour, and turning off touch gestures and text selection. Still writing the page styles: I'm adding the title block, the cursor glow, and the hint bar, all fixed over the full-screen canvas. Still writing the page styles: I'm sizing the hint bar and its pill shape, giving it a translucent backdrop and blur, and tightening its text and padding on narrow screens. Still writing the Config section: I'm setting the flee radius, which is the world-space distance at which fish start to scatter from the cursor. Still writing the Config section: I'm defining the tank's half-extents and the fish swim box, plus the fish and plankton counts and the camera field of view. Setting up the renderer, scene, and camera next: the tone mapping, fog, and pixel ratio, plus the camera's field of view. Still setting up the scene: I'm writing the camera and its field of view, then adding the hemisphere and directional lights and the raycaster that will handle the pointer. Still writing the fish logic: I'm defining the scratch vectors and matrices the boids steps will reuse, so each frame avoids allocating new ones. Still checking the fish-bounds and obstacle logic, I'm confident the fish stay in the tank and clear of the rocks. Now I'm setting up the initial layout call and spawn, plus a resize listener that re-runs layout. Still writing the script: I'm making sure the glow and hint elements are queried at the top, and that the hint text updates on the first frame. Still writing the fish-motion logic: I'm defining a helper that sways each weed by shifting its vertices sideways based on height and time. Still writing the bubble setup: I'm creating a buffer of bubble positions and a point material for them. Still writing the bubble update: I'm moving each bubble upward with a slight sideways drift, and respawning it at the bottom once it rises past the top of the tank. Still writing the bubbles: I'm respawning each one at a random spot near the floor, so they start from the bottom again. I'm checking that the flee logic uses the real pointer ray, so the fish don't stay scared of a virtual pointer all the time. Still tuning the fish-scare maths: I'm checking the smoothstep curve for the flee strength and confirming the acceleration stays capped so the fish don't dart too fast. Still checking the fish-scare decay: the boost fades over about 3.6 seconds, and the speed cap sits roughly between 3.6 and 5 units per second. Now I'm checking whether the floor shows up in view, since the camera sits slightly above the tank and the floor sits well below it. I'm checking the floor's visibility now: the camera sits a little above the tank, the floor is well below it, and the vertical field of view should still catch a band of floor along the bottom edge of the frame. I'm checking that the floor stays visible at the bottom of the frame and that the tank's top edge falls inside the field of view. I'm also making sure the back wall fills the tank's far side, with the clear colour showing outside it. Still writing the code: I'm finishing the bubble setup and the update step, then wrapping up the start-up call and the event listeners. Still writing the final code: I'm settling on the render setup and clear colour, and checking that the Three.js helpers I'm calling exist in this version.