Models 44

Qwen3.8 Flash

by QwenReasoning1 console error
Open in new window

Running live in a sandboxed frame. Click inside it before using the keyboard.

How it went

Total time
1 min 31 s
Cost
$0.0032
Output tokens
6,773
Spent thinking
0 tokens
Speed
75.6 tok/s
Console errors
1
First token after
1.6 s
First visible text after
1.6 s
Prompt tokens
593
Code size
17.3 KB
Finish reason
stop
Served by
Alibaba
Model id
qwen/qwen3.8-flash
Reasoning sent
{"effort":"none"}
Max output
64,000 tokens
Generated
Nov 11, 2026

Console errors

Caught in the first 3 seconds after load, in a sandboxed iframe like the one above.

Failed to load resource: the server responded with a status of 404 ()

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 · 493 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Campfire — Low-Poly Night Forest</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body { width: 100%; height: 100%; overflow: hidden; background: #05060f; }
  canvas { display: block; }

  .ui {
    position: fixed;
    left: 20px;
    bottom: 18px;
    z-index: 10;
    color: rgba(232, 234, 246, 0.72);
    font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
    font-size: 12px;
    line-height: 1.65;
    letter-spacing: 0.03em;
    pointer-events: none;
    text-shadow: 0 1px 8px rgba(0,0,0,0.85);
  }
  .ui b { color: #ff9a4d; font-weight: 700; letter-spacing: 0.08em; }
  .ui .dim { color: rgba(200,205,225,0.42); }

  .hint {
    position: fixed;
    top: 18px;
    left: 50%;
    transform: translateX(-50%);
    z-index: 10;
    color: rgba(214,220,240,0.55);
    font-family: ui-monospace, Menlo, Consolas, monospace;
    font-size: 11px;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    pointer-events: none;
    text-shadow: 0 1px 6px rgba(0,0,0,0.8);
  }

  .vignette {
    position: fixed;
    inset: 0;
    z-index: 5;
    pointer-events: none;
    background: radial-gradient(ellipse at 50% 42%, transparent 42%, rgba(0,0,0,0.55) 100%);
  }
</style>
</head>
<body>
<div class="hint">drag to orbit · scroll to zoom</div>
<div class="ui"><b>CAMPFIRE</b><br><span class="dim">low-poly night forest clearing</span></div>
<div class="vignette"></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';

/* ---------- deterministic RNG ---------- */
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;};}
const rng = mulberry32(20240617);
const rand=(a,b)=>a+rng()*(b-a);
const pick=(arr)=>arr[(rng()*arr.length)|0];

/* ---------- renderer / scene / camera ---------- */
const renderer = new THREE.WebGLRenderer({ antialias:true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x070a16);
scene.fog = new THREE.FogExp2(0x0a1020, 0.018);

const camera = new THREE.PerspectiveCamera(52, window.innerWidth/window.innerHeight, 0.1, 600);
camera.position.set(9, 6, 12);

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.06;
controls.target.set(0, 1.2, 0);
controls.minDistance = 5;
controls.maxDistance = 30;
controls.minPolarAngle = 0.15;   // don't fly over the top
controls.maxPolarAngle = Math.PI*0.49; // stay above ground
controls.autoRotate = true;      // self-demo so it's alive on load
controls.autoRotateSpeed = 0.35;
controls.update();
renderer.domElement.addEventListener('pointerdown', ()=>{ controls.autoRotate=false; }, { once:true });

/* ---------- helpers ---------- */
function flatMat(color, opts={}){
  const m = new THREE.MeshStandardMaterial({ color, roughness:opts.rough??0.9, metalness:opts.metal??0.0, emissive:opts.emissive??0x000000, emissiveIntensity:opts.ei??1 });
  return m;
}
function jitter(geo, amt){
  const p = geo.attributes.position;
  for(let i=0;i<p.count;i++){ p.setXYZ(i, p.getX(i)+rand(-amt,amt), p.getY(i)+rand(-amt,amt), p.getZ(i)+rand(-amt,amt)); }
  p.needsUpdate = true;
  geo.computeVertexNormals();
}

/* ---------- lighting ---------- */
const hemi = new THREE.HemisphereLight(0x2a3a66, 0x0a0e1c, 0.5);
scene.add(hemi);

const moon = new THREE.DirectionalLight(0x9fb4e8, 0.5);
moon.position.set(-40, 55, -25);
moon.castShadow = true;
moon.shadow.mapSize.set(2048, 2048);
const mc = moon.shadow.camera;
mc.near = 1; mc.far = 160; mc.left=-45; mc.right=45; mc.top=45; mc.bottom=-45;
moon.shadow.bias = -0.0004;
moon.shadow.normalBias = 0.03;
scene.add(moon);

// campfire point light (flickers + casts shadows)
const fireLight = new THREE.PointLight(0xff7b2e, 3.4, 46, 1.6);
fireLight.position.set(0, 1.3, 0);
fireLight.castShadow = true;
fireLight.shadow.mapSize.set(1024, 1024);
fireLight.shadow.camera.near = 0.3;
fireLight.shadow.camera.far = 42;
fireLight.shadow.bias = -0.002;
scene.add(fireLight);

// warm glow fill (no shadow) so nearby faces aren't pitch black
const glow = new THREE.PointLight(0xff9a4a, 0.9, 16, 2);
glow.position.set(0, 1.0, 0);
scene.add(glow);

/* ---------- ground ---------- */
const gGeo = new THREE.PlaneGeometry(120, 120, 48, 48);
gGeo.rotateX(-Math.PI/2);
{
  const pos = gGeo.attributes.position;
  for(let i=0;i<pos.count;i++){
    const x=pos.getX(i), z=pos.getZ(i);
    const d=Math.hypot(x,z);
    let y = Math.sin(x*0.3)*0.12 + Math.cos(z*0.27)*0.12 + Math.sin((x+z)*0.15)*0.08;
    if(d<7) y *= smoothstep(0.5,7,d); // flatten clearing
    pos.setY(i, y);
  }
  pos.needsUpdate=true;
  gGeo.computeVertexNormals();
}
const ground = new THREE.Mesh(gGeo, flatMat(0x17241a,{rough:1}));
ground.receiveShadow = true;
scene.add(ground);

function smoothstep(a,b,x){ x=Math.max(0,Math.min(1,(x-a)/(b-a))); return x*x*(3-2*x); }

/* ---------- trees ---------- */
const treeGroup = new THREE.Group();
scene.add(treeGroup);
const trunkCols=[0x3a2818,0x33241a,0x42301f,0x2e2015];
const leafCols=[0x1f3a24,0x27452b,0x2c5233,0x223f27,0x1b331f];
const leafMats = leafCols.map(c=>flatMat(c,{rough:0.95}));
const trunkMats = trunkCols.map(c=>flatMat(c,{rough:1}));

function makeTree(){
  const g = new THREE.Group();
  const th = rand(1.6,2.6), tr = rand(0.22,0.34);
  const trunk = new THREE.Mesh(new THREE.CylinderGeometry(tr*0.7,tr,th,6), pick(trunkMats));
  trunk.position.y = th/2; trunk.castShadow=true; trunk.receiveShadow=true;
  g.add(trunk);
  const tiers = 3 + ((rng()*2)|0);
  let baseY = th*0.85;
  let r = rand(1.3,1.9);
  let h = rand(1.4,1.8);
  const mat = pick(leafMats);
  for(let i=0;i<tiers;i++){
    const cone = new THREE.Mesh(new THREE.ConeGeometry(r,h,6), mat);
    cone.position.y = baseY + h*0.4;
    cone.rotation.y = rand(0,Math.PI);
    cone.castShadow=true; cone.receiveShadow=true;
    g.add(cone);
    baseY += h*0.62;
    r *= 0.72;
    h *= 0.85;
  }
  return g;
}

let placed=0, tries=0;
while(placed<36 && tries<4000){
  tries++;
  const a=rand(0,Math.PI*2), rr=rand(11,46);
  const x=Math.cos(a)*rr, z=Math.sin(a)*rr;
  if(rr<11) continue;
  const s=rand(0.8,1.5);
  const t=makeTree();
  t.position.set(x,0,z);
  t.scale.setScalar(s);
  t.rotation.y=rand(0,Math.PI*2);
  treeGroup.add(t);
  placed++;
}

/* ---------- rocks ---------- */
const rockCols=[0x3b3f47,0x33363d,0x464a52,0x2c2f36];
for(let i=0;i<14;i++){
  const g=new THREE.IcosahedronGeometry(rand(0.4,1.1),0);
  jitter(g,0.28);
  const m=new THREE.Mesh(g, flatMat(pick(rockCols),{rough:1}));
  const a=rand(0,Math.PI*2), rr=rand(4.5,13);
  m.position.set(Math.cos(a)*rr, rand(-0.15,0.1), Math.sin(a)*rr);
  m.rotation.set(rand(0,3),rand(0,3),rand(0,3));
  m.scale.y*=rand(0.6,0.9);
  m.castShadow=true; m.receiveShadow=true;
  scene.add(m);
}

/* ---------- logs around fire ---------- */
const logMat = flatMat(0x4a3320,{rough:1});
const logEndMat = flatMat(0x6b4a2c,{rough:1});
function makeLog(len,rad){
  const g=new THREE.Group();
  const body=new THREE.Mesh(new THREE.CylinderGeometry(rad,rad,len,7),logMat);
  body.rotation.z=Math.PI/2; body.castShadow=true; body.receiveShadow=true;
  g.add(body);
  for(const sx of [-1,1]){
    const e=new THREE.Mesh(new THREE.CircleGeometry(rad*0.98,7),logEndMat);
    e.position.x=sx*len/2; e.rotation.y=sx*Math.PI/2; e.receiveShadow=true;
    g.add(e);
  }
  return g;
}
for(let i=0;i<5;i++){
  const a=i/5*Math.PI*2+0.4;
  const L=makeLog(rand(1.6,2.2), rand(0.28,0.4));
  L.position.set(Math.cos(a)*3.1, 0.35, Math.sin(a)*3.1);
  L.rotation.y = a + Math.PI/2 + rand(-0.3,0.3);
  scene.add(L);
}

/* ---------- stone ring around fire ---------- */
for(let i=0;i<11;i++){
  const a=i/11*Math.PI*2;
  const g=new THREE.IcosahedronGeometry(rand(0.4,0.6),0);
  jitter(g,0.12);
  const m=new THREE.Mesh(g, flatMat(pick(rockCols),{rough:1}));
  m.position.set(Math.cos(a)*1.75, 0.18, Math.sin(a)*1.75);
  m.rotation.set(rand(0,3),rand(0,3),rand(0,3));
  m.scale.y*=0.7;
  m.castShadow=true; m.receiveShadow=true;
  scene.add(m);
}

/* ---------- firewood inside ring ---------- */
for(let i=0;i<3;i++){
  const w=makeLog(1.3,0.16);
  const a=i/3*Math.PI*2+0.5;
  w.position.set(Math.cos(a)*0.3, 0.35, Math.sin(a)*0.3);
  w.rotation.y=a;
  w.rotation.z=rand(-0.25,0.25);
  scene.add(w);
}

/* ---------- embers / glowing coals ---------- */
const emberMat = new THREE.MeshBasicMaterial({ color:0xff5a1e, fog:false });
const emberBase = new THREE.Color(0xff5a1e);
const embers=[];
for(let i=0;i<16;i++){
  const m=new THREE.Mesh(new THREE.IcosahedronGeometry(rand(0.08,0.16),0), emberMat.clone());
  const a=rand(0,Math.PI*2), rr=rand(0,0.9);
  m.position.set(Math.cos(a)*rr, rand(0.12,0.3), Math.sin(a)*rr);
  m.userData.ph=rand(0,Math.PI*2);
  scene.add(m); embers.push(m);
}

/* ---------- flames ---------- */
const flameMat = new THREE.ShaderMaterial({
  transparent:true, depthWrite:false, blending:THREE.AdditiveBlending, side:THREE.DoubleSide,
  uniforms:{ uTime:{value:0}, uScale:{value:new THREE.Vector3(1,1,1)} },
  vertexShader:`
    uniform float uTime; uniform vec3 uScale;
    varying vec2 vUv;
    void main(){
      vUv = uv;
      vec3 p = position * uScale;
      float up = clamp(p.y, 0.0, 1.0);
      float wob = sin(uTime*3.0 + p.y*5.0 + p.x*3.0) * 0.10 * up;
      p.x += wob; p.z += cos(uTime*2.6 + p.y*4.0)*0.08*up;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(p,1.0);
    }`,
  fragmentShader:`
    precision highp float;
    uniform float uTime; varying vec2 vUv;
    void main(){
      float v = vUv.y;
      float n = fract(sin(dot(vUv,vec2(12.9898,78.233)))*43758.5453);
      float flick = 0.85 + 0.15*sin(uTime*12.0 + n*6.2831);
      vec3 cLow=vec3(0.45,0.08,0.0);
      vec3 cMid=vec3(1.0,0.45,0.05);
      vec3 cTop=vec3(1.0,0.85,0.45);
      vec3 col=mix(cLow,cMid,smoothstep(0.0,0.4,v));
      col=mix(col,cTop,smoothstep(0.4,0.9,v));
      float edge=smoothstep(0.0,0.18,v)*smoothstep(1.0,0.7,v);
      float alpha=edge*flick*0.95;
      gl_FragColor=vec4(col,alpha);
    }`
});
function makeFlame(h,r,w){
  const g=new THREE.ConeGeometry(r,h,w,5,true);
  g.translate(0,h/2,0);
  const m=new THREE.Mesh(g,flameMat);
  m.position.y=0.35;
  return m;
}
const flames=[];
function addFlame(h,r,w,ang,dist,scaleSeed){
  const f=makeFlame(h,r,w);
  f.position.x=Math.cos(ang)*dist;
  f.position.z=Math.sin(ang)*dist;
  f.userData={h,r,base:h,seed:scaleSeed,ph:rand(0,Math.PI*2)};
  scene.add(f); flames.push(f);
}
addFlame(1.7,0.42,6,0,0,1);
addFlame(1.15,0.30,5,Math.PI*0.4,0.28,1);
addFlame(1.05,0.28,5,Math.PI*1.15,0.26,1);
addFlame(0.85,0.24,5,Math.PI*1.7,0.22,1);
addFlame(0.75,0.22,5,Math.PI*0.05,-0.24,1);

/* soft additive core glow */
function radialTex(inner,outer){
  const c=document.createElement('canvas'); c.width=c.height=128;
  const ctx=c.getContext('2d');
  const g=ctx.createRadialGradient(64,64,0,64,64,64);
  g.addColorStop(0,inner); g.addColorStop(1,outer);
  ctx.fillStyle=g; ctx.fillRect(0,0,128,128);
  const t=new THREE.CanvasTexture(c); t.colorSpace=THREE.SRGBColorSpace; return t;
}
const glowSprite=new THREE.Sprite(new THREE.SpriteMaterial({ map:radialTex('rgba(255,150,60,0.9)','rgba(255,120,40,0)'), blending:THREE.AdditiveBlending, depthWrite:false, fog:false }));
glowSprite.scale.set(3.2,3.2,1); glowSprite.position.set(0,1.0,0); scene.add(glowSprite);

/* ---------- sparks ---------- */
const sparkCount=180;
const sparkPos=new Float32Array(sparkCount*3);
const sparkVel=new Float32Array(sparkCount*3);
const sparkLife=new Float32Array(sparkCount);
const sparkMax=new Float32Array(sparkCount);
function respawnSpark(i){
  const a=rand(0,Math.PI*2), rr=rand(0,0.5);
  sparkPos[i*3]=Math.cos(a)*rr;
  sparkPos[i*3+1]=rand(0.5,1.0);
  sparkPos[i*3+2]=Math.sin(a)*rr;
  sparkVel[i*3]=rand(-0.35,0.35);
  sparkVel[i*3+1]=rand(1.1,2.4);
  sparkVel[i*3+2]=rand(-0.35,0.35);
  sparkMax[i]=rand(1.4,3.2);
  sparkLife[i]=sparkMax[i]*rand(0.2,1.0);
}
for(let i=0;i<sparkCount;i++) respawnSpark(i);
const sparkGeo=new THREE.BufferGeometry();
sparkGeo.setAttribute('position',new THREE.BufferAttribute(sparkPos,3));
const sparkMat=new THREE.PointsMaterial({ size:0.07, map:radialTex('rgba(255,235,190,1)','rgba(255,180,80,0)'), color:0xffffff, transparent:true, blending:THREE.AdditiveBlending, depthWrite:false, sizeAttenuation:true, fog:false });
const sparks=new THREE.Points(sparkGeo,sparkMat);
scene.add(sparks);

/* ---------- smoke ---------- */
const smokeCount=40;
const smokePos=new Float32Array(smokeCount*3);
const smokeData=[];
for(let i=0;i<smokeCount;i++){
  smokeData.push({ vy:rand(0.5,1.0), ph:rand(0,Math.PI*2), max:rand(4,7), life:rand(0,7) });
  respawnSmoke(i);
}
function respawnSmoke(i){
  const a=rand(0,Math.PI*2), rr=rand(0,0.5);
  smokePos[i*3]=Math.cos(a)*rr;
  smokePos[i*3+1]=rand(1.6,2.2);
  smokePos[i*3+2]=Math.sin(a)*rr;
}
const smokeGeo=new THREE.BufferGeometry();
smokeGeo.setAttribute('position',new THREE.BufferAttribute(smokePos,3));
const smokeMat=new THREE.PointsMaterial({ size:1.6, map:radialTex('rgba(120,120,130,0.5)','rgba(60,60,70,0)'), color:0x2a2a33, transparent:true, opacity:0.5, depthWrite:false, sizeAttenuation:true, fog:false });
const smoke=new THREE.Points(smokeGeo,smokeMat);
scene.add(smoke);

/* ---------- stars ---------- */
const starCount=1400;
const starPos=new Float32Array(starCount*3);
const starCol=new Float32Array(starCount*3);
for(let i=0;i<starCount;i++){
  const u=rng(), v=rng();
  const theta=u*Math.PI*2;
  const phi=Math.acos(v);           // upper hemisphere biased
  const R=rand(150,220);
  const y=Math.abs(Math.cos(phi))*R + 20;
  starPos[i*3]=Math.sin(phi)*Math.cos(theta)*R;
  starPos[i*3+1]=y;
  starPos[i*3+2]=Math.sin(phi)*Math.sin(theta)*R;
  const b=rand(0.5,1.0);
  const tint=pick([[1,1,1],[0.8,0.85,1],[1,0.9,0.75]]);
  starCol[i*3]=tint[0]*b; starCol[i*3+1]=tint[1]*b; starCol[i*3+2]=tint[2]*b;
}
const starGeo=new THREE.BufferGeometry();
starGeo.setAttribute('position',new THREE.BufferAttribute(starPos,3));
starGeo.setAttribute('color',new THREE.BufferAttribute(starCol,3));
const stars=new THREE.Points(starGeo,new THREE.PointsMaterial({ size:0.9, sizeAttenuation:true, vertexColors:true, transparent:true, opacity:0.9, fog:false, depthWrite:false }));
scene.add(stars);

/* ---------- moon ---------- */
const moonMesh=new THREE.Mesh(new THREE.IcosahedronGeometry(6,1), new THREE.MeshBasicMaterial({ color:0xdfe6f5, fog:false }));
moonMesh.position.set(-70,70,-60);
scene.add(moonMesh);
const moonHalo=new THREE.Sprite(new THREE.SpriteMaterial({ map:radialTex('rgba(200,215,255,0.7)','rgba(160,180,255,0)'), blending:THREE.AdditiveBlending, depthWrite:false, fog:false }));
moonHalo.scale.set(40,40,1); moonHalo.position.copy(moonMesh.position);
scene.add(moonHalo);

/* ---------- animation ---------- */
const clock=new THREE.Clock();
const _tmp=new THREE.Vector3();

function animate(){
  requestAnimationFrame(animate);
  const dt=Math.min(clock.getDelta(),0.05);
  const t=clock.elapsedTime;

  // flames sway + scale pulse
  for(const f of flames){
    const s=1 + 0.16*Math.sin(t*6+f.userData.ph) + 0.08*Math.sin(t*13+f.userData.seed);
    f.userData.base=f.userData.h;
    f.scale.set(1, s, 1);
    f.rotation.y += dt*0.4;
    f.material.uniforms.uTime.value=t;
  }
  flameMat.uniforms.uTime.value=t;

  // fire light flicker
  const fl=0.72 + 0.28*Math.sin(t*11) + 0.12*Math.sin(t*23+1.3) + 0.08*Math.sin(t*37);
  fireLight.intensity=3.4*fl;
  fireLight.position.x=Math.sin(t*7)*0.12;
  fireLight.position.z=Math.cos(t*9)*0.12;
  glow.intensity=0.9*(0.7+0.3*fl);
  glowSprite.scale.setScalar(3.0 + 0.5*fl);

  // embers pulsing
  for(const e of embers){
    const k=0.5+0.5*Math.sin(t*4+e.userData.ph);
    e.material.color.copy(emberBase).multiplyScalar(0.6+0.9*k);
  }

  // sparks
  for(let i=0;i<sparkCount;i++){
    sparkLife[i]-=dt;
    if(sparkLife[i]<=0){ respawnSpark(i); continue; }
    sparkVel[i*3+1]+=dt*0.6;                 // buoyancy
    sparkVel[i*3]+=Math.sin(t*2+i)*dt*0.4;   // drift
    sparkVel[i*3+2]+=Math.cos(t*2+i)*dt*0.4;
    sparkPos[i*3]+=sparkVel[i*3]*dt;
    sparkPos[i*3+1]+=sparkVel[i*3+1]*dt;
    sparkPos[i*3+2]+=sparkVel[i*3+2]*dt;
  }
  sparkGeo.attributes.position.needsUpdate=true;

  // smoke rise
  for(let i=0;i<smokeCount;i++){
    const d=smokeData[i];
    d.life+=dt;
    smokePos[i*3+1]+=d.vy*dt;
    smokePos[i*3]+=Math.sin(t*0.5+d.ph)*dt*0.15;
    smokePos[i*3+2]+=Math.cos(t*0.4+d.ph)*dt*0.15;
    if(d.life>d.max){ d.life=0; respawnSmoke(i); }
  }
  smokeGeo.attributes.position.needsUpdate=true;

  // gentle star twinkle via slow rotation
  stars.rotation.y += dt*0.005;

  controls.update();
  renderer.render(scene,camera);
}
animate();

/* ---------- resize ---------- */
window.addEventListener('resize',()=>{
  camera.aspect=window.innerWidth/window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth,window.innerHeight);
});
</script>
</body>
</html>