[object Object]

← back to Atmos Game

ATMOS Skyfire: Three.js clone of atmos.leeroy.ca with missile firing + birds to avoid

47c5cdce52c1e7b31fe80a44eba8b9b188c50a57 · 2026-07-23 14:51:03 -0700 · Steve

Files touched

Diff

commit 47c5cdce52c1e7b31fe80a44eba8b9b188c50a57
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 23 14:51:03 2026 -0700

    ATMOS Skyfire: Three.js clone of atmos.leeroy.ca with missile firing + birds to avoid
---
 .gitignore |   8 ++
 README.md  |  35 +++++
 index.html | 436 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 479 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..95fa49d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,35 @@
+# ATMOS · Skyfire
+
+An original **Three.js** homage to [atmos.leeroy.ca](https://atmos.leeroy.ca) (Leeroy's Playground) —
+turned from a scroll-driven cloud "journey" into a playable arcade flyer.
+
+## What's different from the original
+The original ATMOS is a scroll-controlled cinematic experience (Three.js + GSAP ScrollTrigger).
+This clone keeps the aesthetic — gradient sky dome, fog-depth haze, drifting billboard clouds,
+a banking low-poly plane — but replaces the scroll timeline with a **real-time game loop** and
+adds the two requested mechanics:
+
+- 🚀 **Fire missiles** — twin barrels, `SPACE` (or click / touch)
+- 🐦 **Birds to avoid** — they close on you fast; a hit costs hull integrity, shoot them for points
+
+No proprietary code or art from the original is used — it's a from-scratch build in the same spirit.
+
+## Controls
+| Input | Action |
+|-------|--------|
+| `◀ ▶` / `A D` / mouse | steer left–right |
+| `▲ ▼` / `W S` | climb / dive |
+| `SPACE` / click / touch | fire missiles |
+
+## Run it
+Any static file server works (it uses a Three.js CDN import-map, no build step):
+
+```sh
+cd ~/Projects/atmos-game
+python3 -m http.server 8080
+# open http://localhost:8080
+```
+
+## Tuning
+All feel knobs live in the `CFG` object at the top of the `<script>` in `index.html`
+— `baseSpeed` (journey pace), `fireCooldown`, `birdMax`, `birdDamage`, spawn timing, etc.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..e14157d
--- /dev/null
+++ b/index.html
@@ -0,0 +1,436 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
+<title>ATMOS — Skyfire</title>
+<style>
+  :root { --ink:#0c1b2a; --sky:#8ec9e8; }
+  * { margin:0; padding:0; box-sizing:border-box; }
+  html,body { height:100%; overflow:hidden; background:var(--sky); font-family:"Helvetica Neue",Arial,sans-serif; color:#fff; }
+  #canvas { position:fixed; inset:0; display:block; }
+
+  /* ---- HUD ---- */
+  #hud { position:fixed; inset:0; pointer-events:none; z-index:10; }
+  .corner { position:absolute; padding:16px 20px; text-shadow:0 1px 3px rgba(8,20,32,.45); }
+  #score  { top:0; left:0; font-size:14px; letter-spacing:.18em; }
+  #score b { display:block; font-size:34px; font-weight:600; letter-spacing:.02em; margin-top:2px; }
+  #right  { top:0; right:0; text-align:right; font-size:12px; letter-spacing:.18em; opacity:.85; }
+  #hpwrap { position:absolute; bottom:26px; left:50%; transform:translateX(-50%); width:min(360px,60vw); }
+  #hpwrap span { font-size:11px; letter-spacing:.25em; opacity:.85; }
+  #hpbar { height:8px; margin-top:6px; border-radius:6px; background:rgba(255,255,255,.25); overflow:hidden; }
+  #hpfill { height:100%; width:100%; border-radius:6px; background:linear-gradient(90deg,#ffd36e,#ff7a59); transition:width .18s ease; }
+
+  /* ---- Overlays ---- */
+  .overlay { position:fixed; inset:0; z-index:20; display:flex; flex-direction:column;
+             align-items:center; justify-content:center; text-align:center; gap:18px;
+             background:radial-gradient(120% 90% at 50% 20%, rgba(142,201,232,.25), rgba(12,27,42,.72));
+             backdrop-filter:blur(2px); -webkit-backdrop-filter:blur(2px); transition:opacity .5s ease; }
+  .overlay h1 { font-size:clamp(42px,9vw,96px); font-weight:200; letter-spacing:.42em; margin-left:.42em; }
+  .overlay p  { font-size:14px; letter-spacing:.28em; opacity:.9; text-transform:uppercase; }
+  .keys { display:flex; gap:26px; margin-top:6px; font-size:12px; letter-spacing:.14em; opacity:.85; }
+  .keys div span { display:inline-block; min-width:34px; padding:5px 9px; margin-right:8px; border:1px solid rgba(255,255,255,.5);
+                   border-radius:6px; font-weight:600; letter-spacing:.05em; }
+  .cta { margin-top:14px; font-size:13px; letter-spacing:.3em; opacity:.75; animation:pulse 2.2s ease-in-out infinite; }
+  @keyframes pulse { 0%,100%{opacity:.45} 50%{opacity:.95} }
+  #gameover { opacity:0; pointer-events:none; }
+  #gameover.show { opacity:1; pointer-events:auto; }
+  .stat { font-size:16px; letter-spacing:.16em; }
+  .stat b { font-size:26px; font-weight:600; }
+  .hidden { opacity:0 !important; pointer-events:none !important; }
+  .credit { position:fixed; bottom:10px; right:14px; z-index:15; font-size:10px; letter-spacing:.16em; opacity:.5; }
+</style>
+</head>
+<body>
+  <canvas id="canvas"></canvas>
+
+  <div id="hud">
+    <div id="score" class="corner">SCORE<b>0</b></div>
+    <div id="right" class="corner">ALT <span id="alt">32,000</span> FT<br/>DIST <span id="dist">0</span> KM</div>
+    <div id="hpwrap"><span>HULL INTEGRITY</span><div id="hpbar"><div id="hpfill"></div></div></div>
+  </div>
+
+  <div id="start" class="overlay">
+    <p>Leeroy's Playground · unofficial tribute</p>
+    <h1>ATMOS</h1>
+    <p>Skyfire</p>
+    <div class="keys">
+      <div><span>◀ ▶</span>steer &nbsp; <span>▲ ▼</span>climb / dive</div>
+      <div><span>SPACE</span>fire missiles</div>
+    </div>
+    <div class="cta">click or press space to begin the journey</div>
+  </div>
+
+  <div id="gameover" class="overlay">
+    <p>flight terminated</p>
+    <h1 style="letter-spacing:.2em;margin-left:.2em;font-size:clamp(34px,7vw,72px)">DOWN</h1>
+    <div class="stat">FINAL SCORE <b id="finalScore">0</b></div>
+    <div class="stat" style="opacity:.8">DISTANCE <b id="finalDist">0</b> KM · BIRDS DOWNED <b id="finalKills">0</b></div>
+    <div class="cta">click or press space to fly again</div>
+  </div>
+
+  <div class="credit">original © leeroy — atmos.leeroy.ca · this is an original clone</div>
+
+<script type="importmap">
+{ "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js" } }
+</script>
+
+<script type="module">
+import * as THREE from 'three';
+
+/* ============================================================================
+   ATMOS · Skyfire  — an original Three.js homage to atmos.leeroy.ca
+   The original is a scroll-driven cloud "journey". This turns that aesthetic
+   into a playable arcade flyer: fly the plane, FIRE MISSILES, DODGE BIRDS.
+   ========================================================================== */
+
+// ---- Tunables (one place to feel the game) --------------------------------
+const CFG = {
+  worldFar:   -260,   // z where obstacles spawn (deep into the scene)
+  worldNear:    16,   // z past the camera where they get recycled
+  baseSpeed:    60,   // forward world speed (units/sec) — the "journey" pace
+  steerX:       15,   // horizontal fly bounds (+/-)
+  steerYmin:   -8,
+  steerYmax:    9,
+  missileSpeed:220,
+  fireCooldown: 0.16, // seconds between shots
+  birdMax:      14,   // active birds in the pool
+  cloudCount:  120,
+  hitRadiusBird:2.6,  // plane<->bird collision
+  hitRadiusShot:3.4,  // missile<->bird collision
+  birdDamage:   26,
+};
+
+// ---- Renderer / scene / camera --------------------------------------------
+const canvas   = document.getElementById('canvas');
+const renderer = new THREE.WebGLRenderer({ canvas, antialias:true });
+renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+renderer.setSize(innerWidth, innerHeight);
+
+const scene = new THREE.Scene();
+const SKY_TOP = new THREE.Color(0x2f7fb5);
+const SKY_BOT = new THREE.Color(0xbfe6f5);
+scene.background = SKY_BOT.clone();
+scene.fog = new THREE.Fog(0xbfe6f5, 60, 250);   // depth haze — the ATMOS mood
+
+const camera = new THREE.PerspectiveCamera(62, innerWidth/innerHeight, 0.1, 600);
+camera.position.set(0, 2.5, 14);
+camera.lookAt(0, 1, -30);
+
+// ---- Lights ----------------------------------------------------------------
+scene.add(new THREE.HemisphereLight(0xffffff, 0x6f97b0, 1.05));
+const sun = new THREE.DirectionalLight(0xfff4e0, 1.15);
+sun.position.set(-8, 14, 6);
+scene.add(sun);
+
+// ---- Gradient sky dome (backdrop behind the fog) ---------------------------
+{
+  const skyGeo = new THREE.SphereGeometry(400, 24, 12);
+  const skyMat = new THREE.ShaderMaterial({
+    side: THREE.BackSide, depthWrite:false, fog:false,
+    uniforms:{ top:{value:SKY_TOP}, bot:{value:SKY_BOT} },
+    vertexShader:`varying vec3 vp; void main(){ vp=position; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
+    fragmentShader:`varying vec3 vp; uniform vec3 top; uniform vec3 bot;
+      void main(){ float h=clamp(vp.y/400.0*0.5+0.5,0.0,1.0); gl_FragColor=vec4(mix(bot,top,pow(h,0.8)),1.0);}`
+  });
+  scene.add(new THREE.Mesh(skyGeo, skyMat));
+}
+
+// ---- Soft cloud sprite texture (drawn once to a canvas) --------------------
+function makePuffTexture(){
+  const s=128, c=document.createElement('canvas'); c.width=c.height=s;
+  const g=c.getContext('2d');
+  const grd=g.createRadialGradient(s/2,s/2,4,s/2,s/2,s/2);
+  grd.addColorStop(0,'rgba(255,255,255,0.95)');
+  grd.addColorStop(0.5,'rgba(255,255,255,0.55)');
+  grd.addColorStop(1,'rgba(255,255,255,0)');
+  g.fillStyle=grd; g.beginPath(); g.arc(s/2,s/2,s/2,0,Math.PI*2); g.fill();
+  const t=new THREE.CanvasTexture(c); t.needsUpdate=true; return t;
+}
+const puffTex = makePuffTexture();
+
+// ---- Cloud field: recycled billboard sprites -------------------------------
+const clouds = [];
+for (let i=0;i<CFG.cloudCount;i++){
+  const m = new THREE.Sprite(new THREE.SpriteMaterial({
+    map:puffTex, transparent:true, depthWrite:false, fog:true,
+    opacity:0.55+Math.random()*0.35, color:0xffffff
+  }));
+  resetCloud(m, true);
+  scene.add(m); clouds.push(m);
+}
+function resetCloud(m, initial){
+  const sc = 14 + Math.random()*46;
+  m.scale.set(sc, sc*(0.55+Math.random()*0.3), 1);
+  m.position.set(
+    (Math.random()*2-1)*70,
+    -14 + Math.random()*40,
+    initial ? CFG.worldNear - Math.random()*(CFG.worldNear-CFG.worldFar)
+            : CFG.worldFar - Math.random()*40
+  );
+  m.userData.spin = (Math.random()*2-1)*0.05;
+}
+
+// ---- The player plane (stylized low-poly, built from primitives) -----------
+const plane = new THREE.Group();
+{
+  const body = new THREE.Mesh(
+    new THREE.CylinderGeometry(0.5,0.28,4.4,16),
+    new THREE.MeshStandardMaterial({color:0xf5f7fa, metalness:0.2, roughness:0.55}));
+  body.rotation.x = Math.PI/2; plane.add(body);
+
+  const nose = new THREE.Mesh(new THREE.ConeGeometry(0.5,1.1,16),
+    new THREE.MeshStandardMaterial({color:0xff7a59, metalness:0.1, roughness:0.5}));
+  nose.rotation.x = -Math.PI/2; nose.position.z=-2.5; plane.add(nose);
+
+  const wingMat = new THREE.MeshStandardMaterial({color:0xdfe6ee, metalness:0.15, roughness:0.6});
+  const wing = new THREE.Mesh(new THREE.BoxGeometry(7.4,0.14,1.5), wingMat);
+  wing.position.set(0,0,0.2); plane.add(wing);
+
+  const tailWing = new THREE.Mesh(new THREE.BoxGeometry(2.8,0.12,0.9), wingMat);
+  tailWing.position.set(0,0,1.9); plane.add(tailWing);
+
+  const fin = new THREE.Mesh(new THREE.BoxGeometry(0.12,1.3,1.0), wingMat);
+  fin.position.set(0,0.6,2.0); plane.add(fin);
+
+  const stripe = new THREE.Mesh(new THREE.BoxGeometry(0.55,0.02,4.2),
+    new THREE.MeshStandardMaterial({color:0xff7a59}));
+  stripe.position.set(0,0.5,0.1); plane.add(stripe);
+}
+plane.position.set(0,0,2);
+scene.add(plane);
+
+// ---- Missile pool ----------------------------------------------------------
+const missiles = [];
+const missileGeo = new THREE.CapsuleGeometry(0.16,0.7,4,8);
+const missileMat = new THREE.MeshStandardMaterial({color:0xffca3a, emissive:0xff7a00, emissiveIntensity:0.8});
+for (let i=0;i<40;i++){
+  const m = new THREE.Mesh(missileGeo, missileMat);
+  m.rotation.x = Math.PI/2; m.visible=false; m.userData.alive=false;
+  scene.add(m); missiles.push(m);
+}
+function fireMissile(){
+  // two barrels, one per wingtip-ish
+  for (const dx of [-1.1, 1.1]){
+    const m = missiles.find(x=>!x.userData.alive);
+    if (!m) return;
+    m.position.set(plane.position.x+dx, plane.position.y, plane.position.z-2.4);
+    m.visible=true; m.userData.alive=true;
+  }
+}
+
+// ---- Bird pool (dark low-poly, flapping) -----------------------------------
+const birds = [];
+function makeBird(){
+  const g = new THREE.Group();
+  const mat = new THREE.MeshStandardMaterial({color:0x243242, roughness:0.9, metalness:0.0});
+  const wl = new THREE.Mesh(new THREE.BoxGeometry(2.2,0.12,0.9), mat);
+  const wr = wl.clone();
+  wl.position.x = -1.15; wr.position.x = 1.15;
+  const bodyB = new THREE.Mesh(new THREE.SphereGeometry(0.42,10,8), mat);
+  g.add(wl,wr,bodyB);
+  g.userData = {wl, wr, alive:false, flap:Math.random()*6, sway:Math.random()*6, swayAmp:2+Math.random()*4};
+  return g;
+}
+for (let i=0;i<CFG.birdMax;i++){ const b=makeBird(); b.visible=false; scene.add(b); birds.push(b); }
+function spawnBird(b){
+  b.position.set((Math.random()*2-1)*CFG.steerX, CFG.steerYmin + Math.random()*(CFG.steerYmax-CFG.steerYmin), CFG.worldFar - Math.random()*30);
+  b.userData.alive=true; b.visible=true;
+  b.userData.baseX = b.position.x;
+  b.userData.sway = Math.random()*6;
+}
+
+// ---- Explosion puff pool (sprites that expand + fade) ----------------------
+const puffs = [];
+for (let i=0;i<24;i++){
+  const p = new THREE.Sprite(new THREE.SpriteMaterial({map:puffTex, transparent:true, color:0xffce7a, depthWrite:false}));
+  p.visible=false; p.userData.alive=false; scene.add(p); puffs.push(p);
+}
+function boom(x,y,z,color){
+  for (let i=0;i<5;i++){
+    const p = puffs.find(q=>!q.userData.alive); if(!p) break;
+    p.position.set(x+(Math.random()*2-1), y+(Math.random()*2-1), z);
+    p.material.color.set(color); p.material.opacity=0.95;
+    p.userData.alive=true; p.userData.life=0; p.userData.max=0.45+Math.random()*0.2;
+    p.userData.grow=6+Math.random()*8; p.visible=true; p.scale.set(2,2,1);
+  }
+}
+
+// ---- Input -----------------------------------------------------------------
+const keys = {};
+const target = { x:0, y:1 };   // where the plane wants to be
+addEventListener('keydown', e=>{
+  keys[e.code]=true;
+  if (e.code==='Space'){ e.preventDefault(); if(state==='play') firing=true; else beginOrRestart(); }
+});
+addEventListener('keyup', e=>{ keys[e.code]=false; if(e.code==='Space') firing=false; });
+// pointer steering (mouse / touch)
+function pointerMove(cx, cy){
+  const nx = (cx/innerWidth)*2-1;
+  const ny = (cy/innerHeight)*2-1;
+  target.x = nx*CFG.steerX;
+  target.y = THREE.MathUtils.clamp(-ny*CFG.steerYmax, CFG.steerYmin, CFG.steerYmax);
+}
+addEventListener('mousemove', e=>pointerMove(e.clientX, e.clientY));
+addEventListener('touchmove', e=>{ const t=e.touches[0]; pointerMove(t.clientX,t.clientY); firing=true; }, {passive:true});
+addEventListener('touchend',  ()=>{ firing=false; });
+canvas.addEventListener('click', ()=>{ if(state!=='play') beginOrRestart(); });
+
+// ---- Game state ------------------------------------------------------------
+let state='start';               // 'start' | 'play' | 'over'
+let firing=false, cooldown=0;
+let score=0, kills=0, dist=0, hp=100;
+const startEl=document.getElementById('start');
+const overEl =document.getElementById('gameover');
+const hpFill =document.getElementById('hpfill');
+const scoreEl=document.querySelector('#score b');
+const distEl =document.getElementById('dist');
+const altEl  =document.getElementById('alt');
+
+function beginOrRestart(){
+  if (state==='play') return;
+  score=0; kills=0; dist=0; hp=100; firing=false; cooldown=0;
+  target.x=0; target.y=1; plane.position.set(0,0,2);
+  birds.forEach(b=>{ b.userData.alive=false; b.visible=false; });
+  missiles.forEach(m=>{ m.userData.alive=false; m.visible=false; });
+  state='play';
+  startEl.classList.add('hidden');
+  overEl.classList.remove('show');
+}
+function endGame(){
+  state='over';
+  document.getElementById('finalScore').textContent=score;
+  document.getElementById('finalDist').textContent=Math.floor(dist/100);
+  document.getElementById('finalKills').textContent=kills;
+  overEl.classList.add('show');
+}
+
+// ---- Resize ----------------------------------------------------------------
+addEventListener('resize', ()=>{
+  camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix();
+  renderer.setSize(innerWidth,innerHeight);
+});
+
+// ---- Main loop -------------------------------------------------------------
+const clock = new THREE.Clock();
+let flash=0;                       // red damage flash amount
+let spawnTimer=0;
+
+function tick(){
+  requestAnimationFrame(tick);
+  const dt = Math.min(clock.getDelta(), 0.05);
+  const t  = clock.elapsedTime;
+
+  // world always drifts (even on menus) so the sky feels alive
+  const worldSpeed = (state==='play') ? CFG.baseSpeed : CFG.baseSpeed*0.35;
+
+  // clouds
+  for (const c of clouds){
+    c.position.z += worldSpeed*dt;
+    c.material.rotation += c.userData.spin*dt;
+    if (c.position.z > CFG.worldNear) resetCloud(c, false);
+  }
+
+  if (state==='play'){
+    // ---- steer plane toward target (keyboard nudges the pointer target) ----
+    if (keys['ArrowLeft']||keys['KeyA'])  target.x -= 42*dt;
+    if (keys['ArrowRight']||keys['KeyD']) target.x += 42*dt;
+    if (keys['ArrowUp']||keys['KeyW'])    target.y += 30*dt;
+    if (keys['ArrowDown']||keys['KeyS'])  target.y -= 30*dt;
+    target.x = THREE.MathUtils.clamp(target.x, -CFG.steerX, CFG.steerX);
+    target.y = THREE.MathUtils.clamp(target.y, CFG.steerYmin, CFG.steerYmax);
+
+    const prevX = plane.position.x;
+    plane.position.x += (target.x - plane.position.x)*Math.min(1, 6*dt);
+    plane.position.y += (target.y - plane.position.y)*Math.min(1, 6*dt);
+    // bank into the turn + pitch with climb — reads as real flight
+    const dx = plane.position.x - prevX;
+    plane.rotation.z += ((-dx*4) - plane.rotation.z)*Math.min(1,8*dt);
+    plane.rotation.x += ((-(target.y-plane.position.y)*0.06) - plane.rotation.x)*Math.min(1,6*dt);
+    plane.position.y += Math.sin(t*2.2)*0.004;  // subtle idle bob
+
+    // ---- firing ----
+    cooldown -= dt;
+    if (firing && cooldown<=0){ fireMissile(); cooldown=CFG.fireCooldown; }
+
+    // ---- distance / score-over-time ----
+    dist  += worldSpeed*dt;
+    score += Math.floor(worldSpeed*dt*0.1);
+
+    // ---- spawn birds ----
+    spawnTimer -= dt;
+    const activeBirds = birds.filter(b=>b.userData.alive).length;
+    if (spawnTimer<=0 && activeBirds < CFG.birdMax){
+      const b = birds.find(x=>!x.userData.alive);
+      if (b) spawnBird(b);
+      spawnTimer = 0.5 + Math.random()*0.7;   // difficulty knob
+    }
+  }
+
+  // ---- missiles fly forward, cull, and hit birds ----
+  for (const m of missiles){
+    if (!m.userData.alive) continue;
+    m.position.z -= CFG.missileSpeed*dt;
+    if (m.position.z < CFG.worldFar-20){ m.userData.alive=false; m.visible=false; continue; }
+    for (const b of birds){
+      if (!b.userData.alive) continue;
+      if (m.position.distanceTo(b.position) < CFG.hitRadiusShot){
+        boom(b.position.x,b.position.y,b.position.z,0xffce7a);
+        b.userData.alive=false; b.visible=false;
+        m.userData.alive=false; m.visible=false;
+        kills++; score+=100;
+        break;
+      }
+    }
+  }
+
+  // ---- birds fly toward the player, flap, and can hit the plane ----
+  for (const b of birds){
+    if (!b.userData.alive) continue;
+    b.position.z += (worldSpeed + 22)*dt;              // birds close faster than clouds
+    b.userData.sway += dt;
+    b.position.x = b.userData.baseX + Math.sin(b.userData.sway*1.3)*b.userData.swayAmp*0.15;
+    const flap = Math.sin(t*16 + b.userData.flap)*0.7; // wing beat
+    b.userData.wl.rotation.z =  flap;
+    b.userData.wr.rotation.z = -flap;
+    if (state==='play' && b.position.distanceTo(plane.position) < CFG.hitRadiusBird){
+      boom(plane.position.x,plane.position.y,plane.position.z,0xff5a4d);
+      b.userData.alive=false; b.visible=false;
+      hp -= CFG.birdDamage; flash=1;
+      if (hp<=0){ hp=0; endGame(); }
+    }
+    if (b.position.z > CFG.worldNear){ b.userData.alive=false; b.visible=false; }  // dodged — recycle
+  }
+
+  // ---- explosion puffs ----
+  for (const p of puffs){
+    if (!p.userData.alive) continue;
+    p.userData.life += dt;
+    const k = p.userData.life/p.userData.max;
+    const s = 2 + p.userData.grow*k;
+    p.scale.set(s,s,1);
+    p.material.opacity = 0.95*(1-k);
+    if (k>=1){ p.userData.alive=false; p.visible=false; }
+  }
+
+  // ---- camera gently trails the plane ----
+  camera.position.x += (plane.position.x*0.5 - camera.position.x)*Math.min(1,3*dt);
+  camera.position.y += ((plane.position.y*0.4+2.5) - camera.position.y)*Math.min(1,3*dt);
+  camera.lookAt(plane.position.x*0.6, plane.position.y*0.5+1, -30);
+
+  // ---- damage flash via background tint ----
+  if (flash>0){ flash=Math.max(0,flash-dt*2.2); scene.background.copy(SKY_BOT).lerp(new THREE.Color(0xff4433), flash*0.5); }
+  else scene.background.copy(SKY_BOT);
+
+  // ---- HUD ----
+  hpFill.style.width = hp+'%';
+  scoreEl.textContent = score;
+  distEl.textContent  = Math.floor(dist/100);
+  altEl.textContent   = (32000 + Math.round(plane.position.y*140)).toLocaleString();
+
+  renderer.render(scene, camera);
+}
+tick();
+</script>
+</body>
+</html>

(oldest)  ·  back to Atmos Game  ·  auto-save: 2026-07-23T15:22:12 (1 files) — assets/ 328159d →