← Canyon Run results

Canyon Run

Ornith 1.5 35B A3B · full

Initial view of Canyon Run

Browser check: Artifact runtime error. This is a technical check, not a quality review.

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 47,873 bytes · SHA-256 33c1ff0bf172
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>CANYON FLOW — High-Speed Procedural Canyon Racer</title>
<style>
  :root{
    --gold:#ffb347; --orange:#ff7a18; --purple:#6a2c8f; --deep:#1a0b2e;
    --cyan:#4df2ff; --pink:#ff4fa2;
  }
  *{ margin:0; padding:0; box-sizing:border-box; }
  html,body{ width:100%; height:100%; overflow:hidden; background:#0a0516; }
  body{ font-family:'Segoe UI',system-ui,-apple-system,sans-serif; color:#fff; user-select:none; }
  #app{ position:fixed; inset:0; }
  canvas{ display:block; }

  /* ---------- HUD ---------- */
  #hud{ position:fixed; inset:0; pointer-events:none; z-index:10; }
  .hud-num{ font-weight:800; letter-spacing:2px; }

  #scoreWrap{ position:absolute; left:26px; top:22px; }
  #score{ font-size:40px; color:#fff; text-shadow:0 0 18px rgba(255,179,71,.7); }
  #score .lbl{ font-size:12px; font-weight:600; letter-spacing:4px; color:#ffb347; opacity:.85; display:block; }
  #best{ font-size:12px; color:#c9a9ff; margin-top:2px; letter-spacing:2px; opacity:.8; }

  #multiWrap{ position:absolute; right:30px; top:18px; text-align:right; }
  #multi{ font-size:64px; line-height:1; color:#4df2ff; text-shadow:0 0 24px rgba(77,242,255,.7); transition:transform .08s; }
  #multi .lbl{ font-size:12px; font-weight:600; letter-spacing:5px; color:#4df2ff; opacity:.8; }
  #multi.hot{ transform:scale(1.18); color:#fff; text-shadow:0 0 30px rgba(255,79,162,.9); }

  #speedWrap{ position:absolute; left:50%; bottom:26px; transform:translateX(-50%); width:min(340px,56vw); text-align:center; }
  #speedVal{ font-size:30px; font-weight:800; letter-spacing:2px; }
  #speedVal .unit{ font-size:13px; font-weight:600; opacity:.7; letter-spacing:3px; margin-left:5px; }
  #speedBar{ height:3px; margin:6px auto 0; width:100%; background:rgba(255,255,255,.12); border-radius:2px; overflow:hidden; }
  #speedFill{ height:100%; width:0%; background:linear-gradient(90deg,#4df2ff,#ff4fa2); box-shadow:0 0 12px #4df2ff; }

  /* energy bar bottom center-left */
  #energyWrap{ position:absolute; left:26px; bottom:26px; width:min(240px,38vw); }
  #energyLbl{ font-size:11px; letter-spacing:4px; font-weight:700; color:#ffb347; opacity:.85; margin-bottom:6px; }
  #energyBar{ height:8px; width:100%; background:rgba(255,255,255,.12); border-radius:4px; overflow:hidden; }
  #energyFill{ height:100%; width:100%; background:linear-gradient(90deg,#ff7a18,#ffb347); transition:width .1s linear; }
  #energyFill.low{ background:linear-gradient(90deg,#ff2d55,#ff7a18); }

  /* turbo meter */
  #turboWrap{ position:absolute; right:26px; bottom:26px; width:14px; height:150px; background:rgba(255,255,255,.1); border-radius:8px; overflow:hidden; border:1px solid rgba(255,255,255,.12); }
  #turboLbl{ position:absolute; right:24px; bottom:182px; font-size:10px; letter-spacing:3px; font-weight:700; color:#4df2ff; opacity:.8; }
  #turboFill{ position:absolute; bottom:0; width:100%; height:0%; background:linear-gradient(0deg,#4df2ff,#ff4fa2); border-radius:8px; }
  #turboFill.ready{ box-shadow:0 0 16px #4df2ff; animation:tpulse .8s infinite; }
  @keyframes tpulse{ 50%{ opacity:.55; } }

  #boostHint{ position:absolute; right:52px; bottom:182px; font-size:11px; letter-spacing:2px; color:#ff4fa2; opacity:0; transition:opacity .2s; }
  #boostHint.show{ opacity:1; }

  /* ---------- Overlays ---------- */
  .overlay{ position:fixed; inset:0; z-index:20; display:flex; flex-direction:column; align-items:center; justify-content:center;
    text-align:center; background:radial-gradient(ellipse at center, rgba(40,15,70,.72), rgba(10,5,22,.94)); backdrop-filter:blur(3px); }
  .overlay.hidden{ display:none; }
  .title{ font-size:clamp(48px,11vw,120px); font-weight:900; letter-spacing:6px; line-height:.95;
    background:linear-gradient(180deg,#fff, #ffb347 55%, #ff7a18); -webkit-background-clip:text; background-clip:text; color:transparent;
    text-shadow:0 0 60px rgba(255,122,24,.35); }
  .subtitle{ font-size:14px; letter-spacing:8px; color:#c9a9ff; margin-top:6px; text-transform:uppercase; }
  .card{ margin-top:34px; padding:26px 34px; border-radius:14px; background:rgba(255,255,255,.05); border:1px solid rgba(255,255,255,.12); }
  .stat{ display:flex; justify-content:space-between; gap:40px; font-size:15px; margin:8px 0; letter-spacing:1px; }
  .stat b{ font-weight:800; }
  .stat .v{ color:#ffb347; }
  .btn{ pointer-events:auto; cursor:pointer; margin-top:26px; padding:16px 44px; font-size:18px; font-weight:800; letter-spacing:3px;
    color:#1a0b2e; border:none; border-radius:40px; background:linear-gradient(90deg,#ffb347,#ff7a18);
    box-shadow:0 8px 30px rgba(255,122,24,.5); transition:transform .12s, box-shadow .12s; }
  .btn:hover{ transform:translateY(-2px) scale(1.03); box-shadow:0 12px 40px rgba(255,122,24,.7); }
  .btn:active{ transform:scale(.97); }
  .hint{ margin-top:24px; font-size:12px; letter-spacing:2px; color:#9b86c4; line-height:2; }
  .hint kbd{ display:inline-block; padding:2px 8px; margin:0 2px; background:rgba(255,255,255,.12); border-radius:5px; font-family:inherit; font-weight:700; color:#fff; }
  .gameover .title{ background:linear-gradient(180deg,#fff,#ff4fa2 60%,#ff7a18); -webkit-background-clip:text; background-clip:text; color:transparent; }
  #pauseTag{ position:fixed; top:20px; left:50%; transform:translateX(-50%); z-index:15; font-size:12px; letter-spacing:6px;
    color:#fff; opacity:.85; display:none; }
</style>
</head>
<body>
<div id="app"></div>

<!-- HUD -->
<div id="hud">
  <div id="scoreWrap">
    <span class="hud-num" id="score">0<span class="lbl">SCORE</span></span>
    <div id="best">BEST 0</div>
  </div>

  <div id="multiWrap">
    <span id="multi">x1<span class="lbl">NEAR</span></span>
  </div>

  <div id="energyWrap">
    <div id="energyLbl">ENERGY</div>
    <div id="energyBar"><div id="energyFill"></div></div>
  </div>

  <div id="turboLbl">TURBO</div>
  <div id="boostHint">HOLD</div>
  <div id="turboWrap"><div id="turboFill"></div></div>

  <div id="speedWrap">
    <span id="speedVal" class="hud-num">0</span><span class="unit">KM/H</span>
    <div id="speedBar"><div id="speedFill"></div></div>
  </div>
</div>

<div id="pauseTag">PAUSED</div>

<!-- Start -->
<div id="startScreen" class="overlay">
  <div class="title">CANYON<br>FLOW</div>
  <div class="subtitle">High-Speed Procedural Racer</div>
  <div class="card">
    <div class="stat"><span>Speed</span><b class="v">Faster = More Score</b></div>
    <div class="stat"><span>Near-Miss</span><b class="v">Build Multiplier</b></div>
    <div class="stat"><span>Energy</span><b class="v">Collect Orbs</b></div>
    <div class="stat"><span>Turbo</span><b class="v">Hold to Boost</b></div>
  </div>
  <button class="btn" id="startBtn">LAUNCH</button>
  <div class="hint">
    MOVE: <kbd>Mouse</kbd> or <kbd>← →</kbd> / <kbd>A D</kbd> &nbsp;·&nbsp; BOOST: <kbd>Hold</kbd> <kbd>Space</kbd> / <kbd>W</kbd><br>
    PAUSE: <kbd>P</kbd> &nbsp;·&nbsp; Collect glowing orbs before energy runs out.
  </div>
</div>

<!-- Pause -->
<div id="pauseScreen" class="overlay hidden">
  <div class="title" style="font-size:clamp(36px,8vw,72px)">PAUSED</div>
  <button class="btn" id="resumeBtn">RESUME</button>
</div>

<!-- Game Over -->
<div id="overScreen" class="overlay gameover hidden">
  <div class="title">WRECKED</div>
  <div class="card">
    <div class="stat"><span>Final Score</span><b class="v" id="finalScore">0</b></div>
    <div class="stat"><span>Best Score</span><b class="v" id="finalBest">0</b></div>
    <div class="stat"><span>Max Speed</span><b class="v" id="maxSpeed">0</b></div>
    <div class="stat"><span>Distance</span><b class="v" id="distance">0</b></div>
  </div>
  <button class="btn" id="restartBtn">RETRY</button>
</div>

<script type="importmap">
{
  "imports": {
    "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
    "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
  }
}
</script>

<script type="module">
import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
import { FXAAShader } from 'three/addons/shaders/FXAAShader.js';

/* ============================================================
   CANYON FLOW — procedural canyon racer (Race the Sun inspired)
   ============================================================ */

// ---------- Globals ----------
let renderer, composer, scene, camera;
let skyMesh, sunMesh, sunGlow;
let player, engineGlowL, engineGlowR;
let bloomPass, fxaaPass;
let clock = new THREE.Clock();

// Game state
const STATE = { MENU:0, PLAY:1, PAUSE:2, OVER:3 };
let state = STATE.MENU;

let segLen = 90, segGap = 8, numSeg = 9;
let segments = [];
let obstacles = [];   // { box, type, pos, half, nearMissed }
let orbs = [];        // mesh + data
let nextSegZ = 0;
let spawnZ = 0;

// Player physics
const P = {
  pos: new THREE.Vector3(0, 0, 0),
  vel: new THREE.Vector3(),
  bank: 0,        // roll
  pitch: 0,       // visual pitch
  yaw: 0,         // horizontal heading offset
  speed: 60,      // forward speed
  targetBank: 0,
  targetPitch: 0,
};

// Gameplay tunables
let energy = 1.0;
let score = 0;
let best = Number(localStorage.getItem('canyonflow_best') || 0);
let mult = 1;
let multTimer = 0;
let turbo = 0;            // 0..1
let boosting = false;
let maxSpeed = 0;
let distance = 0;
let difficulty = 0;

// Input
const input = { x: 0, boost: false };
let pointerX = 0;
let pointerY = 0;

// Audio
let audio = null;

// Particles
let trailSys, dustSys, streakSys;

// Camera shake
let shakeAmt = 0;

// DOM
const $ = id => document.getElementById(id);
const elScore = $('score'), elBest = $('best'), elMulti = $('multi');
const elSpeedVal = $('speedVal'), elSpeedFill = $('speedFill');
const elEnergyFill = $('energyFill'), elTurboFill = $('turboFill'), elTurboHint = $('boostHint');
const elFinalScore = $('finalScore'), elFinalBest = $('finalBest'), elMaxSpeed = $('maxSpeed'), elDistance = $('distance');

// ---------- Helpers ----------
function clamp(v,a,b){ return v<a?a:(v>b?b:v); }
function rand(a,b){ return a + Math.random()*(b-a); }
function lerp(a,b,t){ return a+(b-a)*t; }
const randInt = (a,b)=>Math.floor(rand(a,b+1));

// ============================================================
//  INIT / SCENE
// ============================================================
function init(){
  const app = $('app');
  renderer = new THREE.WebGLRenderer({ antialias:true, powerPreference:'high-performance' });
  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.05;
  app.appendChild(renderer.domElement);

  scene = new THREE.Scene();
  scene.fog = new THREE.FogExp2(0x2a0f3a, 0.0062);

  camera = new THREE.PerspectiveCamera(78, window.innerWidth/window.innerHeight, 0.5, 1200);
  camera.position.set(0, 5, 14);

  // ---- Lights ----
  const hemi = new THREE.HemisphereLight(0xffd9a0, 0x2a0f3a, 0.7);
  scene.add(hemi);

  const sun = new THREE.DirectionalLight(0xffa54a, 2.4);
  sun.position.set(-0.4, 0.35, -1).normalize().multiplyScalar(200);
  sun.castShadow = true;
  sun.shadow.mapSize.set(1024,1024);
  sun.shadow.camera.near = 10;
  sun.shadow.camera.far = 500;
  sun.shadow.camera.left = -120; sun.shadow.camera.right = 120;
  sun.shadow.camera.top = 120; sun.shadow.camera.bottom = -120;
  sun.shadow.bias = -0.0008;
  scene.add(sun);
  scene.add(sun.target);

  const rim = new THREE.DirectionalLight(0xff4fa2, 0.6);
  rim.position.set(0.6, 0.2, 1).normalize().multiplyScalar(120);
  scene.add(rim);

  buildSky();
  buildSun();
  buildPlayer();
  buildParticles();

  // ---- Post processing ----
  composer = new EffectComposer(renderer);
  composer.addPass(new RenderPass(scene, camera));
  bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 0.9, 0.7, 0.82);
  composer.addPass(bloomPass);
  fxaaPass = new ShaderPass(FXAAShader);
  fxaaPass.material.uniforms['resolution'].value.set(1/window.innerWidth, 1/window.innerHeight);
  composer.addPass(fxaaPass);

  window.addEventListener('resize', onResize);
  bindInput();

  $('best').textContent = 'BEST ' + best;
}

function buildSky(){
  const geo = new THREE.SphereGeometry(700, 32, 20);
  const mat = new THREE.ShaderMaterial({
    side: THREE.BackSide, depthWrite:false,
    uniforms:{ },
    vertexShader:`
      varying vec3 vDir;
      void main(){ vDir = normalize(position); gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
    fragmentShader:`
      varying vec3 vDir;
      void main(){
        float h = clamp(vDir.y*0.5+0.35, 0.0, 1.0);
        vec3 low = vec3(0.10,0.05,0.20);
        vec3 mid = vec3(0.55,0.18,0.42);
        vec3 high = vec3(1.0,0.55,0.22);
        vec3 top = vec3(0.18,0.08,0.36);
        vec3 col = mix(low, mid, smoothstep(0.0,0.35,h));
        col = mix(col, high, smoothstep(0.25,0.6,h));
        col = mix(col, top, smoothstep(0.6,1.0,h));
        gl_FragColor = vec4(col,1.0);
      }`
  });
  skyMesh = new THREE.Mesh(geo, mat);
  scene.add(skyMesh);
}

function buildSun(){
  const sunGeo = new THREE.SphereGeometry(60, 32, 32);
  const sunMat = new THREE.MeshBasicMaterial({ color:0xffd27a, fog:false });
  sunMesh = new THREE.Mesh(sunGeo, sunMat);
  sunMesh.position.set(-120, 60, -520);
  scene.add(sunMesh);
  // glow sprite
  const glowTex = makeGlowTex();
  const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map:glowTex, color:0xffa040, transparent:true, depthWrite:false, blending:THREE.AdditiveBlending }));
  sp.scale.set(320,320,1);
  sp.position.copy(sunMesh.position);
  sunGlow = sp;
  scene.add(sp);
}

function makeGlowTex(){
  const c = document.createElement('canvas'); c.width=c.height=128;
  const g = c.getContext('2d');
  const grd = g.createRadialGradient(64,64,0,64,64,64);
  grd.addColorStop(0,'rgba(255,240,200,1)');
  grd.addColorStop(0.3,'rgba(255,170,80,0.7)');
  grd.addColorStop(0.7,'rgba(255,120,40,0.15)');
  grd.addColorStop(1,'rgba(255,120,40,0)');
  g.fillStyle=grd; g.fillRect(0,0,128,128);
  return new THREE.CanvasTexture(c);
}

function buildPlayer(){
  player = new THREE.Group();

  // Body — sleek dart
  const bodyMat = new THREE.MeshStandardMaterial({ color:0xd8dde8, flatShading:true, metalness:0.6, roughness:0.35 });
  const nose = new THREE.ConeGeometry(0.42, 2.4, 4);
  nose.rotateX(-Math.PI/2);
  const noseM = new THREE.Mesh(nose, bodyMat); noseM.position.z = -1.5;
  const fus = new THREE.BoxGeometry(0.7, 0.32, 2.2);
  const fusM = new THREE.Mesh(fus, bodyMat);
  const cockpit = new THREE.Mesh(new THREE.SphereGeometry(0.34, 6, 5), new THREE.MeshStandardMaterial({ color:0x2b3a55, emissive:0x1a2b45, metalness:0.9, roughness:0.2 }));
  cockpit.scale.set(1,0.7,1.4); cockpit.position.set(0,0.22,-0.2);

  // Main wings
  const wingMat = new THREE.MeshStandardMaterial({ color:0xff7a18, flatShading:true, metalness:0.4, roughness:0.5, emissive:0x331200 });
  const wingShape = new THREE.Shape();
  wingShape.moveTo(0,0); wingShape.lineTo(2.6,-0.9); wingShape.lineTo(2.6,-1.3); wingShape.lineTo(0.2,-0.5); wingShape.closePath();
  const wingGeo = new THREE.ExtrudeGeometry(wingShape, { depth:0.08, bevelEnabled:true, bevelThickness:0.03, bevelSize:0.04, bevelSegments:0 });
  wingGeo.translate(0,0,0);
  const wingR = new THREE.Mesh(wingGeo, wingMat); wingR.rotation.y = 0; wingR.position.set(0.15,0,0.2);
  const wingL = new THREE.Mesh(wingGeo, wingMat); wingL.scale.x = -1; wingL.position.set(-0.15,0,0.2);

  // Tail fins
  const tailMat = new THREE.MeshStandardMaterial({ color:0xff4fa2, flatShading:true, metalness:0.3, roughness:0.5, emissive:0x330018 });
  const tailGeo = new THREE.BoxGeometry(0.12, 0.9, 0.9);
  const tail = new THREE.Mesh(tailGeo, tailMat); tail.position.set(0,0.55,1.1);
  const tailGeo2 = new THREE.BoxGeometry(1.0, 0.12, 0.7);
  const tailW = new THREE.Mesh(tailGeo2, tailMat); tailW.position.set(0,-0.05,1.2);

  // Engine glows
  const engMat = new THREE.MeshBasicMaterial({ color:0x4df2ff });
  engineGlowL = new THREE.Mesh(new THREE.SphereGeometry(0.18,8,8), engMat);
  engineGlowL.position.set(0.25,0,1.3);
  engineGlowR = new THREE.Mesh(new THREE.SphereGeometry(0.18,8,8), engMat);
  engineGlowR.position.set(-0.25,0,1.3);

  player.add(noseM, fusM, cockpit, wingR, wingL, tail, tailW, engineGlowL, engineGlowR);

  // Collision sphere
  player.userData.coll = new THREE.Sphere(new THREE.Vector3(), 1.1);
  player.userData.radius = 1.1;

  scene.add(player);
}

// ============================================================
//  PARTICLES
// ============================================================
function buildParticles(){
  // Trail — engine exhaust behind player
  trailSys = makeParticleField(60, 0x4df2ff, 0.9);
  scene.add(trailSys.points);
  trailSys.points.visible = false;

  // Speed streaks — long lines flying past
  streakSys = makeParticleField(80, 0xffffff, 2.4);
  scene.add(streakSys.points);
  streakSys.points.visible = false;

  // Dust / debris
  dustSys = makeParticleField(120, 0xffc88a, 1.6);
  scene.add(dustSys.points);
  dustSys.points.visible = false;
}

// Simple pooled point field. Returns { points, sys } where sys is the controller.
function makeParticleField(n, color, baseSize){
  const geo = new THREE.BufferGeometry();
  const pos = new Float32Array(n*3);
  const life = new Float32Array(n);
  const size = new Float32Array(n);
  geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  geo.setAttribute('customSize', new THREE.BufferAttribute(size, 1));
  geo.setAttribute('life', new THREE.BufferAttribute(life, 1));

  const mat = new THREE.ShaderMaterial({
    transparent:true, depthWrite:false, blending:THREE.AdditiveBlending,
    uniforms:{ globalScale:{ value: baseSize } },
    vertexShader:`
      attribute float customSize;
      attribute float life;
      uniform float globalScale;
      varying float vLife;
      void main(){
        vLife = life;
        vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
        gl_PointSize = globalScale * customSize * (300.0 / -mvPosition.z);
        gl_Position = projectionMatrix * mvPosition;
      }`,
    fragmentShader:`
      varying float vLife;
      void main(){
        float d = distance(gl_PointCoord, vec2(0.5));
        if(d > 0.5) discard;
        float a = smoothstep(0.5, 0.0, d) * clamp(vLife, 0.0, 1.0);
        gl_FragColor = vec4(1.0, 1.0, 1.0, a);
      }`
  });

  const points = new THREE.Points(geo, mat);
  points.frustumCulled = false;

  const sys = {
    points, geo, pos, life, size, n, next:0,
    spawn(p, s){
      const i = this.next;
      this.pos[i*3]=p.x; this.pos[i*3+1]=p.y; this.pos[i*3+2]=p.z;
      this.life[i]=s; this.size[i]=Math.min(s, 1);
      this.next=(this.next+1)%this.n;
    },
    update(dt, fwd){
      const arr = this.geo.attributes.position.array;
      const sarr = this.geo.attributes.customSize.array;
      const larr = this.geo.attributes.life.array;
      for(let i=0;i<this.n;i++){
        this.life[i]-=dt;
        const s = Math.max(0, this.life[i]);
        sarr[i] = this.size[i]*(0.4+s);
        larr[i] = s;
        // keep particles anchored relative to player (they don't move in world)
      }
      this.geo.attributes.position.needsUpdate=true;
      this.geo.attributes.customSize.needsUpdate=true;
      this.geo.attributes.life.needsUpdate=true;
    }
  };
  return { points, sys };
}

function makeStreakField(n){
  return makeParticleField(n, 0xffffff, 2.4);
}

// ============================================================
//  PROCEDURAL CANYON GENERATION
// ============================================================
function segHalfWidths(z){
  // canyon floor half-width (x) and ceiling gap (y from ground up)
  const floorW = 42;
  const gapY = 30;
  return { floorW, gapY };
}

function makeSegment(z, diff){
  const group = new THREE.Group();
  group.position.z = z;
  const d = Math.random();

  let floorW=42, gapY=30, leftX=0, rightX=0;
  if(d < 0.34){ // straight wide
    floorW = rand(40,52); gapY = rand(28,36);
  } else if(d < 0.62){ // tight squeeze (narrow)
    floorW = rand(20,28); gapY = rand(20,26);
  } else if(d < 0.82){ // high ceiling (dip)
    floorW = rand(36,46); gapY = rand(38,48);
  } else { // low ceiling (dive)
    floorW = rand(34,44); gapY = rand(16,22);
  }
  // curve the canyon center
  leftX = rand(-6,6);
  rightX = leftX;

  const seg = { group, z, floorW, gapY, leftX, obstacles:[], floorM:null, ceilM:null, wallLM:null, wallRM:null };

  const matRock = (em)=> new THREE.MeshStandardMaterial({ color:0x3a2b52, flatShading:true, metalness:0.1, roughness:0.9, emissive:em||0x000000, emissiveIntensity:0.7 });
  const matCeil = matRock(0x150a25);
  const matWall = matRock(0x241030);

  // Ground floor segments (two quads)
  const floorMat = matRock(0x2a1640);
  const leftFloorGeo = new THREE.PlaneGeometry(floorW+leftX, segLen, 1, 1);
  const leftFloor = new THREE.Mesh(leftFloorGeo, floorMat);
  leftFloor.rotation.x = -Math.PI/2;
  leftFloor.position.set(-(floorW/2 + leftX/2), -12, 0);
  leftFloor.receiveShadow = true;
  group.add(leftFloor);

  const rightFloorGeo = new THREE.PlaneGeometry(floorW+leftX, segLen, 1,1);
  const rightFloor = new THREE.Mesh(rightFloorGeo, floorMat);
  rightFloor.rotation.x = -Math.PI/2;
  rightFloor.position.set((floorW/2 + leftX/2), -12, 0);
  rightFloor.receiveShadow = true;
  group.add(rightFloor);

  // Left wall (vertical rock formation)
  const wallH = 44;
  const wallGeo = new THREE.PlaneGeometry(wallH, segLen, 1, 1);
  const leftWall = new THREE.Mesh(wallGeo, matWall);
  leftWall.rotation.y = Math.PI/2;
  leftWall.position.set(-(floorW + leftX), 8, 0);
  leftWall.castShadow = true; leftWall.receiveShadow = true;
  group.add(leftWall); seg.wallLM = leftWall;

  const rightWall = new THREE.Mesh(wallGeo, matWall);
  rightWall.rotation.y = -Math.PI/2;
  rightWall.position.set((floorW + leftX), 8, 0);
  rightWall.castShadow = true; rightWall.receiveShadow = true;
  group.add(rightWall); seg.wallRM = rightWall;

  // Ceiling (with gap) — two ceiling slabs
  const ceilGeo = new THREE.PlaneGeometry(floorW*0.9, segLen, 1,1);
  const ceilL = new THREE.Mesh(ceilGeo, matCeil);
  ceilL.rotation.x = Math.PI/2;
  ceilL.position.set(-(floorW*0.45), gapY+6, 0);
  ceilL.castShadow=true; ceilL.receiveShadow=true;
  group.add(ceilL); seg.ceilLM = ceilL;
  const ceilR = new THREE.Mesh(ceilGeo, matCeil);
  ceilR.rotation.x = Math.PI/2;
  ceilR.position.set((floorW*0.45), gapY+6, 0);
  ceilR.castShadow=true; ceilR.receiveShadow=true;
  group.add(ceilR); seg.ceilRM = ceilR;

  scene.add(group);
  segments.push(seg);

  // Spawn obstacles for this segment
  spawnObstaclesFor(seg, diff);
  return seg;
}

function spawnObstaclesFor(seg, diff){
  const zc = seg.z;
  const cx = seg.leftX; // canyon center offset
  const floorW = seg.floorW;
  const gapY = seg.gapY;

  // Determine how many obstacles
  let count = 0;
  const r = Math.random();
  if(r < 0.25 + diff*0.3) count = 1;
  else if(r < 0.55 + diff*0.2) count = 2;

  for(let i=0;i<count;i++){
    const typeRoll = Math.random();
    let obs;
    if(typeRoll < 0.45){
      // Floating monolith
      const w = rand(3,6), h = rand(4,10), d = rand(3,6);
      const ox = cx + rand(-(floorW*0.55), floorW*0.55);
      const oy = rand(-4, gapY*0.5);
      obs = makeMonolith(w,h,d, ox, oy);
    } else if(typeRoll < 0.75){
      // Tight squeeze — pillar pair narrowing gap
      const gap = rand(10, 16) * (1 - diff*0.3);
      obs = makeSqueeze(gap, cx, floorW, gapY);
    } else {
      // Orb cluster (reward) — guaranteed sometimes
      obs = makeOrb(cx, floorW, gapY);
      seg.orb = obs;
    }
    if(obs){ seg.obstacles.push(obs); }
  }
  // ensure at least one orb roughly every few segments
  if(!seg.orb && Math.random() < 0.5){
    const o = makeOrb(cx, floorW, gapY);
    if(o){ seg.orb = o; seg.obstacles.push(o); }
  }
}

function makeMonolith(w,h,d,ox,oy){
  const grp = new THREE.Group();
  const mat = new THREE.MeshStandardMaterial({ color:0x5a3a7a, flatShading:true, metalness:0.2, roughness:0.7, emissive:0x1a0a2e, emissiveIntensity:0.4 });
  const box = new THREE.Mesh(new THREE.BoxGeometry(w,h,d), mat);
  box.castShadow = true; grp.add(box);
  grp.position.set(ox, oy, 0);
  scene.add(grp);
  const half = new THREE.Vector3(w/2+0.6, h/2+0.6, d/2+0.6);
  return { grp, type:'monolith', half, pos: grp.position, mesh: box, w,h,d };
}

function makeSqueeze(gap, cx, floorW, gapY){
  // two monoliths from floor and ceiling, leaving a horizontal gap between them
  const grp = new THREE.Group();
  const mat = new THREE.MeshStandardMaterial({ color:0x6a3a8a, flatShading:true, metalness:0.2, roughness:0.7, emissive:0x1a0a2e, emissiveIntensity:0.4 });
  const side = cx + rand(-4,4);
  const gapCenterY = rand(2, gapY*0.6);
  const monW = rand(4,6), monH = 20, monD = rand(4,7);
  const belowH = gapCenterY - monH/2;
  const aboveH = (gapY - gapCenterY - monH/2);
  if(belowH > 3){
    const b = new THREE.Mesh(new THREE.BoxGeometry(monW,belowH,monD), mat);
    b.position.set(side, -12 + belowH/2, 0); b.castShadow=true; grp.add(b);
  }
  if(aboveH > 3){
    const a = new THREE.Mesh(new THREE.BoxGeometry(monW,aboveH,monD), mat);
    a.position.set(side, gapY + 6 - aboveH/2, 0); a.castShadow=true; grp.add(a);
  }
  grp.position.z = 0;
  scene.add(grp);
  const half = new THREE.Vector3(monW/2+0.8, monH/2+0.8, monD/2+0.8);
  return { grp, type:'squeeze', half, pos: grp.position, mesh: grp.children[0], w:monW, h:monH, d:monD };
}

function makeOrb(cx, floorW, gapY){
  const grp = new THREE.Group();
  const mat = new THREE.MeshStandardMaterial({ color:0x4df2ff, emissive:0x22d3ee, emissiveIntensity:1.6, flatShading:true, metalness:0.3, roughness:0.3 });
  const geo = new THREE.IcosahedronGeometry(1.1, 0);
  const m = new THREE.Mesh(geo, mat);
  grp.add(m);
  // glow sprite
  const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map:makeGlowTex(), color:0x66f7ff, transparent:true, blending:THREE.AdditiveBlending, depthWrite:false }));
  sp.scale.set(7,7,1); grp.add(sp);
  const oy = rand(-2, gapY*0.6);
  const ox = cx + rand(-(floorW*0.55), floorW*0.55);
  grp.position.set(ox, oy, 0);
  scene.add(grp);
  const orb = { grp, type:'orb', mesh:m, radius:1.6, pos:grp.position, spin:rand(1,2.5) };
  return orb;
}

function ensureSegments(){
  // generate ahead until we've covered player.z + margin
  const margin = 400;
  while(spawnZ < P.pos.z + margin){
    makeSegment(spawnZ, difficulty);
    spawnZ += segLen + segGap;
  }
  // remove behind
  const cullZ = P.pos.z - 160;
  for(let i=segments.length-1;i>=0;i--){
    const s = segments[i];
    if(s.z < cullZ){
      scene.remove(s.group);
      // dispose geometries/materials
      s.group.traverse(o=>{ if(o.geometry) o.geometry.dispose(); if(o.material) o.material.dispose(); });
      segments.splice(i,1);
    }
  }
}

// ============================================================
//  COLLISION & NEAR-MISS
// ============================================================
function updateCollision(){
  const pp = P.pos;
  const pr = P.radius;
  const nearSphere = new THREE.Sphere(pp.clone(), pr + 4.5); // near-miss threshold

  for(const seg of segments){
    for(const o of seg.obstacles){
      const op = o.pos;
      const dx = pp.x - op.x, dy = pp.y - op.y, dz = pp.z - op.z;
      const rHalf = o.radius || 0;
      // distance to box center (approx sphere vs box)
      const dist = Math.sqrt(dx*dx + dy*dy + dz*dz);

      if(o.type==='orb'){
        if(dist < pr + rHalf){
          collectOrb(o, seg);
        }
        continue;
      }

      // Near-miss: within nearSphere but not colliding
      if(!o.nearMissed && dist < (pr + rHalf + 3.2) && dist > (pr + rHalf)){
        // check not inside box (collision handled below)
        o.nearMissed = true;
        registerNearMiss(o, clamp(dist,0,6));
      }

      // Collision: player sphere center within box half extents + player radius (approx)
      const hx = o.half.x, hy = o.half.y, hz = o.half.z;
      const insideX = Math.abs(dx) < hx;
      const insideY = Math.abs(dy) < hy;
      const insideZ = Math.abs(dz) < hz;
      if(insideX && insideY && insideZ){
        // collision!
        gameOver();
        return;
      }
    }
  }
}

function registerNearMiss(obs, dist){
  obs.nearMissed = true;
  // closer => bigger multiplier boost
  const boost = clamp((6 - dist)/6, 0.05, 1);
  mult += 0.5 + boost*2;
  mult = Math.round(mult*10)/10;
  multTimer = 1.4;
  score += Math.floor(mult * 10 * boost);
  turbo = clamp(turbo + 0.12 + boost*0.15, 0, 1);
  shakeAmt = Math.max(shakeAmt, 0.5 + boost*1.2);
  if(audio) audio.whoosh(dist);
  pulseMulti();
}

function collectOrb(orb, seg){
  orb.collected = true;
  energy = clamp(energy + 0.32, 0, 1);
  score += 50 * Math.max(1, Math.floor(mult));
  turbo = clamp(turbo + 0.28, 0, 1);
  // pop effect
  if(audio) audio.ping();
  spawnBurst(orb.pos.clone(), 0x4df2ff);
  // fade & remove
  orb.grp.visible = false;
  const idx = seg.obstacles.indexOf(orb);
  if(idx>=0) seg.obstacles.splice(idx,1);
  if(seg.orb===orb) seg.orb=null;
}

// burst particles (simple)
let bursts = [];
function spawnBurst(pos, color){
  for(let i=0;i<10;i++){
    const m = new THREE.Mesh(new THREE.TetrahedronGeometry(0.3), new THREE.MeshBasicMaterial({color}));
    m.position.copy(pos);
    m.userData.v = new THREE.Vector3(rand(-1,1),rand(-1,1),rand(-1,1)).normalize().multiplyScalar(rand(8,20));
    m.userData.life = 0.7;
    scene.add(m); bursts.push(m);
  }
}

// ============================================================
//  GAME FLOW
// ============================================================
function startGame(){
  state = STATE.PLAY;
  // reset
  energy = 1.0; score = 0; mult = 1; multTimer = 0; turbo = 0; boosting=false;
  maxSpeed = 0; distance = 0; difficulty = 0;
  shakeAmt = 0;
  P.pos.set(0,0,0); P.vel.set(0,0,0); P.bank=0; P.pitch=0; P.yaw=0; P.speed=60; P.targetBank=0; P.targetPitch=0;
  pointerX=0; input.x=0;

  // clear segments & obstacles
  for(const s of segments){ scene.remove(s.group); s.group.traverse(o=>{ if(o.geometry)o.geometry.dispose(); if(o.material)o.material.dispose(); }); }
  segments.length=0; obstacles.length=0; orbs.length=0;
  bursts.forEach(b=>{ scene.remove(b.mesh); b.mesh.geometry.dispose(); b.mesh.material.dispose(); });
  bursts.length=0;

  spawnZ = 0;
  ensureSegments();

  $('startScreen').classList.add('hidden');
  $('overScreen').classList.add('hidden');
  $('pauseScreen').classList.add('hidden');
  $('pauseTag').style.display='none';
  if(audio) audio.start();
}

function gameOver(){
  if(state!==STATE.PLAY) return;
  state = STATE.OVER;
  if(audio) audio.crash();
  shakeAmt = 3.5;
  P.speed = 0;
  // update best
  if(score>best){ best=score; localStorage.setItem('canyonflow_best', best); }
  elFinalScore.textContent = score.toLocaleString();
  elFinalBest.textContent = best.toLocaleString();
  elMaxSpeed.textContent = Math.round(maxSpeed*3.6).toLocaleString();
  elDistance.textContent = Math.round(distance)+'m';
  $('best').textContent = 'BEST '+best.toLocaleString();
  setTimeout(()=>$('overScreen').classList.remove('hidden'), 700);
}

function togglePause(){
  if(state===STATE.PLAY){ state=STATE.PAUSE; $('pauseScreen').classList.remove('hidden'); $('pauseTag').style.display='block'; if(audio)audio.pauseSound(); }
  else if(state===STATE.PAUSE){ state=STATE.PLAY; $('pauseScreen').classList.add('hidden'); $('pauseTag').style.display='none'; if(audio)audio.resumeSound(); }
}

// ============================================================
//  UPDATE LOOP
// ============================================================
function update(dt){
  if(dt>0.05) dt=0.05;

  if(state===STATE.PLAY){
    updatePlayerInput(dt);
    updatePhysics(dt);
    updateEnergy(dt);
    updateDifficulty();
    updateMultiplier(dt);
    ensureSegments();
    updateCollision();
    updateParticles(dt);
    updateCamera(dt);
    updateHUD(dt);
  } else {
    // idle camera drift / decay shake
    shakeAmt *= 0.9;
  }
  // particle fields update even when paused-ish (for trail)
  renderLoop();
  requestAnimationFrame(update);
}

function updatePlayerInput(dt){
  // pointer-based steering (mouse) with keyboard fallback
  let steer = input.x;
  if(Math.abs(pointerX) > 0.02) steer = THREE.MathUtils.clamp(pointerX*1.6, -1, 1);
  // keyboard
  if(keys['ArrowLeft']||keys['a']) steer = -1;
  if(keys['ArrowRight']||keys['d']) steer = 1;

  P.targetBank = clamp(steer * 0.9, -0.9, 0.9);
  // vertical steering (up/down) via mouse Y or arrow up/down
  let vert = 0;
  if(keys['ArrowUp']||keys['w']) vert -= 1;
  if(keys['ArrowDown']) vert += 1;
  if(Math.abs(pointerY) > 0.02) vert = -pointerY*1.2;
  P.targetPitch = clamp(vert*0.5, -0.5, 0.5);

  boosting = input.boost || (keys['Space']||keys['w']) && turbo>=1;
  boosting = input.boost && turbo>=1;
}

function updatePhysics(dt){
  // banking smoothing (weighty)
  P.bank = lerp(P.bank, P.targetBank, 1 - Math.pow(0.001, dt));
  P.pitch = lerp(P.pitch, P.targetPitch, 1 - Math.pow(0.002, dt));

  // horizontal movement toward target
  const steerForce = P.targetBank * 55;
  P.vel.x += steerForce * dt;
  P.vel.x *= (1 - 2.2*dt); // damping (weight)
  P.vel.x = clamp(P.vel.x, -38, 38);
  P.pos.x += P.vel.x * dt;

  // vertical movement
  const vertForce = -P.targetPitch * 40;
  P.vel.y += vertForce * dt;
  P.vel.y *= (1 - 2.0*dt);
  P.pos.y += P.vel.y * dt;

  // forward speed control
  let targetSpeed = 62 + difficulty*22;
  if(boosting){ targetSpeed = 150 + difficulty*30; turbo = clamp(turbo - dt*0.9, 0, 1); }
  else { // recover turbo slowly when not boosting
    turbo = clamp(turbo + dt*0.05, 0, 1);
  }
  P.speed = lerp(P.speed, targetSpeed, 1 - Math.pow(0.02, dt));
  if(P.speed > maxSpeed) maxSpeed = P.speed;

  // move player forward
  P.pos.z += P.speed * dt;
  distance += P.speed * dt;

  // canyon bounds (walls/ceiling/floor) collision
  const seg = segAt(P.pos.z);
  let boundW = 60, boundY = 40, cx = 0;
  if(seg){ boundW = seg.floorW + 6; cx = seg.leftX; boundY = seg.gapY + 4; }
  // clamp player within canyon (this is a "wall hit" too, but we treat as soft clamp + damage-ish; keep it as bounds)
  const maxX = cx + boundW/2 - 2;
  const minX = cx - boundW/2 + 2;
  if(P.pos.x > maxX){ P.pos.x = maxX; P.vel.x *= -0.3; shakeAmt=Math.max(shakeAmt,1.0); }
  if(P.pos.x < minX){ P.pos.x = minX; P.vel.x *= -0.3; shakeAmt=Math.max(shakeAmt,1.0); }
  if(P.pos.y > boundY){ P.pos.y = boundY; P.vel.y *= -0.3; }
  if(P.pos.y < -11){ P.pos.y = -11; P.vel.y *= -0.3; }

  // player mesh transform
  player.position.copy(P.pos);
  player.rotation.z = -P.bank;       // roll
  player.rotation.x = P.pitch*0.6;   // slight pitch
  player.rotation.y = -P.vel.x*0.01; // yaw into turn
  // banking visual lean
  player.rotation.z = THREE.MathUtils.lerp(player.rotation.z, -P.bank, 1);

  // engine glow based on speed
  const glow = 0.5 + clamp(P.speed/150,0,1)*1.2 + (boosting?1.5:0);
  engineGlowL.material.color.setRGB(1, lerp(0.7,1,boosting), lerp(0.2,0.99,boosting));
  engineGlowL.scale.setScalar(glow);
  engineGlowR.scale.setScalar(glow);

  // camera FOV widens with speed & boost
  const targetFov = 78 + clamp((P.speed-60)/90,0,1)*22 + (boosting?18:0);
  camera.fov = lerp(camera.fov, targetFov, 1 - Math.pow(0.05, dt));
  camera.updateProjectionMatrix();
}

function updateEnergy(dt){
  energy -= dt * 0.032 * (1 + difficulty*0.15); // faster decay at higher difficulty
  energy = clamp(energy, 0, 1);
  if(energy <= 0){ gameOver(); }
}

function updateMultiplier(dt){
  multTimer -= dt;
  if(multTimer <= 0){
    mult -= 0.5*dt; // decay
    if(mult < 1) mult = 1;
  }
}

function updateDifficulty(){
  difficulty = clamp(score / 2500, 0, 1);
}

function updateCamera(dt){
  // chase camera behind & above player
  const back = boosting ? 20 : 15;
  const up = boosting ? 6 : 5;
  const target = new THREE.Vector3(P.pos.x*0.6, P.pos.y*0.5 + up, P.pos.z - back);
  camera.position.lerp(target, 1 - Math.pow(0.03, dt));
  camera.lookAt(P.pos.x*0.7, P.pos.y*0.6, P.pos.z - 30);

  // shake
  if(shakeAmt > 0.001){
    const s = shakeAmt * (boosting?1.4:1);
    camera.position.x += rand(-1,1)*s*0.4;
    camera.position.y += rand(-1,1)*s*0.4;
    shakeAmt *= 1 - 3*dt;
  }
}

function updateParticles(dt){
  // trail behind engine
  trailSys.points.visible = true;
  const ep = P.pos.clone();
  ep.z += 1.3;
  if(trailSys.sys.next % 1 === 0 || Math.random()<0.8){
    trailSys.sys.spawn(ep.clone(), rand(0.4,0.8));
  }
  // move trail points with player (they flow backward relative to world)
  trailSys.points.position.set(0,0,P.pos.z);
  trailSys.sys.update(dt);

  // speed streaks
  streakSys.points.visible = true;
  streakSys.points.position.set(0,0,P.pos.z);
  if(Math.random() < (boosting?0.9:0.35)){
    streakSys.sys.spawn(new THREE.Vector3(P.pos.x+rand(-30,30), P.pos.y+rand(-20,20), P.pos.z-40));
  }
  streakSys.points.position.copy(trailSys.points.position);
  streakSys.sys.update(dt);

  // dust
  dustSys.points.visible = true;
  dustSys.points.position.set(0,0,P.pos.z);
  if(Math.random() < (boosting?0.7:0.25)){
    dustSys.sys.spawn(new THREE.Vector3(P.pos.x+rand(-40,40), rand(-12,20), P.pos.z-45));
  }
  dustSys.points.position.copy(trailSys.points.position);
  dustSys.sys.update(dt);

  // bursts
  for(let i=bursts.length-1;i>=0;i--){
    const b=bursts[i];
    b.userData.life-=dt;
    b.mesh.position.addScaledVector(b.userData.v, dt);
    b.userData.v.y -= 15*dt;
    b.mesh.rotation.x+=dt*5; b.mesh.rotation.y+=dt*5;
    b.mesh.scale.setScalar(Math.max(0.01,b.userData.life*1.4));
    if(b.userData.life<=0){ scene.remove(b.mesh); b.mesh.geometry.dispose(); b.mesh.material.dispose(); bursts.splice(i,1); }
  }
}

function updateHUD(dt){
  elScore.firstChild.textContent = score.toLocaleString();
  elMulti.firstChild.textContent = 'x'+mult.toFixed(1);
  const kph = Math.round(P.speed*3.6);
  elSpeedVal.textContent = kph;
  elSpeedFill.style.width = clamp(P.speed/180*100,0,100)+'%';
  elEnergyFill.style.width = (energy*100).toFixed(0)+'%';
  elEnergyFill.classList.toggle('low', energy<0.25);
  elTurboFill.style.height = (turbo*100).toFixed(0)+'%';
  elTurboFill.classList.toggle('ready', turbo>=1);
  elTurboHint.classList.toggle('show', turbo>=1 && !boosting);
}

function pulseMulti(){
  elMulti.parentElement.classList.add('hot');
  setTimeout(()=>elMulti.parentElement.classList.remove('hot'), 120);
}

function segAt(z){
  // find nearest segment
  let best=null, bd=1e9;
  for(const s of segments){ const d = Math.abs(s.z-z); if(d<bd){ bd=d; best=s; } }
  return best;
}

// ============================================================
//  RENDER
// ============================================================
function renderLoop(){
  // animate sky/sun follow camera
  if(skyMesh) skyMesh.position.copy(camera.position).multiplyScalar(0.3);
  if(sunMesh) sunMesh.position.set(camera.position.x-120, 60, camera.position.z-520);
  if(sunGlow) sunGlow.position.copy(sunMesh.position);

  // animate orbs spin
  for(const s of segments){
    for(const o of s.obstacles){
      if(o.type==='orb'){ o.mesh.rotation.y += o.spin*0.016; o.mesh.rotation.x += 0.01; }
    }
  }

  composer.render();
}

// ============================================================
//  INPUT
// ============================================================
const keys = {};
function bindInput(){
  window.addEventListener('keydown', e=>{
    keys[e.key]=true;
    if(e.key===' '||e.key==='ArrowUp'||e.key==='ArrowDown') e.preventDefault();
    if(e.key==='p'||e.key==='P') togglePause();
    if((state===STATE.MENU||state===STATE.OVER) && (e.key===' '||e.key==='Enter')) startGame();
  });
  window.addEventListener('keyup', e=>{ keys[e.key]=false; });

  window.addEventListener('pointermove', e=>{
    pointerX = (e.clientX/window.innerWidth)*2 - 1;
    pointerY = (e.clientY/window.innerHeight)*2 - 1;
  });
  renderer.domElement.addEventListener('pointerdown', e=>{
    input.boost = true;
    pointerX = (e.clientX/window.innerWidth)*2 - 1;
  });
  window.addEventListener('pointerup', e=>{ input.boost=false; });

  // touch: left half steer left, right half steer right, tap = boost
  window.addEventListener('touchstart', e=>{
    input.boost = true;
    const t = e.touches[0]; if(t){ pointerX = (t.clientX/window.innerWidth)*2-1; pointerY=(t.clientY/window.innerHeight)*2-1; }
  }, {passive:true});
  window.addEventListener('touchmove', e=>{
    const t=e.touches[0]; if(t){ pointerX=(t.clientX/window.innerWidth)*2-1; pointerY=(t.clientY/window.innerHeight)*2-1; }
  }, {passive:true});
  window.addEventListener('touchend', ()=>{ input.boost=false; pointerX=0; pointerY=0; });

  $('startBtn').addEventListener('click', startGame);
  $('restartBtn').addEventListener('click', startGame);
  $('resumeBtn').addEventListener('click', togglePause);
}

function onResize(){
  camera.aspect = window.innerWidth/window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
  composer.setSize(window.innerWidth, window.innerHeight);
  fxaaPass.material.uniforms['resolution'].value.set(1/window.innerWidth, 1/window.innerHeight);
}

// ============================================================
//  AUDIO (Web Audio API synthesis)
// ============================================================
class AudioEngine{
  constructor(){
    this.ctx=null; this.engine=null; this.engineGain=null; this.started=false;
    this.noiseSrc=null; this.noiseGain=null; this.filter=null;
  }
  ensure(){
    if(this.ctx) return;
    const Ctx = window.AudioContext || window.webkitAudioContext;
    this.ctx = new Ctx();
    this.master = this.ctx.createGain(); this.master.gain.value=0.9;
    this.master.connect(this.ctx.destination);
  }
  start(){
    this.ensure();
    if(this.started) return; this.started=true;
    // engine hum: two detuned saws + sub
    const g = this.ctx.createGain(); g.gain.value=0.0; g.connect(this.master); this.engineGain=g;
    this.engine=this.ctx.createOscillator(); this.engine.type='sawtooth'; this.engine.frequency.value=62;
    const e2=this.ctx.createOscillator(); e2.type='triangle'; e2.frequency.value=93;
    const filt=this.ctx.createBiquadFilter(); filt.type='lowpass'; filt.frequency.value=600; filt.Q.value=2;
    this.engine.connect(filt); e2.connect(filt); filt.connect(g);
    this.engine.start(); e2.start();
    this._e2=e2; this._filt=filt;
    g.gain.linearRampToValueAtTime(0.18, this.ctx.currentTime+0.5);
    // filter follows speed
    this._engineUpdate();
    // background noise whoosh bed
    const ng=this.ctx.createGain(); ng.gain.value=0.05; ng.connect(this.master); this.noiseGain=ng;
    this.noiseSrc=this.createNoise();
    const nf=this.ctx.createBiquadFilter(); nf.type='bandpass'; nf.frequency.value=900; nf.Q.value=0.7;
    this.noiseSrc.connect(nf); nf.connect(ng); this.noiseSrc.start();
    this._nf=nf;
  }
  pauseSound(){ if(this.ctx && this.ctx.state==='running'){ this.ctx.suspend(); } }
  resumeSound(){ if(this.ctx && this.ctx.state==='suspended'){ this.ctx.resume(); } }
  _engineUpdate(){
    if(!this.started) return;
    const base = 62 + P.speed*0.35;
    if(this.engine) this.engine.frequency.setTargetAtTime(base, this.ctx.currentTime, 0.08);
    if(this._e2) this._e2.frequency.setTargetAtTime(base*1.5, this.ctx.currentTime, 0.08);
    if(this._filt) this._filt.frequency.setTargetAtTime(500 + P.speed*6 + (input.boost?800:0), this.ctx.currentTime, 0.1);
    if(this._nf) this._nf.frequency.setTargetAtTime(700 + P.speed*3, this.ctx.currentTime, 0.15);
    requestAnimationFrame(()=>this._engineUpdate());
  }
  createNoise(){
    const bufSize = this.ctx.sampleRate*2;
    const buf = this.ctx.createBuffer(1, bufSize, this.ctx.sampleRate);
    const d = buf.getChannelData(0);
    for(let i=0;i<bufSize;i++) d[i]=Math.random()*2-1;
    const src=this.ctx.createBufferSource(); src.buffer=buf; src.loop=true; return src;
  }
  ping(){
    if(!this.ctx) return;
    const t=this.ctx.currentTime;
    const o=this.ctx.createOscillator(); o.type='sine'; o.frequency.value=880;
    const g=this.ctx.createGain(); g.gain.setValueAtTime(0.0001,t);
    o.connect(g); g.connect(this.master);
    o.frequency.exponentialRampToValueAtTime(1760,t+0.08);
    g.gain.exponentialRampToValueAtTime(0.25,t+0.01);
    g.gain.exponentialRampToValueAtTime(0.0001,t+0.4);
    o.start(t); o.stop(t+0.4);
    // second harmonic
    const o2=this.ctx.createOscillator(); o2.type='sine'; o2.frequency.value=1320;
    const g2=this.ctx.createGain(); g2.gain.setValueAtTime(0.0001,t);
    o2.connect(g2); g2.connect(this.master);
    g2.gain.exponentialRampToValueAtTime(0.15,t+0.02);
    g2.gain.exponentialRampToValueAtTime(0.0001,t+0.3);
    o2.start(t); o2.stop(t+0.3);
  }
  whoosh(dist){
    if(!this.ctx) return;
    const t=this.ctx.currentTime;
    const amt = clamp((6-dist)/6,0.3,1);
    const src=this.createNoise();
    const g=this.ctx.createGain(); g.gain.setValueAtTime(0.0001,t);
    const f=this.ctx.createBiquadFilter(); f.type='bandpass'; f.frequency.value=1800; f.Q.value=1.2;
    src.connect(f); f.connect(g); g.connect(this.master);
    g.gain.exponentialRampToValueAtTime(0.2*amt,t+0.03);
    g.gain.exponentialRampToValueAtTime(0.0001,t+0.35);
    f.frequency.exponentialRampToValueAtTime(600,t+0.35);
    src.start(t); src.stop(t+0.4);
  }
  boostStart(){
    if(!this.ctx) return;
    const t=this.ctx.currentTime;
    const o=this.ctx.createOscillator(); o.type='sawtooth'; o.frequency.value=120;
    const g=this.ctx.createGain(); g.gain.setValueAtTime(0.0001,t);
    o.connect(g); g.connect(this.master);
    o.frequency.exponentialRampToValueAtTime(400,t+0.3);
    g.gain.exponentialRampToValueAtTime(0.2,t+0.05);
    g.gain.exponentialRampToValueAtTime(0.0001,t+0.6);
    o.start(t); o.stop(t+0.6);
  }
  crash(){
    if(!this.ctx) return;
    const t=this.ctx.currentTime;
    // noise burst
    const src=this.createNoise();
    const g=this.ctx.createGain(); g.gain.setValueAtTime(0.4,t);
    const f=this.ctx.createBiquadFilter(); f.type='lowpass'; f.frequency.setValueAtTime(2000,t); f.frequency.exponentialRampToValueAtTime(120,t+0.8);
    src.connect(f); f.connect(g); g.connect(this.master);
    g.gain.exponentialRampToValueAtTime(0.0001,t+1.2);
    src.start(t); src.stop(t+1.2);
    // low boom
    const o=this.ctx.createOscillator(); o.type='sine'; o.frequency.setValueAtTime(90,t); o.frequency.exponentialRampToValueAtTime(30,t+0.9);
    const og=this.ctx.createGain(); og.gain.setValueAtTime(0.5,t); og.gain.exponentialRampToValueAtTime(0.0001,t+1.0);
    o.connect(og); og.connect(this.master); o.start(t); o.stop(t+1.0);
    // cut engine
    if(this.engineGain) this.engineGain.gain.setTargetAtTime(0.0001, t, 0.1);
  }
}

// ============================================================
//  BOOT
// ============================================================
init();
audio = new AudioEngine();
requestAnimationFrame(update);
</script>
</body>
</html>
<!-- agent-meta {"model":"bartowski/Ornith-1.5-35B-A3B","provider":"brigid","persona":"full","sessionId":"6b8907f2-63f1-42c5-a015-ee98ab9d3c04","tokensIn":4767866,"tokensOut":72554,"tokensTotal":4840420,"cost":0,"turns":66,"toolCalls":65,"failedToolCalls":12,"timestamp":"2026-08-23T20:27:19.608Z"} -->