Interactive 3D aquariumOne-line
Gemini 3.8 Flash
by GoogleReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 45.8 s
- Cost
- $0.029
- Output tokens
- 7,773
- Spent thinking
- 0 tokens
- Speed
- 179.4 tok/s
- Console errors
- 0
- First token after
- 2.4 s
- First visible text after
- 2.4 s
- Prompt tokens
- 448
- Code size
- 25.4 KB
- Finish reason
- stop
- Served by
- Model id
google/gemini-3.8-flash- Reasoning sent
{"effort":"low"}- Max output
- 65,536 tokens
- Generated
- Nov 12, 2026
What it was asked
The prompt
“a 3D aquarium with Three.js where the fish swim away when I move the mouse near them (Load Three.js only through this import map: <script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}</script>)”
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task. Output rules: - Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block. - Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task. - Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw. - The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar. - Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction. - Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session. - Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing. - The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message. - The code must run with zero errors in the console of the latest Chrome.
Code run-1.html · 653 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D Interactive Aquarium</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
user-select: none;
}
body, html {
width: 100%;
height: 100%;
overflow: hidden;
background-color: #020b14;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
#webgl-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
#ui-overlay {
position: absolute;
top: 24px;
left: 24px;
z-index: 10;
color: #d1f4ff;
text-shadow: 0 2px 10px rgba(0, 30, 60, 0.8);
pointer-events: none;
backdrop-filter: blur(8px);
background: rgba(2, 20, 40, 0.35);
padding: 16px 22px;
border-radius: 14px;
border: 1px solid rgba(80, 200, 255, 0.25);
box-shadow: 0 8px 32px rgba(0, 10, 25, 0.4);
max-width: 320px;
}
h1 {
font-size: 20px;
font-weight: 600;
letter-spacing: 0.5px;
margin-bottom: 6px;
color: #7ce8ff;
}
p {
font-size: 13px;
line-height: 1.4;
color: #a4d8ec;
}
.hud-status {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 10px;
font-size: 12px;
color: #4ef2bb;
font-weight: 500;
}
.status-dot {
width: 8px;
height: 8px;
background: #4ef2bb;
border-radius: 50%;
box-shadow: 0 0 8px #4ef2bb;
animation: pulse 1.8s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; transform: scale(0.9); }
50% { opacity: 1; transform: scale(1.2); }
}
#reticle {
position: absolute;
width: 36px;
height: 36px;
border: 2px dashed rgba(110, 220, 255, 0.6);
border-radius: 50%;
pointer-events: none;
transform: translate(-50%, -50%);
z-index: 5;
transition: width 0.2s, height 0.2s, border-color 0.2s;
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>
<div id="ui-overlay">
<h1>Bioluminescent Tank</h1>
<p>Move mouse or touch to disturb the water. Fish react dynamically and scatter from your reach.</p>
<div class="hud-status">
<span class="status-dot"></span>
<span id="fish-count">48 Schooling Fish active</span>
</div>
</div>
<div id="reticle"></div>
<canvas id="webgl-canvas"></canvas>
<script type="module">
import * as THREE from 'three';
// --- CONSTANTS & CONFIG ---
const TANK_WIDTH = 52;
const TANK_HEIGHT = 30;
const TANK_DEPTH = 32;
const FISH_COUNT = 52;
const BUBBLE_COUNT = 140;
// --- SETUP SCENE, CAMERA, RENDERER ---
const canvas = document.getElementById('webgl-canvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, powerPreference: 'high-performance' });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x021124);
scene.fog = new THREE.FogExp2(0x021124, 0.024);
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 200);
camera.position.set(0, 2, 48);
// --- LIGHTING ---
const ambientLight = new THREE.AmbientLight(0x0a324b, 1.8);
scene.add(ambientLight);
// Sun caustics beam from above
const topLight = new THREE.DirectionalLight(0x73e2ff, 3.2);
topLight.position.set(5, 30, 8);
topLight.castShadow = true;
topLight.shadow.mapSize.width = 1024;
topLight.shadow.mapSize.height = 1024;
topLight.shadow.camera.near = 10;
topLight.shadow.camera.far = 60;
topLight.shadow.camera.left = -30;
topLight.shadow.camera.right = 30;
topLight.shadow.camera.top = 25;
topLight.shadow.camera.bottom = -25;
scene.add(topLight);
const fillLight = new THREE.PointLight(0x1a8bc8, 2.5, 70);
fillLight.position.set(-15, -8, 10);
scene.add(fillLight);
// Interactive cursor spotlight in 3D
const mouseLight = new THREE.PointLight(0x52f6ff, 3.0, 25);
mouseLight.position.set(0, 0, 0);
scene.add(mouseLight);
// --- TANK BOUNDS & ENVIRONMENT ---
// Sandy bottom
const floorGeo = new THREE.PlaneGeometry(TANK_WIDTH * 1.1, TANK_DEPTH * 1.1, 48, 48);
const floorPos = floorGeo.attributes.position;
for (let i = 0; i < floorPos.count; i++) {
const u = floorPos.getX(i);
const v = floorPos.getY(i);
// gentle sand ripples
const zRipples = Math.sin(u * 0.4) * 0.35 + Math.cos(v * 0.4) * 0.28 + Math.sin(u * 0.9 + v * 0.7) * 0.15;
floorPos.setZ(i, zRipples);
}
floorGeo.computeVertexNormals();
const floorMat = new THREE.MeshStandardMaterial({
color: 0x1f3c4c,
roughness: 0.85,
metalness: 0.1,
flatShading: true
});
const floor = new THREE.Mesh(floorGeo, floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -TANK_HEIGHT / 2;
floor.receiveShadow = true;
scene.add(floor);
// Tank Frame / Glass Edges
const tankBox = new THREE.BoxGeometry(TANK_WIDTH, TANK_HEIGHT, TANK_DEPTH);
const tankEdges = new THREE.EdgesGeometry(tankBox);
const tankLineMat = new THREE.LineBasicMaterial({ color: 0x41a8d8, opacity: 0.35, transparent: true });
const tankWire = new THREE.LineSegments(tankEdges, tankLineMat);
scene.add(tankWire);
// Rocks at the bottom
const rockGeo = new THREE.DodecahedronGeometry(1, 1);
const rockMat = new THREE.MeshStandardMaterial({ color: 0x152834, roughness: 0.9 });
for (let i = 0; i < 18; i++) {
const rock = new THREE.Mesh(rockGeo, rockMat);
const rx = (Math.random() - 0.5) * (TANK_WIDTH - 8);
const rz = (Math.random() - 0.5) * (TANK_DEPTH - 8);
const scale = 1.4 + Math.random() * 2.8;
rock.scale.set(scale * (0.8 + Math.random() * 0.4), scale * (0.6 + Math.random() * 0.8), scale * (0.8 + Math.random() * 0.4));
rock.position.set(rx, -TANK_HEIGHT / 2 + scale * 0.4, rz);
rock.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, 0);
rock.receiveShadow = true;
rock.castShadow = true;
scene.add(rock);
}
// Aquatic Plants (Seaweed ribbon strands)
const seaweeds = [];
const seaweedMat = new THREE.MeshStandardMaterial({
color: 0x0fa37f,
roughness: 0.6,
side: THREE.DoubleSide
});
for (let j = 0; j < 14; j++) {
const height = 9 + Math.random() * 11;
const segments = 10;
const plantGeo = new THREE.PlaneGeometry(0.75, height, 1, segments);
const plant = new THREE.Mesh(plantGeo, seaweedMat);
const px = (Math.random() - 0.5) * (TANK_WIDTH - 10);
const pz = (Math.random() - 0.5) * (TANK_DEPTH - 10);
plant.position.set(px, -TANK_HEIGHT / 2 + height / 2, pz);
plant.rotation.y = Math.random() * Math.PI;
scene.add(plant);
seaweeds.push({ mesh: plant, originalGeo: plantGeo.clone(), speed: 1.5 + Math.random() * 1.5, offset: Math.random() * 10 });
}
// --- BUBBLES ---
const bubbleGeo = new THREE.SphereGeometry(0.18, 8, 8);
const bubbleMat = new THREE.MeshPhysicalMaterial({
color: 0xffffff,
transmission: 0.85,
opacity: 1,
transparent: true,
roughness: 0.1,
ior: 1.15
});
const bubbles = [];
for (let i = 0; i < BUBBLE_COUNT; i++) {
const b = new THREE.Mesh(bubbleGeo, bubbleMat);
b.position.set(
(Math.random() - 0.5) * TANK_WIDTH,
-TANK_HEIGHT / 2 + Math.random() * TANK_HEIGHT,
(Math.random() - 0.5) * TANK_DEPTH
);
const scale = 0.4 + Math.random() * 0.9;
b.scale.set(scale, scale, scale);
b.userData = {
speedY: 0.05 + Math.random() * 0.08,
wobbleSpeed: 2 + Math.random() * 3,
wobbleAmp: 0.015 + Math.random() * 0.02,
seed: Math.random() * 100
};
scene.add(b);
bubbles.push(b);
}
// --- PROCEDURAL FISH MESH CREATION ---
function createFishGeometry() {
// Fish body: elongated tapered shape
const bodyGeo = new THREE.ConeGeometry(0.55, 2.2, 10, 4);
bodyGeo.rotateZ(Math.PI / 2); // points forward along +X initially
bodyGeo.scale(1.0, 1.25, 0.55);
// Tail fin
const tailGeo = new THREE.BufferGeometry();
const tailVertices = new Float32Array([
-1.1, 0.0, 0.0,
-2.0, 0.8, 0.0,
-1.8, 0.0, 0.0,
-1.1, 0.0, 0.0,
-1.8, 0.0, 0.0,
-2.0, -0.8, 0.0
]);
tailGeo.setAttribute('position', new THREE.BufferAttribute(tailVertices, 3));
tailGeo.computeVertexNormals();
// Dorsal fin
const dorsalGeo = new THREE.BufferGeometry();
const dorsalVerts = new Float32Array([
0.1, 0.65, 0.0,
-0.6, 1.1, 0.0,
-0.7, 0.4, 0.0
]);
dorsalGeo.setAttribute('position', new THREE.BufferAttribute(dorsalVerts, 3));
dorsalGeo.computeVertexNormals();
// Pectoral fin right & left
const pecGeo = new THREE.BufferGeometry();
const pecVerts = new Float32Array([
0.2, -0.1, 0.35,
-0.3, -0.3, 0.95,
-0.4, -0.2, 0.3
]);
pecGeo.setAttribute('position', new THREE.BufferAttribute(pecVerts, 3));
pecGeo.computeVertexNormals();
return { bodyGeo, tailGeo, dorsalGeo, pecGeo };
}
const fishGeos = createFishGeometry();
// Palette presets for varied vibrant tropical fish
const FISH_PALETTES = [
{ body: 0xff5e3a, accent: 0xffd152, emissive: 0x441100 }, // Clownfish Orange
{ body: 0x00c4cc, accent: 0x88ffaa, emissive: 0x002228 }, // Teal Neon
{ body: 0x9333ea, accent: 0xf43f5e, emissive: 0x24063c }, // Royal Violet/Pink
{ body: 0xfacc15, accent: 0x2563eb, emissive: 0x332600 }, // Tang Gold/Cobalt
{ body: 0x10b981, accent: 0x38bdf8, emissive: 0x022c22 } // Emerald Laguna
];
class Fish {
constructor(id) {
this.id = id;
this.group = new THREE.Group();
const pal = FISH_PALETTES[Math.floor(Math.random() * FISH_PALETTES.length)];
this.baseColor = pal.body;
const bodyMat = new THREE.MeshStandardMaterial({
color: pal.body,
roughness: 0.35,
metalness: 0.15,
emissive: pal.emissive,
emissiveIntensity: 0.6
});
const finMat = new THREE.MeshStandardMaterial({
color: pal.accent,
roughness: 0.4,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.85
});
// Body Mesh
this.body = new THREE.Mesh(fishGeos.bodyGeo, bodyMat);
this.body.castShadow = true;
this.group.add(this.body);
// Fin hinges & meshes for realistic swimming animation
this.tailHinge = new THREE.Group();
this.tailHinge.position.set(-0.3, 0, 0);
const tailMesh = new THREE.Mesh(fishGeos.tailGeo, finMat);
tailMesh.position.set(0.3, 0, 0);
this.tailHinge.add(tailMesh);
this.group.add(this.tailHinge);
const dorsalMesh = new THREE.Mesh(fishGeos.dorsalGeo, finMat);
this.group.add(dorsalMesh);
const pecR = new THREE.Mesh(fishGeos.pecGeo, finMat);
const pecL = pecR.clone();
pecL.scale.set(1, 1, -1);
this.group.add(pecR);
this.group.add(pecL);
// Scale variation
const s = 0.8 + Math.random() * 0.7;
this.group.scale.set(s, s, s);
// Physics state
this.position = new THREE.Vector3(
(Math.random() - 0.5) * (TANK_WIDTH - 6),
(Math.random() - 0.5) * (TANK_HEIGHT - 6),
(Math.random() - 0.5) * (TANK_DEPTH - 6)
);
this.velocity = new THREE.Vector3(
(Math.random() - 0.5) * 2,
(Math.random() - 0.5) * 1,
(Math.random() - 0.5) * 2
).normalize().multiplyScalar(0.08 + Math.random() * 0.08);
this.acceleration = new THREE.Vector3();
this.maxSpeed = 0.22 + Math.random() * 0.06;
this.maxForce = 0.012;
this.frightenedTimer = 0;
this.tailWiggle = Math.random() * 10;
this.wiggleFreq = 7;
this.group.position.copy(this.position);
scene.add(this.group);
}
flee(targetPos, radius = 13.0) {
const dist = this.position.distanceTo(targetPos);
if (dist < radius && dist > 0.001) {
const fleeForce = new THREE.Vector3().subVectors(this.position, targetPos);
const strength = Math.pow((1.0 - dist / radius), 2.2); // Exponential panic when closer
fleeForce.normalize().multiplyScalar(this.maxSpeed * 3.5 * strength);
const steer = new THREE.Vector3().subVectors(fleeForce, this.velocity);
steer.clampLength(0, this.maxForce * 4.0);
this.acceleration.add(steer);
this.frightenedTimer = 1.8; // Burst state
}
}
flock(allFish) {
let neighborDist = 7.0;
let sepDist = 3.0;
let sep = new THREE.Vector3();
let ali = new THREE.Vector3();
let coh = new THREE.Vector3();
let totalCohesion = 0;
let totalAlignment = 0;
let totalSeparation = 0;
for (let other of allFish) {
if (other === this) continue;
const d = this.position.distanceTo(other.position);
// Separation
if (d < sepDist && d > 0) {
const diff = new THREE.Vector3().subVectors(this.position, other.position);
diff.normalize().divideScalar(d);
sep.add(diff);
totalSeparation++;
}
// Alignment & Cohesion
if (d < neighborDist) {
ali.add(other.velocity);
coh.add(other.position);
totalCohesion++;
totalAlignment++;
}
}
if (totalSeparation > 0) {
sep.divideScalar(totalSeparation);
sep.setLength(this.maxSpeed);
sep.sub(this.velocity);
sep.clampLength(0, this.maxForce * 1.5);
this.acceleration.add(sep.multiplyScalar(1.6));
}
if (totalAlignment > 0) {
ali.divideScalar(totalAlignment);
ali.setLength(this.maxSpeed);
ali.sub(this.velocity);
ali.clampLength(0, this.maxForce);
this.acceleration.add(ali.multiplyScalar(0.9));
}
if (totalCohesion > 0) {
coh.divideScalar(totalCohesion);
const desired = new THREE.Vector3().subVectors(coh, this.position);
desired.setLength(this.maxSpeed);
const steer = new THREE.Vector3().subVectors(desired, this.velocity);
steer.clampLength(0, this.maxForce);
this.acceleration.add(steer.multiplyScalar(0.7));
}
}
containment() {
const padX = TANK_WIDTH / 2 - 2.5;
const padY = TANK_HEIGHT / 2 - 2.5;
const padZ = TANK_DEPTH / 2 - 2.5;
const wallForce = 0.025;
if (this.position.x > padX) this.velocity.x -= wallForce;
if (this.position.x < -padX) this.velocity.x += wallForce;
if (this.position.y > padY) this.velocity.y -= wallForce;
if (this.position.y < -padY) this.velocity.y += wallForce;
if (this.position.z > padZ) this.velocity.z -= wallForce;
if (this.position.z < -padZ) this.velocity.z += wallForce;
}
update(delta) {
// Apply frightened sprint
const currentMaxSpeed = this.frightenedTimer > 0 ? this.maxSpeed * 2.2 : this.maxSpeed;
this.velocity.add(this.acceleration);
this.velocity.clampLength(0.02, currentMaxSpeed);
this.position.add(this.velocity);
this.group.position.copy(this.position);
// Smooth rotation towards velocity direction
if (this.velocity.lengthSq() > 0.0001) {
const lookTarget = this.position.clone().add(this.velocity);
const targetQuat = new THREE.Quaternion();
const dummy = new THREE.Object3D();
dummy.position.copy(this.position);
dummy.lookAt(lookTarget);
// Fish model faces +X by default; orient properly
dummy.rotateY(Math.PI / 2);
this.group.quaternion.slerp(dummy.quaternion, 0.12);
}
// Fin wiggle animation proportional to speed
const speed = this.velocity.length();
const freq = (this.frightenedTimer > 0 ? 22 : 9) * (speed / this.maxSpeed);
this.tailWiggle += freq * delta;
const tailAngle = Math.sin(this.tailWiggle) * 0.45;
this.tailHinge.rotation.y = tailAngle;
if (this.frightenedTimer > 0) {
this.frightenedTimer -= delta;
}
// Reset force accumulator
this.acceleration.set(0, 0, 0);
}
}
const fishes = [];
for (let i = 0; i < FISH_COUNT; i++) {
fishes.push(new Fish(i));
}
// --- MOUSE & RAYCAST INTERACTION ---
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2(-999, -999);
const virtualInteractionPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
const target3D = new THREE.Vector3(0, 0, 0);
let hasUserMovedMouse = false;
let idleTimer = 0;
const reticle = document.getElementById('reticle');
function onPointerMove(e) {
hasUserMovedMouse = true;
idleTimer = 0;
const x = (e.clientX / window.innerWidth) * 2 - 1;
const y = -(e.clientY / window.innerHeight) * 2 + 1;
pointer.set(x, y);
// Reticle visual position
reticle.style.display = 'block';
reticle.style.left = `${e.clientX}px`;
reticle.style.top = `${e.clientY}px`;
}
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerdown', (e) => {
onPointerMove(e);
// Pulse on click to startle all fish in wide radius
fishes.forEach(fish => fish.flee(target3D, 24.0));
reticle.style.width = '64px';
reticle.style.height = '64px';
reticle.style.borderColor = '#ff6b81';
setTimeout(() => {
reticle.style.width = '36px';
reticle.style.height = '36px';
reticle.style.borderColor = 'rgba(110, 220, 255, 0.6)';
}, 250);
});
// --- RESIZE HANDLER ---
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// --- ANIMATION LOOP ---
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = Math.min(clock.getDelta(), 0.05);
const time = clock.getElapsedTime();
// Self-playing / idle demo patrol if user hasn't interacted
if (!hasUserMovedMouse) {
target3D.x = Math.sin(time * 0.9) * 16.0;
target3D.y = Math.sin(time * 1.3) * 8.0;
target3D.z = Math.cos(time * 0.7) * 8.0;
mouseLight.position.copy(target3D);
mouseLight.intensity = 2.2 + Math.sin(time * 3) * 0.8;
} else {
// Raycast against the depth plane of the aquarium center
raycaster.setFromCamera(pointer, camera);
raycaster.ray.intersectPlane(virtualInteractionPlane, target3D);
// Clamp target within tank limits so cursor doesn't project to infinity
target3D.x = THREE.MathUtils.clamp(target3D.x, -TANK_WIDTH / 2, TANK_WIDTH / 2);
target3D.y = THREE.MathUtils.clamp(target3D.y, -TANK_HEIGHT / 2, TANK_HEIGHT / 2);
target3D.z = THREE.MathUtils.clamp(target3D.z, -TANK_DEPTH / 2, TANK_DEPTH / 2);
mouseLight.position.lerp(target3D, 0.15);
mouseLight.intensity = 3.5;
}
// Animate Seaweeds (undulating aquatic sway)
for (let i = 0; i < seaweeds.length; i++) {
const sw = seaweeds[i];
const pos = sw.mesh.geometry.attributes.position;
const orig = sw.originalGeo.attributes.position;
for (let k = 0; k < pos.count; k++) {
const y = orig.getY(k);
// Higher vertices sway wider
const swayFactor = Math.max(0, (y + 5) / 10);
const wave = Math.sin(time * sw.speed + y * 0.4 + sw.offset) * 0.45 * swayFactor;
pos.setX(k, orig.getX(k) + wave);
}
pos.needsUpdate = true;
}
// Animate Rising Air Bubbles
for (let i = 0; i < bubbles.length; i++) {
const b = bubbles[i];
b.position.y += b.userData.speedY;
b.position.x += Math.sin(time * b.userData.wobbleSpeed + b.userData.seed) * b.userData.wobbleAmp;
// Reset to tank floor when reaching top
if (b.position.y > TANK_HEIGHT / 2) {
b.position.y = -TANK_HEIGHT / 2 + 0.5;
b.position.x = (Math.random() - 0.5) * (TANK_WIDTH - 4);
b.position.z = (Math.random() - 0.5) * (TANK_DEPTH - 4);
}
}
// Update Schooling Fish
for (let i = 0; i < fishes.length; i++) {
const fish = fishes[i];
// Repel violently away from mouse / threat location
fish.flee(mouseLight.position, 13.5);
// Natural Boids flocking rules
fish.flock(fishes);
// Keep inside tank
fish.containment();
// Move & animate
fish.update(delta);
}
// Gentle cinematic camera drift
camera.position.x = Math.sin(time * 0.18) * 4.5;
camera.position.y = 2.0 + Math.cos(time * 0.22) * 2.0;
camera.lookAt(0, 0, 0);
renderer.render(scene, camera);
}
// Start render loop immediately
animate();
</script>
</body>
</html>