Nemotron 3.5 Lightning (free)
by NVIDIAReasoning1 console error
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 5 min 52 s
- Cost
- $0
- Output tokens
- 22,769
- Spent thinking
- 19,537 tokens
- Speed
- 64.7 tok/s
- Console errors
- 1
- First token after
- 279 ms
- First visible text after
- 5 min 3 s
- Prompt tokens
- 584
- Code size
- 9.9 KB
- Finish reason
- stop
- Served by
- Nvidia
- Model id
nvidia/nemotron-3.5-lightning:free- Reasoning sent
model default- Max output
- 32,000 tokens
- Generated
- Nov 9, 2026
Console errors
Caught in the first 3 seconds after load, in a sandboxed iframe like the one above.
Cannot read properties of null (reading 'radius')
What it was asked
The prompt
Build a low-poly 3D scene of a campfire in a forest clearing at night, with Three.js in a single HTML file. Load Three.js only through this import map (do not use any other URL or version): <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> Requirements: - A ground with a few low-poly rocks and logs around a campfire in the center. - At least 30 low-poly trees placed randomly around the clearing (not on the fire). - The campfire has animated flames and a warm point light that flickers and casts shadows. - Sparks rising from the fire as a simple particle system. - A dark night sky with stars and a moon, plus fog that fades the distant trees. - OrbitControls to look around, with limits so the camera stays above the ground. - The renderer fills the window and handles resizing correctly.
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 · 287 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Campfire 3D Scene</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; background: #050512; font-family: sans-serif; }
#overlay {
position: fixed; inset: 0;
background: rgba(10,10,20,0.75);
display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #ffd580; pointer-events: auto; z-index: 20;
}
#start-message { font-size: 1.9rem; text-shadow: 0 0 8px #fff; }
canvas { display: block; }
</style>
</head>
<body>
<div id="overlay">
<div id="start-message">Click or press a key to start</div>
</div>
<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>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x02020b, 0.014);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 200);
camera.position.set(0, 10, 16);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.minPolar = 0.5;
controls.maxPolar = Math.PI - 0.5;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.35;
controls.update();
// Ground
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(80, 80),
new THREE.MeshStandardMaterial({ color: 0x251a0c })
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
// Rocks
for (let i = 0; i < 12; i++) {
const s = 0.3 + Math.random() * 0.7;
const rock = new THREE.Mesh(
new THREE.IcosahedronGeometry(s),
new THREE.MeshStandardMaterial({ color: 0x7c756d })
);
rock.position.set(
(Math.random() - 0.5) * 45,
rock.geometry.boundingSphere.radius,
(Math.random() - 0.5) * 45
);
rock.castShadow = true;
rock.receiveShadow = true;
scene.add(rock);
}
// Logs around fire
for (let i = 0; i < 10; i++) {
const len = 1.2 + Math.random() * 1.5;
const log = new THREE.Mesh(
new THREE.BoxGeometry(len, 0.25, 0.25),
new THREE.MeshStandardMaterial({ color: 0x8b4513 })
);
const angle = Math.random() * Math.PI * 2;
const r = 3 + Math.random() * 7;
log.position.set(Math.cos(angle) * r, log.geometry.bounds.getCenter().y, Math.sin(angle) * r);
log.rotation.x = Math.PI / 2;
log.rotation.z = Math.random() * Math.PI;
log.castShadow = true;
scene.add(log);
}
// Trees (35)
const treeCount = 35;
for (let i = 0; i < treeCount; i++) {
const a = Math.random() * Math.PI * 2;
const r = 6 + Math.random() * 30; // min radius keeps them clear of fire centre
const x = Math.cos(a) * r;
const z = Math.sin(a) * r;
const h = 1.5 + Math.random() * 2;
const trunk = new THREE.Mesh(
new THREE.CylinderGeometry(0.1, 0.18, h, 8, 1),
new THREE.MeshStandardMaterial({ color: 0x5d3a12 })
);
trunk.position.y = h / 2;
const canopy = new THREE.Mesh(
new THREE.IcosahedronGeometry(0.35 + Math.random() * 0.2, 0),
new THREE.MeshStandardMaterial({ color: 0x2e5a2e })
);
canopy.position.y = h + 0.35;
const treeGroup = new THREE.Group();
treeGroup.add(trunk);
treeGroup.add(canopy);
treeGroup.position.set(x, 0, z);
scene.add(treeGroup);
}
// Campfire center
const fireBase = new THREE.Mesh(
new THREE.BoxGeometry(1.8, 0.5, 1.8),
new THREE.MeshStandardMaterial({ color: 0x4a2c00 })
);
fireBase.position.y = 0.25;
fireBase.castShadow = true;
scene.add(fireBase);
// Point light (flickering fire)
const fireLight = new THREE.PointLight(0xffa500, 1.5, 18);
fireLight.position.set(0, 2.5, 0);
fireLight.castShadow = true;
scene.add(fireLight);
// Ambient light for base illumination
const ambient = new THREE.AmbientLight(0x111122, 0.35);
scene.add(ambient);
// Flame particle system
const flameCount = 120;
const flamePos = new Float32Array(flameCount * 3);
const flameSz = new Float32Array(flameCount);
for (let i = 0; i < flameCount; i++) {
flamePos[i * 3] = (Math.random() - 0.5) * 0.4;
flamePos[i * 3 + 1] = Math.random() * 1.5;
flamePos[i * 3 + 2] = (Math.random() - 0.5) * 0.4;
flameSz[i] = Math.random() * 0.3 + 0.1;
}
const flameGeo = new THREE.BufferGeometry();
flameGeo.setAttribute('position', new THREE.BufferAttribute(flamePos, 3));
flameGeo.setAttribute('size', new THREE.BufferAttribute(flameSz, 1));
const flameMat = new THREE.PointsMaterial({
color: 0xff8c00,
size: 0.25,
transparent: true,
opacity: 0.92,
sizeAttenuation: true
});
const flameParticles = new THREE.Points(flameGeo, flameMat);
scene.add(flameParticles);
// Sparks particle system
const sparkCount = 40;
const sparkPos = new Float32Array(sparkCount * 3);
const sparkVel = new Float32Array(sparkCount * 3);
const sparkSz = new Float32Array(sparkCount);
for (let i = 0; i < sparkCount; i++) {
sparkPos[i * 3] = (Math.random() - 0.5) * 0.3;
sparkPos[i * 3 + 1] = Math.random() * 0.5;
sparkPos[i * 3 + 2] = (Math.random() - 0.5) * 0.3;
sparkVel[i * 3] = (Math.random() - 0.5) * 0.1;
sparkVel[i * 3 + 1] = Math.random() * 0.2 + 0.08;
sparkVel[i * 3 + 2] = (Math.random() - 0.5) * 0.1;
sparkSz[i] = Math.random() * 0.15 + 0.05;
}
const sparkGeo = new THREE.BufferGeometry();
sparkGeo.setAttribute('position', new THREE.BufferAttribute(sparkPos, 3));
sparkGeo.setAttribute('velocity', new THREE.BufferAttribute(sparkVel, 3));
sparkGeo.setAttribute('size', new THREE.BufferAttribute(sparkSz, 1));
const sparkMat = new THREE.PointsMaterial({
color: 0xffff00,
size: 0.12,
transparent: true,
opacity: 0.65,
sizeAttenuation: true
});
const sparkParticles = new THREE.Points(sparkGeo, sparkMat);
scene.add(sparkParticles);
// Stars
const starCount = 1500;
const starPos = new Float32Array(starCount * 3);
for (let i = 0; i < starCount; i++) {
const r = 40 + Math.random() * 160;
const theta = Math.acos(2 * Math.random() - 1);
const phi = Math.random() * Math.PI * 2;
starPos[i * 3] = r * Math.sin(theta) * Math.cos(phi);
starPos[i * 3 + 1] = r * Math.sin(theta) * Math.sin(phi);
starPos[i * 3 + 2] = r * Math.cos(theta);
}
const starGeo = new THREE.BufferGeometry();
starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
const starMat = new THREE.PointsMaterial({
color: 0xffffff,
size: 0.38,
transparent: false,
depthWrite: false
});
const starField = new THREE.Points(starGeo, starMat);
scene.add(starField);
// Moon
const moon = new THREE.Mesh(
new THREE.SphereGeometry(2.5, 24, 24),
new THREE.MeshBasicMaterial({ color: 0xf5f5f5 })
);
moon.position.set(0, 12, -22);
scene.add(moon);
// Animation loop
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
// Update flame particles
for (let i = 0; i < flameCount; i++) {
flamePos[i * 3 + 1] += 0.03 * delta;
if (flamePos[i * 3 + 1] > 1.8) {
flamePos[i * 3 + 1] = -Math.random() * 0.3;
flamePos[i * 3] = (Math.random() - 0.5) * 0.4;
flamePos[i * 3 + 2] = (Math.random() - 0.5) * 0.4;
}
}
flameGeo.attributes.position.needsUpdate = true;
// Update sparks
for (let i = 0; i < sparkCount; i++) {
sparkPos[i * 3 + 1] += sparkVel[i * 3 + 1] * delta;
if (sparkPos[i * 3 + 1] > 4) {
sparkPos[i * 3 + 1] = -0.3;
sparkPos[i * 3] = (Math.random() - 0.5) * 0.3;
sparkPos[i * 3 + 2] = (Math.random() - 0.5) * 0.3;
}
}
sparkGeo.attributes.position.needsUpdate = true;
// Flicker light intensity and color
fireLight.intensity = 1.5 + Math.random() * 0.4;
const h = 0.08 + Math.random() * 0.02;
const s = 0.7 + Math.random() * 0.3;
const l = 0.4 + Math.random() * 0.3;
fireLight.color.setHSL(h, s, l);
controls.update();
renderer.render(scene, camera);
}
animate();
// Resize handler
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// Overlay interaction + auto-fade
const overlay = document.getElementById('overlay');
window.addEventListener('click', () => {
overlay.style.transition = 'opacity 0.5s';
overlay.style.opacity = '0';
setTimeout(() => { overlay.style.display = 'none'; }, 500);
});
window.addEventListener('keydown', () => {
overlay.style.transition = 'opacity 0.5s';
overlay.style.opacity = '0';
setTimeout(() => { overlay.style.display = 'none'; }, 500);
});
// Auto-fade overlay so screenshot at ~3s shows the scene cleanly
setTimeout(() => {
overlay.style.transition = 'opacity 1s';
overlay.style.opacity = '0';
setTimeout(() => { overlay.style.display = 'none'; }, 1200);
}, 2000);
</script>
</body>
</html>Reasoning trace (19,537 tokens)
Here's a thinking process:
1. **Analyze the Requirements:**
- Low-poly 3D scene of a campfire in a forest clearing at night
- Three.js via specific import map (version 0.170.0)
- Inline HTML/CSS/JS, no external files
- Ground with low-poly rocks and logs around campfire
- At least 30 low-poly trees placed randomly (not on fire)
- Campfire with animated flames, warm point light that flickers/casts shadows
- Sparks rising as simple particle system
- Dark night sky with stars, moon, fog fading distant trees
- OrbitControls with camera limits (stay above ground)
- Renderer fills window, handles resizing
- No external files, images, fonts, network requests except CDN URLs allowed
- No localStorage, sessionStorage, cookies, alert, confirm, prompt, window.open
- Must work in Chrome, zero errors
- Visible on first paint
- Self-playing demo mode with "Click or press a key to start" message (but this is a static scene, so maybe just start automatically or with a simple intro)
2. **Identify Constraints & Edge Cases:**
- Import map is fixed: `three` and `three/addons/` from cdn.jsdelivr.net/npm/three@0.170.0
- Must be a single fenced code block with `html`
- Inline CSS/JS
- No `alert()`, `confirm()`, `prompt()`, etc.
- No horizontal scrollbar, responsive 1280x800 down to 375px
- First paint must show something meaningful
- OrbitControls with limits
- Flickering light, particle system, fog, stars, moon
- Low-poly aesthetic
3. **Design the Scene (Mental Draft):**
- Setup: Three.js with import map
- Scene, camera, renderer
- Ground plane
- Low-poly rocks/logs around fire
- Campfire: mesh (low-poly geometry) + animated flame particles/material
- Point light that flickers (random intensity/color shift)
- Shadows enabled
- Trees: at least 30, low-poly (icosphere or custom geometry), random positions, avoid fire center
- Sky: stars (simple points or textured spheres), moon (large sphere with glow)
- Fog: Expensity or FogExp2 with color
- OrbitControls: limit polar angle so camera stays above ground
- Particles for sparks: BufferGeometry with updating positions
- "Click or press a key to start" message - since it's a static demo, maybe just show it initially, then hide after first interaction or timeout. But the requirement says "Games must start in a self-playing demo mode... behind a 'Click or press a key to start' message". For a non-game scene, I'll add a simple UI text that says "Click or press a key to start" and after a click/keypress, maybe enable some animation or just let it run. Actually, the requirement says "so the game can be judged in action without playing". I'll make it so the scene starts with a visible message, and on click/keypress, the animations begin or continue. Since everything can be auto-rotating/animating from start, but the message is required if keyboard is used. I'll show the message initially, and on first interaction (click/keypress), fade it out and ensure controls are active.
Wait, requirement: "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." So I must show that message. I'll add it as a HTML overlay.
- No `alert/confirm/prompt`, so I'll use a simple div overlay.
4. **Technical Implementation Plan:**
- HTML structure: `<canvas>` for Three.js, `<div>` for overlay message
- Import map script
- CSS: reset, body margin 0, canvas display block, overlay position fixed
- JS:
- Import `three` and addons via `createImportMap` or just use the `<script type="importmap">` approach. Actually, the standard way with import maps: `<script type="importmap">...</script>` then `<script type="module">` importing from `three`. I need to make sure the import map works. In the `<script type="module">`, I can `import * THREE from 'three'`. The import map will redirect `three` to the CDN URL.
- Set up renderer: `WebGLRenderer({ antialias: true })`, set size, append to DOM
- Set up camera: `PerspectiveCamera`, position
- Set up controls: `OrbitControls(camera, renderer.domElement)`, set limits: `minPolar: 0.5`, `maxPolar: Math.PI - 0.5` so camera stays above ground
- Ground: large plane mesh, low-poly texture? Just a color/material. Maybe add some rocks/logs as simple geometries.
- Campfire: central mesh (maybe a cylinder or low-poly pyramid), flame: point light + particle system for sparks. The flame animation: update particle positions slightly each frame, change light intensity/color randomly.
- Trees: at least 30. Low-poly: `IcosahedronGeometry` or custom low-poly. Random positions in a circle around fire, but avoid a radius around fire. Use `THREE.InstancedMesh` for performance, but with 30 it's fine individually. Each tree: position on a ring, rotation, scale. Material: dark green/brown.
- Sky: stars: many small spheres or points with `THREE.Points`, or just simple meshes. Moon: large sphere with yellow material, maybe emissive. Fog: `scene.fog = new THREE.FogExp2(0x020208, 0.01)` or `Fog` with far range.
- Animation loop: requestAnimationFrame, update particles, flicker light, rotate things slightly.
Let's think about low-poly aesthetics: use basic geometries, muted colors, no textures (or simple color materials). Shadows: enable `renderer.shadowMap = true`, set `camera.shadow.enabled = true` for light.
Campfire flames: I'll use a `Points` system with `Sprite` or `Mesh` with `Billboard` shader, but to keep it simple and low-poly, I can use a few `Mesh` objects with `ConeGeometry` that scale/rotate randomly, or a particle system with `Points` and `ShaderMaterial` for glowing. Given the complexity, I'll do a simple particle system: an array of spark objects, each with position, velocity, size. Update each frame, remove when too high, add new ones at base.
Actually, Three.js `Points` with `BufferGeometry` is efficient. I'll create a points system for flames and sparks. But to keep code size manageable and avoid errors, I'll use a simple approach: a `Mesh` with `ConeGeometry` for flame, and animate its height/color, plus a separate simple particle system for sparks using `Points`.
Let's structure the code:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Campfire 3D Scene</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; background: #0a0a1a; }
canvas { display: block; }
#overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #ffd580; font-family: sans-serif; pointer-events: auto;
}
#start-message { font-size: 1.5rem; }
</style>
</head>
<body>
<div id="overlay">
<div id="start-message">Click or press a key to start</div>
</div>
<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>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// Scene, camera, renderer
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 10, 15);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
// Controls
const controls = new OrbitControls(camera, renderer.domElement);
controls.minPolar = 0.5; // above ground
controls.maxPolar = Math.PI - 0.5;
controls.update();
// Fog
scene.fog = new THREE.FogExp2(0x020208, 0.015);
// Ground
const groundGeo = new THREE.PlaneGeometry(100, 100, 50, 50);
const groundMat = new THREE.MeshStandardMaterial({ color: 0x2c2c3e });
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
// ... add rocks, logs, trees, fire, sky, particles
// Animation
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
// update fire, particles, controls
controls.update();
renderer.render(scene, camera);
}
animate();
// Resize
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// Overlay interaction
const overlay = document.getElementById('overlay');
const startMsg = document.getElementById('start-message');
// on click or keypress, hide overlay
window.addEventListener('click', () => { overlay.style.display = 'none'; });
window.addEventListener('keydown', () => { overlay.style.display = 'none'; });
</script>
</body>
</html>
```
Now I need to fill in the rest: rocks, logs, trees (30+), campfire with animated flames, point light flicker, sparks particle system, stars, moon.
Let's think carefully about each component, keeping code concise but functional.
**Rocks/Logs**: I'll create a few simple low-poly meshes. Maybe `BoxGeometry` for logs, `IcosahedronGeometry` for rocks, placed randomly around the fire but not too close.
**Trees**: At least 30. I'll use `InstancedMesh` for performance, but with 30 it's fine to just loop. Each tree: a trunk (cylinder) and canopy (icosphere or cone). I'll randomize positions on a ring or across the ground, avoiding a central circle (fire zone). Use a simple material color.
**Campfire**: Center mesh: maybe a small cylinder or pyramid as "logs". Flame: I'll use a `Points` system with `BufferGeometry` containing positions that jitter slightly each frame. Color will be warm orange/red. Also a `PointLight` that flickers: randomly vary intensity and color each frame.
**Sparks**: Separate particle system from the fire. Small meshes or points that rise and fade. I'll combine with the flame points but with different behavior: sparks start at base, have upward velocity, shrink and fade.
**Stars/Moon**: Stars can be a simple `Points` system with random positions on a large sphere, but only visible side facing camera, or just meshes. Moon: a large sphere at y=20, material with yellow color and emissive.
**Fog**: Already set.
**Overlay**: As planned.
Let's code it step by step in my mind, ensuring no external URLs beyond the import map, no `alert/confirm/prompt`, inline everything.
Potential issues:
- Import map with `three/addons/` prefix: I need to make sure `OrbitControls` path is correct. The import map maps `three/addons/` to the JSM examples URL. So I can `import { OrbitControls } from 'three/addons/controls/OrbitControls.js'`. That should work.
- `three` module import: `import * as THREE from 'three'`.
- Renderer resizing: need to handle.
- Camera limits: `minPolar` and `maxPolar` in OrbitControls. Note: `polar` is from top, so `minPolar` small means closer to top, `maxPolar` near PI means closer to bottom. Setting `minPolar: 0.5` (about 28 degrees from top) and `maxPolar: Math.PI - 0.5` (about 152 degrees from top, i.e., 28 degrees from bottom) will keep camera above ground. But OrbitControls' polar angle is from y-axis usually? Actually, in three.js OrbitControls, `polar` is the angle from the y-axis (0 = up, PI = down). So `minPolar: 0.5` radians (~28.6° from up), `maxPolar: Math.PI - 0.5` (~151.4° from up, i.e., 28.6° from down). That will prevent camera from going below ground level. Good.
- First paint visibility: The scene should have ground, fire, some trees visible immediately. No loading hidden things.
- "Click or press a key to start": I'll add event listeners for `click` and `keydown` to hide the overlay. Also, maybe on first interaction, enable some specific animation, but since everything is continuous, I'll just fade it out.
Let's draft the full code mentally, keeping it compact.
I'll structure the JS within the `<script type="module">`.
**Components to generate in code:**
1. Ground with rocks/logs
2. 30+ trees
3. Campfire + flame points + flickering point light
4. Sparks particle system
5. Sky (stars + moon)
6. Fog
7. OrbitControls with limits
8. Animation loop
9. Resize handler
10. Overlay interaction
Let's write the code carefully.
For low-poly trees: I'll use a trunk (`CylinderGeometry`) and canopy (`IcosahedronGeometry`). Scale and position randomly. To avoid fire center, place them on a minimum radius.
For rocks: `IcosahedronGeometry` with random scale and position.
For logs: `BoxGeometry` rotated horizontally.
Campfire center: a `BoxGeometry` or `SphereGeometry` slightly embedded in ground, with the point light above.
Flame points: I'll create a `Points` object with 100 particles. Each has position, velocity, color, size. In update: position += velocity * delta; velocity.y -= gravity * delta; if position.y > maxHeight, reset to base. Color based on height. Also the point light flickers: `light.intensity = 0.8 + Math.random() * 0.4; light.color.setHSL(0.08 + Math.random()*0.02, 0.8, 0.5);` roughly.
Sparks: similar but with higher initial velocity, and they fade out. I'll just have one system that serves as both flame and sparks, or two separate. To keep code short, I'll make a single particle system for "flame+sparks" but differentiate visually maybe by size/color. Actually, requirement: "Sparks rising from the fire as a simple particle system." I'll have the flame points as the main fire, and sparks as a separate smaller system. But to save code, I'll integrate: the flame particles are the fire, and some of them act as sparks. Or I'll just have a sparkle effect: small meshes that emit from fire and rise. Let's do a simple spark particle system: an array of objects, but using `Points` is better.
Let's design the particle system:
- Create `BufferGeometry` with `position`, `color`, `size` attributes.
- Start with positions in a circle at fire base.
- Each frame: update positions, some die and are reborn at base.
- Point light linked to particle system's average color/position.
Actually, I'll keep it very simple:
- `flameParticles`: a `Points` mesh with `Sprite` materials that grow/shrink and color-shift. But `Points` with `ShaderMaterial` might be complex. I'll use `Mesh` objects: a few `ConeGeometry` flames that scale randomly each frame. And sparks as small `SphereGeometry` meshes that rise and disappear. But that creates many objects.
Better: use `InstancedMesh` for sparks/flames. But maybe overkill.
Let's think of the simplest approach that looks good and fits in code size:
- Campfire: a `Mesh` with `MeshBasicMaterial` for the log pile (dark color).
- Flame: a `PointLight` with varying intensity/color, plus a `SphereGeometry` mesh that is scaled and colored orange, positioned slightly above light, with `transparent` material and `additive` blending. I'll animate its scale and color each frame.
- Sparks: small `SphereGeometry` meshes instanced or just a few, spawned at fire base, moving up, fading.
Actually, Three.js `Points` is the most efficient for "particle system". I'll use `THREE.Points` with a simple `BufferGeometry`. Here's a pattern:
```js
const flameCount = 200;
const positions = new Float32Array(flameCount * 3);
const colors = new Float32Array(flameCount * 3);
const sizes = new Float32Array(flameCount);
for (let i=0; i<flameCount; i++) {
// initial positions random around origin at base
positions[i*3] = (Math.random()-0.5) * 0.5;
positions[i*3+1] = Math.random() * 2; // height 0-2
positions[i*3+2] = (Math.random()-0.5) * 0.5;
// colors warm orange
const h = 0.06 + Math.random()*0.04;
const s = 0.8 + Math.random()*0.2;
const l = 0.5 + Math.random()*0.3;
// convert hsl to rgb manually or use Color
colors[i*3] = new THREE.Color().setHSL(h, s, l).r;
colors[i*3+1] = ... g ...;
colors[i*3+2] = ... b ...;
sizes[i] = Math.random() * 0.5 + 0.2;
}
// then update each frame
```
But writing HSL to RGB manually in float arrays is tedious. I can use `THREE.Color` and extract values, but that creates objects. To keep it simple and avoid performance issues, I'll use a simpler approach: a `Mesh` for flame (a scaled cone/sphere) and `PointLight` for flicker. For sparks, I'll use a few `Mesh` objects with `BasicMaterial` that move up and fade. Given the code size constraint and error avoidance, I'll prioritize working simple stuff over complex particle systems.
Let's reconsider: The requirement says "Sparks rising from the fire as a simple particle system." A "simple particle system" can be implemented with a loop of basic meshes, or with `Points`. I'll use `Points` but keep the shader/material very basic: use `THREE.PointsMaterial` with `sizeAttenuation: true` and `transparent: true`, and set `color` attribute. I'll initialize colors using a function that returns an array of `0xfff` etc., but as floats. Actually, `BufferAttribute` for color expects `Float32Array` of R,G,B per vertex. I can populate it with simple values.
Let's just use `THREE.Points` with `new THREE.MeshBasicMaterial({ color: 0xffa500, size: 2 })` but that's a single color. To have varying colors, I need the attribute. Alternatively, I can use `Mesh` with `InstancedMesh` and set color per instance. But that's more code.
Maybe I'll fake it: the flame is a `PointLight` with flicker, and sparks are a few small `Sphere` meshes that spawn and rise. I'll call it a "particle system" loosely. But to be safe, I'll implement a basic `Points` system.
Let's look at time. I need to produce the final HTML file now. I'll write it carefully.
Plan for particle system:
- I'll create a `Points` object with `BufferGeometry`.
- Positions: random in a small volume above the fire base.
- Each frame: positions.y += 0.05 * delta; positions.y > some height -> reset to base at bottom of flame.
- Colors: I'll just set all to orange via `PointsMaterial.color`, and maybe vary size. To keep code short, I'll omit per-vertex colors and just use a single warm color for the points material, and rely on the point light for color variation. The requirement might not strictly check per-particle colors. It says "sparks rising from the fire as a simple particle system." A `Points` mesh with orange color rising satisfies that.
Actually, I can use `THREE.PointsMaterial` with `color: 0xffa500` and it'll look like fire. I'll add some size variation.
Let's structure the code now.
I'll write the HTML.
One important thing: The import map script must be before the module script. And the module script imports `three` and `three/addons/...`. The import map syntax given is:
```html
<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>
```
This maps `three` to the main module, and `three/addons/` to the JSM folder. So in the module script, I can do:
```js
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
```
This should resolve correctly because the import map redirects `three/addons/controls/OrbitControls.js` to the CDN URL.
Let's verify: The import map has `"three/addons/": "..."`. So `three/addons/controls/OrbitControls.js` becomes `https://.../examples/jsm/controls/OrbitControls.js`. That's correct.
Now, the code. I'll keep it in one `<script type="module">` block.
Let's outline the full JS:
```js
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x020208, 0.015);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 8, 12);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.minPolar = 0.5;
controls.maxPolar = Math.PI - 0.5;
controls.update();
// Ground
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(80, 80),
new THREE.MeshStandardMaterial({ color: 0x2a1f0c })
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
// Rocks
for (let i = 0; i < 8; i++) {
const rock = new THREE.Mesh(
new THREE.IcosahedronGeometry(0.5 + Math.random()),
new THREE.MeshStandardMaterial({ color: 0x8b7355 })
);
rock.position.set(
(Math.random() - 0.5) * 30,
rock.geometry.boundingSphere.radius,
(Math.random() - 0.5) * 30
);
rock.castShadow = true;
rock.receiveShadow = true;
scene.add(rock);
}
// Logs around fire
for (let i = 0; i < 6; i++) {
const log = new THREE.Mesh(
new THREE.BoxGeometry(1.5 + Math.random()*1, 0.3, 0.3),
new THREE.MeshStandardMaterial({ color: 0x8b5a2b })
);
log.position.set(
(Math.random() - 0.5) * 20,
log.geometry.bounds.getCenter().y,
(Math.random() - 0.5) * 20
);
log.rotation.x = Math.PI / 2 * (Math.random() > 0.5 ? 1 : -1);
log.castShadow = true;
scene.add(log);
}
// Trees (30+)
const treeCount = 35;
for (let i = 0; i < treeCount; i++) {
const angle = (Math.random() * Math.PI * 2);
const radius = 8 + Math.random() * 25; // avoid center
const x = Math.cos(angle) * radius;
const z = Math.sin(angle) * radius;
const height = 1 + Math.random() * 2;
// Trunk
const trunk = new THREE.Mesh(
new THREE.CylinderGeometry(0.1, 0.2, height, 8, 1),
new THREE.MeshStandardMaterial({ color: 0x6b4226 })
);
trunk.position.y = height / 2;
trunk.castShadow = true;
// Canopy
const canopy = new THREE.Mesh(
new THREE.IcosahedronGeometry(0.4 + Math.random()*0.3, 0),
new THREE.MeshStandardMaterial({ color: 0x2d5a27 })
);
canopy.position.y = height + 0.4;
const tree = new THREE.Group();
tree.add(trunk);
tree.add(canopy);
tree.position.set(x, 0, z);
scene.add(tree);
}
// Campfire center
const fireBase = new THREE.Mesh(
new THREE.BoxGeometry(2, 0.5, 2),
new THREE.MeshStandardMaterial({ color: 0x5d3a10 })
);
fireBase.position.y = 0.25;
fireBase.castShadow = true;
scene.add(fireBase);
// Point light for fire (flickers)
const fireLight = new THREE.PointLight(0xffa500, 1.5, 20);
fireLight.position.set(0, 3, 0);
fireLight.castShadow = true;
scene.add(fireLight);
// Flame particle system (simple Points)
const flameCount = 150;
const flamePositions = new Float32Array(flameCount * 3);
const flameSizes = new Float32Array(flameCount);
for (let i = 0; i < flameCount; i++) {
// start at base, random spread
flamePositions[i * 3] = (Math.random() - 0.5) * 0.5;
flamePositions[i * 3 + 1] = Math.random() * 2; // 0 to 2
flamePositions[i * 3 + 2] = (Math.random() - 0.5) * 0.5;
flameSizes[i] = Math.random() * 0.4 + 0.2;
}
const flameGeo = new THREE.BufferGeometry();
flameGeo.setAttribute('position', new THREE.BufferAttribute(flamePositions, 3));
flameGeo.setAttribute('size', new THREE.BufferAttribute(flameSizes, 1));
const flameMat = new THREE.PointsMaterial({
color: 0xffa500,
size: 0.3,
transparent: true,
opacity: 0.9,
sizeAttenuation: true
});
const flameParticles = new THREE.Points(flameGeo, flameMat);
scene.add(flameParticles);
// Sparks particle system (smaller, faster rise)
const sparkCount = 50;
const sparkPositions = new Float32Array(sparkCount * 3);
const sparkVelocities = new Float32Array(sparkCount * 3);
const sparkSizes2 = new Float32Array(sparkCount);
for (let i = 0; i < sparkCount; i++) {
sparkPositions[i * 3] = (Math.random() - 0.5) * 0.3;
sparkPositions[i * 3 + 1] = Math.random() * 0.5; // start near base
sparkPositions[i * 3 + 2] = (Math.random() - 0.5) * 0.3;
sparkVelocities[i * 3] = (Math.random() - 0.5) * 0.1;
sparkVelocities[i * 3 + 1] = Math.random() * 0.3 + 0.1; // upward
sparkVelocities[i * 3 + 2] = (Math.random() - 0.5) * 0.1;
sparkSizes2[i] = Math.random() * 0.2 + 0.1;
}
const sparkGeo = new THREE.BufferGeometry();
sparkGeo.setAttribute('position', new THREE.BufferAttribute(sparkPositions, 3));
sparkGeo.setAttribute('velocity', new THREE.BufferAttribute(sparkVelocities, 3));
sparkGeo.setAttribute('size', new THREE.BufferAttribute(sparkSizes2, 1));
const sparkMat = new THREE.PointsMaterial({
color: 0xffff00,
size: 0.15,
transparent: true,
opacity: 0.7,
sizeAttenuation: true
});
const sparkParticles = new THREE.Points(sparkGeo, sparkMat);
scene.add(sparkParticles);
// Stars (simple points on a huge sphere, but only front faces visible - use Points with random positions in a cube and clamp, or just meshes. I'll use a Points system with position in a large cube, and set size to make them look like stars. To avoid depth issues, I'll use `THREE.Points` with `Material` that has `depthWrite: false`.)
const starCount = 2000;
const starPositions = new Float32Array(starCount * 3);
for (let i = 0; i < starCount; i++) {
const r = 100 + Math.random() * 200; // distance
const theta = Math.acos(2 * Math.random() - 1);
const phi = Math.random() * Math.PI * 2;
starPositions[i * 3] = r * Math.sin(theta) * Math.cos(phi);
starPositions[i * 3 + 1] = r * Math.sin(theta) * Math.sin(phi);
starPositions[i * 3 + 2] = r * Math.cos(theta);
}
const starGeo = new THREE.BufferGeometry();
starGeo.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
const starMat = new THREE.PointsMaterial({
color: 0xffffff,
size: 0.5,
transparent: false,
depthWrite: false
});
const stars = new THREE.Points(starGeo, starMat);
scene.add(stars);
// Moon
const moonGeo = new THREE.SphereGeometry(3, 32, 32);
const moonMat = new THREE.MeshBasicMaterial({ color: 0xf0f0f0, side: THREE.BackSide });
const moon = new THREE.Mesh(moonGeo, moonMat);
moon.position.set(0, 15, -20);
scene.add(moon);
// Animate
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
// Update flame particles
for (let i = 0; i < flameCount; i++) {
flamePositions[i * 3 + 1] += 0.02 * delta;
if (flamePositions[i * 3 + 1] > 2.5) {
flamePositions[i * 3 + 1] = -Math.random() * 0.5; // reset to base
flamePositions[i * 3] = (Math.random() - 0.5) * 0.5;
flamePositions[i * 3 + 2] = (Math.random() - 0.5) * 0.5;
}
}
flameGeo.attributes.position.needsUpdate = true;
// Update sparks
for (let i = 0; i < sparkCount; i++) {
sparkPositions[i * 3 + 1] += sparkVelocities[i * 3 + 1] * delta;
if (sparkPositions[i * 3 + 1] > 5) {
// reset
sparkPositions[i * 3 + 1] = -0.5;
sparkPositions[i * 3] = (Math.random() - 0.5) * 0.3;
sparkPositions[i * 3 + 2] = (Math.random() - 0.5) * 0.3;
}
}
sparkGeo.attributes.position.needsUpdate = true;
// Flicker light
fireLight.intensity = 1.5 + Math.random() * 0.3;
fireLight.color.setHSL(0.08 + Math.random() * 0.02, 0.8, 0.5 + Math.random() * 0.1);
controls.update();
renderer.render(scene, camera);
}
animate();
// Resize
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// Overlay
const overlay = document.getElementById('overlay');
const startMsg = document.getElementById('start-message');
window.addEventListener('click', () => { overlay.style.display = 'none'; });
window.addEventListener('keydown', () => { overlay.style.display = 'none'; });
```
Wait, I need to be careful with the `for` loops updating particle positions. Modifying the `Float32Array` directly and then calling `needsUpdate` is correct. But I'm using `flamePositions` array that I initialized, and I'm modifying it in the loop. That's fine.
However, I'm iterating with `for (let i = 0; i < flameCount; i++)` and modifying `flamePositions[i * 3 + 1]`. But `flamePositions` is a `Float32Array` linked to the buffer geometry. I need to make sure I'm updating the buffer attribute correctly. The pattern `geo.attributes.position.needsUpdate = true` after modifying the array is correct.
But there's a subtlety: I'm modifying the array in place, but the array was created outside. That's fine.
Let's check the OrbitControls polar limits. `controls.minPolar = 0.5;` means the camera can't go closer than 0.5 radians from the y-axis (top). `controls.maxPolar = Math.PI - 0.5;` means it can't go beyond 0.5 radians from the bottom. This should keep the camera above the ground. However, OrbitControls default target is (0,0,0). The ground is at y=0. Camera starts at (0,8,12). With polar limits, it should stay above. I'll also set `controls.target.set(0, 0, 0);` maybe.
One issue: `OrbitControls` might not have `minPolar` and `maxPolar` in older versions, but three.js 0.170.0 should have them. Yes.
Another thing: The `import { OrbitControls } from 'three/addons/controls/OrbitControls.js'` path. With the import map `"three/addons/": "..."`, it should resolve. I'll double-check the path: three.js examples jsm has `controls/OrbitControls.js`. So `three/addons/controls/OrbitControls.js` maps correctly.
Now, the overlay: I'll make it cover the whole screen, centered text. On click or keydown, it fades out. I'll use `style.transition` and `opacity` instead of `display:none` maybe, but `display none` is fine. I'll just set `display = 'none'`.
Let's think about the "first paint" requirement: Something meaningful must be visible on first paint. The scene will have ground, fire, trees, sky. The overlay will be there initially, covering it. After click/key, it fades. The screenshot is taken automatically about 3 seconds after load. If the overlay covers everything, the screenshot might just be the overlay. I need to ensure that either the overlay fades quickly, or the scene is visible. The requirement: "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." Wait: "without any interaction" - meaning the screenshot happens automatically, no user interaction needed. So the overlay must not prevent the scene from being visible, or the overlay must be part of the first paint but the scene is also visible. Actually, re-read: "Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction." This implies that at first paint, something meaningful is already visible. The overlay "Click or press a key to start" might cover it, but maybe I should make the scene visible immediately, and the overlay is just a message that can be clicked away, but the scene is already behind it or partially visible. However, the requirement also: "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." And: "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."
So the overlay is mandatory for keyboard interaction, but the scene should be visible too. I think I can make the overlay semi-transparent, or place it over the scene but with some opacity, or just have the scene render first, and the overlay appears after a tiny delay, or the overlay is just text on top but the scene colors/shapes are still somewhat visible. To be safe, I'll make the overlay have a semi-transparent dark background and the text, and maybe after 3 seconds, the screenshot captures the scene with the overlay still there, but the requirement says "without any interaction" for the screenshot. It doesn't say the overlay must be gone. It says "Something meaningful must be visible on first paint". The campfire scene is meaningful. If the overlay covers it, it's still meaningful? Maybe not. I'll make the overlay only appear after a very short time, or make it fade out automatically after 2 seconds, but the screenshot is at 3 seconds. Hmm.
Let's re-read carefully: "Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction." This means when the page loads, the first paint shows something meaningful, and the screenshot at 3s will capture that state. It doesn't necessarily mean the overlay can't be there, but typically the overlay is part of the first paint. I think it's acceptable to have the "Click or press a key to start" message as part of the first paint, and the scene is still recognizable (e.g., fire, trees visible around it). I'll make the overlay semi-transparent dark tint so the scene behind is visible, and the text is clear. Or I can make the overlay only show for a moment, but the screenshot at 3s might capture it empty. Actually, I can have the overlay start visible, and then automatically fade out after 2 seconds, so at 3s the screenshot shows the full scene without overlay. But the requirement says "Games must start in a self-playing demo mode behind a 'Click or press a key to start' message". It doesn't say the message must persist forever. I'll make it fade out automatically after 2 seconds, and also allow click/key to skip. That way, first paint shows the message (meaningful: you see the prompt), and at 3s the scene is visible. But "something meaningful must be visible on first paint" - the message is meaningful. I think it's fine.
Wait, maybe I should just not have the overlay obscure the scene. I'll position the overlay absolutely over the canvas, with a semi-transparent background, and the scene is fully visible through it. The text "Click or press a key to start" will be overlaid. The screenshot at 3s will capture the overlay still there if I don't fade it. But I can fade it after 2 seconds. Let's make it fade out automatically after 2 seconds, and the screenshot at 3s will show the scene without overlay. That satisfies "something meaningful visible on first paint" (the message + scene), and "screenshot taken automatically about 3 seconds after load, without any interaction" (scene visible).
Let's implement overlay fade-out after 2 seconds:
```js
setTimeout(() => { overlay.style.opacity = '0'; setTimeout(() => { overlay.style.display = 'none'; }, 1000); }, 2000);
```
But the requirement says "if the demo uses the keyboard, display a clear 'Click or press a key to start' message." It doesn't say it must stay forever. I'll just show it initially, and fade it out after 2s. Or maybe better: keep it visible but make it not block the view entirely. I'll use a background that darkens the scene but leaves the center bright.
Actually, to keep it simple and comply with "Click or press a key to start" being clear, I'll make the overlay a centered message with a dark semi-transparent background, and after 2.5 seconds, fade it out. The screenshot at 3s will capture the clean scene. The "first paint" will show the message over the fire scene, which is meaningful.
Let's design the overlay CSS:
```css
#overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.6);
display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #ffd580; font-size: 1.5rem; font-family: sans-serif; pointer-events: auto;
z-index: 10;
}
```
And automatically fade:
```js
setTimeout(() => { overlay.style.transition = 'opacity 1s'; overlay.style.opacity = '0'; }, 2500);
setTimeout(() => { overlay.style.display = 'none'; }, 3500);
```
But I need to ensure the screenshot at ~3s captures the scene. If I set display none at 3.5s, the 3s screenshot might still have the overlay fading. I'll set display none at 2.8s, so at 3s it's gone. Or I'll just not auto-hide, and rely on the user clicking, but the screenshot is without interaction. Hmm.
Actually, re-reading: "a screenshot is taken automatically about 3 seconds after load, without any interaction." This is a system thing; I don't control when the screenshot is taken. I just need to ensure that at 3 seconds after load, the scene looks good and nothing is broken. The overlay might be okay as long as the scene is recognizable. But to be safe, I'll make the overlay fade out starting at 2 seconds, completing by 2.5 seconds, so at 3s it's gone. I'll use a simple timeout.
Let's plan the timeline:
- Load: overlay visible, scene rendered.
- 2s: overlay starts fading.
- 2.5s: overlay fully transparent / hidden.
- 3s: screenshot captured (scene clean).
- User can still click/key to hide earlier.
I'll implement that.
Now, let's think about the "low-poly" aesthetic. I'll use basic geometries, no textures, solid colors. The campfire and logs use brown/orange colors. Trees use dark green and brown. Sky is dark blue/black with white stars and yellow moon. Fog is subtle.
Let's also ensure the ground is large enough, and trees are placed not overlapping the fire too much. I'll avoid a central radius of like 5 units around fire for trees.
Let's refine the tree placement: I'll use a ring distribution or Poisson-disc-ish, but simple random with minimum radius is fine. I'll set `radius = 8 + Math.random() * 25`, and if too close to origin, reject or just accept since 35 trees will cover area. To ensure they don't overlap the fire, I'll just make the minimum radius 6 (fire base is radius 1). That should be fine.
For rocks: I'll place them randomly, maybe closer to the fire area.
For logs: around the fire, maybe in a loose circle.
Now, let's write the actual code. I need to be very careful with JavaScript syntax and Three.js API for version 0.170.0. Some things might have changed, but generally it's compatible with r170.
Let's check a few things:
- `THREE.FogExp2` is available.
- `renderer.shadowMap.enabled = true`
- `OrbitControls` has `minPolar`, `maxPolar` - yes.
- `BufferGeometry.setAttribute` is correct.
- `PointsMaterial` `sizeAttenuation` property.
- `Color.setHSL` works.
One potential issue: In three.js r170, `OrbitControls` might require `DampingFactor` or not, but basic usage works.
Another thing: The import map. I'll include the `<script type="importmap">` exactly as specified, at the top of `<head>` or before the module script. The requirement says: "Load Three.js only through this import map (do not use any other URL or version)". So I must not include any other `<script>` src for three.js. The import map handles it. I'll put the import map first, then the module script.
Let's structure the HTML:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Campfire 3D Scene</title>
<style>
/* ... */
</style>
</head>
<body>
<div id="overlay">
<div id="start-message">Click or press a key to start</div>
</div>
<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>
<script type="module">
// JS code
</script>
</body>
</html>
```
Wait, the import map script should be inside `<head>` or body? It can be anywhere, but typically in head. I'll put it right after meta, before the module script. The module script is at the end of body. That's fine.
Now, let's write the JS part completely. I'll try to keep it concise but functional.
I need to be careful with the particle system loops modifying BufferAttributes. I'll use `clock.getDelta()` and update positions.
Let's draft the JS mentally and then write it.
One issue: The `for` loop updating flame particles every frame might be a bit slow if not optimized, but with 150 particles it's fine.
Let's also add some stars that are actually a sphere of points, but I'll just use the Points approach.
Moon: I'll add it as a mesh with `MeshBasicMaterial`, and maybe add a slight glow by making it larger and transparent, but keep it simple.
Fog: `scene.fog = new THREE.FogExp2(0x020208, 0.015);` will affect the whole scene.
Let's also make the ground receive shadows, and objects cast shadows.
Now, about the "OrbitControls to look around, with limits so the camera stays above the ground." I'll set `controls.minPolar = 0.5;` and `controls.maxPolar = Math.PI - 0.5;`. Also, I should set `controls.enablePan = true;` maybe, but default is orbit. I'll leave default.
One important thing: `OrbitControls` target defaults to (0,0,0). The ground center is at (0,0,0). That's fine.
Let's write the code now. I'll produce the final HTML fenced code block.
I'll make sure:
- No external URLs except the import map CDN.
- No `alert`, `confirm`, `prompt`, `window.open`, `localStorage`, etc.
- CSS and JS inline.
- Canvas fills window, resizes.
- Overlay message.
- All requirements met.
Let's go step by step in the code writing.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Campfire 3D Scene</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; background: #050512; font-family: sans-serif; }
#overlay {
position: fixed; inset: 0;
background: rgba(10,10,20,0.7);
display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #ffd580; pointer-events: auto; z-index: 20;
}
#start-message { font-size: 1.8rem; text-shadow: 0 0 6px #fff; }
canvas { display: block; }
</style>
</head>
<body>
<div id="overlay">
<div id="start-message">Click or press a key to start</div>
</div>
<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>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x02020b, 0.014);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 200);
camera.position.set(0, 10, 16);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.minPolar = 0.5;
controls.maxPolar = Math.PI - 0.5;
controls.target.set(0, 1, 0);
controls.update();
// Ground
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(80, 80),
new THREE.MeshStandardMaterial({ color: 0x251a0c })
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
// Rocks
for (let i = 0; i < 10; i++) {
const s = 0.4 + Math.random() * 0.6;
const rock = new THREE.Mesh(
new THREE.IcosahedronGeometry(s),
new THREE.MeshStandardMaterial({ color: 0x7c756d })
);
rock.position.set(
(Math.random() - 0.5) * 40,
rock.geometry.boundingSphere.radius,
(Math.random() - 0.5) * 40
);
rock.castShadow = true;
rock.receiveShadow = true;
scene.add(rock);
}
// Logs around fire
for (let i = 0; i < 8; i++) {
const len = 1.5 + Math.random() * 1;
const log = new THREE.Mesh(
new THREE.BoxGeometry(len, 0.25, 0.25),
new THREE.MeshStandardMaterial({ color: 0x8b4513 })
);
const angle = (Math.random() * Math.PI * 2);
const r = 4 + Math.random() * 6;
log.position.set(Math.cos(angle) * r, log.geometry.bounds.getCenter().y, Math.sin(angle) * r);
log.rotation.x = Math.PI / 2;
log.rotation.z = Math.random() * Math.PI;
log.castShadow = true;
scene.add(log);
}
// Trees (35)
const treeCount = 35;
for (let i = 0; i < treeCount; i++) {
const a = Math.random() * Math.PI * 2;
const r = 7 + Math.random() * 28; // min radius 7, avoid fire center ~2
const x = Math.cos(a) * r;
const z = Math.sin(a) * r;
const h = 1.5 + Math.random() * 2;
const trunk = new THREE.Mesh(
new THREE.CylinderGeometry(0.1, 0.18, h, 8, 1),
new THREE.MeshStandardMaterial({ color: 0x5d3a12 })
);
trunk.position.y = h / 2;
const canopy = new THREE.Mesh(
new THREE.IcosahedronGeometry(0.35 + Math.random() * 0.2, 0),
new THREE.MeshStandardMaterial({ color: 0x2e5a2e })
);
canopy.position.y = h + 0.35;
const treeGroup = new THREE.Group();
treeGroup.add(trunk);
treeGroup.add(canopy);
treeGroup.position.set(x, 0, z);
scene.add(treeGroup);
}
// Campfire center
const fireBase = new THREE.Mesh(
new THREE.BoxGeometry(1.8, 0.5, 1.8),
new THREE.MeshStandardMaterial({ color: 0x4a2c00 })
);
fireBase.position.y = 0.25;
fireBase.castShadow = true;
scene.add(fireBase);
// Point light (flickering fire)
const fireLight = new THREE.PointLight(0xffa500, 1.5, 18);
fireLight.position.set(0, 2.5, 0);
fireLight.castShadow = true;
scene.add(fireLight);
// Flame particle system
const flameCount = 120;
const flamePos = new Float32Array(flameCount * 3);
const flameSz = new Float32Array(flameCount);
for (let i = 0; i < flameCount; i++) {
flamePos[i * 3] = (Math.random() - 0.5) * 0.4;
flamePos[i * 3 + 1] = Math.random() * 1.5; // 0 to 1.5
flamePos[i * 3 + 2] = (Math.random() - 0.5) * 0.4;
flameSz[i] = Math.random() * 0.3 + 0.1;
}
const flameGeo = new THREE.BufferGeometry();
flameGeo.setAttribute('position', new THREE.BufferAttribute(flamePos, 3));
flameGeo.setAttribute('size', new THREE.BufferAttribute(flameSz, 1));
const flameMat = new THREE.PointsMaterial({
color: 0xff8c00,
size: 0.25,
transparent: true,
opacity: 0.9,
sizeAttenuation: true
});
const flameParticles = new THREE.Points(flameGeo, flameMat);
scene.add(flameParticles);
// Sparks particle system
const sparkCount = 40;
const sparkPos = new Float32Array(sparkCount * 3);
const sparkVel = new Float32Array(sparkCount * 3);
const sparkSz = new Float32Array(sparkCount);
for (let i = 0; i < sparkCount; i++) {
sparkPos[i * 3] = (Math.random() - 0.5) * 0.3;
sparkPos[i * 3 + 1] = Math.random() * 0.5;
sparkPos[i * 3 + 2] = (Math.random() - 0.5) * 0.3;
sparkVel[i * 3] = (Math.random() - 0.5) * 0.1;
sparkVel[i * 3 + 1] = Math.random() * 0.2 + 0.08;
sparkVel[i * 3 + 2] = (Math.random() - 0.5) * 0.1;
sparkSz[i] = Math.random() * 0.15 + 0.05;
}
const sparkGeo = new THREE.BufferGeometry();
sparkGeo.setAttribute('position', new THREE.BufferAttribute(sparkPos, 3));
sparkGeo.setAttribute('velocity', new THREE.BufferAttribute(sparkVel, 3));
sparkGeo.setAttribute('size', new THREE.BufferAttribute(sparkSz, 1));
const sparkMat = new THREE.PointsMaterial({
color: 0xffff00,
size: 0.12,
transparent: true,
opacity: 0.6,
sizeAttenuation: true
});
const sparkParticles = new THREE.Points(sparkGeo, sparkMat);
scene.add(sparkParticles);
// Stars
const starCount = 1500;
const starPos = new Float32Array(starCount * 3);
for (let i = 0; i < starCount; i++) {
const r = 50 + Math.random() * 150;
const theta = Math.acos(2 * Math.random() - 1);
const phi = Math.random() * Math.PI * 2;
starPos[i * 3] = r * Math.sin(theta) * Math.cos(phi);
starPos[i * 3 + 1] = r * Math.sin(theta) * Math.sin(phi);
starPos[i * 3 + 2] = r * Math.cos(theta);
}
const starGeo = new THREE.BufferGeometry();
starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
const starMat = new THREE.PointsMaterial({
color: 0xffffff,
size: 0.4,
transparent: false,
depthWrite: false
});
const starField = new THREE.Points(starGeo, starMat);
scene.add(starField);
// Moon
const moon = new THREE.Mesh(
new THREE.SphereGeometry(2.5, 24, 24),
new THREE.MeshBasicMaterial({ color: 0xf5f5f5, side: THREE.BackSide })
);
moon.position.set(0, 12, -22);
scene.add(moon);
// Animation loop
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
// Update flame particles
for (let i = 0; i < flameCount; i++) {
flamePos[i * 3 + 1] += 0.03 * delta;
if (flamePos[i * 3 + 1] > 1.8) {
flamePos[i * 3 + 1] = -Math.random() * 0.3;
flamePos[i * 3] = (Math.random() - 0.5) * 0.4;
flamePos[i * 3 + 2] = (Math.random() - 0.5) * 0.4;
}
}
flameGeo.attributes.position.needsUpdate = true;
// Update sparks
for (let i = 0; i < sparkCount; i++) {
sparkPos[i * 3 + 1] += sparkVel[i * 3 + 1] * delta;
if (sparkPos[i * 3 + 1] > 4) {
sparkPos[i * 3 + 1] = -0.3;
sparkPos[i * 3] = (Math.random() - 0.5) * 0.3;
sparkPos[i * 3 + 2] = (Math.random() - 0.5) * 0.3;
}
}
sparkGeo.attributes.position.needsUpdate = true;
// Flicker light intensity and color
fireLight.intensity = 1.5 + Math.random() * 0.4;
const h = 0.08 + Math.random() * 0.02;
const s = 0.7 + Math.random() * 0.3;
const l = 0.4 + Math.random() * 0.3;
fireLight.color.setHSL(h, s, l);
controls.update();
renderer.render(scene, camera);
}
animate();
// Resize handler
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// Overlay interaction + auto-fade
const overlay = document.getElementById('overlay');
const startMsg = document.getElementById('start-message');
window.addEventListener('click', () => {
overlay.style.transition = 'opacity 0.5s';
overlay.style.opacity = '0';
setTimeout(() => { overlay.style.display = 'none'; }, 500);
});
window.addEventListener('keydown', () => {
overlay.style.transition = 'opacity 0.5s';
overlay.style.opacity = '0';
setTimeout(() => { overlay.style.display = 'none'; }, 500);
});
// Auto-fade overlay after 2.2 seconds so screenshot at ~3s is clean
setTimeout(() => {
overlay.style.transition = 'opacity 1s';
overlay.style.opacity = '0';
setTimeout(() => { overlay.style.display = 'none'; }, 1200);
}, 2200);
</script>
</body>
</html>
```
Let's review each requirement:
- "A ground with a few low-poly rocks and logs around a campfire in the center." ✓ Ground plane, 10 rocks, 8 logs, campfire base.
- "At least 30 low-poly trees placed randomly around the clearing (not on the fire)." ✓ 35 trees, random positions with min radius 7, fire center is small.
- "The campfire has animated flames and a warm point light that flickers and casts shadows." ✓ Point light flickers intensity/color, flame particles animate, shadows enabled.
- "Sparks rising from the fire as a simple particle system." ✓ Separate spark points system.
- "A dark night sky with stars and a moon, plus fog that fades the distant trees." ✓ Star Points, moon mesh, fog Exp2.
- "OrbitControls to look around, with limits so the camera stays above the ground." ✓ minPolar/maxPolar set, controls added.
- "The renderer fills the window and handles resizing correctly." ✓ setSize on init and resize event.
- "Load Three.js only through this import map... do not use any other URL or version." ✓ Import map only, no other three.js scripts.
- "Single HTML file, inline CSS/JS." ✓
- "No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task." ✓ Only the import map CDN URLs.
- "Do not use localStorage, sessionStorage, cookies, alert(), confirm(), prompt() or window.open():" ✓ None used.
- "The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar." ✓ body overflow:hidden, canvas responsive via resize handler, viewport meta set.
- "Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction." ✓ Scene renders immediately with overlay, auto-fade ensures at 3s scene is visible. The first paint shows the campfire scene with the start message, which is meaningful.
- "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." ✓ All animations start immediately.
- "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 scene is self-animating (flames, sparks, flickering light, rotating/orbiting camera via controls, but OrbitControls is manual. Wait: "OrbitControls to look around" - the user controls it. But "Games must start in a self-playing demo mode". Hmm. The requirement says: "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." But this is not a game per se, it's a 3D scene. The requirement might be generic for demos, but for this specific task, it says: "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." And earlier: "Games must start in a self-playing demo mode...". I think for this scene, the "self-playing demo mode" means the animations (fire, sparks, light flicker) start automatically, and the camera can be orbited. The "Click or press a key to start" is just the message to enable keyboard/controls. The scene already animates on load. So it's fine.
Wait, "Games must start in a self-playing demo mode (a simple AI plays) behind a 'Click or press a key to start' message" - maybe I should add a simple rotating or orbiting camera movement automatically, or make the trees sway? But the requirement says "OrbitControls to look around, with limits so the camera stays above the ground." It doesn't say an AI must play, but the generic task says that. I'll assume the automatic animations (fire, sparks, flickering light) serve as the "self-playing demo mode". I could also make the camera automatically rotate a bit, but OrbitControls is user-controlled. I'll add a simple automatic rotation of the camera target or the scene objects to make it self-playing. For example, make the trees slightly rotate, or the camera orbit slowly around the scene. But the requirement "OrbitControls to look around" implies the user can look around, but maybe it should also auto-rotate initially. I'll add a simple `controls.autoRotate = true;` maybe with a timeout, or just let the automatic particle/fire animations be the demo mode. The screenshot is taken automatically, so as long as things are moving, it's a demo mode. I'll set `controls.autoRotate = true;` but then the user might not be able to stop? Actually, `autoRotate` makes the camera auto-rotate, but `OrbitControls` still responds to input. I'll set `controls.autoRotate = true;` and `controls.autoRotateSpeed = 0.2;` so it slowly rotates. That serves as the "simple AI plays". And the overlay message is there. I'll also keep the manual orbit possible.
Let's add `controls.autoRotate = true;` and `controls.autoRotateSpeed = 0.5;`. But wait, if `autoRotate` is true, the polar limits still apply. That's fine. It will demo the scene. I'll add it.
But careful: The requirement "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." This is about keyboard input for controls. With `autoRotate`, keyboard might not be needed, but the message is still shown. I'll keep it.
Let's add `controls.autoRotate = true;` after creating controls.
Also, I should ensure the camera limits keep it above ground with autoRotate. `minPolar` and `maxPolar` will handle that.
Now, check for any `alert/confirm/prompt` - none.
Check for `window.open` - none.
Check for external network requests - only the import map URLs.
One thing: The import map script type is `importmap`. I have it as `<script type="importmap">...</script>`. That's correct.
The module script imports `three` and `three/addons/controls/OrbitControls.js`. The import map maps `three/addons/` to the JSM examples URL. So `three/addons/controls/OrbitControls.js` resolves to `https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/controls/OrbitControls.js`. That should work.
Let's double-check the OrbitControls import path. In three.js r170, the examples are under `jsm/`. The file `OrbitControls.js` is in `controls/`. So the path is correct.
Potential issue: The import map might need to map `three/addons/controls/` or just `three/addons/`. The given map maps `three/addons/` to the JSM base. So `three/addons/controls/OrbitControls.js` becomes `https://.../examples/jsm/controls/OrbitControls.js`. That's correct.
Another thing: In the JS, I'm using `clock.getDelta()` and modifying particle positions. The `Float32Array` is linked to the buffer geometry. I'm modifying the array in place, then calling `needsUpdate`. That's correct.
Let's think about performance and errors:
- The `for` loops in animate run every frame. With 120+40+1500 particles, it's fine.
- `setAttribute` calls with `needsUpdate` are correct.
- The `setTimeout` for overlay fade: I set `overlay.style.display = 'none'` after opacity 0. That's fine.
- The `click` and `keydown` listeners set opacity to 0 and display none after delay. That's fine.
- The auto-fade timeout: 2200ms, then opacity 1s, display none 1.2s total ~3.2s. The screenshot is taken "about 3 seconds after load". It might catch the overlay still fading, but likely it's gone or nearly gone. To be safer, I'll set the auto-fade to complete by 2.5s. I'll change to 2000ms timeout, 0.8s transition, display none 0.2s after = 2.5s faster. Actually, let's just set the overlay to `display none` at 2.2s with a quick transition. I'll adjust.
Let's change auto-fade:
```js
setTimeout(() => {
overlay.style.transition = 'opacity 0.5s';
overlay.style.opacity = '0';
}, 2000);
setTimeout(() => { overlay.style.display = 'none'; }, 2500);
```
This way, by 2.5s it's gone. Screenshot at ~3s is clean.
But the requirement says "without any interaction" for the screenshot. If the user hasn't clicked, the overlay fades automatically. That's fine.
Now, let's consider the "Click or press a key to start" message. The text is there. On click/key, it fades. On auto-fade, it also fades. Good.
One more thing: The requirement "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." This implies that until the user clicks the canvas, keyboard events aren't received. The message tells the user to click. I have the message, and I have click/keydown listeners that fade it. But the keyboard listener might not work until the canvas is clicked, because the sandbox might block keyboard events until focus/click. The requirement says: "The iframe only receives keyboard events after the user clicks it". So the `window.addEventListener('keydown', ...)` might not fire until the user clicks the iframe. That's fine; the message is there, and after click, the overlay fades and controls work. I'll keep the listeners as is; they just hide the overlay on click/key, but the actual OrbitControls keyboard panning/rotating will start working after click. The message requirement is satisfied.
Now, let's review the code for any mistakes.
- `renderer.setPixelRatio(window.devicePixelRatio)` - fine.
- `controls.minPolar = 0.5;` - 0.5 rad is about 28.6 degrees from straight up. `controls.maxPolar = Math.PI - 0.5;` is about 151.4 degrees from up, i.e., 28.6 degrees from straight down. This should keep camera above ground. But OrbitControls polar angle is measured from the positive y-axis? Yes, in three.js, `OrbitControls` uses spherical coordinates where `phi` (or polar) is the angle from the y-axis (0 to PI). So `minPolar: 0.5` prevents going below 28° from top, `maxPolar: PI - 0.5` prevents going below 28° from bottom. That means camera can't go below ground if ground is at y=0 and center at origin. Camera starts at (0,10,16), which is polar angle arctan(10/ sqrt(0^2+16^2))? Actually, position (0,10,16) has distance from y-axis = sqrt(0^2+16^2)=16, height=10, so polar angle from y-axis = acos(10/sqrt(10^2+16^2)) = acos(10/18.44) ≈ acos(0.542) ≈ 1.01 rad (58°). That's above 0.5, so within limits. As it orbits, it might get closer to ground, but maxPolar limits it. Should be fine.
- I set `controls.target.set(0, 1, 0);` maybe unnecessary, but target is usually (0,0,0). I'll leave target default, or set to (0,0,0). Actually, setting target to (0,1,0) might lift the orbit center, but ground is at y=0. I'll just not set target, let it default to (0,0,0). I'll remove that line.
- In the tree creation, I have `const canopy = new THREE.Mesh( new THREE.IcosahedronGeometry(0.35 + Math.random() * 0.2, 0), ... )`. The second argument to IcosahedronGeometry is `detail`. I'll use `0` for basic icosahedron. That's fine.
- Logs: I used `log.rotation.z = Math.random() * Math.PI;` Might cause weird orientations, but okay.
- Flame particles: I update `flamePos[i * 3 + 1]` and reset. The initial `flamePos[i * 3 + 1] = Math.random() * 1.5;` gives heights 0 to 1.5. Then in update, they increase by 0.03*delta, and reset if > 1.8. That creates a rising effect.
- Sparks: similar, initial height 0 to 0.5, upward velocity 0.08-0.3, reset if > 4. That makes them rise higher and fade.
- Star field: I used `Math.acos(2 * Math.random() - 1)` for uniform sphere distribution. That's correct.
- Moon: `MeshBasicMaterial` with `side: THREE.BackSide` means the inside of the sphere is visible from outside, which makes it look like a shell. For a moon, we usually want the outside. I'll change to no `side` special, just default front side. But then it's a solid sphere. I'll just use default `side: THREE.FrontSide`. But it's a moon, so a yellow sphere is fine. I'll remove `side: THREE.BackSide`.
- Fog color: `0x02020b` is a dark blue-black. Good.
- Ground material: `MeshStandardMaterial` needs light to be visible. I have a point light, but maybe I should add ambient light or make the ground emissive? With `MeshStandardMaterial`, the ground will be dark if only a point light is present and it's not facing the light. I'll add an `AmbientLight` to softly illuminate the scene. But the requirement doesn't forbid ambient light; it just says load Three.js through import map. I can add lights. Adding `AmbientLight` will make the scene less flat. I'll add a soft ambient light: `new THREE.AmbientLight(0x111122, 0.4)`. That should help.
- Also, the point light casts shadows. I have `fireLight.castShadow = true;`. By default, point light shadow map size is 512, which is fine. I might need to increase `shadow.mapSize` for better quality, but not necessary.
- I should ensure the renderer's `toneMapping` is off or set to `ACESFilmic` to keep colors natural, but default is fine.
Let's add `AmbientLight`.
Also, the `start-message` text color `#ffd580` is orange-yellow, visible over the dark overlay.
Let's finalize the code.
One more thing: The requirement "No horizontal scrollbar." I have `body { overflow: hidden; }` and `canvas { display: block; }`. That should prevent scrollbars.
"The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide." The resize handler adjusts camera and renderer. The overlay is full-screen. The canvas fills the page. With `overflow: hidden` on body, no scrollbar appears. The content should fit.
Let's write the final HTML.
I'll make sure the import map script is the exact one given, no modifications.
I'll also make sure the `<script type="module">` is at the end of `</body>`, which I have.
One potential issue: The import map `<script>` must be placed before the module script that imports `three`. I have it in `<head>`, and the module script at the end of `</body>`. That's fine; the import map is parsed early.
Another thing: The `three/addons/` import map prefix. When I do `import { OrbitControls } from 'three/addons/controls/OrbitControls.js'`, the module resolver will look up the path. With the import map `"three/addons/": "..."`, it should resolve the base, and then `/controls/OrbitControls.js` is appended. So the full URL becomes `https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/controls/OrbitControls.js`. That's correct.
Let's also consider if `three` module imports need the `three/addons/` prefix for anything else. I don't think so.
Now, I'll produce the final code block.
I'll review each section one last time.
- HTML structure: correct.
- CSS: resets, overlay, canvas.
- Import map: exact.
- Module script:
- Imports three and OrbitControls.
- Scene, fog.
- Camera, renderer, shadows.
- Controls with autoRotate and polar limits.
- Ground, rocks, logs, trees, fire base, point light, flame particles, spark particles, stars, moon.
- Animation loop: updates particles, flickers light, renders.
- Resize handler.
- Overlay click/key/auto-fade.
I think it's solid.
One small fix: In the tree loop, I have `const canopy = new THREE.Mesh( new THREE.IcosahedronGeometry(0.35 + Math.random() * 0.2, 0), ... )`. The `0` detail is fine, but `IcosahedronGeometry` with detail 0 has 20 faces. That's low-poly enough.
For rocks: `new THREE.IcosahedronGeometry(s)` where `s` is 0.4 to 1.0. Good.
For the ground: `new THREE.PlaneGeometry(80, 80)` - 80 units square, enough.
For the campfire base: `new THREE.BoxGeometry(1.8, 0.5, 1.8)` - centered at (0,0.25,0), so top at y=0.75, bottom at y=-0.25? Wait, BoxGeometry centers at (0,0,0) by default. So `fireBase.position.y = 0.25` raises it so its bottom is at y=0, top at y=0.5. That sits on the ground. Good.
Point light position: `fireLight.position.set(0, 2.5, 0);` height 2.5, above fire base top (0.5). Good.
Flame particles initial height: `Math.random() * 1.5` from 0 to 1.5. They rise and reset at >1.8. So they go from base up to just above fire. Good.
Sparks initial height: `Math.random() * 0.5` from 0 to 0.5. Upward velocity 0.08-0.3, reset at >4. So they rise higher and disappear. Good.
Stars: distance 50-200, uniform sphere. Good.
Moon: position (0,12,-22), size 2.5. Good.
Overlay auto-fade: I'll set timeout to 2000ms, transition 0.5s, display none at 2500ms. That should be clean by 3s.
Let's also add `controls.autoRotate = true;` and `controls.autoRotateSpeed = 0.3;` for the self-playing demo mode. I'll add it right after controls creation.
But wait: If `autoRotate` is true, the camera automatically rotates, but the user can still control it with mouse/keyboard. The requirement "OrbitControls to look around" is satisfied. The "self-playing demo mode" is satisfied by autoRotate. I'll add it.
However, `autoRotate` might conflict with the polar limits trying to keep camera above ground. It should be fine; the limits will prevent it from going too low.
Let's add it.
Now, I'll output the final HTML.
One more check: The requirement "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." I have the message, and I have `window.addEventListener('keydown', ...)` which fades the overlay. But technically, keyboard events for the scene (like controlling OrbitControls) won't fire until the user clicks the canvas. The message is there. I think it's compliant.
I'll now produce the code.