Interactive 3D aquariumOne-line
GLM 5.3 Flash
by Z.aiReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 1 min 53 s
- Cost
- $0.0050
- Output tokens
- 9,872
- Spent thinking
- 4,064 tokens
- Speed
- 88 tok/s
- Console errors
- 0
- First token after
- 418 ms
- First visible text after
- 1 min 14 s
- Prompt tokens
- 424
- Code size
- 16.2 KB
- Finish reason
- stop
- Served by
- Cohere
- Model id
z-ai/glm-5.3-flash- Reasoning sent
{"effort":"low"}- Max output
- 64,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 · 477 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aqua Escape — 3D Aquarium</title>
<style>
html, body {
margin: 0; padding: 0; width: 100%; height: 100%;
overflow: hidden;
background: radial-gradient(ellipse at 50% 30%, #10314f 0%, #081c33 45%, #030a14 100%);
font-family: "Georgia", "Times New Roman", serif;
color: #cfe8ff;
}
#scene { position: fixed; inset: 0; display: block; }
.hud {
position: fixed; pointer-events: none; user-select: none;
z-index: 10;
}
#title {
top: 22px; left: 26px;
}
#title h1 {
margin: 0; font-size: 30px; font-weight: normal; letter-spacing: 3px;
color: #bfe3ff;
text-shadow: 0 0 18px rgba(80,160,255,.55), 0 2px 4px rgba(0,0,0,.6);
}
#title p {
margin: 6px 0 0 2px; font-size: 13px; font-style: italic;
color: rgba(180,215,255,.75); letter-spacing: 1px;
text-shadow: 0 1px 3px rgba(0,0,0,.6);
}
#hint {
bottom: 26px; left: 50%; transform: translateX(-50%);
font-size: 14px; letter-spacing: 1.5px; font-style: italic;
color: rgba(200,230,255,.85);
background: rgba(8,25,48,.45);
border: 1px solid rgba(120,190,255,.22);
padding: 9px 20px; border-radius: 30px;
backdrop-filter: blur(4px);
text-shadow: 0 1px 3px rgba(0,0,0,.7);
animation: pulse 3s ease-in-out infinite;
white-space: nowrap;
}
@keyframes pulse {
0%,100% { opacity: .85; } 50% { opacity: .5; }
}
#stats {
top: 26px; right: 26px; text-align: right;
font-family: "Courier New", monospace; font-size: 12px;
color: rgba(150,200,255,.6); letter-spacing: 2px;
text-shadow: 0 1px 3px rgba(0,0,0,.6);
}
#vignette {
position: fixed; inset: 0; pointer-events: none; z-index: 5;
background: radial-gradient(ellipse at center, transparent 55%, rgba(0,5,15,.55) 100%);
}
@media (max-width: 600px) {
#title h1 { font-size: 22px; }
#hint { font-size: 11px; padding: 7px 14px; bottom: 16px; }
#stats { display: none; }
}
</style>
<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}
</script>
</head>
<body>
<canvas id="scene"></canvas>
<div id="vignette"></div>
<div class="hud" id="title">
<h1>AQUA ESCAPE</h1>
<p>a living reef in your browser</p>
</div>
<div class="hud" id="stats">FISH IN TANK: <span id="count">0</span></div>
<div class="hud" id="hint">Move your mouse near the fish — they will dart away</div>
<script type="module">
import * as THREE from 'three';
/* ---------------- Setup ---------------- */
const canvas = document.getElementById('scene');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x06182e, 0.030);
const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 1.4, 11.5);
/* ---------------- Lighting ---------------- */
scene.add(new THREE.HemisphereLight(0x8fb8e8, 0x2a2416, 0.85));
const sun = new THREE.DirectionalLight(0xdff2ff, 1.5);
sun.position.set(3, 12, 6);
scene.add(sun);
const fill = new THREE.PointLight(0x3a7ec8, 1.2, 30);
fill.position.set(-6, 3, 5);
scene.add(fill);
/* ---------------- Tank bounds ---------------- */
const TANK = { x: 7.6, yMin: -2.6, yMax: 4.4, z: 3.1 };
/* ---------------- Sand floor ---------------- */
{
const g = new THREE.PlaneGeometry(46, 24, 46, 24);
const pos = g.attributes.position;
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i), y = pos.getY(i);
pos.setZ(i, Math.sin(x * 0.7) * 0.18 + Math.cos(y * 0.9 + x * 0.3) * 0.14);
}
g.computeVertexNormals();
const sand = new THREE.Mesh(g, new THREE.MeshStandardMaterial({ color: 0xb99a5e, roughness: 1 }));
sand.rotation.x = -Math.PI / 2;
sand.position.y = -3.1;
scene.add(sand);
}
/* ---------------- Rocks ---------------- */
{
const rockMat = new THREE.MeshStandardMaterial({ color: 0x4a5560, roughness: 0.95, flatShading: true });
const rng = mulberry(7);
for (let i = 0; i < 7; i++) {
const r = new THREE.Mesh(new THREE.IcosahedronGeometry(0.35 + rng() * 0.6, 0), rockMat);
r.scale.y = 0.55 + rng() * 0.3;
r.position.set(-7 + rng() * 14, -2.9, -2.8 + rng() * 5.6);
r.rotation.set(rng() * 3, rng() * 3, rng() * 3);
scene.add(r);
}
}
/* ---------------- Swaying seaweed (shader) ---------------- */
const weeds = [];
{
const rng = mulberry(21);
const geo = new THREE.PlaneGeometry(0.38, 1, 1, 10);
geo.translate(0, 0.5, 0);
const vert = `
uniform float uTime; uniform float uPhase;
varying float vY;
void main(){
vec3 p = position;
float h = clamp(p.y, 0.0, 1.0);
p.x += sin(uTime*1.3 + uPhase + p.y*2.2) * h*h * 0.38;
p.z += sin(uTime*0.9 + uPhase*1.7 + p.y*1.5) * h*h * 0.18;
vY = h;
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}`;
const frag = `
varying float vY;
void main(){
vec3 c = mix(vec3(0.03,0.20,0.10), vec3(0.20,0.65,0.35), vY);
gl_FragColor = vec4(c, 1.0);
}`;
for (let i = 0; i < 16; i++) {
const mat = new THREE.ShaderMaterial({
vertexShader: vert, fragmentShader: frag, side: THREE.DoubleSide,
uniforms: { uTime: { value: 0 }, uPhase: { value: rng() * 6.28 } }
});
const w = new THREE.Mesh(geo, mat);
const h = 1.6 + rng() * 2.4;
w.scale.set(0.7 + rng() * 0.7, h, 1);
w.position.set(-7.5 + rng() * 15, -3.0, -2.6 + rng() * 4.8);
w.rotation.y = rng() * 6.28;
scene.add(w);
weeds.push(mat);
}
}
/* ---------------- Bubbles ---------------- */
const bubbles = [];
{
const rng = mulberry(99);
const bMat = new THREE.MeshBasicMaterial({ color: 0xbfe6ff, transparent: true, opacity: 0.30 });
for (let i = 0; i < 46; i++) {
const b = new THREE.Mesh(new THREE.SphereGeometry(0.03 + rng() * 0.06, 8, 8), bMat);
b.position.set(-7 + rng() * 14, -3 + rng() * 7.5, -2.8 + rng() * 5.6);
scene.add(b);
bubbles.push({ mesh: b, speed: 0.5 + rng() * 1.1, phase: rng() * 6.28 });
}
}
/* ---------------- Dust motes ---------------- */
{
const rng = mulberry(5);
const n = 220, arr = new Float32Array(n * 3);
for (let i = 0; i < n; i++) {
arr[i*3] = -7.5 + rng() * 15;
arr[i*3+1] = -2.8 + rng() * 7;
arr[i*3+2] = -3 + rng() * 6;
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(arr, 3));
const dust = new THREE.Points(g, new THREE.PointsMaterial({
color: 0x9fc8e8, size: 0.045, transparent: true, opacity: 0.5, sizeAttenuation: true
}));
scene.add(dust);
}
/* ---------------- Tank frame hint ---------------- */
{
const box = new THREE.BoxGeometry(TANK.x * 2, TANK.yMax - TANK.yMin + 1.2, TANK.z * 2);
const edges = new THREE.LineSegments(
new THREE.EdgesGeometry(box),
new THREE.LineBasicMaterial({ color: 0x7ab8ff, transparent: true, opacity: 0.14 })
);
box.dispose();
edges.position.y = (TANK.yMax + TANK.yMin) / 2 + 0.4;
scene.add(edges);
}
/* ---------------- Fish factory ---------------- */
function makeFish(rng) {
const group = new THREE.Group();
const hue = [0.05, 0.09, 0.13, 0.45, 0.52, 0.58, 0.85, 0.92][Math.floor(rng() * 8)];
const sat = 0.75, light = 0.55;
const col = new THREE.Color().setHSL(hue, sat, light);
const mat = new THREE.MeshStandardMaterial({
color: col, roughness: 0.45, metalness: 0.15,
emissive: new THREE.Color().setHSL(hue, sat, 0.12)
});
const finMat = new THREE.MeshStandardMaterial({
color: col, roughness: 0.6, transparent: true, opacity: 0.85,
emissive: new THREE.Color().setHSL(hue, sat, 0.08)
});
// body (length along local +X)
const body = new THREE.Mesh(new THREE.SphereGeometry(0.5, 18, 12), mat);
body.scale.set(1.6, 0.72, 0.48);
group.add(body);
// tail pivot + tail fin
const tailPivot = new THREE.Group();
tailPivot.position.x = -0.78;
const tail = new THREE.Mesh(new THREE.ConeGeometry(0.26, 0.55, 10), finMat);
tail.rotation.z = -Math.PI / 2;
tail.position.x = -0.26;
tail.scale.z = 0.25;
tailPivot.add(tail);
group.add(tailPivot);
// dorsal fin
const dorsal = new THREE.Mesh(new THREE.ConeGeometry(0.17, 0.36, 8), finMat);
dorsal.position.set(-0.05, 0.42, 0);
dorsal.scale.z = 0.22;
group.add(dorsal);
// pectoral fins
for (const s of [-1, 1]) {
const fin = new THREE.Mesh(new THREE.SphereGeometry(0.16, 8, 6), finMat);
fin.scale.set(1.4, 0.4, 0.14);
fin.position.set(0.15, -0.08, 0.28 * s);
fin.rotation.y = -0.5 * s;
group.add(fin);
}
// eyes
const eyeMat = new THREE.MeshStandardMaterial({ color: 0x101418, roughness: 0.3 });
for (const s of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.065, 8, 8), eyeMat);
eye.position.set(0.58, 0.09, 0.185 * s);
group.add(eye);
}
const size = 0.55 + rng() * 0.75;
group.scale.setScalar(size);
scene.add(group);
return {
group, tailPivot, size,
pos: new THREE.Vector3(-TANK.x + rng() * TANK.x * 2, TANK.yMin + rng() * (TANK.yMax - TANK.yMin), -TANK.z + rng() * TANK.z * 2),
vel: new THREE.Vector3(rng() - 0.5, (rng() - 0.5) * 0.3, rng() - 0.5).multiplyScalar(1.2),
target: new THREE.Vector3(),
baseSpeed: 1.1 + rng() * 0.7,
panic: 0,
wigglePhase: rng() * 6.28,
bank: 0,
prevYaw: 0,
newTarget() {
this.target.set(
-TANK.x + 0.8 + rng() * (TANK.x * 2 - 1.6),
TANK.yMin + 0.5 + rng() * (TANK.yMax - TANK.yMin - 1.0),
-TANK.z + 0.6 + rng() * (TANK.z * 2 - 1.2)
);
}
};
}
function mulberry(seed) {
return function () {
seed |= 0; seed = seed + 0x6D2B79F5 | 0;
let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
/* ---------------- Spawn fish ---------------- */
const rng = mulberry(1337);
const FISH = [];
for (let i = 0; i < 30; i++) {
const f = makeFish(rng);
f.newTarget();
FISH.push(f);
}
document.getElementById('count').textContent = FISH.length;
/* ---------------- Mouse "hand" indicator ---------------- */
const handLight = new THREE.PointLight(0x9fd8ff, 0, 7);
scene.add(handLight);
const handOrb = new THREE.Mesh(
new THREE.SphereGeometry(0.35, 16, 16),
new THREE.MeshBasicMaterial({ color: 0xbfe6ff, transparent: true, opacity: 0.10, blending: THREE.AdditiveBlending, depthWrite: false })
);
scene.add(handOrb);
/* ---------------- Interaction ---------------- */
const mouseNDC = new THREE.Vector2(0, 0);
let mouseActive = false;
const mouseWorld = new THREE.Vector3();
const raycaster = new THREE.Raycaster();
const movePlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), -1.2); // z = 1.2
window.addEventListener('pointermove', (e) => {
mouseNDC.x = (e.clientX / window.innerWidth) * 2 - 1;
mouseNDC.y = -(e.clientY / window.innerHeight) * 2 + 1;
mouseActive = true;
document.getElementById('hint').style.animation = 'none';
document.getElementById('hint').style.opacity = '0.7';
});
window.addEventListener('pointerleave', () => { mouseActive = false; });
window.addEventListener('pointerdown', (e) => {
mouseNDC.x = (e.clientX / window.innerWidth) * 2 - 1;
mouseNDC.y = -(e.clientY / window.innerHeight) * 2 + 1;
mouseActive = true;
});
/* ---------------- Simulation helpers ---------------- */
const X_AXIS = new THREE.Vector3(1, 0, 0);
const _v1 = new THREE.Vector3(), _v2 = new THREE.Vector3(), _v3 = new THREE.Vector3();
const _q = new THREE.Quaternion(), _qRoll = new THREE.Quaternion();
function updateFish(f, dt, t) {
const speedMul = f.panic > 0 ? 2.6 : 1;
const maxSpeed = f.baseSpeed * speedMul * (0.85 + f.size * 0.3);
const accel = f.panic > 0 ? 14 : 3.2;
// wander steering
if (f.pos.distanceTo(f.target) < 1.0 || f.vel.lengthSq() < 0.01 && Math.random() < 0.01) f.newTarget();
_v1.subVectors(f.target, f.pos).normalize().multiplyScalar(maxSpeed * 0.7).sub(f.vel);
f.vel.addScaledVector(_v1, Math.min(accel, 3.5) * dt);
// FLEE from mouse point
if (mouseActive) {
const d = f.pos.distanceTo(mouseWorld);
const R = 3.4;
if (d < R) {
const w = (R - d) / R;
_v2.subVectors(f.pos, mouseWorld).normalize();
_v3.copy(_v2).multiplyScalar(maxSpeed * (1.6 + w * 1.6)).sub(f.vel);
f.vel.addScaledVector(_v3, Math.min(1, w * 2.2) * dt * 10);
f.panic = Math.min(1, f.panic + w * dt * 6);
}
}
f.panic = Math.max(0, f.panic - dt * 1.4);
// separation from neighbours
for (const o of FISH) {
if (o === f) continue;
const dx = f.pos.x - o.pos.x, dy = f.pos.y - o.pos.y, dz = f.pos.z - o.pos.z;
const d2 = dx * dx + dy * dy + dz * dz;
if (d2 < 1.1 && d2 > 1e-6) {
const inv = (1.05 - Math.sqrt(d2)) * 2.5;
f.vel.x += dx / Math.sqrt(d2) * inv * dt * 3;
f.vel.y += dy / Math.sqrt(d2) * inv * dt * 3;
f.vel.z += dz / Math.sqrt(d2) * inv * dt * 3;
}
}
// soft bounds
const m = 0.7;
if (f.pos.x > TANK.x - m) f.vel.x -= (f.pos.x - (TANK.x - m)) * 8 * dt;
if (f.pos.x < -TANK.x + m) f.vel.x -= (f.pos.x + (TANK.x - m)) * 8 * dt;
if (f.pos.y > TANK.yMax - m) f.vel.y -= (f.pos.y - (TANK.yMax - m)) * 8 * dt;
if (f.pos.y < TANK.yMin + m) f.vel.y -= (f.pos.y + (TANK.yMin + m)) * 8 * dt;
if (f.pos.z > TANK.z - m) f.vel.z -= (f.pos.z - (TANK.z - m)) * 8 * dt;
if (f.pos.z < -TANK.z + m) f.vel.z -= (f.pos.z + (TANK.z - m)) * 8 * dt;
// clamp speed
const sp = f.vel.length();
if (sp > maxSpeed) f.vel.multiplyScalar(maxSpeed / sp);
if (sp < 0.25) f.vel.setLength(0.25);
f.pos.addScaledVector(f.vel, dt);
f.pos.y += Math.sin(t * 1.7 + f.wigglePhase) * 0.15 * dt; // gentle bob
// orientation: align local +X with velocity, slerp smoothly
_v1.copy(f.vel).normalize();
_q.setFromUnitVectors(X_AXIS, _v1);
// banking based on yaw rate
const yaw = Math.atan2(_v1.z, _v1.x);
let dyaw = yaw - f.prevYaw;
if (dyaw > Math.PI) dyaw -= Math.PI * 2;
if (dyaw < -Math.PI) dyaw += Math.PI * 2;
f.prevYaw = yaw;
const bankTarget = THREE.MathUtils.clamp(-dyaw / Math.max(dt, 1e-4) * 0.12, -0.6, 0.6);
f.bank += (bankTarget - f.bank) * Math.min(1, dt * 5);
_qRoll.setFromAxisAngle(X_AXIS, f.bank);
_q.multiply(_qRoll);
f.group.quaternion.slerp(_q, Math.min(1, dt * 7));
f.group.position.copy(f.pos);
// tail wiggle — faster & wider when panicking
const wig = 5 + sp * 2.5 + f.panic * 9;
f.tailPivot.rotation.y = Math.sin(t * wig + f.wigglePhase) * (0.38 + f.panic * 0.35);
}
/* ---------------- Resize ---------------- */
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
/* ---------------- Main loop ---------------- */
const clock = new THREE.Clock();
const camTarget = new THREE.Vector3(0, 0.8, 0);
let camX = 0, camY = 1.4;
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
const t = clock.elapsedTime;
// mouse -> world point on plane
if (mouseActive) {
raycaster.setFromCamera(mouseNDC, camera);
if (raycaster.ray.intersectPlane(movePlane, mouseWorld)) {
mouseWorld.x = THREE.MathUtils.clamp(mouseWorld.x, -TANK.x - 1, TANK.x + 1);
mouseWorld.y = THREE.MathUtils.clamp(mouseWorld.y, TANK.yMin - 0.5, TANK.yMax + 1);
handLight.position.copy(mouseWorld);
handLight.intensity = 4;
handOrb.position.copy(mouseWorld);
handOrb.material.opacity = 0.10 + Math.sin(t * 5) * 0.03;
}
} else {
handLight.intensity = 0;
handOrb.material.opacity = 0;
}
for (const f of FISH) updateFish(f, dt, t);
for (const m of weeds) m.uniforms.uTime.value = t;
for (const b of bubbles) {
b.mesh.position.y += b.speed * dt;
b.mesh.position.x += Math.sin(t * 2 + b.phase) * 0.15 * dt;
if (b.mesh.position.y > 4.8) {
b.mesh.position.y = -3.1;
}
}
// gentle camera parallax
const tx = mouseActive ? mouseNDC.x * 0.9 : 0;
const ty = mouseActive ? mouseNDC.y * 0.5 : 0;
camX += (tx - camX) * Math.min(1, dt * 2.5);
camY += (ty - camY) * Math.min(1, dt * 2.5);
camera.position.set(camX, 1.4 + camY * 0.6 + Math.sin(t * 0.4) * 0.12, 11.5);
camera.lookAt(camTarget);
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>Reasoning trace (4,064 tokens)
Build a 3D aquarium with Three.js, fish swim away from mouse cursor. Self-contained, import map given. Need self-playing demo mode behind "Click or press a key to start"? It's not a game per se, but interactive — mouse needed. The sandbox iframe gets pointer events presumably on click? Actually pointer events work in iframe, keyboard needs focus. Mouse move should work directly. But rules say if demo uses keyboard, show "Click or press a key to start". I can just use mouse. Still, maybe include a small hint. I'll keep it simple: no keyboard, mouse works in iframe directly.
Fish: build procedural fish from cones/spheres or simple geometry with tail wiggle via shader or vertex animation. Simpler: use a fish made of a few meshes grouped (body ellipsoid, tail cone, fins), animate tail rotation. Many fish (30-ish), boids-like wandering behavior, flee from mouse point projected into 3D tank plane.
Mouse ray: cast onto plane at tank front or intersect a plane through tank; compute a 3D point, fish flee if within radius. Also add camera parallax slight.
Visuals: dark blue gradient background, caustics-like light? Add floating particles (food/dust), bubbles rising, seaweed swaying (simple planes or cones with sine sway), sand floor, glass tank edges maybe. Lighting: hemisphere + directional.
First paint meaningful: everything visible immediately, animate.
Let me write the code.
Fish design: body — use a squashed sphere (SphereGeometry scaled), tail — cone rotated, with group hierarchy: fishGroup > body, tailPivot at back. Wiggle tail with sin(time*speed). Add eyes. Color varies via HSL.
Boid-ish: each fish has velocity, wander target, flee from mousePoint. Keep within tank bounds with soft steering.
Tank: box 10x6x6 maybe, camera at z=9 looking at center. Slight camera sway following mouse a bit.
Seaweed: several blades made of PlaneGeometry bent? Simpler: cylinder chains — use a few segments as a TubeGeometry animated is heavy. Use a simple approach: a group of stacked cones/planes with sway rotation increasing with height. Or a plane with custom vertex shader sway — do a small shader material for seaweed: plane geometry, in vertex shader offset x by sin(time + y)*y factor. That's elegant.
Bubbles: small spheres rising, reset.
Dust particles: THREE.Points.
Caustics on sand: could fake with a moving light or just light color. Keep simple.
Fleeing: mouse in NDC, raycaster with plane z = something? Better: find intersection with a vertical plane facing camera at the mouse's... Simplest good UX: raycast against the front plane of the tank (z = tankMaxZ) plus maybe an invisible sphere region. Fish flee from point on plane z=2.5 (mid-front). Actually let ray intersect plane z = 0 through tank center; fish flee from that point in 3D — feels right.
Also fish should flee when mouse near — also make nearby fish dart faster tail wiggle.
HUD: title overlay, hint text "Move your mouse — fish dart away". Maybe small count. Use CSS glass overlay corners.
Resize handling.
Let me code:
```js
import * as THREE from 'three';
```
Scene, fog maybe. Background color deep blue with fog for depth.
Sand: large plane at bottom y=-3, color sandy, roughness. Add few rocks (icosahedrons).
Lighting: HemisphereLight (skyblue, sandcolor), DirectionalLight from above, PointLight subtle.
Fish count ~28.
Fish geometry: body sphere scaled (1, 0.45, 0.35?) Actually fish length along z or x? Let fish move along its local +z? Convention: make body length along x, forward = +x. Tail at -x. Cone tail pointing... Tail pivot group at x=-0.5, cone rotated so it extends -x, wiggling rotation.y.
Body: SphereGeometry(0.5, 16, 12) scaled (1.6, 0.75, 0.5). Color material with slight emissive. Stripes? skip.
Dorsal fin: small triangle cone. Side fins: two small planes. Eyes: small black spheres at x=+0.55, y=0.1, z=±0.22.
Wiggle: tailPivot.rotation.y = sin(t*speed)*0.5; also body slight rotation.y = sin(...)*0.1; and whole fish banks on turn: rotation.z proportional to angular change.
Movement: per fish:
- vel Vector3; wander: target point random in tank, steer toward target with maxSpeed, plus flee: if dist to mousePoint < R (say 2.6), add strong steer away scaled by (R-d)/R * fleeStrength; also boost maxSpeed temporarily (panic timer).
- Keep in bounds: if outside margin, steer inward strongly.
- Orient: fishGroup.lookAt(pos + vel) but our forward is +x. Use quaternion: set group rotation so local +x aligns with vel: use matrix lookAt with custom. Simpler: create quaternion from unit X to vel direction: q.setFromUnitVectors(new Vector3(1,0,0), dir). Smooth with slerp.
Banking: after slerp, multiply by rotation around forward axis based on turn rate. Keep simple: bank via rotateOnAxis.
Panic: when fleeing, speed multiplier 2, tail wiggle faster, color flash? Just speed.
Mouse point: raycaster.setFromCamera(mouse, camera), intersect plane z = 1.5? Tank z range -3..3. Use plane z=0.8 so it feels inside. Use THREE.Plane(new Vector3(0,0,1), -planeZ) i.e., plane constant: plane with normal (0,0,1) and constant -planeZ gives z=planeZ. ray.intersectPlane.
Also touch support: pointermove works.
Camera: position (0, 1.5, 10), slight parallax: camera.position.x = mouse.x*0.6 etc, lookAt(0,0,0). But parallax changes ray; fine.
Glass tank: draw frame edges with LineSegments from EdgesGeometry of box, subtle. Or skip glass (looks cleaner open water). I'll add a faint frame + subtle glass panels with very low opacity? Panels might mess raycast — exclude from raycasting by only raycasting the math plane, fine. I'll add faint top frame lines only. Actually an open aquarium look: sand floor extending, dark background, bubbles — nice.
Seaweed shader material: geometry PlaneGeometry(0.3, height, 1, 8), translated so base at y=0. vertex: pos.x += sin(uTime*speed + instanceOffset + pos.y*1.5) * pow(max(pos.y,0)/height,2)*0.4. Use per-mesh uniforms or merge? Create ~12 weeds, each with own material clone sharing time via uniform updated per frame (store array). Fine.
Bubbles: instanced or just 40 small spheres in group, each with speed, x wobble. Recycle from bottom.
Dust: Points with 200 random positions in tank, slight drift via time in shader? Just rotate points group slowly / offset. Simple: points with tiny size, animate material via points.rotation? That would rotate the cloud out of tank. Instead offset positions in CPU each frame cheaply for 200 points: fine, or skip drift — add slow sine on the whole points object position slightly. OK.
Vignette + CSS gradient background behind canvas (scene background transparent? renderer alpha true, CSS radial gradient dark blue). Use renderer with alpha and CSS bg for nicer gradient.
Title overlay: top-left "The Reef — move your mouse, watch them scatter". Bottom hint fade.
Performance: fine.
Handle resize.
Write code now.
Edge: tail cone geometry: ConeGeometry(0.22, 0.5, 8) points +y by default; rotate z by -90° to point -x... Cone apex along +y. To make tail extend in -x with apex attaching to body: rotate so cone axis along x: rotation.z = Math.PI/2 makes +y -> +x? Rotating around z by +90°: +y axis maps to -x? Rotation z by 90°: (0,1,0) -> (-1,0,0). Yes rotation.z = Math.PI/2 points apex toward -x. Place cone inside tailPivot at position x=-0.25 (half length) so apex at pivot... apex should attach to body: apex at pivot (x=0 of pivot), cone body extends -x. Cone apex at +y originally at top; after rotation apex at -x side. Hmm: I want apex (tip) at the body attach point and the wide fan extending backward? Actually tail fin: narrow at body, wide fan at end. Cone with apex at body, base (wide) at tail end. Cone apex is at +y*height/2. After rotation.z=90°, apex points -x. So apex would be at far end — wrong. Use rotation.z = -Math.PI/2: +y -> +x, apex at +x. Then position cone at x=-0.25 relative to pivot: apex at x=0 (pivot), base at x=-0.5. Good. Scale base flattening: scale.y? Cone base is circle in x-z after rotation... base circle lies in plane perpendicular to axis, i.e., y-z plane — vertical fin should be thin in z: scale cone z by 0.3? The cone geometry radius in y-z plane; after rotation, thin dimension should be z (thickness) — scale.z = 0.25 gives flat vertical fin. Wait scale is applied in local space before rotation? Scale is applied in local geometry space; geometry axis is y. Thin dimension of fin is z (world) which is local z (rotation about z doesn't change z). So scale.z=0.25 flattens in z. Good.
Dorsal fin: cone scaled flat in z, pointing up: ConeGeometry(0.18,0.35), position y=0.4, scale z 0.2. Fine.
Side fins: two small flattened spheres or planes at z=±0.3, rotated. Use small cone flattened, rotation to sweep back. Keep simple: SphereGeometry(0.15) scaled (1.2,0.4,0.15) at (0.1, -0.05, ±0.35). OK.
Eyes: SphereGeometry(0.06) black at (0.62, 0.08, ±0.2). Body half-length x = 0.5*1.6 = 0.8. So eye near x=0.62 on scaled sphere surface: check z extent: at x=0.62 (u=0.62/0.8=0.775), sphere cross radius: sqrt(1-0.775²)=0.63; y radius 0.375*... whatever, place eyes at (0.6, 0.06, ±0.19) and they'll roughly sit on surface; slight embedding is fine visually.
Colors: HSL random hue between 0.05 and 0.65 excluding? Use palette: pick from nice set: orange, yellow, teal, blue, pink. Material: MeshStandardMaterial with metalness 0.1, roughness 0.5, emissive same hue *0.15.
Fish sizes random 0.6–1.3 scale.
Tank bounds: x ±8? Tank: width 16 (x), height 8 (y from -3 to 5?), depth 7 (z -3.5..3.5). Camera at (0,1.2,11), fov 55. Sand at y=-3. Weeds from sand up.
Wander targets within margins.
Mouse plane z: 1.0.
Flee radius 3.0, strength scaled.
Now demo mode/auto-play rule: it's not a game; mouse interactivity works without click? In sandboxed iframe, pointer events do fire without click. I'll not add a start gate; but instructions say games must start in self-playing demo. This is an interactive visualization, not a game. I'll make fish wander naturally (visible immediately) and react to mouse. Maybe add a hint overlay that fades after first move. Also to be safe on first paint: all visible at load. Good.
Also add slight floating of fish (bob) — already via steering.
Code structure with requestAnimationFrame, clock.
Bubbles: group of 50 spheres MeshBasicMaterial white transparent opacity 0.35, radius random 0.03–0.09, spawn x random -7..7, z random -3..3, y start -3, speed 0.5–1.5, wobble phase. When y>4.5 reset to -3.2.
Caustics: add a moving SpotLight? Skip; add subtle animated point light color shifting? Fine without.
Frame edges: EdgesGeometry(BoxGeometry(16,8,7)) centered at y=1? Sand at -3, top of water at y=5 → box center y=1. LineBasicMaterial color 0x88ccff transparent opacity 0.15. Nice touch suggesting tank.
Water tint: fog color 0x0a1a33 near? Use scene.fog = new THREE.FogExp2(0x061224, 0.028). Background: CSS gradient; renderer alpha true, setClearColor transparent. Fog on transparent background still colors objects — fine.
God rays: skip.
Let's also make the mousePoint have a visible subtle indicator: a soft glowing sphere (light) at mouse point so users see where their "hand" is — like a hand/predator shadow. Nice: a PointLight attached + faint sphere. Yes, adds feedback. Opacity low (0.15), additive.
When fish panic near point, they accelerate. Compute flee each frame.
Write final HTML.
Also handle pointerleave: mouse active flag false → no flee.
Smoothing camera: target from mouse.
Add slight fish avoidance of each other? Optional — skip for perf, wander targets mostly separate them. Maybe simple separation for nicer look: for each fish, check a few others... O(n²) with 30 fish = 900 checks, fine. Add mild separation.
Now code.
const TANK = {x:7.5, yMin:-2.7, yMax:4.5, z:3.2}
Fish update:
```
steer toward wanderTarget; if close (<1), pick new target.
vel += steerForce * dt; clamp speed to maxSpeed*(panic?2.4:1)
pos += vel*dt
```
Steering: desired = (target - pos).normalize()*maxSpeed; steer = desired - vel; apply limited force (say accel 2). Plus flee desired = dirAway * fleeSpeed, weight high.
Separation: for neighbors within 0.9, push away.
Bounds: if pos.x > TANK.x - m, steer.x -= (overshoot)*k... simpler: force toward center scaled when outside margin.
Orientation: dir = vel.normalize (guard zero). targetQuat from setFromUnitVectors(X, dir). slerp 0.08. Banking: roll = clamp(turnRate)* something — compute using lateral velocity change; simpler: use cross of prevDir and dir y-component: bank = -dirdelta... I'll compute yawRate from angle change in horizontal plane: prevAngle vs newAngle atan2. bank target = clamp(-yawDelta*8, -0.5, 0.5); smooth. Apply as extra rotation about local x (forward) axis: after setting quaternion, group.rotateOnAxis? Since slerped quat is absolute, multiply: q * qRoll where qRoll about X axis by bank. quaternion.multiply(qRollAboutX) — multiplication applies roll in local space (q * local). Yes: finalQ = qAlign * qRoll(local X). Good.
Tail wiggle speed: base 6 + speed*3; amplitude 0.4 + panic.
Write it.
Weeds: heights 1.5–3.5, positions around x ±(2..7), z random. Shader:
vertexShader:
```
uniform float uTime;
varying float vY;
void main(){
vec3 p = position;
float h = clamp(p.y / uHeight, 0.0, 1.0);
float sway = sin(uTime*1.2 + uPhase + p.y*1.5) * h*h * 0.35;
p.x += sway; p.z += sin(uTime*0.9+uPhase)*h*h*0.15;
vY = h;
gl_Position = projectionMatrix * modelViewMatrix * vec4(p,1.0);
}
```
fragment: gradient green: mix(dark, light, vY). DoubleSide.
Geometry: PlaneGeometry(0.35, 1, 1, 10) then translate y +0.5 so base y=0; scale mesh to height. Pass uHeight = 1 (normalized since p.y in 0..1 after translate? plane from 0..1 after translate(0,0.5,0)). Yes p.y in [0,1]. uHeight=1.
Each weed own ShaderMaterial (uniforms cloned). ~14 weeds.
Also rocks: 5 icosahedrons flattened, dark gray, on sand.
Sand: PlaneGeometry(40, 20) rotated, color 0xc2a76b roughness 1, receive fog. Maybe vertex noise for dunes: displace plane vertices randomly small. Use PlaneGeometry(40,20, 40,20) and add sin ripples: z += sin(x*0.8)*0.15 etc. Nice.
Point light at mouse: color 0x9fd8ff intensity 2 distance 6. Sphere: MeshBasicMaterial 0xbfe6ff transparent 0.12 additive.
HUD:
- top-left: "🐙" no emoji maybe fine? Use text "Aqua Escape" title + subtitle.
- bottom-center hint: "Move your mouse near the fish — they'll dart away" fades in, and once mouse moves maybe keep. Fine keep static.
- Also FPS-ish fish count? skip.
Fonts: system, italic serif for title? Use CSS.
Ensure no console errors. Use module script with importmap.
Write it out.
One concern: raycaster plane intersection when camera parallel — fine.
Touch: pointermove covers. Also on mobile no hover — fine.
Camera parallax: camX = mouse.x*0.8, camY = 1.2 + mouse.y*0.5, smooth lerp. lookAt(0,0.8,0).
Let me now produce final file.