Flint (free)
by Recursivereasoning default3 console errors
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 2 min 47 s
- Cost
- $0
- Output tokens
- 29,143
- Spent thinking
- 22,184 tokens
- Speed
- 174.9 tok/s
- Console errors
- 3
- First token after
- 737 ms
- First visible text after
- 2 min 21 s
- Prompt tokens
- 545
- Code size
- 12.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
Console errors
Caught in the first 3 seconds after load, in a sandboxed iframe like the one above.
THREE.BufferGeometry.computeBoundingSphere(): Computed radius is NaN. The "position" attribute is likely to have NaN values. ConeGeometry THREE.BufferGeometry.computeBoundingSphere(): Computed radius is NaN. The "position" attribute is likely to have NaN values. ConeGeometry THREE.BufferGeometry.computeBoundingSphere(): Computed radius is NaN. The "position" attribute is likely to have NaN values. ConeGeometry
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 · 328 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Low Poly Campfire Night</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #04060e; overflow: hidden; }
body { font-family: Arial, Helvetica, sans-serif; }
#scene { position: fixed; inset: 0; }
#scene canvas { display: block; }
#overlay {
position: fixed; inset: 0; z-index: 30;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 18px; background: rgba(2, 4, 10, 0.55); text-align: center; cursor: pointer;
}
#overlay.hidden { display: none; }
#title { color: #ffcf9e; font-size: clamp(30px, 7vw, 64px); font-weight: 800; letter-spacing: 0.3em; text-indent: 0.3em;
text-shadow: 0 0 24px rgba(255, 150, 60, 0.6); }
#subtitle { color: #cfa87e; font-size: clamp(11px, 1.8vw, 15px); letter-spacing: 0.45em; text-indent: 0.45em; text-transform: uppercase; }
#hint { margin-top: 6px; color: #ffe3bd; font-size: clamp(13px, 2.4vw, 19px); letter-spacing: 0.2em; text-indent: 0.2em; text-transform: uppercase;
padding: 12px 26px; border-radius: 999px; border: 1px solid rgba(255, 180, 110, 0.45); background: rgba(20, 10, 4, 0.45); }
</style>
</head>
<body>
<div id="scene"></div>
<div id="overlay">
<div id="title">CAMPFIRE</div>
<div id="subtitle">low poly night demo</div>
<div id="hint">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.background = new THREE.Color(0x04060e);
scene.fog = new THREE.Fog(0x04060e, 16, 60);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 500);
camera.position.set(0, 7, 15);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
document.getElementById('scene').appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.enablePan = false;
controls.minDistance = 4;
controls.maxDistance = 45;
controls.maxPolarAngle = Math.PI * 0.49;
controls.target.set(0, 2.2, 0);
scene.add(new THREE.HemisphereLight(0x22334a, 0x0a1208, 0.35));
const fireLight = new THREE.PointLight(0xffb35c, 10, 30, 2);
fireLight.position.set(0, 4, 0);
fireLight.castShadow = true;
scene.add(fireLight);
const moonLight = new THREE.DirectionalLight(0x3a4a66, 0.4);
moonLight.position.set(25, 35, -30);
scene.add(moonLight);
// Ground
const ground = new THREE.Mesh(new THREE.CircleGeometry(90, 72), new THREE.MeshStandardMaterial({ color: 0x2c3822, roughness: 1 }));
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
// Campfire pit
const pit = new THREE.Mesh(new THREE.CylinderGeometry(1.3, 1.5, 0.6, 10), new THREE.MeshStandardMaterial({ color: 0x14100c, roughness: 0.95 }));
pit.position.y = 0.3;
pit.castShadow = true;
pit.receiveShadow = true;
scene.add(pit);
// Crossed logs inside the fire
function addCrossLog() {
const l1 = new THREE.Mesh(new THREE.CylinderGeometry(0.13, 0.16, 2.8, 6), new THREE.MeshStandardMaterial({ color: 0x6b4a2b, roughness: 1 }));
l1.geometry.flatShading = true;
l1.rotation.y = Math.PI / 2;
l1.position.set(0, 0.35, 0);
const l2 = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.15, 2.6, 6), new THREE.MeshStandardMaterial({ color: 0x6b4a2b, roughness: 1 }));
l2.geometry.flatShading = true;
l2.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0, 1));
l2.position.set(0, 0.35, 0);
scene.add(l1, l2);
}
addCrossLog();
// Logs lying around the campfire
function addLog(x, z, alongAngle, length, radius) {
const dir = new THREE.Vector3(Math.cos(alongAngle), 0, Math.sin(alongAngle));
const log = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius * 1.15, length, 6), new THREE.MeshStandardMaterial({ color: 0x6b4a2b, roughness: 1 }));
log.geometry.flatShading = true;
log.position.set(x, radius * 1.2, z);
log.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
log.castShadow = true;
log.receiveShadow = true;
scene.add(log);
}
addLog(2.3, 0.6, Math.atan2(0.6, 2.3), 2.0, 0.13);
addLog(-2.2, -0.8, Math.atan2(-0.8, -2.2), 2.0, 0.13);
addLog(-0.6, -2.4, Math.atan2(-2.4, -0.6), 1.8, 0.12);
// Rocks
function addRock(x, z, r) {
const rock = new THREE.Mesh(new THREE.IcosahedronGeometry(r, 0), new THREE.MeshStandardMaterial({ color: 0x6b6259, roughness: 0.95 }));
rock.position.set(x, r * 0.6, z);
rock.scale.y = 0.75;
rock.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
rock.castShadow = true;
rock.receiveShadow = true;
scene.add(rock);
}
addRock(2.2, 1.8, 0.5);
addRock(-2.0, 2.4, 0.6);
addRock(1.2, -2.6, 0.45);
addRock(-2.6, -1.2, 0.55);
addRock(3.2, -0.8, 0.4);
addRock(-3.4, 0.4, 0.5);
// Flames
const flames = [];
function addFlame(x, z, baseY, height, radius, phase) {
const mesh = new THREE.Mesh(new THREE.ConeGeometry(radius * 0.85, radius, height, 6, 2), new THREE.MeshBasicMaterial({ color: 0xff8c22 }));
mesh.position.set(x, baseY, z);
mesh.geometry.flatShading = true;
mesh.castShadow = true;
scene.add(mesh);
const inner = new THREE.Mesh(new THREE.ConeGeometry(radius * 0.5, radius * 0.5, height * 0.6, 6, 2), new THREE.MeshBasicMaterial({ color: 0xffe066 }));
inner.position.set(x, baseY + height * 0.1, z);
scene.add(inner);
const posAttr = mesh.geometry.getAttribute('position');
const baseYArr = [];
for (let k = 0; k < posAttr.count; k++) baseYArr.push(posAttr.getY(k));
flames.push({ mesh, inner, baseY, height, phase, baseYArr });
}
addFlame(0, 0, 0.55, 1.5, 0.4, 0);
addFlame(-0.32, 0.2, 0.6, 1.15, 0.3, 2);
addFlame(0.32, -0.2, 0.6, 1.15, 0.3, 4);
// Sparks
const sparkCount = 140;
const sparkPosArr = new Float32Array(sparkCount * 3);
const sparkVelArr = new Float32Array(sparkCount * 3);
const sparkLifeArr = new Float32Array(sparkCount);
const sparkMaxLifeArr = new Float32Array(sparkCount);
const sparkColArr = new Float32Array(sparkCount * 3);
const sparkPosAttr = new THREE.BufferAttribute(sparkPosArr, 3);
const sparkColAttr = new THREE.BufferAttribute(sparkColArr, 3);
sparkPosAttr.setUsage(THREE.DynamicDrawUsage);
sparkColAttr.setUsage(THREE.DynamicDrawUsage);
const sparkGeom = new THREE.BufferGeometry();
sparkGeom.setAttribute('position', sparkPosAttr);
sparkGeom.setAttribute('color', sparkColAttr);
const sparkMat = new THREE.PointsMaterial({ size: 0.09, color: 0xffaa44, vertexColors: true, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, sizeAttenuation: true });
const sparks = new THREE.Points(sparkGeom, sparkMat);
sparks.frustumCulled = false;
scene.add(sparks);
for (let i = 0; i < sparkCount; i++) {
const i3 = i * 3;
sparkPosArr[i3] = (Math.random() - 0.5) * 1.0;
sparkPosArr[i3 + 1] = 0.6 + Math.random() * 0.4;
sparkPosArr[i3 + 2] = (Math.random() - 0.5) * 1.0;
sparkVelArr[i3 + 1] = 7 + Math.random() * 6;
sparkVelArr[i3] = (Math.random() - 0.5) * 1.2;
sparkVelArr[i3 + 2] = (Math.random() - 0.5) * 1.2;
sparkMaxLifeArr[i] = 0.8 + Math.random() * 0.5;
sparkLifeArr[i] = sparkMaxLifeArr[i];
}
function updateSparks(dt) {
const pos = sparkPosAttr.array;
const col = sparkColAttr.array;
for (let i = 0; i < sparkCount; i++) {
const i3 = i * 3;
sparkLifeArr[i] -= dt;
if (sparkLifeArr[i] <= 0) {
sparkLifeArr[i] = sparkMaxLifeArr[i];
pos[i3] = (Math.random() - 0.5) * 1.2;
pos[i3 + 1] = 0.7 + Math.random() * 0.5;
pos[i3 + 2] = (Math.random() - 0.5) * 1.2;
sparkVelArr[i3 + 1] = 7 + Math.random() * 6;
sparkVelArr[i3] = (Math.random() - 0.5) * 1.4;
sparkVelArr[i3 + 2] = (Math.random() - 0.5) * 1.4;
}
sparkVelArr[i3 + 1] -= 16 * dt;
pos[i3] += sparkVelArr[i3] * dt;
pos[i3 + 1] += sparkVelArr[i3 + 1] * dt;
pos[i3 + 2] += sparkVelArr[i3 + 2] * dt;
const t = 1 - sparkLifeArr[i] / sparkMaxLifeArr[i];
col[i3] = 1.0 * (1 - t);
col[i3 + 1] = 0.45 * (1 - t);
col[i3 + 2] = 0.1 * (1 - t);
if (pos[i3 + 1] > 12) sparkLifeArr[i] = 0;
}
sparkPosAttr.needsUpdate = true;
sparkColAttr.needsUpdate = true;
}
// Stars
const starCount = 900;
const starPosArr = new Float32Array(starCount * 3);
for (let i = 0; i < starCount; i++) {
const u = Math.random() * 2 - 1;
const theta = Math.random() * Math.PI * 2;
const r = 60 + Math.random() * 220;
const s = Math.sqrt(Math.max(0, 1 - u * u));
starPosArr[i * 3] = Math.cos(theta) * s * r;
starPosArr[i * 3 + 1] = Math.abs(u) * r;
starPosArr[i * 3 + 2] = Math.sin(theta) * s * r;
}
const starGeom = new THREE.BufferGeometry();
const starAttr = new THREE.BufferAttribute(starPosArr, 3);
starGeom.setAttribute('position', starAttr);
const starMat = new THREE.PointsMaterial({ color: 0xccddff, size: 2.4, sizeAttenuation: false, transparent: true, opacity: 0.95, depthWrite: false, fog: false });
const stars = new THREE.Points(starGeom, starMat);
scene.add(stars);
// Moon + glow
const moon = new THREE.Mesh(new THREE.CircleGeometry(5, 6), new THREE.MeshBasicMaterial({ color: 0xe8e6d8 }));
moon.position.set(26, 24, -34);
moon.lookAt(0, 10, 0);
scene.add(moon);
const glow = new THREE.Mesh(new THREE.CircleGeometry(11, 6), new THREE.MeshBasicMaterial({ color: 0xffeedd, transparent: true, opacity: 0.22, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide }));
glow.position.copy(moon.position);
glow.lookAt(0, 10, 0);
scene.add(glow);
// Trees
function makeTree() {
const g = new THREE.Group();
const trunkMat = new THREE.MeshStandardMaterial({ color: 0x5a4230, roughness: 1 });
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.14, 0.9, 6), trunkMat);
trunk.geometry.flatShading = true;
trunk.position.y = 0.45;
const foliageMat = new THREE.MeshStandardMaterial({ color: 0x35502a, roughness: 0.95 });
const c1 = new THREE.Mesh(new THREE.ConeGeometry(1.05, 1.5, 7), foliageMat);
c1.geometry.flatShading = true;
c1.position.y = 1.8;
const c2 = new THREE.Mesh(new THREE.ConeGeometry(0.72, 1.3, 7), foliageMat);
c2.geometry.flatShading = true;
c2.position.y = 2.8;
g.add(trunk, c1, c2);
return g;
}
for (let i = 0; i < 36; i++) {
const angle = Math.random() * Math.PI * 2;
const dist = 9 + Math.random() * 30;
const t = makeTree();
const fm = t.children[1].material;
fm.color.setHSL(0.27 + Math.random() * 0.07, 0.42, 0.13 + Math.random() * 0.06);
const fm2 = t.children[2].material;
fm2.color.setHSL(0.27 + Math.random() * 0.07, 0.42, 0.11 + Math.random() * 0.06);
t.position.set(Math.cos(angle) * dist, 0, Math.sin(angle) * dist);
t.traverse((o) => { if (o.isMesh) o.castShadow = true; });
scene.add(t);
}
// Start overlay
const overlay = document.getElementById('overlay');
let started = false;
function startDemo() {
if (started) return;
started = true;
overlay.classList.add('hidden');
controls.autoRotate = true;
controls.autoRotateSpeed = 0.5;
}
overlay.addEventListener('pointerdown', startDemo);
window.addEventListener('keydown', startDemo);
// Resize
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// Animation
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
const t = clock.elapsedTime;
for (const f of flames) {
const l = 1 + Math.sin(t * 5 + f.phase) * 0.12 + Math.sin(t * 9.3 + f.phase * 1.7) * 0.08;
const s = 0.8 + l * 0.35;
f.mesh.scale.set(s, l, s);
f.inner.scale.set(0.55, 0.55, 0.55);
f.inner.scale.multiplyScalar(0.55 * l);
const pos = f.mesh.geometry.getAttribute('position');
const arr = pos.array;
const base = f.baseYArr;
for (let k = 0; k < base.length; k++) {
const idx = k * 3;
const x = arr[idx];
const z = arr[idx + 2];
arr[idx + 1] = base[k] + Math.sin(t * 6 + f.phase + x * 3.7) * 0.08 + Math.sin(t * 11 + f.phase * 2 + z * 2.3) * 0.06;
}
pos.needsUpdate = true;
}
fireLight.intensity = 10 * (0.72 + Math.random() * 0.56);
fireLight.position.y = 3.6 + Math.sin(t * 6.5) * 0.5;
updateSparks(dt);
controls.update();
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>Reasoning trace (22,184 tokens)
Let me break down this task:
1. Build a low-poly 3D scene of a campfire in a forest clearing at night, with Three.js in a single HTML file.
2. Load Three.js only through this import map (do not use any other URL or version):
```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>
```
3. 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.
4. 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.
Let me think about the design:
**Scene setup:**
- Scene with fog
- Camera (PerspectiveCamera)
- WebGLRenderer with shadows, antialias
- OrbitControls with limits (camera stays above ground)
**Ground:**
- Large circle or plane with brownish/green color
- Maybe some grass color
**Campfire:**
- A fire pit: cylinder (dark) at center
- Logs: 2-3 cylinders crossed (brown)
- Flames: 2-3 cone or icosahedron shapes with animated vertices (flickering), orange/yellow colors
- PointLight with warm color, flickering intensity
**Rocks:**
- A few low-poly icosahedron rocks around fire
**Logs around fire:**
- Small cylinders/logs
**Trees:**
- Low-poly trees: cone (pine) or icosahedron blobs on cylinders
- At least 30 randomly placed around clearing, not on fire
- Should be within some radius of center, not overlapping fire too much
**Sky:**
- Night sky: dark blue-ish background
- Stars: Points with random positions in a sphere/box
- Moon: circle with glow
**Fog:**
- Fog that fades distant trees
**Sparks:**
- Particle system: Points with velocity, gravity, fading out
**Controls:**
- OrbitControls with maxPolarAngle to stay above ground (e.g., ~1.45 rad or so), maybe minDistance/maxDistance
**Resize:**
- handle window resize
**Demo mode:**
- "Click or press a key to start" message overlay
- Self-playing demo mode: a simple AI plays (for this scene, maybe the camera slowly orbits or something). Actually the task says "Games must start in a self-playing demo mode (a simple AI plays) behind a 'Click or press a key to start' message". For a scene like this, the AI could be camera movement or just the scene animating. Let me make it so when not started, it shows the message; when started (click/key), the animation loop runs. Or maybe the scene auto-runs but requires click to start interaction. Let me interpret: the demo should have a start screen saying "Click or press a key to start" and when clicked/pressed, it starts. The "simple AI plays" could mean the camera does a slow orbit or the scene plays itself. Let me make the camera have an auto slow orbit when demo mode is on, or just have the scene animate (flames flicker, sparks rise) automatically.
Actually, to be safe and simple: I'll have a start overlay with the text "Click or press a key to start". On click or keydown, it sets `started = true` and hides overlay. The animation loop runs always but maybe only after start? The screenshot is taken ~3 seconds after load without interaction, so the scene should look good immediately. Let me just run the animation loop from the start, but the overlay covers it until started. Hmm, but screenshot after 3s without interaction — if overlay is visible, the screenshot shows the overlay. That's fine as long as something meaningful is visible. Better: start the scene immediately, and show the overlay message; when user clicks/key presses, hide overlay. Actually to satisfy "Games must start in a self-playing demo mode (a simple AI plays) behind a 'Click or press a key to start' message", I'll show the overlay initially, and the scene renders behind it. When clicked/started, hide overlay and begin the demo (auto camera orbit or whatever).
Let me design the "simple AI": maybe the camera slowly orbits around the campfire automatically in demo mode, or the demo just plays. I'll add an auto-rotate for the camera in demo mode. OrbitControls has `enableRotate` etc. I could set controls.autoRotate = true when demo starts. Or implement simple orbit manually. Simpler: set `controls.autoRotate = true; controls.autoRotateSpeed = 0.5;` after start. Before start, maybe autoRotate false. Actually just enable autoRotate always? The requirement says camera should be able to look around via OrbitControls with limits. autoRotate is fine.
Let me keep it simple: OrbitControls with `enableDamping`, minDistance, maxDistance, maxPolarAngle (e.e., 1.5 radians to stay above ground... actually maxPolarAngle of Math.PI/2 * 0.95 ≈ 1.5 keeps camera above ground looking slightly down? Polar angle is angle from +Y axis. To stay above ground, maxPolarAngle < Math.PI/2. Let me use ~1.45). Also `minDistance` ~3, `maxDistance` ~40.
**Low-poly aesthetic:**
- Use IcosahedronGeometry for rocks, trees (cones), flames (cones/icosahedrons with flatShading).
- MeshLambertMaterial or MeshStandardMaterial with flatShading: true, low-poly look.
**Trees:**
- Pine tree: cylinder trunk + 2-3 cones. Or a single icosahedron? Classic low-poly pine: stacked cones. Or blob tree: icosahedron on cylinder. Let me do cone-based pine: trunk cylinder (r=0.08, h=0.5), cones at heights with radius decreasing. Low-poly: ConeGeometry with maybe 4 vertices? ConeGeometry(radiusTop, radiusBottom, height, radialSegments). Keep radialSegments low like 6? Actually radialSegments default 32 is smooth-ish; for low-poly use 6-8. Hmm, ConeGeometry's radial segments affect smoothness around the circle; flat shading will make facets anyway with flatShading. Let me use radialSegments: 8, flatShading: true.
Actually for low-poly look, use IcosahedronGeometry everywhere with flatShading: true.
Pine tree:
- trunk: CylinderGeometry(0.09, 0.12, 0.8, 6) brown
- foliage: 2-3 cones: ConeGeometry(1.2, 1.5, 7) etc. Or icosahedrons. Cones read as pine trees. Low poly: ConeGeometry with radialSegments 6? Let me just use flatShading cones.
Simpler: blobby trees using IcosahedronGeometry scaled — like low poly stylized trees. But pines with cones are classic low-poly. I'll do cones.
Number of trees: at least 30. Place within radius ~ 25-35 from center, random angle/position, ensure not within fire radius (fire at center, logs around). Avoid placing too close to fire (distance > 2.5). Also maybe avoid overlapping each other too much — not critical.
**Fire:**
- Fire pit: CylinderGeometry radius 1.2, height 0.5, dark gray/black material, at y ~0.25. Or a torus? Keep cylinder.
- Logs: CylinderGeometry(0.12, 0.15, 2.5, 6) rotated — 2 logs crossed at angles, brown, slightly above ground.
- Flames: group with 2-3 meshes; each flame is a ConeGeometry or IcosahedronGeometry with flatShading, orange/yellow colors, positioned above logs, animated: scale y and random vertex displacement, material emissive? Use MeshBasicMaterial for flames (unlit) with color lerp. Animated: per frame, adjust flame geometry vertices (displace along y with noise), or just scale/rotate. Simplest good-looking: use a cone, animate its scale and rotation slightly, and use vertex displacement for flicker.
Let me do: flame mesh = IcosahedronGeometry(0.5, 0) (icosahedron = 20 faces, low poly) or ConeGeometry. I'll use ConeGeometry(0.45, 1.2, 8) with flatShading. Animate: store base positions; each frame add random small offsets; update positions. Also scale. Colors: inner flame yellow-orange, outer orange. Could use two layers.
- PointLight: warm color (0xffaa33), intensity ~1.5, distance ~15, decay ~2. Flicker: intensity = base * (1 + random*0.3), maybe y-offset. CastShadow: true. Point lights cast shadows in three.js? Yes, PointLight can castShadow if shadow enabled on light and mesh castShadow. Actually for point lights, shadows need `light.castShadow = true` and the light's shadow map... Point lights support shadows in WebGLRenderer? Shadow mapping for point lights: WebGLRenderer supports PCFSoftShadowMap; point light shadows work (they use cube shadow map? Actually three.js supports point light shadows via shadow cube maps? Hmm, historically point light shadows were supported with WebGLShadowMap using cube maps... I recall that three.js does support point lights casting shadows — yes, WebGLShadowMap handles point lights (it uses a cube shadow map? Actually it renders the shadow from the point light perspective using shadow cube maps? I believe three r150+ supports it). Let me just set `pointLight.castShadow = true;` and it should work. If not, no error. To be safe, I might enable `renderer.shadowMap.enabled = true` and `type = THREE.PCFSoftShadowMap`. And set ground receiveShadow, fire meshes castShadow, trees castShadow maybe.
Hmm, risk: if point light shadow fails, it's fine, no console error. Let's include it.
**Sparks:**
- Points with BufferGeometry, positions updated each frame with velocity + gravity, life. Simple: 80 points, each with position, velocity, life. PointsMaterial with size, color orange, transparent, blending additive (AdditiveBlending), depthWrite false. Update positions in CPU array, needsUpdate. Fade via alpha — PointsMaterial doesn't support per-point alpha easily; could use vertexColors for fade. Simpler: just move them up and let them... without fade they'd stay. Use life: when life < 0, reset position to fire base. Alpha per point: use vertexColors with alpha? PointsMaterial has `vertexColors` and color; alpha is material-level. Alternative: use `THREE.Points` with custom shader? Too complex. Use vertexColors with fading color (black at end) and AdditiveBlending — black additive is invisible-ish? Additive with black = no add, so effectively fades out. Yes! Additive blending: result = source + dest; black source adds nothing → invisible. So set color to fade from orange to black using vertexColors.
Sparks: each spark: pos (x,y,z), vel, life (remaining frames), maxLife. On update: pos += vel; vel.y -= gravity; life -= 1; if life <= 0 or y > some max: reset. Set color attribute = life/maxLife ? orange : black. PointsMaterial({size: 0.08, vertexColors: true, transparent: true, blending: AdditiveBlushing... AdditiveBlending, depthWrite: false}).
Actually with vertexColors and PointsMaterial, set `pointsMaterial.vertexColors = true` and set `color`. The color attribute needs `setUsage(THREE.DynamicDrawUsage)`. Fine.
**Sky:**
- Scene background: dark color (0x0a0e1a or similar). Maybe a gradient via large sphere? Keep simple: scene.background = new THREE.Color(0x05070f). Plus fog.
- Fog: THREE.Fog(0x05070f, 20, 60) — fades distant trees.
- Stars: Points with random positions in a big sphere radius ~200, or a circular distribution. Use PointsMaterial size 1.5, color white, sizeAttenuation false maybe. Generate ~1000 points in sphere shell. Or use THREE.Points with geometry of random positions in [0, 200] sphere. Ensure not in camera view issues — fine.
Better stars: use a BufferGeometry with random positions: for i < count: pick random direction in sphere, radius between 50 and 250. PointsMaterial({color: 0xffffff, size: 2, sizeAttenuation: false, transparent, opacity...}). Maybe add slight blue-ish tint.
- Moon: CircleGeometry or SphereGeometry, emissive/bright color, position at (30, 25, -30)? Moon should be in sky: place at upper area, e.g., (25, 30, -40) looking toward origin. Use MeshBasicMaterial or MeshStandardMaterial emissive with intensity. Add glow: larger circle with additive blending, low opacity. Keep simple: moon = CircleGeometry(4, 6) rotated to face camera? Circle faces +Z by default; rotate -PI/2 around X to face +Y. Position (20, 24, -35). Material: MeshBasicMaterial color 0xe8e8d8 or emissive. Add glow sprite: circle with additive.
**Camera:**
- PerspectiveCamera(75, aspect, 0.1, 1000). Position (0, 6, 14) looking at (0, 2, 0).
- OrbitControls: create with camera, target (0,2,0). enableDamping. minDistance: 3, maxDistance: 40, maxPolarAngle: Math.PI * 0.49 (≈88.2°, stays above ground slightly) — or 1.45 rad. Also enablePan maybe false? Requirement: "limits so the camera stays above the ground". maxPolarAngle < PI/2 keeps above ground. Set maxPolarAngle = Math.PI * 0.48. Hmm, polar angle max 0.48*π ≈ 86°, fine.
**Renderer:**
- new THREE.WebGLRenderer({antialias: true}); setSize(window.innerWidth, window.innerHeight); setPixelRatio(window.devicePixelRatio); shadowMap enabled PCFSoft. Output color space default in r170 is SRGB. Fine.
**Resize:** window.addEventListener('resize', ...) update camera aspect, renderer size.
**Animation loop:** requestAnimationFrame; controls.update(); flames flicker; sparks update; renderer.render.
**Demo mode / start overlay:**
- HTML: full-screen overlay div with pointer-events auto, background transparent? Text: "Click or press a key to start". When user clicks overlay or presses key → hide overlay, started = true.
- In demo mode: "a simple AI plays" — for this scene, I can make the camera auto-orbit slowly (controls.autoRotate = true) when started. Or the scene just runs. I'll set autoRotate when started becomes true, and maybe speed. Actually let's have the AI = camera slowly orbiting so viewer sees the scene. Or maybe keep controls manual after start? The requirement says self-playing demo mode with simple AI behind the start message. I'll implement: before start, overlay visible, scene still renders (so screenshot ~3s shows scene? If overlay covers with semi-transparent background, screenshot would show overlay text. Better make overlay semi-transparent dark so scene visible behind, plus text centered. Screenshot shows scene + text. "Something meaningful must be visible on first paint" — yes scene visible.)
Hmm, but "Nothing hidden behind a high score, a later level or a long play session" — fine.
When started: hide overlay, set controls.autoRotate = true (AI camera movement), autoRotateSpeed ~0.6, and maybe stop on user interaction? OrbitControls autoRotate continues; user can drag to take over. Fine.
Keyboard: keydown listener → start. Also click on overlay → start.
**Fog & distant trees:** fog handles it.
**Ground:** CircleGeometry radius 60? Or plane 200x200. Use CircleGeometry(70, 48) rotated flat, color green-gray (0x3a4a2f?) night: dark desaturated green (0x2f3b26). receiveShadow. Maybe add slight noise? Keep flat. Add a few darker patches? Keep simple.
Also ground should be below fire: y=0. CircleGeometry radius 70.
**Rocks:** 5-7 icosahedrons (radius 0.3-0.7, detail 0) scattered around fire at radius 1.5-4, y=0, gray-brown, castShadow. Avoid overlapping fire pit? Fire pit radius 1.2; rocks at distance > 2 from center. Fine.
**Logs:** around fire: maybe 3 logs (cylinders) lying around at radius ~1.5-2.5, or just 2 crossed logs in fire. Requirement: "A ground with a few low-poly rocks and logs around a campfire in the center." So logs around campfire on ground: place 2-3 log cylinders at radius ~2 from center, lying flat (rotation y = PI/2), brown. Plus crossed logs in fire maybe. Let me place logs around: positions at angle random, radius 2.0-3.0, cylinder rotated to lie horizontal, length 2, radius 0.12. And in fire: 2 crossed logs.
**Trees placement:** 30 trees, radius between 12 and 40 from center (clearing), not within radius 3 of fire center (fire radius ~1.2 + logs). Random pos; maybe check min distance between trees? Not necessary but nice: keep simple random.
Tree count: let's do 35.
Each tree: group. Trunk cylinder (6 radial seg) + 2 cones. Height ~3-4. Low-poly colors: trunk 0x4a3423, foliage dark green 0x2d4a26 (night, so darker: 0x22371f). Maybe some variation in hue/saturation via instance color. Keep simple.
**Flames detail:**
- Fire group at (0, 0.5, 0) (logs at y~0.15). Pit cylinder at y=0.25 h=0.5 → top at 0.5. Logs at y=0.15? Logs radius 0.15 → center y=0.15. Flames above logs: base at y~0.5.
Flame meshes:
- flame1: ConeGeometry(0.35, 1.0, 8) color inner #ffdd44? MeshBasicMaterial. Actually MeshBasicMaterial with color; for glowing look maybe MeshLambertMaterial? Basic is unlit = glow-ish. Use MeshBasicMaterial({color: 0xffaa22}).
- Animate: per frame, for each flame: positions need update — with ConeGeometry, can do `geom.dispose()`? No — to update cone we can set position of geometry? Easier: scale the mesh: base scale (0.9, 1.2, 0.9), animate scale.y = base * (0.85 + 0.3*noise), and random tilt. Plus vertex displacement for extra flicker: get position attribute, add random y offsets based on time. Let me do vertex displacement — it's easy:
```js
const pos = flame.geometry.getAttribute('position');
const time = clock.elapsedTime;
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i);
const n = Math.sin(x * 10 + time * 3) * Math.cos(z * 8 + time * 2.5) * 0.05;
pos.setY(i, y + n);
}
pos.needsUpdate = true;
```
And scale flickle: flame.scale.set(1 + Math.random()*0.15, baseH * (0.8+0.4*Math.random()), ...) — random per frame causes pop; use sin-based. Fine.
Colors: maybe two flames per side (inner yellow, outer orange) — keep one mesh per flame with basic material, maybe add inner smaller. Keep 3 flames (two side, one center bigger).
PointLight flicker: `light.intensity = 2 * (0.85 + Math.random() * 0.3);` Also maybe `light.distance = 18`. Warm color 0xffb35c.
Shadows: `renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap;` Ground receiveShadow, trees castShadow? 35 trees with castShadow might be heavy but okay. Maybe castShadow on trees and rocks, fire flames cast? Flames basic material — castShadow ok? Basic materials can cast. Keep: ground receive; rocks cast; logs cast; trunks cast; foliage cast maybe not (cheap enough? shadow map with many casts could be slow but okay). Let me cast on rocks and logs and trunks, and foliage maybe receive? Simpler: castShadow on all tree meshes — might cause shadow acne but fine. Actually to keep performance and simplicity, set castShadow on rocks/logs/trunks/flames, and receiveShadow on ground. Trees foliage castShadow = true might be fine.
PointLight shadow: `light.castShadow = true;` — three.js r170 supports point light shadows? Checking memory: WebGLShadowMap in three.js supports directional and point lights? The shadow map implementation: for point lights, it uses... I recall `WebGLShadowMap` has `cube` handling for point lights? Hmm. Actually I remember that three.js added point light shadow support? Let me think: In three.js, `LightShadow`... The WebGLShadowMap renderer checks `light.isDirectionalLight` or `light.isPointLight`? I believe shadow mapping works for directional lights primarily; point lights... Actually three.js does support point/spot lights casting shadows since they use shadow cube maps? Hmm, I think there is support: WebGLShadowMap renders shadow maps for directional, point, and spot lights (point uses cube map of faces? no...).
Let me recall: three.js WebGLShadowMap.js — it has uniforms for shadow maps; for point lights I think there was historically no support and `castShadow` on point lights did nothing? Actually, I now recall that three.js does NOT support point light shadows via shadow maps until... hmm. There is `THREE.ShadowMap` types. I believe WebGLShadowMap handles directional lights only for cube/cascade maps, and point lights...
Searching memory: In three.js source, WebGLShadowMap.js: `if ( light.isDirectionalLight ) {...} else if ( light.isSpotLight ) {...}`? Not sure about point. To avoid risk of console errors (there wouldn't be errors even if unsupported — it just might not render shadow), I'll still set castShadow = true on the point light; if unsupported it just won't cast, no error. Actually there might be a check that logs? Unlikely. Fine.
Alternatively, use a DirectionalLight for shadows (moonlight-ish) plus point light for fire. But requirement: campfire has warm point light that flickers and casts shadows. So point light should cast shadow. I'll set it; if the engine ignores, acceptable. Let me check quickly: three.js r170 WebGLShadowMap — I'm fairly confident point lights DO get shadow support now? Hmm, actually I remember reading that point light shadows are supported via shadow cube maps in three.js... There is `WebGLShadowMap` with `shadowCube` uniforms for point lights? I genuinely can't recall 100%. Either way, setting the property won't throw.
Let me now write the code.
Structure:
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Campfire</title>
<style>
html, body { margin:0; height:100%; overflow:hidden; background:#05070f; }
#scene { width:100%; height:100%; display:block; }
#overlay {
position:fixed; inset:0; display:flex; align-items:center; justify-content:center;
flex-direction:column; gap:12px; color:#ffd9a0; font-family: monospace...;
background: rgba(3,6,14,0.35); cursor:pointer; z-index:10; text-align:center;
pointer-events:auto;
}
#overlay.hidden { display:none; }
...
</style>
</head>
<body>
<div id="scene"></div>
<div id="overlay">...</div>
<script type="importmap">...</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// ... scene setup
</script>
</body>
</html>
```
Note: import map must be present before module script. The 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/"}}}`. Copy exactly.
Font: no external fonts. Use monospace system font. Fine.
Overlay content: "Click or press a key to start" big, plus small "The campfire demo". Keep visible initially.
Start logic:
```js
const overlay = document.getElementById('overlay');
let started = false;
function start() {
if (started) return;
started = true;
overlay.classList.add('hidden');
controls.autoRotate = true;
controls.autoRotateSpeed = 0.8;
}
overlay.addEventListener('click', start);
window.addEventListener('keydown', start); // keydown for any key
```
Note: keydown on window fires even before click? The iframe receives keyboard only after click per note; window keydown listener is fine.
Should the animation loop run before start? Yes, run always (scene behind overlay). Screenshot at 3s shows scene. Good.
Camera initial: position (0, 7, 16), target (0, 2.5, 0).
OrbitControls limits: minDistance 4, maxDistance 45, maxPolarAngle: Math.PI * 0.49 (~88.2°) — hmm to "stay above the ground" maybe maxPolarAngle = Math.PI/2 - 0.05. Use `Math.PI * 0.49`. minDistance 4 means can't go too close to campfire. Good.
maxDistance 45 with fog to 60 — distant trees fade. Good.
Now the code details.
```js
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x04060e);
scene.fog = new THREE.Fog(0x04060e, 18, 55);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 500);
camera.position.set(0, 7, 16);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const canvas = renderer.domElement;
document.getElementById('scene').appendChild(canvas);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.minDistance = 4;
controls.maxDistance = 45;
controls.maxPolarAngle = Math.PI * 0.49;
controls.target.set(0, 2.5, 0);
```
Lights:
```js
const fireLight = new THREE.PointLight(0xffb35c, 2.2, 20, 2);
fireLight.position.set(0, 4, 0);
fireLight.castShadow = true;
scene.add(fireLight);
```
Maybe also a dim moon/sun light? Night scene: maybe a very dim blue directional light for fill? Could add `THREE.DirectionalLight(0x334466, 0.15)` from moon direction. Nice touch, no harm.
Ground:
```js
const groundGeo = new THREE.CircleGeometry(80, 64);
const groundMat = new THREE.MeshStandardMaterial({ color: 0x2e3a22, roughness: 1, flatShading: true });
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI/2;
ground.receiveShadow = true;
scene.add(ground);
```
Wait: CircleGeometry is in XY plane facing +Z; rotate x by -PI/2 → faces +Y. Good.
Fire pit:
```js
const pitMat = new THREE.MeshStandardMaterial({ color: 0x14100c, roughness: 0.9 });
const pit = new THREE.Mesh(new THREE.CylinderGeometry(1.3, 1.5, 0.6, 10), pitMat);
pit.position.y = 0.3;
pit.castShadow = true; pit.receiveShadow = true;
scene.add(pit);
```
Logs in fire:
```js
function addLog(rotationZ) {
const log = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.17, 2.6, 6), logMat);
log.rotation.z = rotationZ; // lay down? Cylinder axis is Y; rotate x by PI/2 to lie along Z...
}
```
CylinderGeometry axis along Y. To lay along X: rotation.y = PI/2. Along Z: rotation.y = PI/2 and rotation.z = PI/2? Rotating around Y by 90° maps Y axis to X. Then to make it along Z, rotate around Z by 90° additionally — order matters; simpler: set rotation.set(PI/2, angle, 0)? Euler order default XYZ applies X then Y then Z... messy. Just: log.rotation.y = Math.PI/2 makes axis along X; for second log rotation.y = Math.PI/2 + Math.PI/2? That's rotation around Y only — rotating around Y axis spins around its own axis? No: rotation around Y by θ maps Y-axis vector to (sinθ, cosθ, 0)? Vector (0,1,0) rotated around Y by θ → (sinθ, cosθ, 0). So rotation.y = π/2 → (1,0,0) axis along X. To get axis along Z: rotation.y = π/2, rotation.z = π/2? With Euler order 'XYZ', rotation matrix R = Rx then Ry then Rz? Three.js Euler 'XYZ' means R = Rz * Ry * Rx? Hmm, three.js applies in order such that... To avoid confusion: create log, set `log.rotation.set(Math.PI/2, 0, Math.PI/2)`? Vector (0,1,0) under R = Rz(π/2) * Ry(0) * Rx(π/2)? Ugh.
Simplest reliable: rotation.y = Math.PI/2 → axis along +X. For along Z: rotation.y = Math.PI/2 and rotation.z = Math.PI/2? Let me just test conceptually: Euler order in three.js is 'XYZ' meaning rotation matrix = Rx * Ry * Rz? Actually three.js constructs matrix as: for 'XYZ', m = Rx(x) * Ry(y) * Rz(z)? I think three.js applies rotations in the order such that X is applied first... The docs: "Euler angles... order 'XYZ' means the rotation matrix is R = Rz @ Ry @ Rx"? I don't remember precisely.
Easiest: for a log along Z, set rotation.y = Math.PI/2 and rotation.z = Math.PI/2, OR just create with axis: use `new THREE.CylinderGeometry` then rotate; alternatively use quaternion: `log.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0), new THREE.Vector3(0,0,1))`. That's clean and reliable. Similarly for crossed logs just use rotation.y = Math.PI/2 for one and quaternion for the other.
I'll use quaternion approach for the second log.
Rocks:
```js
function addRock(x,y,z,r) {
const rock = new THREE.Mesh(new THREE.IcosahedronGeometry(r, 0), rockMat);
rock.position.set(x,y,z);
rock.scale.y = 0.7;
rock.rotation.set(Math.random()*3, Math.random()*3, Math.random()*3);
rock.castShadow = true; rock.receiveShadow = true;
scene.add(rock);
}
```
IcosahedronGeometry(radius, detail) — detail 0 = icosahedron (20 faces). Good low-poly.
Logs around fire: place 3 logs lying on ground around fire:
```js
const logPositions = [ {a: 0.3, r: 2.2}, {a: 2.5, r: 2.6}, {a: 4.2, r: 2.0} ];
```
Each: angle a, radius r; position (r*cos, 0.12, r*sin); quaternion from Y to direction (cos a, 0, sin a). Cylinder length 2.2 along that direction.
Flames:
```js
const flameMat = new THREE.MeshBasicMaterial({ color: 0xffaa33 });
const flameMatInner = new THREE.MeshBasicMaterial({ color: 0xffee55 });
flames = [];
function addFlame(x, z, baseY, height, radius) {
const flame = new THREE.Mesh(new THREE.ConeGeometry(radius, height, 8, 1), flameMat);
flame.position.set(x, baseY, z);
flame.castShadow = true;
scene.add(flame);
const inner = new THREE.Mesh(new THREE.ConeGeometry(radius*0.6, height*0.6, 8, 1), flameMatInner);
inner.position.set(x, baseY + height*0.15, z);
scene.add(inner);
flames.push({ mesh: flame, inner, baseY, height, radius });
}
```
ConeGeometry(radius, height, radialSegments, heightSegments). For low-poly maybe heightSegments: 1? Cone with few segments looks low-poly. Use radialSegments: 8, heightSegments: 2? Flat shading makes facets. Set `flatShading` not on cone via material? ConeGeometry has `flatShading` property? BufferGeometry has flatShading property; yes ConeGeometry has flatShading option? IcosahedronGeometry has `flatShading` param? Actually `IcosahedronGeometry(radius, detail, flatShading)`. ConeGeometry(radiusTop, radiusBottom, height, radialSegments, heightSegments) — has flatShading? I think all geometries support flatShading via constructor? Hmm, ConeGeometry doesn't take flatShading in older versions; it might now. To be safe, set `geom.flatShading = true;` after creation — BufferGeometry has flatShading getter/setter. Yes, BufferGeometry.prototype.flatShading exists. Good, set it.
Flame animation:
```js
const clock = new THREE.Clock();
function animateFlames(time) {
for (const f of flames) {
const t = time;
const s = 1 + Math.sin(t * 6 + f.phase) * 0.08 + Math.sin(t * 11 + f.phase * 2) * 0.05;
f.mesh.scale.set(s * 0.9, f.height * 0.8 * (0.9 + Math.sin(t*7)*0.15), s * 0.9);
...
}
}
```
Simpler: per frame compute flicker value per flame, set scale.
Vertex flicker: for cone, positions attribute. Let me implement generically:
```js
function flickerFlame(f, t) {
const pos = f.mesh.geometry.getAttribute('position');
const arr = pos.array;
const n = arr.length / 3;
const phase = f.phase;
for (let i = 0; i < n; i++) {
const x = arr[i*3], y = arr[i*3+1], z = arr[i*3+2];
const nx = x + (Math.sin(x*9 + t*3 + phase) * Math.cos(z*7 + t*2) * 0.06);
const ny = y + Math.sin(t*5 + phase) * 0.08 + Math.sin(t*9 + x*5) * 0.05;
arr[i*3] = nx; arr[i*3+1] = ny; arr[i*3+2] = z;
}
pos.needsUpdate = true;
}
```
Careful: geometry position attribute might need computeVertexNormals? Not needed for basic material.
Also inner flame scales similarly maybe skip (keep inner static-ish scale flicker).
Point light flicker:
```js
fireLight.intensity = 2.2 * (0.8 + Math.random() * 0.4);
```
Random flicker ok.
Sparks:
```js
const sparkCount = 120;
const sparkPos = new Float32Array(sparkCount * 3);
const sparkVel = new Float32Array(sparkCount * 3);
const sparkLife = new Float32Array(sparkCount);
const sparkMaxLife = new Float32Array(sparkCount);
// init positions in fire area, vel upward random
const sparkGeom = new THREE.BufferGeometry();
const sparkPosArr = new Float32Array(sparkCount * 3);
const sparkColorArr = new Float32Array(sparkCount * 3);
const sparkPosAttr = new THREE.BufferAttribute(sparkPosArr, 3);
const sparkColorAttr = new THREE.BufferAttribute(sparkColorArr, 3);
sparkPosAttr.setUsage(THREE.DynamicDrawUsage);
sparkColorAttr.setUsage(THREE.DynamicDrawUsage);
const sparkMat = new THREE.PointsMaterial({
size: 0.09, color: 0xffaa44, vertexColors: true, transparent: true,
blending: THREE.AdditiveBlending, depthWrite: false, sizeAttenuation: true
});
const sparks = new THREE.Points(sparkGeom, sparkMat);
scene.add(sparks);
for (let i = 0; i < sparkCount; i++) {
const i3 = i*3;
sparkPosArr[i3] = (Math.random()-0.5)*0.8;
sparkPosArr[i3+1] = 0.6 + Math.random()*0.3;
sparkPosArr[i3+2] = (Math.random()-0.5)*0.8;
sparkVel[i3] = (Math.random()-0.5)*0.4;
sparkVel[i3+1] = 1.5 + Math.random()*2;
sparkVel[i3+2] = (Math.random()-0.5)*0.4;
sparkLife[i] = 40 + Math.random()*30;
sparkMaxLife[i] = sparkLife[i];
}
sparkGeom.setAttribute('position', sparkPosAttr);
sparkGeom.setAttribute('color', sparkColorAttr);
```
Update:
```js
function updateSparks(dt) {
const pos = sparkPosAttr.array, col = sparkColorAttr.array, life = sparkLife, maxL = sparkMaxLife;
for (let i = 0; i < sparkCount; i++) {
const i3 = i*3;
life[i] -= dt * 60; // or fixed steps
if (life[i] <= 0) {
pos[i3] = (Math.random()-0.5)*0.8; pos[i3+1] = 0.6+Math.random()*0.3; pos[i3+2] = (Math.random()-0.5)*0.8;
const v = 1.5 + Math.random()*2;
sparkVel[i3+1] = v; sparkVel[i3] = (Math.random()-0.5)*0.4; sparkVel[i3+2] = (Math.random()-0.5)*0.4;
life[i] = maxL[i];
}
pos[i3] += sparkVel[i3] * dt * 60 * 0.05...
}
}
```
Hmm, mixing dt with fixed-step life. Simpler: use time-based: life decreases by dt*60 (frames at 60fps). Position update: pos += vel * dt * someScale. Let me use dt in seconds: vel units per second. Initial vy ~ 8 (upward), gravity -18. life in seconds: max ~1.2s. Life decrement dt. On reset, randomize.
```js
function updateSparks(dt) {
const posAttr = sparkPosAttr, colAttr = sparkColorAttr;
const pos = posAttr.array, col = colAttr.array;
for (let i = 0; i < sparkCount; i++) {
const i3 = i * 3;
sparkLife[i] -= dt;
if (sparkLife[i] <= 0) {
sparkLife[i] = sparkMaxLife[i];
pos[i3] = (Math.random() - 0.5) * 1.0;
pos[i3 + 1] = 0.7 + Math.random() * 0.4;
pos[i3 + 2] = (Math.random() - 0.5) * 1.0;
sparkVel[i3 + 1] = 7 + Math.random() * 6;
sparkVel[i3] = (Math.random() - 0.5) * 1.2;
sparkVel[i3 + 2] = (Math.random() - 0.5) * 1.2;
}
sparkVel[i3 + 1] -= 18 * dt;
pos[i3] += sparkVel[i3] * dt;
pos[i3 + 1] += sparkVel[i3 + 1] * dt;
pos[i3 + 2] += sparkVel[i3 + 2] * dt;
const t = 1 - sparkLife[i] / sparkMaxLife[i];
col[i3] = 1; col[i3 + 1] = 0.55 + 0.2 * t; col[i3 + 2] = 0.15 + 0.2 * t;
if (pos[i3+1] < -5) { /* reset */ }
}
posAttr.needsUpdate = true;
colAttr.needsUpdate = true;
}
```
Wait — with gravity negative, vy starts positive 7-13, gravity -18 → rises then falls. Good.
dt from clock: `clock.getDelta()` clamp to 0.05.
Note: adding gravity makes sparks arc. Good.
Stars:
```js
const starCount = 800;
const starPos = new Float32Array(starCount * 3);
for (...) { random direction sphere, radius 60-300; }
starGeom = new THREE.BufferGeometry(); set attribute position;
starMat = new THREE.PointsMaterial({ color: 0xccddff, size: 2.2, sizeAttenuation: false, transparent: true, opacity: 0.9, depthWrite: false });
```
Stars should not be affected by fog? Points with fog — fine either way; maybe disable fog for stars? Fog applies based on distance; stars far away → faded out. Might be okay, but to keep stars crisp maybe set material.fog = false. Yes set `starMat.fog = false;`.
Moon:
```js
const moon = new THREE.Mesh(new THREE.CircleGeometry(5, 6), new THREE.MeshBasicMaterial({ color: 0xe8e8d0 }));
moon.position.set(28, 26, -38);
moon.lookAt(0, 10, 0)?
```
CircleGeometry faces +Z. To face direction toward scene: compute quaternion from (0,0,1) to direction. `moon.lookAt(camera.position)`? lookAt on mesh orients +Z toward target — Circle faces +Z, so lookAt works. `moon.lookAt(0, 8, 0)`. Good.
Glow: `const glow = new THREE.Mesh(new THREE.CircleGeometry(9, 6), new THREE.MeshBasicMaterial({ color: 0xffeedd, transparent: true, blending: AdditiveBlending, depthWrite: false, opacity: 0.25, side: DoubleSide }));` position same, lookAt same. Or use sprite. Mesh fine.
Moon castShadow false.
Trees:
```js
const treeMatTrunk = new THREE.MeshStandardMaterial({ color: 0x4a3category... });
```
Use MeshStandardMaterial with flatShading for low-poly. Colors: trunk 0x5a4230, foliage 0x2f4a26.
```js
function createTree() {
const group = new THREE.Group();
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.13, 0.9, 6), trunkMat);
trunk.position.y = 0.45;
const c1 = new THREE.Mesh(new THREE.ConeGeometry(1.1, 1.6, 7), foliageMat);
c1.position.y = 1.9;
const c2 = new THREE.Mesh(new THREE.ConeGeometry(0.8, 1.4, 7), foliageMat);
c2.position.y = 2.9;
group.add(trunk, c1, c2);
return group;
}
```
Cylinders with flatShading: set geom.flatShading = true.
Place trees: for i in 35: angle random, dist random between 10 and 38 (avoid fire: also min dist from center 3?). Fire radius ~1.3 + flames; keep dist > 4. So dist between 8 and 38, and if dist < 5 skip/ resample. Simple loop resample.
Tree position y = 0.
Maybe vary foliage color slightly per tree via material clone with different hue? Use `new THREE.MeshStandardMaterial({color: ...})` per tree with random green shade. Cheap. Do it.
castShadow on trunk and cones? Shadows from 35 trees × (3 meshes) with PCFSoft might be slowish but scene is small; acceptable. Maybe castShadow on foliage only + trunk receive? To be safe performance-wise, cast on foliage and trunk. Eh, let's cast on all — should still be OK. Actually many shadow casters can hurt. I'll cast on trunk + top cone, and top cone only cast... Let me just set castShadow = true for trunk and cones; scene small.
Hmm, point light shadow with many casters — fine.
Fog: `scene.fog = new THREE.Fog(0x04060e, 15, 60);` Distant trees fade into sky. Good.
Background: solid color. Maybe add subtle gradient? Keep solid.
Now animation loop:
```js
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
const t = clock.elapsedTime;
// flames
for (const f of flames) { ... }
fireLight.intensity = 2.2 * (0.75 + Math.random() * 0.5);
// maybe light.position.y = 3 + flicker
updateSparks(dt);
controls.update();
renderer.render(scene, camera);
}
animate();
```
Wait: fireLight flicker should probably also flicker position? Keep intensity flicker + maybe slight color. PointLight color constant warm.
Also shadows update: renderer handles automatically each render? Shadow updates happen in render. Fine.
Camera start position (0, 6.5, 15) target (0, 2.2, 0).
Auto demo AI: after start, controls.autoRotate = true; autoRotateSpeed = 0.7. Before start, false (user can still drag? Overlay covers screen with pointer events, so no). Before start, autoRotate false; after start true. Actually maybe enable autoRotate only after start; before start overlay blocks interaction anyway.
Hmm, but screenshot at 3s: camera at initial position, autoRotate false before start → stable shot. Good.
Resize handler:
```js
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
```
Renderer fills window: setSize handles.
Overlay HTML:
```html
<div id="overlay">
<div id="title">CAMPFIRE</div>
<div id="hint">Click or press a key to start</div>
</div>
```
CSS: position fixed, inset 0, display flex column center, background rgba(2,4,10,0.45), z-index 10, transition opacity. #overlay.hidden { opacity: 0; pointer-events: none; } and maybe visibility hidden after transition. Simpler: toggle class; CSS `.hidden { display: none; }` — but display none immediately hides; fine.
Text color: warm #ffb877, font-family: 'Courier New', monospace or system. Letter-spacing. Add text-shadow glow.
z-index: overlay above canvas (canvas z-index default auto, position absolute? canvas appended to #scene div; set #scene {position:fixed; inset:0} and canvas display block).
Let me write CSS:
```css
html, body { margin: 0; padding: 0; height: 100%; background: #04060e; overflow: hidden; }
body { font-family: 'Segoe UI', Arial, sans-serif; }
#scene { position: fixed; inset: 0; }
#scene canvas { display: block; }
#overlay {
position: fixed; inset: 0; z-index: 20;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 18px; background: rgba(3, 5, 12, 0.5);
transition: opacity 0.6s ease; cursor: pointer; text-align: center;
}
#overlay.hidden { opacity: 0; pointer-events: none; visibility: hidden; }
#title { color: #ffcf9e; font-size: clamp(28px, 6vw, 56px); letter-spacing: 0.35em; font-weight: bold; text-shadow: 0 0 18px rgba(255,150,60,0.55); }
#hint { color: #e8c9a8; font-size: clamp(14px, 2.5vw, 20px); letter-spacing: 0.2em; text-transform: uppercase; background: rgba(0,0,0,0.35); padding: 10px 22px; border: 1px solid rgba(255,170,90,0.35); border-radius: 999px; }
```
Hmm transition with visibility: `transition: opacity 0.6s; visibility 0s linear 0.6s;` complicated; just use display none toggle via class without transition, or keep transition opacity and add visibility in hidden. Keep simple: `.hidden { display: none; }`.
No horizontal scrollbar: overflow hidden on html/body.
Now, potential console errors to avoid:
- No localStorage etc. ✓
- No alert/confirm/prompt. ✓
- No window.open. ✓
- No external files. ✓ Only the two CDN script URLs allowed. OrbitControls from addons via import map — allowed ("three/addons/": .../examples/jsm/). ✓
- Font: system only. ✓
- Audio? No.
Now write full code carefully.
One concern: `THREE.Clock` elapsedTime — fine.
Flame meshes with MeshBasicMaterial — they don't receive light; fine, they're emissive-looking.
Fire light: PointLight intensity 2.2 with distance 20, decay 2. In r170, physically correct lighting default: `useLegacyLights` false by default in recent versions? In r170, WebGLRenderer.useLegacyLights default false? Actually in r155+ default is... `useLegacyLights` was default true until r155 where it became false? I recall r155 changed default to physically correct (false). With physical lighting, PointLight intensity is in candela-ish units; intensity 2.2 with decay 2 gives modest light. Might be dim. To be safe, crank intensity: with decay=2 and distance 20, irradiance = intensity/d² * ... whatever. Let me set intensity 5 and distance 25, decay 2, and check visual? Can't check visually. Let me set intensity to 8, distance 30, decay 2 — with physically correct lights, a point light of intensity ~8 at distance ~4 gives decent illumination? Physically: luminous intensity I; illuminance E = I / d² (for point). For I=8, d=4 → E=0.5 — dim-ish but night scene, fine. Hmm hard to predict; set intensity 12, distance 40, decay 2. Also add a weak moon directional light (0x223355, 0.35) for ambient visibility of ground.
Actually, let me reconsider: maybe simpler to disable physical lights? `renderer.useLegacyLights = true;` — this existed and made light intensities behave like old units (point light intensity ~ inverse square with default). Setting useLegacyLights = true makes intensity 1 ≈ decent at a few units. That could give more predictable brightness. But in latest Chrome three r170, useLegacyLights still available (deprecated in newer?). r170 has it. Setting it true is safe and gives brighter result predictably. Hmm, but "latest Chrome" with three 0.170 — useLegacyLights exists in r170. I'll set it true to be safe on brightness and because old-style intensities are easier to reason about.
Actually wait — with useLegacyLights = true, PointLight intensity is like radiance; intensity 2 with decay 2... In legacy mode, light intensity attenuation: point light intensity in "lux"?? Legacy: intensity is multiplied by (1/distance²)? With decay 2 → intensity/d². intensity 2 at d=4 → 0.125 — dim. Hmm, in legacy three.js, PointLight intensity default 1 and it attenuates with distance² (decay=2), and brightness at d=1 is 1. So for campfire glow at distance 4-6, want intensity ~ 2*16 = 32?? That seems high but legacy falloff... Honestly unpredictable from memory.
Safest: use physically correct (default) and set intensity high: `fireLight.intensity = 20; distance = 30; decay = 2;` Then E = I/(4π d²)? with decay 2: E = I/d²? For I=20, d=5 → 0.8 — reasonable-ish. Ugh.
Alternative: just set intensity to a value and also add hemisphere/ambient light so scene isn't pitch black. Night scene will be dark anyway; the fire should be the main light. Let me set:
- fireLight: color 0xffb35c, intensity 8, distance 25, decay 2, position (0,4,0), castShadow true.
- ambient-ish: `new THREE.HemisphereLight(0x334466, 0x111a0a, 0.25)` for faint fill? Or DirectionalLight 0x334466 intensity 0.2. Add DirectionalLight from moon direction, intensity 0.25, no shadow (or with shadow? skip).
With physically correct lights, DirectionalLight intensity 0.25 gives subtle moonlight. PointLight intensity 8, decay 2, distance 25: at distance 3 from fire, irradiance ≈ 8/9 ≈ 0.9 → decent warm glow. Good.
Flicker: intensity = 8 * (0.8 + 0.4*rand) or smooth flicker using sin. Use random smooth: `8 * (0.75 + Math.random() * 0.5)`.
Flames colors: outer 0xff8822, inner 0xffe066. MeshBasicMaterial.
Now stars positions: random points in sphere radius [50, 280]? Camera far 500. Fog affects stars if material.fog default true — set starMat.fog = false.
Moon glow with additive.
Sky: solid color background 0x04060e, fog same color.
Let me also add a subtle "night" — maybe scene.background gradient impossible without shader; solid fine.
Now let me write the final HTML fully.
Also note: module script type with import map — must ensure import map script comes before module script and both in head/body. Import map can be in body before module script.
The importmap:
```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>
```
Must be valid JSON without spaces? JSON allows whitespace; fine.
Now full code:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Low Poly Campfire</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #04060e; overflow: hidden; }
#scene { position: fixed; inset: 0; }
#scene canvas { display: block; }
#overlay {
position: fixed; inset: 0; z-index: 30;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 20px; background: rgba(2, 4, 10, 0.55); text-align: center;
}
#overlay.hidden { display: none; }
#title {
color: #ffcf9e; font-size: clamp(30px, 7vw, 64px); font-weight: 800; letter-spacing: 0.3em;
text-shadow: 0 0 24px rgba(255, 150, 60, 0.6); text-indent: 0.3em;
}
#subtitle { color: #cfa87e; font-size: clamp(12px, 2vw, 16px); letter-spacing: 0.4em; text-transform: uppercase; }
#hint {
margin-top: 8px; color: #ffe3bd; font-size: clamp(14px, 2.6vw, 20px); letter-spacing: 0.18em;
text-transform: uppercase; padding: 12px 26px; border-radius: 999px;
border: 1px solid rgba(255, 180, 110, 0.45); background: rgba(20, 10, 4, 0.45);
box-shadow: 0 0 24px rgba(255, 140, 50, 0.25) inset;
}
</style>
</head>
<body>
<div id="scene"></div>
<div id="overlay">
<div id="title">CAMPFIRE</div>
<div id="subtitle">low poly night demo</div>
<div id="hint">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';
// ...
</script>
</body>
</html>
```
Now the module script in full.
```js
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x04060e);
scene.fog = new THREE.Fog(0x04060e, 16, 60);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 500);
camera.position.set(0, 7, 15);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping; // maybe
renderer.toneMappingExposure = 1.1;
document.getElementById('scene').appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.minDistance = 4;
controls.maxDistance = 45;
controls.maxPolarAngle = Math.PI * 0.49;
controls.target.set(0, 2.2, 0);
// lights
const fireLight = new THREE.PointLight(0xffb35c, 8, 30, 2);
fireLight.position.set(0, 4, 0);
fireLight.castShadow = true;
scene.add(fireLight);
const moonLight = new THREE.DirectionalLight(0x3a4a66, 0.35);
moonLight.position.set(20, 30, -30);
scene.add(moonLight);
const hemi = new THREE.HemisphereLight(0x22334a, 0x0a1208, 0.3);
scene.add(hemi);
```
Wait, hemisphere light color for sky/fog — fine.
Ground, etc. as planned.
Trees:
```js
function makeFoliageMaterial() {
return new THREE.MeshStandardMaterial({ color: new THREE.Color().setHSL(0.28 + Math.random()*0.06, 0.45, 0.16 + Math.random()*0.06), flatShading: true, roughness: 0.9 });
}
```
Hmm MeshStandardMaterial accepts color as Color. flatShading property exists on Material? It's on BufferGeometry; Material doesn't have flatShading... Actually Material does NOT have flatShading; geometries do (BufferGeometry.flatShading). Let me set geom.flatShading = true on geometries. For MeshStandardMaterial, flatShading isn't a material param. Right — set on geometry.
OK.
Tree creation:
```js
const trunkMat = new THREE.MeshStandardMaterial({ color: 0x5a4230, roughness: 1 });
function createTree() {
const g = new THREE.Group();
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.14, 0.9, 6), trunkMat);
trunk.geometry.flatShidding... no: trunk.geometry.flatShading = true;
trunk.position.y = 0.45;
const coneGeo = (r, h) => { const g = new THREE.ConeGeometry(r, h, 7); g.flatShading = true; return g; };
const c1 = new THREE.Mesh(coneGeo(1.05, 1.5), foliageMat);
c1.position.y = 1.85;
const c2 = new THREE.Mesh(coneGeo(0.75, 1.3), foliageMat);
c2.position.y = 2.85;
g.add(trunk, c1, c2);
return g;
}
```
Place 35 trees:
```js
const trees = [];
for (let i = 0; i < 35; i++) {
let angle = Math.random() * Math.PI * 2;
let dist = 9 + Math.random() * 30; // 9..39
const x = Math.cos(angle) * dist, z = Math.sin(angle) * dist;
const t = createTree();
// random foliage color
t.children[1].material.color.setHSL(0.28 + Math.random()*0.08, 0.4, 0.14 + Math.random()*0.05);
t.children[2].material.color.setHSL(0.28 + Math.random()*0.08, 0.4, 0.12 + Math.random()*0.05);
t.position.set(x, 0, z);
t.traverse(o => { if (o.isMesh) { o.castShadow = true; } });
scene.add(t);
trees.push(t);
}
```
dist from 9 to 39 → min distance from fire (radius ~1.5) ok, ≥ 7 away. Clearing radius 40; ground circle radius 80 fine.
Wait — trees might overlap each other; fine visually.
Hmm, "not on the fire" — dist min 9 > fire. Good.
Rocks:
```js
const rockMat = new THREE.MeshStandardMaterial({ color: 0x6b6259, flatShading: true, roughness: 0.95 });
function addRock(x, z, r) {
const m = new THREE.Mesh(new THREE.IcosahedronGeometry(r, 0), rockMat);
m.position.set(x, r * 0.6, z);
m.scale.y = 0.75;
m.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
m.castShadow = true; m.receiveShadow = true;
scene.add(m);
}
const rockSpots = [[2.2, 1.8, 0.5], [-2.0, 2.4, 0.6], [1.2, -2.6, 0.45], [-2.6, -1.2, 0.55], [3.2, -0.8, 0.4], [-3.4, 0.4, 0.5]];
rockSpots.forEach(([x, z, r]) => addRock(x, z, r));
```
Careful: rocks near fire but not on fire; radius from center > 1.3 pit radius — 2.0+ ok. Actually rock at (2.2,1.8) r=0.5 → distance ~2.8 from center, fine.
Logs around fire (lying on ground):
```js
const logMat = new THREE.MeshStandardMaterial({ color: 0x6b4a2b, flatShading: true, roughness: 1 });
function addLog(x, z, alongAngle, length = 2.2, radius = 0.13) {
const dir = new THREE.Vector3(Math.cos(alongAngle), 0, Math.sin(alongAngle));
const q = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
const log = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius * 1.15, length, 6), logMat);
log.geometry.flatShading = true;
log.position.set(x, radius * 1.2, z);
log.quaternion = q;
log.castShadow = true; log.receiveShadow = true;
scene.add(log);
}
addLog(2.3, 0.6, Math.atan2(0.6, 2.3), 2.0);
addLog(-2.2, -0.8, Math.atan2(-0.8, -2.2), 2.0);
addLog(-0.6, -2.4, Math.atan2(-2.4, -0.6), 1.8);
```
Hmm, quaternion from Y to dir — fine.
Also crossed logs in fire:
```js
function addCrossLog() {
const l1 = new THREE.Mesh(new THREE.CylinderGeometry(0.13, 0.16, 2.8, 6), logMat);
l1.geometry.flatShading = true;
l1.rotation.y = Math.PI / 2;
l1.position.set(0, 0.18, 0);
const l2 = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.15, 2.6, 6), logMat);
l2.geometry.flatShading = true;
l2.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0), new THREE.Vector3(0,0,1));
l2.position.set(0, 0.18, 0);
scene.add(l1, l2);
}
addCrossLog();
```
Second log: quaternion from Y to Z. Good.
Fire pit position y: pit cylinder h 0.6 → center y 0.3, top 0.6. Logs in fire at y 0.18 radius ~0.13 → top ~0.31, ok flames base 0.5.
Flames base y = 0.55, heights 1.0-1.4.
addFlame(x, z, baseY, height, radius).
```js
const flames = [];
function addFlame(x, z, baseY, height, radius, phase) {
const mesh = new THREE.Mesh(new THREE.ConeGeometry(radius, height, 8, 2), new THREE.MeshBasicMaterial({ color: 0xff8c22 }));
mesh.position.set(x, baseY, z);
mesh.geometry.flatShading = true;
mesh.castShadow = true;
scene.add(mesh);
const inner = new THREE.Mesh(new THREE.ConeGeometry(radius * 0.55, height * 0.62, 8, 2), new THREE.MeshBasicMaterial({ color: 0xffe066 }));
inner.position.set(x, baseY + height * 0.12, z);
scene.add(inner);
flames.push({ mesh, inner, baseY, height, phase });
}
addFlame(0, 0, 0.55, 1.5, 0.42, 0);
addFlame(-0.35, 0.25, 0.6, 1.15, 0.34, 2);
addFlame(0.35, -0.2, 0.6, 1.15, 0.34, 4);
```
Height segments 2? ConeGeometry(radiusTop, radiusBottom, height, radialSegments, heightSegments). Wait signature: ConeGeometry(radiusTop, radiusBottom, height, radialSegments = 32, heightSegments = 12). So `new ConeGeometry(r1, r2, h, seg, segH)`. For low poly, radialSegments small like 6? With flatShading, radial segments affect facet count around; 6 is chunky-low-poly. Use radialSegments 6, heightSegments 2? heightSegments 2 gives few rings → chunky. Actually heightSegments minimum maybe 1? Use 2.
Hmm, ConeGeometry with radiusTop < radiusBottom? Typically radiusTop=0 for cone; use radiusTop = radius*0.9, radiusBottom = radius. Fine.
Flicker animation:
```js
function animateFlames(time) {
for (const f of flames) {
const flicker = 1 + Math.sin(time * 5 + f.phase) * 0.12 + Math.sin(time * 9 + f.phase * 1.7) * 0.08;
f.mesh.scale.set(0.85 + flicker * 0.3, f.height * (0.75 + flicker * 0.35), 0.85 + flicker * 0.3);
```
Hmm scale y should be height * flicker factor; base height. Let me store base scaleY = height. `f.mesh.scale.set(s, f.height * (0.8 + 0.3 * fL), s)` where s ≈ 0.8-1.2. And rotation slight tilt: `f.mesh.rotation.z = (Math.random()-0.5)*0.1`? random per frame pop — use sin: small.
Vertex displacement for mesh geometry:
```js
const posAttr = f.mesh.geometry.getAttribute('position');
const arr = posAttr.array;
for (let i = 0; i < arr.length; i += 3) {
const x = arr[i], z = arr[i+2];
arr[i+1] += Math.sin(time * 6 + f.phase + x * 4) * 0.06 + Math.sin(time * 11 + f.phase * 2 + z * 3) * 0.05;
}
posAttr.needsUpdate = true;
```
Since scale changes each frame and positions drift, ok.
Careful: modifying position attribute each frame without reset → drift accumulates? The added offsets persist (we add to absolute y each frame). Better to recompute from base: store base positions? Simpler: only add and it's symmetric-ish? No, it accumulates upward. Fix: subtract stored base, or recompute from scratch: since cone geometry positions are fixed local coords, I can reset: arr[i+1] = baseY_local + offset. Store base array copy: `f.basePos = arr.slice()`? Arrays from getAttribute are typed arrays; slice works. Then each frame: `arr[i+1] = f.baseY[i] + offset`. Simpler: keep a copy of y values.
Let me store per-flame: `const base = Array.from({length: posAttr.count}, (_,i)=>posAttr.getY(i))`. Or just recompute cone local y from index? Cone geometry position ordering: it's generated; simpler to snapshot.
I'll snapshot y values.
Inner flame: scale flicker similarly, skip vertex anim.
Point light flicker:
```js
fireLight.intensity = 8 * (0.75 + Math.random() * 0.5);
fireLight.position.y = 3.5 + Math.sin(clock.elapsedTime * 7) * 0.6;
```
Hmm changing light position each frame ok.
Sparks as planned.
Stars:
```js
const starCount = 900;
const starPosArr = new Float32Array(starCount * 3);
for (let i = 0; i < starCount; i++) {
const u = Math.random() * 2 - 1;
const theta = Math.random() * Math.PI * 2;
const r = 60 + Math.random() * 220;
const x = Math.cos(theta) * Math.sqrt(1 - u*u) * r;
const z = Math.sin(theta) * Math.sqrt(1 - u*u) * r;
const y = Math.abs(u) * r;
starPosArr[i*3] = x; starPosArr[i*3+1] = y; starPosArr[i*3+2] = z;
}
```
Sphere via sqrt(1-u²) param ok.
star geometry set attribute, material PointsMaterial({color: 0xccddff, size: 2.4, sizeAttenuation: false, transparent: true, opacity: 0.95, depthWrite: false, fog: false}); Points. Note: PointsMaterial fog property exists — yes, Material.fog. Good.
Moon:
```js
const moonMat = new THREE.MeshBasicMaterial({ color: 0xe8e6d8 });
const moon = new THREE.Mesh(new THREE.CircleGeometry(5, 6), moonMat);
moon.position.set(26, 24, -34);
moon.lookAt(0, 10, 0);
scene.add(moon);
const glowMat = new THREE.MeshBasicMaterial({ color: 0xffeedd, transparent: true, opacity: 0.22, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide });
const glow = new THREE.Mesh(new THREE.CircleGeometry(11, 6), glowMat);
glow.position.copy(moon.position);
glow.lookAt(0, 10, 0);
scene.add(glow);
```
Moon at (26,24,-34): distance ~44 from origin; within fog? fog far 60, camera at (0,7,15) — moon behind trees maybe, fine.
Stars radius up to 280, camera far 500, fine.
Now scene additions and loop.
Also `renderer.outputColorSpace`? default SRGB in r170. Fine. Set `renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0;` — nice for night. OK.
Loop:
```js
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
const t = clock.elapsedTime;
for (const f of flames) {
const l = 1 + Math.sin(t * 5 + f.phase) * 0.12 + Math.sin(t * 9.3 + f.phase * 1.7) * 0.08;
const s = 0.8 + l * 0.35;
f.mesh.scale.set(s, f.height * l, s);
f.inner.scale.set(s * 0.8, f.height * 0.62 * l * 0.9, s * 0.8);
// vertex flicker
const pos = f.mesh.geometry.getAttribute('position');
const arr = pos.array;
const base = f.baseY;
for (let i = 0; i < base.length; i++) {
arr[i+1] = base[i] + Math.sin(t * 6 + f.phase + arr[i] * 3.7) * 0.07 + Math.sin(t * 11 + f.phase * 2 + arr[i+2] * 2.3) * 0.05;
}
pos.needsUpdate = true;
}
fireLight.intensity = 8 * (0.72 + Math.random() * 0.56);
fireLight.position.y = 3.6 + Math.sin(t * 6.5) * 0.5;
updateSparks(dt);
controls.update();
renderer.render(scene, camera);
}
animate();
```
Wait: `arr[i+2]` — position attribute is xyz interleaved, so index i (y) → arr[i], x = arr[i-1], z = arr[i+1]. Since I loop over y indices stepping by 3, x = arr[i-1], z = arr[i+1]. Careful. Let me loop i from 0..count*3 step 3: x = arr[i-1]? No: for index j = i (multiple of 3), x = arr[j-1], y = arr[j], z = arr[j+1]. Messy. Better loop k over count: idx = k*3; x=arr[idx], y=arr[idx+1], z=arr[idx+2].
```js
const count = pos.count;
for (let k = 0; k < count; k++) {
const idx = k * 3;
const x = arr[idx], z = arr[idx+2];
arr[idx+1] = f.baseY[k] + Math.sin(t * 6 + f.phase + x * 3.7) * 0.07 + Math.sin(t * 11 + f.phase * 2 + z * 2.3) * 0.05;
}
```
And snapshot baseY as array of y values per flame.
Actually simpler: since scale changes and we set positions absolutely based on base + offset, fine.
Cone geometry base positions: local y from 0 to height roughly (cone centered? ConeGeometry positions from 0 to height? I think vertices go y from 0 to height, bottom at 0? Actually cone geometry: y from 0 to height? I believe bottom at y=0, top at height? Hmm, ConeGeometry generates vertices with y in [0, height]? Let me think — I recall cone geometry positions span y ∈ [0, height]? Not 100% sure; maybe centered? For CylinderGeometry it's centered on y=0. For Cone, I believe it's from 0 to height as well? Hmm... Actually in three.js ConeGeometry, the cone is built with apex at y = height? Let me recall the source:
```
// ConeGeometry: bottom radius at y=0?
```
I think vertices: bottom circle at y=0, top at y=height? Hmm, actually I recall cone geometry has apex at top and base at bottom, with positions y from 0..height? There's a known fact: CylinderGeometry is centered (y from -h/2 to h/2), ConeGeometry is not (from 0 to height)? I believe ConeGeometry starts at y=0 (base) and apex at y=height... Actually no — I think ConeGeometry also is centered? Hmm.
Doesn't matter much: snapshot base values at creation time and displace relative to snapshot; scale animates. Good.
Snapshot: after creating flame mesh, get attribute position, store `f.baseY = Array.from({length: pos.count}, (_,k)=>pos.getY(k))`. Array.from on typed array gives numbers. Good.
updateSparks(dt):
```js
function updateSparks(dt) {
const posAttr = sparkPosAttr, colAttr = sparkColorAttr;
const pos = posAttr.array, col = colAttr.array;
for (let i = 0; i < sparkCount; i++) {
const i3 = i * 3;
sparkLife[i] -= dt;
if (sparkLife[i] <= 0) {
sparkLife[i] = sparkMaxLife[i];
pos[i3] = (Math.random() - 0.5) * 1.2;
pos[i3 + 1] = 0.7 + Math.random() * 0.5;
pos[i3 + 2] = (Math.random() - 0.5) * 1.2;
sparkVel[i3 + 1] = 7 + Math.random() * 6;
sparkVel[i3] = (Math.random() - 0.5) * 1.4;
sparkVel[i3 + 2] = (Math.random() - 0.5) * 1.4;
}
sparkVel[i3 + 1] -= 16 * dt;
pos[i3] += sparkVel[i3] * dt;
pos[i3 + 1] += sparkVel[i3 + 1] * dt;
pos[i3 + 2] += sparkVel[i3 + 2] * dt;
const t = 1 - sparkLife[i] / sparkMaxLife[i];
col[i3] = 1.0;
col[i3 + 1] = 0.5 * (1 - t) + 0.15 * t; // hmm
col[i3 + 2] = 0.1 * (1 - t) + 0.05 * t;
if (pos[i3 + 1] > 12) { sparkLife[i] = 0; } // cleanup
}
posAttr.needsUpdate = true;
colAttr.needsUpdate = true;
}
```
Wait, color fade: want bright orange when t≈0 (just born) fading to dark. col = (1-t) * bright + t * dark. bright = (1, 0.45, 0.1). So col[i3]=1*(1-t); col[i3+1]=0.45*(1-t); col[i3+2]=0.1*(1-t). When t=1 → black → invisible under additive. Good, simpler.
Life in seconds: maxLife ~ 1.0s (spark rises ~7 u/s, gravity -16 → flight time ~1.4s). Set maxLife 0.9 + rand*0.4. Decrement dt. OK.
Also reset when y > 12 or life <= 0.
PointsMaterial size 0.09, sizeAttenuation true (default) — with world coords, sizeAttenuation scales by distance; fine.
Now, initial spark positions: set at init in arrays before setting attributes.
Let me now also handle: `renderer.setAnimationLoop`? Use RAF.
Edge: dt from clock.getDelta() — before first frame, clock started on first getDelta? Clock auto-starts on first getDelta call. elapsedTime starts at first getDelta? Actually Clock starts when first `getDelta`/`getElapsedTime` called. Fine.
Now overlay start:
```js
const overlay = document.getElementById('overlay');
let started = false;
function startDemo() {
if (started) return;
started = true;
overlay.classList.add('hidden');
controls.autoRotate = true;
controls.autoRotateSpeed = 0.6;
}
overlay.addEventListener('pointerdown', startDemo);
window.addEventListener('keydown', startDemo);
```
Note: keydown fires for any key — fine, "press a key".
But careful: keydown listener on window — after user clicks overlay then presses keys, fine.
Hmm, one issue: `overlay.addEventListener('pointerdown')` — clicking hidden overlay (display:none) won't fire. Good.
Now full code assembly. Also add `renderer.domElement` style display block.
Let me also double check OrbitControls import path: 'three/addons/controls/OrbitControls.js' — with import map mapping "three/addons/" → ".../examples/jsm/", resolves to ".../examples/jsm/controls/OrbitControls.js". Correct.
Now, will there be console errors? Potential: `THREE.Clock` fine. PointsMaterial vertexColors fine.
One more: `new THREE.ConeGeometry(radiusTop, radiusBottom, height, radialSegments, heightSegments)` — heightSegments param: must be ≥ 1? If I pass 2, ok.
Let me write ConeGeometry calls: `new THREE.ConeGeometry(0.42, 0.42, 1.5, 6, 2)`? Wait order: (radiusTop, radiusBottom, height, radialSegments, heightSegments). For a cone with radiusTop=radiusBottom=r, it's a cylinder-ish; for flame use radiusTop = r*0.85, radiusBottom = r.
Hmm wait, actually I want to double check: does ConeGeometry's first param radiusTop? Yes: ConeGeometry(radiusTop, radiusBottom, height, ...). Good.
Flame mesh: `new THREE.Mesh(new THREE.ConeGeometry(0.36, 0.36, height, 6, 2), mat)` — hmm radiusTop must be maybe larger. Use radiusTop = radius * 0.9, radiusBottom = radius.
Simpler: ConeGeometry(radius, height) — if I pass (r, h) it sets radiusTop=r, radiusBottom=r? Signature requires 3 args; defaults radiusTop=radius, radiusBottom=radius? Defaults: radiusTop = radius? Actually default radiusTop = radius, radiusBottom = radius? The default constructor: ConeGeometry(radiusTop = radius, radiusBottom = radius, height = height, ...)? Hmm, I think defaults are radiusTop = radius, radiusBottom = radius? Not sure; pass explicitly anyway.
Let me just define helper:
```js
function coneGeo(r, h) {
const g = new THREE.ConeGeometry(r, h, 6, 2);
g.flatShading = true;
return g;
}
```
With r top=bottom=... Cone with equal radii = cylinder; fine visually for flame? A cone flaring out at bottom: radiusTop smaller. Pass (r*0.85, r, h). OK.
Now write everything into final file. Also remember: no text outside code block, exactly one fenced code block with ```html.
Let me also think about the "no horizontal scrollbar" and 375px width: overlay text uses clamp; scene canvas full. Trees might overflow? No, fixed layout.
The renderer fills window: setSize(window.innerWidth, window.innerHeight) — with CSS 100%. Good.
Let me consider performance: shadows with ~35 trees. Fine.
One concern: `renderer.toneMapping = THREE.ACESFilmicToneMapping;` — fine in r170.
Another: with `useLegacyLights` default false in r170? If physically correct, my light intensities need to be reasonable. I set fireLight intensity 8, distance 30, decay 2. Physically: illuminance at distance d from point light with intensity I (in cd) is E = I / (4π d²)? With decay=2, three computes E = I / (d^decay)? something like I / d². I=8, d=4 → 0.5 → decent warm light. OK good.
Moon directional intensity 0.35 — moonlight dim, fine.
Hemisphere 0.3.
Now, will the scene be too dark to see? Night scene — expected dark. Fire glow should show. Maybe bump fire intensity to 10.
Let me now also add subtle ground color variation? Skip.
Write final code.
Also: "The renderer fills the window and handles resizing correctly." ✓.
"OrbitControls to look around, with limits so the camera stays above the ground." — maxPolarAngle ~ 1.53 rad (< π/2 ≈ 1.5708). Use `Math.PI * 0.49 = 1.539` ≈ 88.2°. Good.
Also maybe set `controls.enablePan = false;`? Not required; pan could go below ground? Pan moves target, not camera; could move target below ground. Leave pan enabled? To be safe, disable pan? The requirement only mentions staying above ground. I'll disable pan to be safe? Hmm, disabling pan is fine and keeps camera controlled. Actually allow pan but limit? Simpler: `controls.enablePan = false;`. Hmm, some users like pan; not required. I'll keep pan enabled? If pan moves target under ground, camera could look under. Disable pan for safety.
Now, write out the complete file.
Full code:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Low Poly Campfire Night</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #04060e; overflow: hidden; }
body { font-family: Arial, Helvetica, sans-serif; }
#scene { position: fixed; inset: 0; }
#scene canvas { display: block; }
#overlay {
position: fixed; inset: 0; z-index: 30;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 18px; background: rgba(2, 4, 10, 0.55); text-align: center; cursor: pointer;
}
#overlay.hidden { display: none; }
#title { color: #ffcf9e; font-size: clamp(30px, 7vw, 64px); font-weight: 800; letter-spacing: 0.3em; text-indent: 0.3em;
text-shadow: 0 0 24px rgba(255, 150, 60, 0.6); }
#subtitle { color: #cfa87e; font-size: clamp(11px, 1.8vw, 15px); letter-spacing: 0.45em; text-indent: 0.45em; text-transform: uppercase; }
#hint { margin-top: 6px; color: #ffe3bd; font-size: clamp(13px, 2.4vw, 19px); letter-spacing: 0.2em; text-indent: 0.2em; text-transform: uppercase;
padding: 12px 26px; border-radius: 999px; border: 1px solid rgba(255, 180, 110, 0.45); background: rgba(20, 10, 4, 0.45);
}
</style>
</head>
<body>
<div id="scene"></div>
<div id="overlay">
<div id="title">CAMPFIRE</div>
<div id="subtitle">low poly night demo</div>
<div id="hint">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">
... code ...
</script>
</body>
</html>
```
CSS note: `inset` shorthand supported in modern Chrome. OK.
Now JS code in full:
```js
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x04060e);
scene.fog = new THREE.Fog(0x04060e, 16, 60);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 500);
camera.position.set(0, 7, 15);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
document.getElementById('scene').appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.enablePan = false;
controls.minDistance = 4;
controls.maxDistance = 45;
controls.maxPolarAngle = Math.PI * 0.49;
controls.target.set(0, 2.2, 0);
scene.add(new THREE.HemisphereLight(0x22334a, 0x0a1208, 0.35));
const fireLight = new THREE.PointLight(0xffb35c, 10, 30, 2);
fireLight.position.set(0, 4, 0);
fireLight.castShadow = true;
scene.add(fireLight);
const moonLight = new THREE.DirectionalLight(0x3a4a66, 0.4);
moonLight.position.set(25, 35, -30);
scene.add(moonLight);
```
Ground:
```js
const ground = new THREE.Mesh(new THREE.CircleGeometry(90, 72), new THREE.MeshStandardMaterial({ color: 0x2c3822, roughness: 1, metalness: 0 }));
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
```
Fire pit + logs + rocks + flames + sparks + stars + moon + trees.
Trees loop with materials.
Then overlay logic, resize, loop.
Let me make sure no variable naming conflicts.
Potential issue: `THREE.PointLight(0xffb35c, 10, 30, 2)` — distance 30, decay 2, intensity 10.
Now writing final answer. I need to be careful that the code is complete and correct. Let me draft the entire script.
Also note: sparks Points object must be added to scene; material fog default true → sparks near origin fine.
Stars: `starMat.fog = false;`
Moon glow material: `side: THREE.DoubleSide` with additive.
Let me write the flame animate with baseY snapshot:
```js
const flames = [];
function addFlame(x, z, baseY, height, radius, phase) {
const mat = new THREE.MeshBasicMaterial({ color: 0xff8c22 });
const mesh = new THREE.Mesh(new THREE.ConeGeometry(radius * 0.9, radius, height, 6, 2), mat);
...
}
```
Hmm ConeGeometry(radiusTop, radiusBottom, height, radialSegments, heightSegments). I want flared: radiusTop = radius*0.85, radiusBottom = radius.
```js
const mesh = new THREE.Mesh(new THREE.ConeGeometry(radius * 0.85, radius, height, 6, 2), new THREE.MeshBasicMaterial({ color: 0xff8c22 }));
mesh.position.set(x, baseY, z);
mesh.geometry.flatShading = true;
mesh.castShadow = true;
scene.add(mesh);
const inner = new THREE.Mesh(new THREE.ConeGeometry(radius * 0.5, radius * 0.5, height * 0.6, 6, 2), new THREE.MeshBasicMaterial({ color: 0xffe066 }));
inner.position.set(x, baseY + height * 0.1, z);
scene.add(inner);
const posAttr = mesh.geometry.getAttribute('position');
const baseYArr = [];
for (let k = 0; k < posAttr.count; k++) baseYArr.push(posAttr.getY(k));
flames.push({ mesh, inner, baseY, height, phase, baseYArr });
}
```
Wait — mesh.position.y = baseY, and geometry y coords from 0..height? If cone geometry spans y∈[0,height] then world y = baseY + local. If it spans [-h/2, h/2], snapshot handles either since we displace local y and scale. Snapshot + local displacement works regardless.
Animate:
```js
for (const f of flames) {
const l = 1 + Math.sin(t * 5 + f.phase) * 0.12 + Math.sin(t * 9.3 + f.phase * 1.7) * 0.08;
const s = 0.75 + l * 0.4;
f.mesh.scale.set(s, f.height * l, s);
f.inner.scale.set(s * 0.85, f.height * 0.6 * l * 0.9, s * 0.85);
const pos = f.mesh.geometry.getAttribute('position');
const arr = pos.array;
const base = f.baseYArr;
for (let k = 0; k < base.length; k++) {
const idx = k * 3;
const x = arr[idx], z = arr[idx + 2];
arr[idx + 1] = base[k] + Math.sin(t * 6 + f.phase + x * 3.7) * 0.08 + Math.sin(t * 11 + f.phase * 2 + z * 2.3) * 0.06;
}
pos.needsUpdate = true;
}
```
Note: scale set before position update — order fine.
Hmm, one issue: mesh.scale.set(s, f.height*l, s) — scale y = height*l; base local y up to height, scaled → ok.
Now sparks init:
```js
const sparkCount = 140;
const sparkPosArr = new Float32Array(sparkCount * 3);
const sparkVelArr = new Float32Array(sparkCount * 3);
const sparkLifeArr = new Float32Array(sparkCount);
const sparkMaxLifeArr = new Float32Array(sparkCount);
const sparkColArr = new Float32Array(sparkCount * 3);
const sparkPosAttr = new THREE.BufferAttribute(sparkPosArr, 3);
const sparkColAttr = new THREE.BufferAttribute(sparkColArr, 3);
sparkPosAttr.setUsage(THREE.DynamicDrawUsage);
sparkColAttr.setUsage(THREE.DynamicDrawUsage);
const sparkGeom = new THREE.BufferGeometry();
sparkGeom.setAttribute('position', sparkPosAttr);
sparkGeom.setAttribute('color', sparkColAttr);
const sparkMat = new THREE.PointsMaterial({
size: 0.09, color: 0xffaa44, vertexColors: true, transparent: true,
blending: THREE.AdditiveBlending, depthWrite: false, sizeAttenuation: true
});
const sparks = new THREE.Points(sparkGeom, sparkMat);
scene.add(sparks);
for (let i = 0; i < sparkCount; i++) {
const i3 = i * 3;
sparkPosArr[i3] = (Math.random() - 0.5) * 1.0;
sparkPosArr[i3 + 1] = 0.6 + Math.random() * 0.4;
sparkPosArr[i3 + 2] = (Math.random() - 0.5) * 1.0;
sparkVelArr[i3 + 1] = 7 + Math.random() * 6;
sparkVelArr[i3] = (Math.random() - 0.5) * 1.2;
sparkVelArr[i3 + 2] = (Math.random() - 0.5) * 1.2;
sparkMaxLifeArr[i] = 0.8 + Math.random() * 0.5;
sparkLifeArr[i] = sparkMaxLifeArr[i];
}
```
updateSparks as planned.
Stars:
```js
const starCount = 900;
const starPosArr2 = new Float32Array(starCount * 3);
for (let i = 0; i < starCount; i++) {
const u = Math.random() * 2 - 1;
const theta = Math.random() * Math.PI * 2;
const r = 60 + Math.random() * 220;
const s = Math.sqrt(Math.max(0, 1 - u * u));
starPosArr2[i*3] = Math.cos(theta) * s * r;
starPosArr2[i*3+1] = Math.abs(u) * r;
starPosArr2[i*3+2] = Math.sin(theta) * s * r;
}
const starGeom = new THREE.BufferGeometry();
const starAttr = new THREE.BufferAttribute(starPosArr2, 3);
starGeom.setAttribute('position', starAttr);
const starMat = new THREE.PointsMaterial({ color: 0xccddff, size: 2.4, sizeAttenuation: false, transparent: true, opacity: 0.95, depthWrite: false, fog: false });
const stars = new THREE.Points(starGeom, starMat);
scene.add(stars);
```
Trees:
```js
function makeTree() {
const g = new THREE.Group();
const trunkMat = new THREE.MeshStandardMaterial({ color: 0x5a4230, roughness: 1 });
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.14, 0.9, 6), trunkMat);
trunk.geometry.flatShading = true;
trunk.position.y = 0.45;
const foliageMat = new THREE.MeshStandardMaterial({ color: 0x35502a, roughness: 0.95, metalness: 0 });
const c1 = new THREE.Mesh(new THREE.ConeGeometry(1.05, 1.5, 7), foliageMat);
c1.geometry.flatShading = true;
c1.position.y = 1.8;
const c2 = new THREE.Mesh(new THREE.ConeGeometry(0.72, 1.3, 7), foliageMat);
c2.geometry.flatShading = true;
c2.position.y = 2.8;
g.add(trunk, c1, c2);
return g;
}
const treeRoots = [];
for (let i = 0; i < 36; i++) {
let angle = Math.random() * Math.PI * 2;
let dist = 9 + Math.random() * 30;
const x = Math.cos(angle) * dist;
const z = Math.sin(angle) * dist;
const t = makeTree();
const fm = t.children[1].material;
fm.color.setHSL(0.27 + Math.random() * 0.07, 0.42, 0.13 + Math.random() * 0.06);
const fm2 = t.children[2].material;
fm2.color.setHSL(0.27 + Math.random() * 0.07, 0.42, 0.11 + Math.random() * 0.06);
t.position.set(x, 0, z);
t.traverse((o) => { if (o.isMesh) o.castShadow = true; });
scene.add(t);
}
```
Wait: `t.children[1].material` — children order: trunk, c1, c2 (added in that order) → indices 0,1,2. ✓.
CylinderGeometry flatShading — does CylinderGeometry support flatShading? BufferGeometry has flatShading property; CylinderGeometry generates with... it should just set the flag. Yes, flatShading is a BufferGeometry property, works for any geometry.
ConeGeometry same.
IcosahedronGeometry(radius, detail, flatShading) — pass flatShading true directly or set property; property works.
Now, trees castShadow via traverse — includes trunk and cones. OK.
Moon and glow.
Overlay & start.
Resize.
Loop.
Let me now write the final complete code text carefully, ensuring valid JS.
One thing: `controls.autoRotate = true` only after start; before start, overlay blocks pointer so no drag. Fine.
Also, when started, autoRotate with damping — fine.
Let me write:
```js
const overlay = document.getElementById('overlay');
let started = false;
function startDemo() {
if (started) return;
started = true;
overlay.classList.add('hidden');
controls.autoRotate = true;
controls.autoRotateSpeed = 0.5;
}
overlay.addEventListener('pointerdown', startDemo);
window.addEventListener('keydown', startDemo);
```
Resize:
```js
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
```
Loop with clock.
I think we're ready. Let me also double-check the import map JSON syntax: `{"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/"}}` — keys with trailing slash fine.
Now, final answer: exactly one fenced code block ```html ... ```, no text before/after.
Let me assemble the complete file content.
Also small detail: `#scene { position: fixed; inset: 0; }` — inset supported. OK.
For the overlay: it's outside #scene, z-index 30 above canvas. Canvas is in #scene which is fixed; overlay fixed z-30. Good.
Write final code now.
One more check — sparks: BufferAttribute with Float32Array of size sparkCount*3 ✓. Points update: set needsUpdate on both attributes. Colors: black at end → additive blending with black = no light added → invisible. PointsMaterial vertexColors requires color attribute and material.vertexColors = true ✓.
Note: for additive blending, background shows through — good.
Potential issue: `sparkPosAttr.array` etc. — BufferAttribute has `.array`. ✓
Potential issue: PointsMaterial `sizeAttenuation` with size 0.09 world units — near fire, visible. OK.
Also might set `sparks.frustumCulled = false`? Not needed; bounding sphere around origin maybe; set `sparks.frustumCulled = false;` to be safe? The points spread; bounding sphere default computed from geometry — since we update positions, bounding sphere may be stale, causing frustum culling issues. Set `frustumCulled = false` on sparks and stars? Stars far away fine. Set sparks.frustumCulled = false.
Now finalize.
Also: to avoid "nothing hidden" — everything visible. ✓
Let me write the final HTML file content in full.
Regarding flames scale: `f.mesh.scale.set(s, f.height * l, s)` — mesh scale is local; geometry local y 0..height; scaled by height*l → height*l * ... wait scale.set(s, f.height*l, s) multiplies geometry coords (0..height) by (s, f.height*l, s) → y range becomes 0 .. height * f.height * l?? No: scale = (s, f.height*l, s); geometry y ∈ [0, height]; scaled y ∈ [0, height * f.height * l]. That's wrong dimensionally. I should set scale to (s, l, s) since geometry already has height in coords. Or set scale.set(s, 1, s) and multiply height... Simplest: `f.mesh.scale.set(s, l, s)` where l ≈ 0.75-1.35 (multiplier of height). But mesh is positioned at baseY with geometry y from 0..height presumably; scaling by l scales height. OK use scale.set(s, l, s).
Hmm but if cone geometry is centered (y from -h/2..h/2), scale still fine since baseY snapshot + local coords... whatever, relative behavior ok.
Actually wait, there's subtlety: mesh.scale affects the displaced positions? Displacements are in local space added to base, then scaled by mesh scale — fine.
Let me set: `f.mesh.scale.set(0.85 + l * 0.3, l, 0.85 + l * 0.3);` and inner: `f.inner.scale.set(0.7 + l*0.25, 0.7 + l*0.2, ...)` — simpler: inner scale = (s*0.85, l*0.85, s*0.85) hmm just do something reasonable.
Simplify:
```js
const s = 0.8 + l * 0.35; // uniform-ish
f.mesh.scale.set(s, l, s);
f.inner.scale.set(s * 0.85, l * 0.8, s * 0.85);
```
Eh, inner flame smaller: inner.scale.set(0.6, 0.6*l, 0.6)? The inner flame should be smaller in height; scaling mesh by (0.6, 0.6, 0.6) shrinks geometry — fine since geometry height already set. OK: `f.inner.scale.setScalar(0.62 * l * 0.9);` — setScalar ok? Matrix4-like; Mesh.scale is Vector3; use `.set(0.56, 0.56, 0.56).multiplyScalar(l)`. Simpler: `f.inner.scale.set(0.55, 0.55, 0.55); f.inner.scale.multiplyScalar(l);` — Vector3.multiplyScalar exists ✓.
Fine.
Now final assembly. I'll write it out.
Also add `scene.add(fireLight)` etc.
Let me now produce the final answer code block.
Double-check: no `alert`, no `localStorage`. ✓. No external CSS/JS files. ✓ Only CDN scripts via importmap. ✓.
Check: "Everything must be inline: CSS in <style>, JavaScript in <script>." ✓
Check: fenced block exactly one, language html. ✓
Let me write the final file now, complete.
Regarding flames: `new THREE.ConeGeometry(radius * 0.85, radius, height, 6, 2)` — radiusTop=0.85r, radiusBottom=r, height, radialSegments=6, heightSegments=2. ✓
baseY for flames: pit top at 0.6, logs crossed at y≈0.18 (radius 0.13 → top 0.31) hmm logs inside pit should be higher: set crossed logs y=0.35? Pit: cylinder r1=1.3, r2=1.5, h=0.6 → top y=0.6. Logs in fire at y=0.35 (radius ~0.14 → top ~0.49). Flames baseY ≈ 0.55.
addFlame(0,0,0.55,1.5,0.4,0); addFlame(-0.32,0.2,0.6,1.15,0.3,2); addFlame(0.32,-0.2,0.6,1.15,0.3,4);
OK.
Rocks: fine.
Let me also consider: ground CircleGeometry(90, 72) radius 90 — camera maxDistance 45, far plane 500, fog 16-60. Ground edge at 90 beyond fog far 60 → beyond fog, background shows; ground circle edge visible? At camera height 7 looking down slightly, ground radius 90 with fog far 60 — ground beyond 60 is fogged to background color; edge might be visible as circle? Fog starts at 16, so ground from 16-60 fades; beyond 60, fog color = background → blends. Should be OK visually.
Maybe make ground radius 120 to be safe. Eh, 90 fine with fog.
Now writing final answer.
Actually one more consideration on the screenshot: taken ~3s after load without interaction. Overlay visible with backdrop rgba(2,4,10,0.55) — scene visible through. Fine.
Also ensure the module script executes and doesn't error before overlay etc.
Let me write the final code.
I'll now produce the final response with the single code block.