Interactive 3D aquariumOne-line
Flint (free)
by Recursivereasoning defaultClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 2 min 48 s
- Cost
- $0
- Output tokens
- 16,952
- Spent thinking
- 12,881 tokens
- Speed
- 101.3 tok/s
- Console errors
- 0
- First token after
- 264 ms
- First visible text after
- 2 min 16 s
- Prompt tokens
- 416
- Code size
- 9.7 KB
- Finish reason
- stop
- Served by
- Recursive
- Model id
recursive/flint-v1.0:free- Reasoning sent
model default- Max output
- 32,000 tokens
- Generated
- Nov 9, 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 · 258 lines
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>3D Aquarium</title>
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
background: #02131a;
}
#scene {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: block;
}
.overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(2, 19, 26, 0.35);
color: #eafcff;
font-family: system-ui, sans-serif;
text-align: center;
pointer-events: none;
z-index: 10;
box-sizing: border-box;
padding: 20px;
}
.overlay h1 {
font-size: clamp(28px, 6vw, 64px);
margin: 0 0 12px;
text-shadow: 0 0 20px rgba(0, 255, 255, 0.5);
}
.overlay p {
font-size: clamp(14px, 2.5vw, 22px);
opacity: 0.9;
margin: 0;
}
</style>
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}</script>
</head>
<body>
<canvas id="scene"></canvas>
<div class="overlay" id="overlay">
<div>
<h1>3D Aquarium</h1>
<p>Click or press a key to start</p>
</div>
</div>
<script type="module">
import * as THREE from 'three';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
const canvas = document.getElementById('scene');
const overlay = document.getElementById('overlay');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight, false);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
renderer.shadowMap.enabled = true;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x02131a);
scene.fog = new THREE.Fog(0x02131a, 18, 45);
const pmremGenerator = new THREE.PMREMGenerator(renderer);
scene.environment = pmremGenerator.fromScene(new RoomEnvironment(1)).texture;
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 2.2, 7.5);
camera.lookAt(0, 1.5, 0);
const hemi = new THREE.HemisphereLight(0x88ccff, 0x334455, 0.7);
scene.add(hemi);
const dir = new THREE.DirectionalLight(0xffffff, 1.5);
dir.position.set(5, 8, 5);
dir.castShadow = true;
scene.add(dir);
const amb = new THREE.AmbientLight(0x334455, 0.5);
scene.add(amb);
const bottomMat = new THREE.MeshStandardMaterial({ color: 0x9b7b4f, roughness: 0.95 });
const bottom = new THREE.Mesh(new THREE.BoxGeometry(6, 0.4, 6), bottomMat);
bottom.position.y = 0.2;
bottom.receiveShadow = true;
scene.add(bottom);
const waterMat = new THREE.MeshStandardMaterial({ color: 0x0e5a68, transparent: true, opacity: 0.35, roughness: 0.05, metalness: 0.2 });
const water = new THREE.Mesh(new THREE.BoxGeometry(5.6, 2.8, 5.6), waterMat);
water.position.y = 1.9;
water.receiveShadow = true;
scene.add(water);
const glassMat = new THREE.MeshPhysicalMaterial({ color: 0xbdf3ff, transparent: true, opacity: 0.08, roughness: 0.05, metalness: 0, side: THREE.DoubleSide, depthWrite: false });
const glass = new THREE.Mesh(new THREE.BoxGeometry(6.2, 3.4, 6.2), glassMat);
glass.position.y = 1.7;
scene.add(glass);
function addRock(pos, scale) {
const geo = new THREE.IcosahedronGeometry(0.5, 0);
const mat = new THREE.MeshStandardMaterial({ color: 0x7a5c43, roughness: 0.9 });
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(...pos);
mesh.scale.setScalar(scale);
mesh.castShadow = true;
scene.add(mesh);
}
addRock([-2.4, 0.8, -2.0], 1);
addRock([2.4, 0.9, -2.1], 1.1);
addRock([0.2, 0.8, 2.3], 0.9);
function addCoral(pos, color) {
const mat = new THREE.MeshStandardMaterial({ color, roughness: 0.6 });
const c1 = new THREE.Mesh(new THREE.CylinderGeometry(0.15, 0.3, 1.2, 6), mat);
c1.position.set(...pos);
scene.add(c1);
const c2 = new THREE.Mesh(new THREE.ConeGeometry(0.35, 0.9, 6), mat);
c2.position.set(pos[0], pos[1] + 0.8, pos[2]);
c2.rotation.y = Math.PI;
scene.add(c2);
return c1;
}
addCoral([-2.0, 0.9, -2.0], 0xff6b6b);
addCoral([2.0, 0.9, -2.2], 0x4ecdc4);
addCoral([0.0, 0.8, 2.2], 0xffa94d);
const fishes = [];
function createFish(data) {
const group = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({ color: data.color, roughness: 0.4, metalness: 0.1 });
const body = new THREE.Mesh(new THREE.SphereGeometry(0.22, 16, 12), bodyMat);
body.castShadow = true;
group.add(body);
const tailMat = new THREE.MeshStandardMaterial({ color: new THREE.Color(data.color).offsetHSL(0, -0.2, -0.15).getHex(), roughness: 0.5 });
const tail = new THREE.Mesh(new THREE.ConeGeometry(0.14, 0.36, 8), tailMat);
tail.position.z = -0.24;
tail.rotation.x = -Math.PI / 2;
group.add(tail);
const finMat = new THREE.MeshStandardMaterial({ color: new THREE.Color(data.color).offsetHSL(0, 0, -0.2).getHex(), roughness: 0.6 });
const fin = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.14, 0.12), finMat);
fin.position.y = 0.14;
fin.position.z = 0.05;
fin.rotation.x = Math.PI / 2;
group.add(fin);
group.position.set(...data.pos);
group.scale.setScalar(data.scale);
scene.add(group);
const fish = {
group,
vel: new THREE.Vector3(),
target: new THREE.Vector3(
Math.max(-2.2, Math.min(2.2, data.pos[0] + (Math.random() - 0.5) * 1.5)),
Math.max(0.8, Math.min(2.6, data.pos[1] + (Math.random() - 0.5) * 0.8)),
Math.max(-2.2, Math.min(2.2, data.pos[2] + (Math.random() - 0.5) * 1.5))
),
speed: 0.35 * data.scale,
phase: Math.random() * Math.PI * 2,
color: data.color
};
fishes.push(fish);
}
createFish({ pos: [-2.0, 1.7, -2.0], color: 0xff6b6b, scale: 1 });
createFish({ pos: [-1.2, 2.2, -1.2], color: 0x4ecdc4, scale: 0.9 });
createFish({ pos: [0.2, 1.5, -2.4], color: 0xffa94d, scale: 1.1 });
createFish({ pos: [1.4, 2.0, -1.6], color: 0xff6bcb, scale: 0.85 });
createFish({ pos: [-0.6, 1.2, -0.8], color: 0x74c0fc, scale: 1 });
createFish({ pos: [0.8, 1.8, 0.6], color: 0x94d82d, scale: 0.95 });
const pointerNdc = new THREE.Vector2();
function onPointerMove(e) {
const rect = renderer.domElement.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
pointerNdc.set(x, y);
}
window.addEventListener('pointermove', onPointerMove);
function start() {
overlay.style.display = 'none';
}
overlay.addEventListener('pointerdown', start);
window.addEventListener('pointerdown', start);
let time = 0;
function updateFish(delta) {
for (const fish of fishes) {
const v = fish.group.position.clone();
v.y = Math.max(0.8, Math.min(3.0, v.y));
v.project(camera);
const screenX = v.x * 0.5 + 0.5;
const screenY = -v.y * 0.5 + 0.5;
const distSq = (screenX - pointerNdc.x) * (screenX - pointerNdc.x) + (screenY - pointerNdc.y) * (screenY - pointerNdc.y);
let desired = new THREE.Vector3();
if (distSq < 0.0025) {
const dirVec = new THREE.Vector3();
camera.getWorldDirection(dirVec);
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -fish.group.position.y);
const ray = new THREE.Ray(camera.position, dirVec);
const point = new THREE.Vector3();
ray.intersectPlane(plane, point);
let flee = fish.group.position.clone().sub(point);
if (flee.lengthSq() < 0.01) flee = fish.group.position.clone().sub(camera.position);
flee.normalize();
desired = flee.clone().multiplyScalar(fish.speed * 1.6);
} else {
const dirToTarget = new THREE.Vector3().subVectors(fish.target, fish.group.position);
if (dirToTarget.lengthSq() < 0.01) {
fish.target.set((Math.random() - 0.5) * 4.4, 0.8 + Math.random() * 1.8, (Math.random() - 0.5) * 4.4);
dirToTarget.subVectors(fish.target, fish.group.position);
}
desired = dirToTarget.clone().normalize().multiplyScalar(fish.speed);
}
const lerp = 1 - Math.exp(-delta * 2.5);
fish.vel.lerp(desired, lerp);
fish.group.position.addScaledVector(fish.vel, delta);
fish.group.position.x = Math.max(-2.5, Math.min(2.5, fish.group.position.x));
fish.group.position.y = Math.max(0.8, Math.min(3.0, fish.group.position.y));
fish.group.position.z = Math.max(-2.5, Math.min(2.5, fish.group.position.z));
fish.group.rotation.y = Math.atan2(fish.vel.x, fish.vel.z);
fish.group.position.y += Math.sin(time * 2 + fish.phase) * 0.002;
}
}
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = Math.min(clock.getDelta(), 0.05);
time += delta;
updateFish(delta);
renderer.render(scene, camera);
}
animate();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight, false);
});
</script>
</body>
</html>Reasoning trace (12,881 tokens)
We need answer only one fenced code block containing complete file. We need craft HTML demo. Need satisfy task: 3D aquarium with Three.js where fish swim away when I move mouse near them. Must use import map exactly? Include script type importmap with those URLs. Everything inline CSS/JS. No external files except CDN allowed. No localStorage etc. No alert confirm prompt. No window.open. Keyboard events after click iframe; if demo uses keyboard display clear "Click or press a key to start". We can avoid keyboard entirely maybe mousemove. But sandboxed iframe: mouse events? They said iframe only receives keyboard events after user clicks; likely mouse too? For screenshot no interaction. Game must start in self-playing demo mode (simple AI plays) behind "Click or press a key to start" message, so judged in action without playing. Everything meaningful visible first seconds, no hidden behind high score etc.
We need likely produce HTML with Three.js via import map. Need ensure no errors in latest Chrome. Three r170. We can build 3D aquarium: tank, glass, water, gravel, coral? Fish. Fish swim away when mouse near them. Need self-playing demo mode behind start message; simple AI plays. Could just auto demo fish swimming, and when mouse near fish, they flee. Need start screen overlay "Click or press a key to start". After click/key, hide overlay, start demo AI? Or start immediately in self-playing demo mode behind message? The message visible first paint; screenshot 3 sec after load without interaction: must have meaningful visible and probably behind message? They say games must start in self-playing demo mode behind a "Click or press a key to start" message, so the game can be judged in action without playing. That implies overlay message, then upon click/key start self-playing AI. Screenshot before interaction should show meaningful visible, maybe overlay message but aquarium visible behind. Good.
Need no keyboard used, so maybe no need. But overlay says Click or press a key to start; if no keyboard, click. User gesture needed to start animation? In sandbox maybe. Could start on pointerdown/click. No keyboard events. That's okay? They specifically if demo uses keyboard, display clear message. We don't use keyboard, but display message.
3D scene: renderer, perspective camera, scene, fog. Aquarium: box glass? Use MeshPhysicalMaterial? Water transparent. Tank dimensions. Fish as group with body (SphereGeometry), tail (ConeGeometry or custom), fins maybe. Need fish swim away when mouse near them. Use raycaster from camera through pointer? Or track mouse vector relative to fish. Simpler: for each fish, compute screen position via project; distance from pointer NDC; if < threshold, increase flee direction/velocity away. Since camera may orbit? Could fixed camera or slight orbit. If fixed camera, mouse move changes? For pointer near fish, flee. Could implement simple AI demo: fish have base positions, wander, avoid? But task only fish swim away when mouse near them. Self-playing demo mode: fish swim around. On mouse move near fish, they swim away.
Need no external files. Use three module import. Inline module script type="module" with import from 'three'. Import map. Use OrbitControls? That is from CDN addons, allowed? Task says Load Three.js only through this import map, includes addons. Could use OrbitControls from three/addons. But to reduce risk, no OrbitControls; implement simple camera follow? Or static camera with slight bob. Mouse move near fish. For visibility, camera at (0, 2, 6) looking at (0,1,0). Fish in tank. Mouse move over canvas, project fish to screen. If near, flee.
Need adapt to 1280x800 and 375px. CSS full viewport, no horizontal scrollbar. Canvas resize. Overlay message.
Need zero console errors. Three r170 with import map. Use ES modules. Ensure no unsupported APIs.
Let's design.
HTML:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>3D Aquarium</title>
<style>
html, body { margin:0; height:100%; overflow:hidden; background:#02131a; }
#scene { position:fixed; inset:0; width:100vw; height:100vh; display:block; }
.overlay {
position:fixed; inset:0; display:flex; align-items:center; justify-content:center;
background: rgba(2,19,26,0.35); color:#eafcff; font-family: system-ui, sans-serif;
text-align:center; pointer-events:none; z-index:10;
}
.overlay h1 { font-size: clamp(28px, 6vw, 64px); margin:0; text-shadow:0 0 20px rgba(0,255,255,.5); }
.overlay p { font-size: clamp(14px, 2.5vw, 22px); opacity:.9; }
</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 class="overlay" id="overlay">
<div>
<h1>3D Aquarium</h1>
<p>Click or press a key to start</p>
</div>
</div>
<script type="module">
import * as THREE from 'three';
...
</script>
</body>
</html>
Check import map JSON: {"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/"}} - valid? Keys with slash? Should be "three/addons/": yes. Maybe quotes.
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);
renderer.shadowMap.enabled = true; // maybe not needed
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x02131a);
scene.fog = new THREE.Fog(0x02131a, 12, 30);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 100);
camera.position.set(0, 2.2, 7);
const pmremGenerator = new THREE.PMREMGenerator(renderer);
const aquariumTex = pmremGenerator.fromScene(new THREE.RoomEnvironment(1.0)).texture;
const aquariumEnv = scene.environment = aquariumTex; // r170 uses PMREMGenerator.fromScene(new RoomEnvironment).texture, or scene.environment = pmremGenerator.fromScene(...).texture. Good.
Maybe RoomEnvironment import from 'three/addons/environments/RoomEnvironment.js'. Or use scene.environment = new THREE.Color? For nice reflections, use RoomEnvironment. Import: import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; const pmrem = new THREE.PMREMGenerator(renderer); scene.environment = pmrem.fromScene(new RoomEnvironment(1)).texture; That should work in r170.
Lights: hemisphere, directional with shadows maybe.
Tank: outer box? Use BoxGeometry(6, 4, 6), MeshPhysicalMaterial color glass? For aquarium, maybe transparent glass. But transparent might cause sorting issues. Use simple: tank walls as boxes with MeshPhysicalMaterial transmission? Transmission may be heavy. Simpler: tank as a box with water inside. Use box (6, 4, 6) glass? We can do outer tank: MeshPhysicalMaterial({ color:0x9be7ff, transparent:true, opacity:0.15, roughness:0.05, metalness:0.1, transmission:0.9? }) Might be okay. Or just build tank from 5 planes? Simpler: a rounded box? Use BoxGeometry for tank and water plane? Maybe:
- Tank base: BoxGeometry(6.4, 0.4, 6.4), MeshStandardMaterial color #0b3b46 metalness 0.8 roughness 0.3.
- Water: BoxGeometry(6, 3.6, 6) MeshPhysicalMaterial({color:0x0a5a6b, transparent:true, opacity:0.35, roughness:0.05, metalness:0.2, transmission:0.8?}) Might render weird. Use MeshStandardMaterial with transparent opacity 0.25, roughness 0.1, color 0x0e5a68. No transmission to avoid issues. Or just semi-transparent blue box.
- Glass: maybe not needed. But aquarium look: tank walls. Could add box with MeshPhysicalMaterial transparent opacity 0.1. Might not need.
Maybe simpler: Tank as three boxes: bottom, water, glass. Fish inside. Gravel: many small spheres? Could use a plane/box with color brown and some small icosahedrons. Coral: some cones/cylinders. Not too complex.
For performance: few fish, maybe 5. Each fish group: body Mesh(SphereGeometry(0.18, 16, 12)), tail Mesh(ConeGeometry(0.14, 0.35, 8) rotated y? , color), eye? Dorsal fin? Keep simple. Fish swims: group position, rotation.y. Velocity vector. Add sine wobble? Use group position and rotation based on velocity.
Fish behavior:
- Each fish has base position within tank (x,y,z), velocity random.
- In self-playing demo mode, AI: slowly steer toward random targets or wander. But task: fish swim away when mouse near them. So we can just have fish swim in loops. On mouse proximity, flee.
Implement fish class or arrays.
Let's create 6 fish:
const fishData = [
{ pos: new THREE.Vector3(-2.2, 1.8, -1.8), color: 0xff6b6b, scale:1 },
...
];
For each, create group. Body material MeshStandardMaterial({color, roughness:0.4, metalness:0.1}). Tail: Mesh(ConeGeometry(0.13, 0.32, 8), new THREE.MeshStandardMaterial({color: darker})). Tail position local -0.25? Rotation y = Math.PI? Let's reason: ConeGeometry points +Y? Actually cone is circular cone along Y. To be tail at back (-Z), rotate geometry? Easier: create tail as ConeGeometry(0.13, 0.32, 8), rotateX(Math.PI/2)? That makes it point along -Z? Let's not overcomplicate; just attach and set rotation. Maybe use a custom shape for tail? Could be fine.
Fish group: body at y=0, z=0. Tail at z=-0.25, y=0, rotation.y = Math.PI? Actually if cone points +Y, rotating y by PI points -Y? Not good. RotateX(Math.PI/2) makes cone axis along Z? Rotation order default XYZ. If rotateX(Math.PI/2), +Y axis becomes +Z? Let's check: rotation about X by 90°, Y axis maps to Z. So cone pointing +Y now points +Z. Tail should point backward (away from fish direction). If fish faces +Z when rotation.y=0? We can set fish faces +Z by default. Tail at back = -Z. So tail cone should point -Z. RotateX(-Math.PI/2) maybe +Y to -Z. Or just set rotation.y = Math.PI? Hmm.
Simpler: Fish group rotation.y controls facing. Body sphere. Tail: ConeGeometry(0.13, 0.32, 8); tail.position.z = -0.22; tail.rotation.x = Math.PI / 2; Actually with rotation.x = PI/2, cone's +Y points to +Z? Rotation about X by +90: Y -> Z. So cone tip (originally +Y) points +Z, which is forward if fish faces +Z. We want tail behind, so maybe position tail at +0.22 and rotation.x = -PI/2? Or just attach at back and not worry; visually tail can be behind.
Let's set fish facing forward = -Z? Common: fish faces forward, tail behind. If tail at z=-0.22, cone should point -Z. Rotation about X by -PI/2: Y -> -Z? Rotation matrix: Rx(θ): y' = y cosθ - z sinθ, z' = y sinθ + z cosθ. For vector (0,1,0), θ=-90: y'=0, z'=-1. Yes, +Y maps to -Z. So tail.rotation.x = -Math.PI/2; tail.position.z = -0.22. Good. Body at 0,0,0. Tail behind.
Fins: maybe dorsal fin: BoxGeometry(0.02, 0.12, 0.08) positioned y=0.12, z=0.02. Not necessary but nice.
Fish update:
- Each fish has position, velocity (Vector3), baseRadius etc.
- In demo AI: fish swims around. Could have target position. Use simple: velocity lerp to direction to target. Or just sine path. For mouse flee, we can modify velocity.
Simplify: Each fish has a home position and a phase; movement is circular or linear. But to look like swimming, set velocity from angle. For self-playing, fish should swim around. We can give each fish a path: position oscillates. Use time variable.
Maybe implement generic update:
fish.position += fish.velocity * delta * speed;
fish.position.y += sin(time*speed + phase)*0.001;
If fleeing, set velocity away from mouse.
But for demo, we need fish to swim. Could have each fish's velocity computed from a heading and turn. Let's do simple AI:
- Each fish has target position (Vector3) within tank, current position, velocity.
- AI update: direction = target - position; desired velocity = direction normalized * fishSpeed; velocity lerp toward desired; turn rotation.y = atan2 of velocity? For fish facing velocity. When reached, pick new random target.
- Mouse flee: if pointer near fish screen pos, compute vector from fish to camera? Actually swim away from mouse pointer. In 3D, away from camera? Or away from mouse ray? Simpler: if mouse NDC near fish, set flee direction = (fish.position - mouseWorldPos).normalize? But mouse world pos is on ray. Or just set velocity away from camera position horizontally? The task: fish swim away when I move mouse near them. So when mouse near fish, they should swim away from the mouse direction. We can compute fish screen pos, distance to pointer. If < threshold, set flee vector = (fish.position - mouseWorldPosition).normalize() or just away from camera: if pointer is near, fish velocity += (fish.position - camera.position) * scare. Better: compute a direction away from the point on screen where mouse is relative to fish. Since camera fixed, we can project fish to NDC, subtract pointer NDC, if length < threshold, set velocity away from pointer: desired direction = (fish.position - pointerWorldPos). But pointerWorldPos: raycast from camera through pointer into tank? Could compute using plane. Simpler: use horizontal angle. Fish has yaw. Mouse NDC x,y. If fish screen pos close, set velocity = fish.position - camera.position + fish.position? Hmm.
Maybe implement: for each fish, project fish world position to screen vector (ndc). Compute distSq to pointer NDC. If < 0.08 (or screen size), trigger flee. Flee direction: away from camera horizontally? Actually if mouse is near fish, fish should swim away from mouse. Since camera is fixed, the mouse position on screen corresponds to a world point along ray. We can compute world point on tank water plane (y=0?) using ray-plane intersection. Then fish velocity = (fish.position - worldPos).normalize() * fishSpeed. Or just away from camera: velocity = fish.position - camera.position; normalize. That means they swim away from viewer, not necessarily away from mouse. But if mouse near, likely they are near screen center, so away from camera is backward. Not very natural but okay? Better: compute world point on plane y = fish.position.y from camera through pointer, then flee away from that point.
Ray-plane: camera at (0,2.2,7). Plane at y = fish.position.y maybe. Use THREE.Plane(new Vector3(0,1,0), -fish.position.y). Ray from camera through NDC. Raycaster? Could use camera.ray? Simpler: use vector math. Camera direction = new Vector3(); camera.getWorldDirection(dir). For perspective camera, direction in world. Ray = camera.position + dir * t. Plane equation: normal (0,1,0), constant = y. Ray: pos = cameraPos + dir * t. y = camY + dir.y * t = planeY => t = (planeY - camY)/dir.y. worldPos = cameraPos + dir * t. Then flee = fish.position - worldPos. Normalize. Good.
But if mouse near fish, worldPos likely near fish, flee away.
Need update loop with clock delta. Self-playing AI: fish targets. When fleeing, override velocity.
Let's plan fish count and positions:
Tank inner approx 5x3x5 (inner). Fish positions:
1. (-2.0, 1.6, -2.0) color 0xff6b6b
2. (-1.2, 2.2, -1.2) color 0x4ecdc4
3. (0.2, 1.4, -2.4) color 0xffa94d
4. (1.4, 2.0, -1.6) color 0x94d82d? maybe fish colors.
5. (-0.6, 1.0, -0.8) color 0xff6bcb
6. (0.8, 1.8, 0.6) color 0x74c0fc
Tank: width 6, height 4, depth 6. Water top at y=3? Camera at y=2.2. Maybe tank bottom at y=0, top at y=3? Fish y around 1.5-2.0. Camera y=2.2, z=7. Good.
Actually camera at (0, 2.2, 7) looking at (0,1.5,0)? We can set lookAt(0,1.5,0). Tank center y=1.5? Let's set tank bottom at y=0, height 4, so top at y=4? Too tall. Let's set tank: bottom y=0, height 3, top y=3. Camera y=2.0, z=7, lookAt(0,1.5,0). Fish y 1.2-2.2.
Maybe simpler: Tank outer BoxGeometry(6, 3.5, 6) centered at (0,1.75,0) so bottom 0, top 3.5. Water top at 3.2. Fish y 1.5-2.5.
Camera: position (0, 2.2, 7.5), lookAt(0,1.5,0). FOV 60.
Fish speeds ~0.25 units/sec.
AI: each fish has target random within tank: x,y,z within ±2.5, ±1.8, ±2.5. velocity lerp. When close to target, pick new target. But in self-playing demo, after start, they swim.
Mouse: listen on window? Canvas. Use pointermove on window? The overlay might intercept? Overlay pointer-events none, so okay. Use window pointermove. Compute NDC from clientX/Y relative to canvas bounding rect. Update pointerNdc.
Flee check: for each fish, project fish.position to NDC: vector.project(camera); x = v.x*0.5+0.5; y = -v.y*0.5+0.5; y = Math.min(Math.max(y,0),1). distSq = (x-px)^2 + (y-py)^2. If < 0.06 (about 6% of screen width? Actually NDC units: 0.06 is ~60px at 1280? 0.06*1280=76px, reasonable). Then flee.
Flee direction: compute world point on plane y=fish.position.y from camera through pointer NDC. t = (fish.position.y - camera.position.y) / dir.y; worldPos = camera.position + dir * t; flee = fish.position - worldPos; if lengthSq < 0.01, flee = fish.position - camera.position; flee.normalize. Then fish.velocity desired = flee * fish.speed * 1.5. AI update should override if fleeing.
Need update rotation: fish facing velocity. Use group.rotation.y = Math.atan2(velocity.x, -velocity.z)? If fish forward is -Z? Let's define fish forward = -Z (tail behind? Actually tail behind means forward +Z? Wait: if tail at -Z, forward is +Z. So forward vector = +Z when rotation.y=0? If rotation.y=0, group faces +Z? In three.js, object looks down local -Z? Actually camera looks down -Z. For a mesh, default forward is +Z? Three.js object with rotation.y=0 has its local axes: x right, y up, z forward? Actually three.js uses right-handed, camera looks down -Z. So if we want fish to face +Z, rotation.y=0? Hmm, in three.js, a plane's normal is +Z, and it faces +Z. A camera looks down -Z. So for fish, forward could be +Z. Tail at -Z is behind, so forward is +Z. Good. So fish facing forward = +Z. To turn toward velocity, yaw = Math.atan2(velocity.x, velocity.z)? Because forward vector is (0,0,1) rotated by yaw around Y: x' = -sin(yaw)? Actually rotation about Y by angle θ: x' = x cosθ + z sinθ, z' = -x sinθ + z cosθ. For forward (0,0,1): x' = sinθ, z' = cosθ. So θ = atan2(x, z). So rotation.y = Math.atan2(velocity.x, velocity.z). Good.
Tail position at z=-0.22, rotation.x = -PI/2 as above.
Fins: dorsal fin at y=0.12, z=0.0, rotation.x = -PI/2? Not critical.
Maybe add eye? Skip.
Water: MeshStandardMaterial({ color:0x0a5f6e, transparent:true, opacity:0.35, roughness:0.05, metalness:0.1 }). BoxGeometry(5.6, 3.0, 5.6) centered (0,1.8,0). Top at 3.3.
Tank walls: maybe not needed, but to look like tank, add glass box? Could add bottom and sides. Simpler: add a BoxGeometry(6.2, 3.6, 6.2) with MeshPhysicalMaterial({ color:0x9fe8ff, transparent:true, opacity:0.12, roughness:0.05, metalness:0.0, side:DoubleSide, depthWrite:false }). But transparent box may have sorting issues. Could just use a frame? Maybe skip glass, just tank bottom and water. Add a few rocks/coral.
Rocks: use IcosahedronGeometry scaled, MeshStandardMaterial color 0x6b4a2f. Place at tank corners. Coral: CylinderGeometry or ConeGeometry, colors.
Gravel: PlaneGeometry? Use a circle/box with color 0x8a6a4f, plus small icosahedrons? Could skip gravel, use bottom color.
Let's build aquarium:
- Bottom: BoxGeometry(6, 0.4, 6) at (0,0.2,0). MeshStandardMaterial color 0x3f5a52? Actually sand: 0x9b7b4f. Roughness 0.9.
- Water: BoxGeometry(5.6, 2.8, 5.6) at (0,1.9,0) top at 3.3. Color 0x0e5a68, transparent opacity 0.3, roughness 0.05, metalness 0.2.
- Glass: maybe BoxGeometry(6.2, 3.4, 6.2) at (0,1.7,0) MeshPhysicalMaterial({color:0xbdf3ff, transparent:true, opacity:0.08, roughness:0.05, metalness:0.0, side:THREE.DoubleSide, depthWrite:false}). Might be okay.
Rocks:
const rockGeo = new THREE.IcosahedronGeometry(0.5, 0);
for positions: (-2.5,0.8,-2.0), (2.3,0.9,-2.2), (0.5,0.8,2.2) etc. MeshStandardMaterial color 0x7a5c43.
Coral: CylinderGeometry(0.2,0.4,1.2) at (-2.0,1.0,-2.0?); ConeGeometry etc.
Not essential. Focus on fish and water.
Background: scene.background color, fog.
Lights:
- HemisphereLight(0x88ccff, 0x334455, 0.6)
- DirectionalLight(0xffffff, 1.2) at (5,8,5), castShadow.
- AmbientLight(0x334455, 0.4)
Shadows: renderer.shadowMap.enabled = true; dir light shadow camera bounds. But fish castShadow? Maybe not needed, but okay.
Resize handler.
Animation loop:
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = Math.min(clock.getDelta(), 0.05);
time += delta;
updateFish(delta);
renderer.render(scene, camera);
}
updateFish:
for fish of fishes:
if fleeing? Actually check proximity each frame.
compute screen pos, dist to pointer.
let targetVelocity = fish.aiTarget...
if (dist < fleeThreshold) { fleeing = true; set velocity away; }
else { fleeing = false; AI update }
fish.position.addScaledVector(velocity, delta * fish.speed? Better store velocity units/sec. We can set velocity directly.
Let's define fish.vel = new Vector3(). AI: desired = direction normalized * fish.speed; vel.lerp(desired, 1 - Math.exp(-delta*3)); When reached, new target.
Flee: desired = fleeDir * fish.speed * 1.6; vel.lerp(desired, ...).
Then position += vel * delta.
Keep within tank: clamp x,y,z.
Update rotation from vel.
Maybe add wobble: position.y += sin(time*2+phase)*0.002.
Actually for AI, if we lerp velocity, fish will stop at target. Better: compute desired, then vel lerp. When close, pick new target. Good.
Flee check should override desired. So:
let desired = new Vector3();
if (dist < threshold) {
// flee
desired = fleeDir * fish.speed * 1.5;
} else {
// AI
desired = directionToTarget clamped...
}
fish.vel.lerp(desired, 1 - Math.exp(-delta * 2.5));
fish.position.addScaledVector(fish.vel, delta);
Then clamp.
Need to avoid all fish fleeing from same pointer? They only if near.
Pointer NDC: update on pointermove. Also set initial center? For start overlay, mouse may not move. Fish should swim in demo. No flee until mouse moves. Good.
Overlay: hide on click/pointerdown? Since self-playing demo starts behind message? The requirement: "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." This could mean the game world runs behind overlay, and message says click to start. But if overlay covers, screenshot before interaction might show overlay. They want meaningful visible first paint. We can have overlay semi-transparent, aquarium behind. On click, hide overlay and start? But they said start in self-playing demo mode behind message. So maybe just start animation immediately, overlay on top with message. On click, hide overlay and allow control. But if we hide, user can move mouse to scare fish. That works. Self-playing AI runs from start behind overlay. Screenshot 3s later shows animation behind message. Good.
So on load, start animate. Overlay visible. On pointerdown/click, overlay.style.display='none'. No keyboard needed.
But requirement: "if the demo uses the keyboard, display a clear 'Click or press a key to start' message." We don't use keyboard, but display it. Good.
No alert/confirm/prompt. No localStorage. No window.open.
Need no horizontal scrollbar at 375px. CSS: html,body margin 0, height 100%, overflow hidden. Canvas resize to window. Overlay font sizes clamp.
Potential issues:
- Three.js r170 with import map: Need to use `three/addons/` for RoomEnvironment. Import: `import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';` Should work.
- PMREMGenerator: `new THREE.PMREMGenerator(renderer); const envTex = pmremGenerator.fromScene(new RoomEnvironment(1)).texture; scene.environment = envTex;`
- In r170, `renderer.outputColorSpace = THREE.SRGBColorSpace;` maybe default. Set.
- `renderer.toneMapping = THREE.ACESFilmicToneMapping;`
- `scene.environment` needs PMREM. Good.
- MeshStandardMaterial with transparent water. BoxGeometry water. Might need `side: THREE.DoubleSide`? Not necessary for top? Box closed, okay.
- Fog: `scene.fog = new THREE.Fog(0x02131a, 15, 40);`
- Camera aspect on resize.
- Renderer size: `renderer.setSize(window.innerWidth, window.innerHeight);` and camera aspect update.
- Handle context loss? Not needed.
- No console errors. Use `window.addEventListener('resize', ...)`.
- Pointer events: `window.addEventListener('pointermove', onPointerMove);` with `event.clientX`. Compute NDC:
const rect = renderer.domElement.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
pointerNdc.set(x,y);
But if overlay covers, pointer events on overlay? Overlay pointer-events none, so events pass? If pointer-events none, yes. But if overlay is child, it won't block. Good.
- For start, listen `window.addEventListener('pointerdown', start)` maybe once. Hide overlay.
- Self-playing AI: fish targets. Initialize targets random.
Fish class? Could just use objects.
Let's write code carefully.
Pseudo:
import * as THREE from 'three';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
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);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
renderer.shadowMap.enabled = true;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x02131a);
scene.fog = new THREE.Fog(0x02131a, 18, 45);
scene.environment = new THREE.PMREMGenerator(renderer).fromScene(new RoomEnvironment(1)).texture; // Wait: need pmrem variable? `const pmrem = new THREE.PMREMGenerator(renderer); scene.environment = pmrem.fromScene(new RoomEnvironment(1)).texture;` Good.
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 2.2, 7.5);
camera.lookAt(0, 1.5, 0);
const hemi = new THREE.HemisphereLight(0x88ccff, 0x334455, 0.7);
scene.add(hemi);
const dir = new THREE.DirectionalLight(0xffffff, 1.5);
dir.position.set(5, 8, 5);
dir.castShadow = true;
scene.add(dir);
const amb = new THREE.AmbientLight(0x334455, 0.5);
scene.add(amb);
// Tank
const bottomMat = new THREE.MeshStandardMaterial({ color: 0x9b7b4f, roughness: 0.95 });
const bottom = new THREE.Mesh(new THREE.BoxGeometry(6, 0.4, 6), bottomMat);
bottom.position.y = 0.2;
bottom.receiveShadow = true;
scene.add(bottom);
const waterMat = new THREE.MeshStandardMaterial({ color: 0x0e5a68, transparent: true, opacity: 0.35, roughness: 0.05, metalness: 0.2 });
const water = new THREE.Mesh(new THREE.BoxGeometry(5.6, 2.8, 5.6), waterMat);
water.position.y = 1.9;
water.receiveShadow = true;
scene.add(water);
const glassMat = new THREE.MeshPhysicalMaterial({ color: 0xbdf3ff, transparent: true, opacity: 0.08, roughness: 0.05, metalness: 0, side: THREE.DoubleSide, depthWrite: false });
const glass = new THREE.Mesh(new THREE.BoxGeometry(6.2, 3.4, 6.2), glassMat);
glass.position.y = 1.7;
scene.add(glass);
// Rocks/coral
function addRock(pos, scale) { const geo = new THREE.IcosahedronGeometry(0.5, 0); const mat = new THREE.MeshStandardMaterial({ color: 0x7a5c43, roughness: 0.9 }); const mesh = new THREE.Mesh(geo, mat); mesh.position.set(...pos); mesh.scale.setScalar(scale); mesh.castShadow = true; scene.add(mesh); return mesh; }
addRock([-2.4, 0.8, -2.0], 1);
addRock([2.4, 0.9, -2.1], 1.1);
addRock([0.2, 0.8, 2.3], 0.9);
function addCoral(pos, color) { const mat = new THREE.MeshStandardMaterial({ color, roughness: 0.6 }); const c1 = new THREE.Mesh(new THREE.CylinderGeometry(0.15, 0.3, 1.2, 6), mat); c1.position.set(...pos); scene.add(c1); const c2 = new THREE.Mesh(new THREE.ConeGeometry(0.35, 0.9, 6), mat); c2.position.set(...pos[0], pos[1]+0.8, pos[2]); c2.rotation.y = Math.PI; scene.add(c2); return c1; }
addCoral([-2.0, 0.9, -2.0], 0xff6b6b);
addCoral([2.0, 0.9, -2.2], 0x4ecdc4);
addCoral([0.0, 0.8, 2.2], 0xffa94d);
// Fish
const fishes = [];
function createFish(data) {
const group = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({ color: data.color, roughness: 0.4, metalness: 0.1 });
const body = new THREE.Mesh(new THREE.SphereGeometry(0.22, 16, 12), bodyMat);
body.castShadow = true;
group.add(body);
const tailMat = new THREE.MeshStandardMaterial({ color: new THREE.Color(data.color).offsetHSL(0, -0.2, -0.15).getHex(), roughness: 0.5 });
const tail = new THREE.Mesh(new THREE.ConeGeometry(0.14, 0.36, 8), tailMat);
tail.position.z = -0.24;
tail.rotation.x = -Math.PI / 2;
group.add(tail);
const finMat = new THREE.MeshStandardMaterial({ color: new THREE.Color(data.color).offsetHSL(0, 0, -0.2).getHex(), roughness: 0.6 });
const fin = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.14, 0.12), finMat);
fin.position.y = 0.14;
fin.position.z = 0.05;
fin.rotation.x = Math.PI / 2;
group.add(fin);
group.position.set(...data.pos);
group.scale.setScalar(data.scale);
scene.add(group);
// store
const fish = {
group,
pos: group.position.clone(),
vel: new THREE.Vector3(),
target: new THREE.Vector3(...data.pos).add(new THREE.Vector3((Math.random()-0.5)*1.5, (Math.random()-0.5)*0.8, (Math.random()-0.5)*1.5)),
speed: 0.35 * data.scale,
phase: Math.random() * Math.PI * 2,
color: data.color,
};
// clamp target
fish.target.set(Math.max(-2.2, Math.min(2.2, fish.target.x)), Math.max(0.8, Math.min(2.6, fish.target.y)), Math.max(-2.2, Math.min(2.2, fish.target.z)));
fishes.push(fish);
return fish;
}
createFish({ pos: [-2.0, 1.7, -2.0], color: 0xff6b6b, scale: 1 });
createFish({ pos: [-1.2, 2.2, -1.2], color: 0x4ecdc4, scale: 0.9 });
createFish({ pos: [0.2, 1.5, -2.4], color: 0xffa94d, scale: 1.1 });
createFish({ pos: [1.4, 2.0, -1.6], color: 0xff6bcb, scale: 0.85 });
createFish({ pos: [-0.6, 1.2, -0.8], color: 0x74c0fc, scale: 1 });
createFish({ pos: [0.8, 1.8, 0.6], color: 0x94d82d, scale: 0.95 });
Wait: fish pos y maybe bottom? Tank bottom at 0, water top 3.3. Fish y 1.2-2.2 okay.
Pointer:
const pointerNdc = new THREE.Vector2();
function onPointerMove(e) {
const rect = renderer.domElement.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
pointerNdc.set(x, y);
}
window.addEventListener('pointermove', onPointerMove);
const overlay = document.getElementById('overlay');
function start() { overlay.style.display = 'none'; }
overlay.addEventListener('pointerdown', start); // or window once
window.addEventListener('pointerdown', start);
Update fish:
function updateFish(delta) {
for (const fish of fishes) {
// project fish to NDC
const v = fish.group.position.clone();
v.y = Math.max(0.8, Math.min(3.0, v.y)); // maybe not needed
v.project(camera);
const screenX = v.x * 0.5 + 0.5;
const screenY = -v.y * 0.5 + 0.5;
const distSq = (screenX - pointerNdc.x) ** 2 + (screenY - pointerNdc.y) ** 2;
let desired = new THREE.Vector3();
if (distSq < 0.05 * 0.05) { // threshold ~0.05 NDC
// flee
const dirVec = new THREE.Vector3();
camera.getWorldDirection(dirVec);
const plane = new THREE.Plane(new THREE.Vector3(0,1,0), -fish.group.position.y);
const ray = new THREE.Ray(camera.position, dirVec);
const point = new THREE.Vector3();
ray.intersectPlane(plane, point);
let flee = fish.group.position.clone().sub(point);
if (flee.lengthSq() < 0.01) flee = fish.group.position.clone().sub(camera.position);
flee.normalize();
desired = flee.clone().multiplyScalar(fish.speed * 1.6);
} else {
// AI to target
desired = fish.target.clone().sub(fish.group.position);
if (desired.lengthSq() < 0.01) {
fish.target.set((Math.random()-0.5)*4.4, 0.8 + Math.random()*1.8, (Math.random()-0.5)*4.4);
}
desired.normalize().multiplyScalar(fish.speed);
}
const lerp = 1 - Math.exp(-delta * 2.5);
fish.vel.lerp(desired, lerp);
fish.group.position.addScaledVector(fish.vel, delta);
// clamp
fish.group.position.x = Math.max(-2.5, Math.min(2.5, fish.group.position.x));
fish.group.position.y = Math.max(0.8, Math.min(3.0, fish.group.position.y));
fish.group.position.z = Math.max(-2.5, Math.min(2.5, fish.group.position.z));
// rotation
fish.group.rotation.y = Math.atan2(fish.vel.x, fish.vel.z);
// wobble
fish.group.position.y += Math.sin(time * 2 + fish.phase) * 0.002;
}
}
Wait: AI target update: if desired lengthSq < 0.01, set new target. But desired is reassigned each frame. Better check distance before normalizing.
Let's restructure:
for fish:
// compute screen pos, distSq, fleeDir
let desired;
if (flee) desired = fleeDir * fish.speed * 1.6;
else {
const dirToTarget = new THREE.Vector3().subVectors(fish.target, fish.group.position);
if (dirToTarget.lengthSq() < 0.01) {
fish.target.set(...random...);
dirToTarget.set(...);
}
desired = dirToTarget.clone().normalize().multiplyScalar(fish.speed);
}
const lerp = 1 - Math.exp(-delta * 2.5);
fish.vel.lerp(desired, lerp);
fish.group.position.addScaledVector(fish.vel, delta);
// clamp etc.
Ray: `new THREE.Ray(camera.position, dirVec)` - dirVec is world direction from camera. Ray constructor takes origin, direction. Good. `ray.intersectPlane(plane, point)` works.
Plane: `new THREE.Plane(new THREE.Vector3(0,1,0), -fish.group.position.y);` Plane equation: normal * x + normal * y + normal * z + constant = 0. constant = -planeDistance. So normal (0,1,0), constant = -fish.position.y. So plane at y = fish.position.y. Good.
But if dirVec.y is near 0, t infinite. Camera looks slightly down, dir.y >0, so okay.
Flee threshold: screen dist squared < 0.05^2 = 0.0025. At 1280 width, NDC 0.05 = 64px. Reasonable.
Animation loop:
const clock = new THREE.Clock();
let time = 0;
function animate() {
requestAnimationFrame(animate);
const delta = Math.min(clock.getDelta(), 0.05);
time += delta;
updateFish(delta);
renderer.render(scene, camera);
}
animate();
Resize:
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
Start overlay: hide on click/pointerdown. But if we hide on pointerdown, initial overlay visible. Good.
Now, check if any console errors: `new THREE.PMREMGenerator(renderer).fromScene(new RoomEnvironment(1)).texture;` might need `await`? No, PMREMGenerator.fromScene is synchronous? In three.js, `pmremGenerator.fromScene(scene, sigma)` returns PMREMResult with texture synchronously? Actually it's synchronous for RoomEnvironment? I think `fromScene` returns a Promise? Wait, in three.js, `PMREMGenerator.fromScene` might be async? Let's recall: `pmremGenerator.fromScene( scene, sigma )` returns `PMREMResult`? I think it's synchronous: returns { texture, ... }. Actually in examples, they do `const pmremGenerator = new THREE.PMREMGenerator( renderer ); const envTex = pmremGenerator.fromScene( new RoomEnvironment( 1 ), 0.04 ).texture;` It's synchronous? I think it's synchronous. In newer versions, maybe async? Let me check memory: `PMREMGenerator.fromScene` is a method that returns `Promise`? No, `fromScene` is synchronous, `fromURLElement` maybe async. In three.js r150+, `pmremGenerator.fromScene( scene ).texture` works. So okay.
Alternatively, avoid RoomEnvironment to reduce risk. Use simple scene.environment = new THREE.Color(0x112233)? But environment with color might not provide reflections? It does. But RoomEnvironment is standard. I think okay.
If we want zero errors, maybe skip RoomEnvironment and use a simple gradient environment? But nice look. Let's use RoomEnvironment; it's standard.
Potential issue: `new THREE.Color(data.color).offsetHSL(0, -0.2, -0.15).getHex()` returns number, okay.
Tail rotation: cone geometry radius 0.14, length 0.36. Position z=-0.24. Rotation.x=-PI/2. Should be okay.
Fin: BoxGeometry(0.04,0.14,0.12) rotated x? Might look odd but okay.
Maybe add eye? Skip.
Water transparency: MeshStandardMaterial with transparent true, opacity 0.35, roughness 0.05, metalness 0.2. Should render.
Glass: MeshPhysicalMaterial with transparent true, opacity 0.08, depthWrite false. Might cause sorting issues but okay.
Maybe no glass to avoid issues? But aquarium should have glass. Keep.
Rocks/coral: ensure they are inside tank and not interfering. Coral might be above bottom. Fine.
Camera: position (0,2.2,7.5), lookAt(0,1.5,0). FOV 60. Tank width 6, so at z=7.5, FOV 60, horizontal FOV ~? Sensor 36, FOV=2*atan(18/ (focal?)) Actually FOV 60, visible width at distance 7.5 = 2*7.5*tan(30)=8.66, so tank fits. Height: aspect 1280/800=1.6, vertical FOV ~37, height visible = 2*7.5*tan(18.5)=5.0, tank height 3.4 fits.
At 375px wide, viewport height ~667, aspect 0.56, vertical FOV ~? camera fits? Should adapt.
Overlay: position fixed, inset 0, display flex, center, background rgba(2,19,26,0.35), color white, text-align center, pointer-events none, z-index 10. h1 font-size clamp(28px, 6vw, 64px). p font-size clamp(14px, 2.5vw, 22px). Since pointer-events none, click passes to window? Actually overlay covers screen, but pointer-events none means it won't capture. Good.
Canvas: position fixed, inset 0, width 100vw, height 100vh, display block. But renderer sets style width/height? We can set canvas style via CSS. `renderer.setSize` sets style width/height to pixel size? It sets canvas style width/height in px? Might conflict. Better set canvas style via CSS and renderer.setSize with updateStyle false? Or just let renderer set size and CSS not override? If we set canvas { width: 100vw; height: 100vh; } it might override. Use renderer.setSize(window.innerWidth, window.innerHeight, false) and CSS width/height 100%. Or just renderer.setSize and canvas display block. To be safe: canvas { position: fixed; inset: 0; width: 100%; height: 100%; } and renderer.setSize(window.innerWidth, window.innerHeight, false) maybe. Simpler: renderer.setSize(window.innerWidth, window.innerHeight); canvas CSS width/height 100% might stretch. Better set renderer.setSize(window.innerWidth, window.innerHeight, false); and CSS width/height 100%. But renderer with false doesn't update style, so canvas keeps CSS. Let's do:
renderer.setSize(window.innerWidth, window.innerHeight, false);
canvas style via CSS: width: 100vw; height: 100vh; position: fixed; inset: 0; display: block;
But if we set width:100vw, height:100vh, it's fine. Or use inset 0.
CSS:
html, body, #scene { margin:0; padding:0; width:100%; height:100%; overflow:hidden; }
#scene { position: fixed; top:0; left:0; width:100vw; height:100vh; display:block; }
Overlay: position: fixed; inset: 0; display:flex; align-items:center; justify-content:center; background: rgba(2,19,26,0.35); color:#eafcff; font-family: system-ui, sans-serif; text-align:center; pointer-events:none; z-index:10; box-sizing: border-box; padding: 20px; }
h1 { font-size: clamp(28px, 6vw, 64px); margin: 0 0 12px; text-shadow: 0 0 20px rgba(0,255,255,.5); }
p { font-size: clamp(14px, 2.5vw, 22px); opacity: .9; margin: 0; }
@media (max-width: 375px) { h1 { font-size: 28px; } p { font-size: 14px; } }
Now, no keyboard. But overlay says Click or press a key to start. We can change to "Click or press a key to start" but we don't use key. Fine.
Should we start self-playing demo mode immediately? Yes, animation loop runs immediately. Overlay on top. On click, hide overlay.
Now, check first paint: overlay visible, aquarium behind. Screenshot 3s later: animation running, fish swimming. Overlay might cover but semi-transparent, so meaningful visible. Good.
Now, final answer must be exactly one fenced code block containing complete file. No text before or after. The code block should be ```html ... ```. Inside, must include import map and everything inline. No external files. CDN allowed.
We must ensure no text outside code block. So final response should be just:
```html
<!DOCTYPE html>
...
</html>
```
No extra explanation.
Let's refine code to avoid errors.
Potential error: `renderer.outputColorSpace = THREE.SRGBColorSpace;` not necessary. But can set. In r170, default is SRGB. Okay.
`scene.environment = pmrem.fromScene(new RoomEnvironment(1)).texture;` Wait, `new THREE.PMREMGenerator(renderer).fromScene(...)` might not be valid because fromScene is a method on generator. So:
const pmremGenerator = new THREE.PMREMGenerator(renderer);
scene.environment = pmremGenerator.fromScene(new RoomEnvironment(1)).texture;
Good.
`const plane = new THREE.Plane(new THREE.Vector3(0,1,0), -fish.group.position.y);` Plane constructor normal, constant. Good.
`const ray = new THREE.Ray(camera.position, dirVec);` Ray constructor origin, direction. Good. `ray.intersectPlane(plane, point);` point is Vector3. Good.
`fish.group.position.addScaledVector(fish.vel, delta);` addScaledVector(vector, scalar). Good.
`fish.group.rotation.y = Math.atan2(fish.vel.x, fish.vel.z);` If vel is zero, atan2(0,0)=0, okay.
Fish target: initial target maybe same as pos, then set random. In createFish, set target random. Good.
Update fish: need to update target if reached. We can do:
const dirToTarget = new THREE.Vector3().subVectors(fish.target, fish.group.position);
if (dirToTarget.lengthSq() < 0.01) {
fish.target.set((Math.random()-0.5)*4.4, 0.8 + Math.random()*1.8, (Math.random()-0.5)*4.4);
dirToTarget.subVectors(fish.target, fish.group.position);
}
desired = dirToTarget.clone().normalize().multiplyScalar(fish.speed);
But if fleeing, we don't update target.
Let's write updateFish as function using closures.
Time variable: `let time = 0;` in module scope.
Clock: `const clock = new THREE.Clock();`
Animate:
function animate() {
requestAnimationFrame(animate);
const delta = Math.min(clock.getDelta(), 0.05);
time += delta;
updateFish(delta);
renderer.render(scene, camera);
}
animate();
Resize handler.
Now, fish positions and tank: Tank bottom at y=0, water top at 3.3. Fish y clamped 0.8 to 3.0. Good.
Camera lookAt: `camera.lookAt(0, 1.5, 0);` But camera.position changes? No.
Lights: DirectionalLight castShadow true. Might need to set shadow camera bounds? Not necessary.
Renderer shadowMap: enabled. But if no meshes castShadow except fish and rocks, okay.
Water mesh: `water.receiveShadow = true;` but water is transparent, okay.
Glass: `glass.receiveShadow`? Not needed.
Rocks: castShadow true. Coral? maybe not.
Now, code size. It's fine.
Let's consider if the iframe screenshot is taken without interaction. The overlay might have pointer-events none, so animation runs. The fish are moving. Good.
The message: "Click or press a key to start". Since we don't use keyboard, but display it. On click, hide. Good.
Should we prevent context menu? Not needed.
No localStorage etc.
No alert/confirm/prompt.
No window.open.
No external files. Only CDN scripts via import map.
Everything inline: CSS in style, JS in script. The import map is inline script type="importmap". The module script is inline.
Check 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>` - Should have no line breaks? JSON can have no line breaks? It's valid if single line. Better single line.
Now, final code. Let's write carefully.
One concern: `new THREE.PMREMGenerator(renderer).fromScene(new RoomEnvironment(1)).texture;` might cause error if fromScene returns Promise? Let's check memory: In three.js, `PMREMGenerator.fromScene( scene, sigma )` returns `PMREMResult` synchronously? Actually I recall it's synchronous. Example:
```
const pmremGenerator = new THREE.PMREMGenerator( renderer );
const envTex = pmremGenerator.fromScene( new RoomEnvironment( 1 ), 0.04 ).texture;
scene.environment = envTex;
```
Yes, synchronous.
Another concern: `MeshPhysicalMaterial` with `side: THREE.DoubleSide` and transparent. Okay.
Another concern: `renderer.toneMapping = THREE.ACESFilmicToneMapping;` okay.
Another concern: `renderer.outputColorSpace = THREE.SRGBColorSpace;` maybe okay but not needed.
Another concern: In updateFish, we create new Vector3 each fish each frame. That's okay for 6 fish.
Another concern: `fish.group.position.y += Math.sin(time * 2 + fish.phase) * 0.002;` This might push fish through tank bottom? It's small.
Another concern: `fish.vel.lerp(desired, lerp);` desired is Vector3. Good.
Another concern: If fleeing, we don't update target, so when flee ends, it goes back to old target. Fine.
Another concern: Fish rotation: if vel is zero, rotation.y=0. Good.
Another concern: Tail rotation: `tail.rotation.x = -Math.PI / 2;` Might be fine.
Let's maybe add eyes? Not necessary.
Let's consider the fish colors and materials. `new THREE.Color(data.color).offsetHSL(0, -0.2, -0.15).getHex()` returns a hex number. Good.
Now, final answer. Ensure no text outside code block. The code block should be complete. Let's write it.
We need to include `<!DOCTYPE html>`, html, head, body.
Check CSS for no horizontal scrollbar: html, body { margin:0; height:100%; overflow:hidden; background:#02131a; } canvas { display:block; width:100%; height:100%; } But renderer sets size, so okay. Use `#scene { position: fixed; inset: 0; width: 100vw; height: 100vh; display: block; }` and canvas id scene.
Actually if canvas is fixed, it's fine.
Overlay: `<div class="overlay" id="overlay">` with h1 and p.
JS: import three.
Let's write the final code block.
Double-check no use of forbidden APIs. We use `window.addEventListener`, `document.getElementById`, `renderer.render`, etc. No localStorage, alert, confirm, prompt, window.open. Good.
No keyboard event used, but overlay says Click or press a key. We don't add keydown. The instruction: "if the demo uses the keyboard, display a clear 'Click or press a key to start' message." We don't use keyboard, but we display it. Good.
The iframe only receives keyboard events after user clicks it: if the demo uses the keyboard, display a clear message. We don't use keyboard, so okay.
The page must look right in 1280×800 and adapt down to 375px. CSS handles.
Something meaningful visible on first paint: overlay has title and message, but aquarium behind. The fish may be visible. Good.
Everything interesting visible within first seconds: yes.
Games must start in self-playing demo mode behind a "Click or press a key to start" message. We start animation immediately, overlay on top. On click, hide. Good.
The code must run with zero errors in latest Chrome. Let's mentally run.
At module load, renderer created. scene.environment set. lights added. tank, water, glass, rocks, coral, fish. renderer.render in loop.
Potential error: `renderer.setSize(window.innerWidth, window.innerHeight, false);` The third parameter is `updateStyle`. If false, it won't update style. Then CSS must set size. We set canvas CSS via `#scene { width: 100vw; height: 100vh; }`. But renderer with false might not set canvas width/height? Actually renderer.setSize sets canvas.width and canvas.height (drawing buffer) and style if updateStyle true. With false, it sets drawing buffer only? Let's check: `renderer.setSize(width, height, updateStyle)` - if updateStyle is true, sets style. If false, doesn't set style. But we set CSS width/height 100vw/100vh, so drawing buffer might be smaller? Actually if we pass false, it might not set canvas style, but it still sets canvas.width/height? I think it sets both drawing buffer and style if updateStyle. So with false, it sets drawing buffer but not style. Then CSS 100vw/100vh will stretch. Might cause pixel ratio issues? Better use `renderer.setSize(window.innerWidth, window.innerHeight);` and CSS canvas { width: 100%; height: 100%; }? To avoid conflict, let's do:
```
renderer.setSize(window.innerWidth, window.innerHeight);
```
and CSS:
```
#scene { position: fixed; top: 0; left: 0; width: 100%; height: 100%; display: block; }
```
But renderer sets inline style width/height in px, which may override CSS? Inline style has higher specificity than CSS. So if renderer sets style width: 1280px, it overrides CSS. On resize, it updates. That's okay. For 375px, it sets width: 375px. So CSS not needed for size. But to be safe, set renderer.setSize and let it set inline. For full viewport, it's fine.
Actually renderer.setSize sets canvas.style.width = width + 'px'. So on 1280x800, canvas style width 1280px, height 800px. That's okay. On resize, updates. No horizontal scrollbar because body overflow hidden and canvas fixed? If canvas is fixed, okay.
Let's set:
```
#scene {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: block;
}
```
But renderer inline style may override. To prevent, we could set `renderer.setSize(window.innerWidth, window.innerHeight, false);` and CSS width/height 100%. Let's do that to be safe:
```
renderer.setSize(window.innerWidth, window.innerHeight, false);
```
CSS:
```
#scene { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; display: block; }
```
With updateStyle false, renderer won't set style, so CSS applies. But it still sets drawing buffer? I think it sets canvas.width and canvas.height based on size, and if updateStyle false, doesn't set style. So drawing buffer might be 1280x800, CSS stretches to 100vw/100vh. With pixel ratio, might be okay. To be safe, use `renderer.setSize(window.innerWidth, window.innerHeight);` and don't set CSS width/height on canvas, just position fixed. Actually if we set position fixed and top/left 0, and renderer sets width/height, it's fine. Let's do:
```
#scene { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; display: block; }
```
and `renderer.setSize(window.innerWidth, window.innerHeight);` This will set inline style width/height to px, which might override CSS. To avoid inline override, use `false`. I'll use `false` and CSS width/height 100vw/100vh.
Wait, if updateStyle false, does it still set canvas.width? Yes, it sets drawing buffer. The style is separate. So CSS 100vw/100vh will stretch drawing buffer. That's okay. But pixel ratio might cause mismatch? It's fine.
Let's write:
```
renderer.setSize(window.innerWidth, window.innerHeight, false);
```
CSS:
```
#scene { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; display: block; }
```
Now, final code.
I'll write the final answer now.