Canyon Run
Claude haiku 5.5 · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Task text captured before this run. Effective runtime system instructions were not captured.
Generation prompt · 175415d5e1
Role: You are an expert Three.js and WebGL game developer specializing in high-performance, minimalist arcade racers. Objective: Create an HTML5 game (HTML/CSS/JS) that is a "High-Speed Procedural Canyon Racer." The gameplay must mimic the "flow" and "tension" of Race the Sun. 1. Core Gameplay Mechanics (The "Feel") Movement Physics: Implement a "flight-based" physics model rather than direct axis control. The player (a sleek, low-poly geometric craft) should have momentum, banking (rolling) during turns, and pitch-based acceleration. Movement should feel "weighty" but highly responsive. The Risk/Reward System (Proximity Scoring): Implement a "Near-Miss" mechanic. The closer the player passes to canyon walls or obstacles without colliding, the higher the score multiplier becomes. Energy/Fuel Mechanic: The player has a constant "energy decay." Collecting glowing geometric orbs (recharges) is essential to stay alive. This forces the player to take risky paths through narrow gaps to stay fueled. Speed & Acceleration: Implement a "Boost" system. Collecting items or maintaining high-speed straightaways builds a "Turbo" meter that allows for temporary extreme velocity and visual FOV widening. 2. Procedural Environment Generation Infinite Generation: Use a chunk-based procedural generation system. As the player moves forward, generate new canyon segments and destroy old ones to ensure the game runs infinitely without memory leaks. Terrain Variety: Generate three types of obstacles: Canyon Walls: Massive, sweeping geological formations. Floating Monoliths: Large, geometric pillars that require banking to avoid. Tight Squeezes: Narrow gaps that require precise pitch/roll control. Difficulty Scaling: As the score increases, increase the speed of the player and the density/tightness of the procedural obstacles. 3. Visual Direction (Aesthetic & Atmosphere) Art Style: "Minimalist Low-Poly." Use a highly stylized, geometric aesthetic. No textures; use flat shading or simple vertex colors. Lighting & Color Palette: Use a "Golden Hour" or "Cyber-Sunset" palette (deep oranges, purples, and silvers). Implement dramatic, long shadows cast by the canyon walls. Post-Processing Effects (via Three.js): Bloom: To make the energy orbs and the craft’s engine glow. Motion Blur: To simulate extreme speed. Camera Shake: Subtle shake when passing very close to walls or during high-speed boosts. Particle Systems: Add a "trail" effect behind the player craft and "dust" or "debris" particles that fly past the player to emphasize speed. 4. HUD & User Interface Minimalist HUD: The HUD should be non-intrusive. Speedometer: A sleek, thin line representing current velocity. Multiplier Counter: A large, stylized number that pulses when a "Near-Miss" is detected. Energy Bar: A glowing bar (possibly circular around the player or at the bottom) that depletes and refills. Game Over Screen: A clean, high-contrast overlay showing "Final Score," "Max Speed," and a "Restart" button. 5. Technical Requirements Frameworks: Use Three.js for 3D rendering (include Three.js via CDN). Performance: Use requestAnimationFrame. Optimize geometry to ensure a smooth 60fps on most hardware. Collision Detection: Implement efficient bounding-box or sphere-based collision detection between the player and the procedural geometry. Sound Synthesis: Use the Web Audio API to generate procedural sound effects (a low hum for the engine, a "ping" for collectibles, and a synthesized "whoosh" for near-misses).
Inspect original source 41,171 bytes · SHA-256 df86f2c826e9
<!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>Ember Canyon - Procedural Canyon Racer</title>
<style>
html,body{margin:0;height:100%;overflow:hidden;background:#120a1c;font-family:"Segoe UI",Helvetica,Arial,sans-serif;color:#f3e6d8;user-select:none;-webkit-user-select:none;touch-action:none}
canvas#c{position:fixed;inset:0;width:100%;height:100%;display:block}
.hud{position:fixed;pointer-events:none}
#score{top:22px;left:28px;font-size:11px;letter-spacing:4px;color:#e9d5c0;opacity:.85}
#scoreVal{display:block;font-size:30px;font-weight:300;letter-spacing:3px;color:#fff3e0;margin-top:4px;font-variant-numeric:tabular-nums}
#speedWrap{top:26px;right:28px;width:200px;text-align:right;font-size:11px;letter-spacing:3px;color:#e9d5c0;opacity:.85}
#speedTrack{margin:8px 0 6px;height:2px;background:rgba(255,255,255,.12);position:relative}
#speedBar{position:absolute;left:0;top:0;bottom:0;width:0;background:linear-gradient(90deg,#ffd08a,#ff5e8a);box-shadow:0 0 10px #ff8a4c}
#speedNum{font-size:12px;font-variant-numeric:tabular-nums;letter-spacing:2px}
#mult{top:16%;left:50%;transform:translateX(-50%);font-size:64px;font-weight:200;color:#ffe2b8;text-shadow:0 0 18px rgba(255,150,80,.55);white-space:nowrap;opacity:.92;text-align:center}
#mult small{display:block;font-size:11px;letter-spacing:5px;color:#c8a8d8;margin-top:-6px}
#mult.pulse{animation:multPulse .38s ease-out}
@keyframes multPulse{0%{transform:translateX(-50%) scale(1.25);color:#ffffff}100%{transform:translateX(-50%) scale(1);color:#ffe2b8}}
#nm{top:25%;left:50%;transform:translateX(-50%);font-size:13px;letter-spacing:5px;color:#fff;opacity:0;white-space:nowrap;text-shadow:0 0 12px #ff9d5c}
#nm.show{animation:nmAnim .9s ease-out}
@keyframes nmAnim{0%{opacity:0;transform:translate(-50%,8px)}15%{opacity:1;transform:translate(-50%,0)}100%{opacity:0;transform:translate(-50%,-16px)}}
#energyWrap{bottom:34px;left:50%;transform:translateX(-50%);width:360px}
#energyLabel{font-size:10px;letter-spacing:4px;color:#e9d5c0;opacity:.8;margin-bottom:8px;display:flex;justify-content:space-between}
#energyTrack{height:6px;background:rgba(255,255,255,.1);border-radius:3px;overflow:hidden;box-shadow:0 0 0 1px rgba(255,220,180,.08)}
#energyBar{height:100%;width:100%;background:linear-gradient(90deg,#ff4f6d,#ffb45c 60%,#ffe39a);box-shadow:0 0 14px rgba(255,170,90,.8)}
#energyBar.low{animation:lowFlash .5s infinite alternate;background:linear-gradient(90deg,#ff2e4d,#ff6a6a)}
@keyframes lowFlash{from{opacity:1}to{opacity:.35}}
#turboTrack{margin-top:7px;height:2px;background:rgba(255,255,255,.1)}
#turboBar{height:100%;width:0;background:linear-gradient(90deg,#8e5bff,#d19bff);box-shadow:0 0 10px #b07bff}
#turboBar.ready{box-shadow:0 0 14px #e0b0ff}
.overlay{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:radial-gradient(ellipse at center,rgba(20,6,30,.45),rgba(6,2,12,.94));z-index:10}
.overlay.hidden{display:none}
.card{max-width:580px;padding:34px 46px;text-align:center}
h1{margin:0;font-weight:200;font-size:44px;letter-spacing:12px;color:#fff3e0;text-shadow:0 0 24px rgba(255,150,80,.5)}
h1 span{display:block;font-size:12px;letter-spacing:6px;color:#f0a878;margin-top:12px}
.sub{font-size:13px;line-height:1.9;color:#d9c6dc;margin:26px 0 0;letter-spacing:1px}
.sub b{color:#ffd9a8;font-weight:500}
.stats{display:grid;grid-template-columns:1fr 1fr;gap:18px 24px;margin:26px 0 4px}
.stat{border-top:1px solid rgba(255,200,150,.25);padding-top:10px}
.stat label{display:block;font-size:10px;letter-spacing:4px;color:#c9a3c8}
.stat div{font-size:26px;font-weight:300;color:#fff;font-variant-numeric:tabular-nums}
.stat.big{grid-column:1/3}
.stat.big div{font-size:46px;color:#ffe2b8}
.reason{font-size:11px;letter-spacing:3px;color:#ff8a8a;margin-top:8px;min-height:14px}
button{margin-top:24px;background:transparent;border:1px solid #ffd9a8;color:#ffe9cf;padding:13px 38px;font-size:13px;letter-spacing:5px;cursor:pointer;box-shadow:0 0 18px rgba(255,160,90,.3);font-family:inherit;transition:all .2s}
button:hover{background:#ffd9a8;color:#1a0a22;box-shadow:0 0 28px rgba(255,200,130,.7)}
.hint{font-size:10px;letter-spacing:3px;color:#9a7fa0;margin-top:16px}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div class="hud" id="score"><span>SCORE</span><span id="scoreVal">000000</span></div>
<div class="hud" id="speedWrap"><div>VELOCITY</div><div id="speedTrack"><div id="speedBar"></div></div><div id="speedNum">0 KPH</div></div>
<div class="hud" id="mult"><span id="multVal">1.0×</span><small>MULTIPLIER</small></div>
<div class="hud" id="nm">NEAR MISS</div>
<div class="hud" id="energyWrap">
<div id="energyLabel"><span>ENERGY</span><span>TURBO</span></div>
<div id="energyTrack"><div id="energyBar"></div></div>
<div id="turboTrack"><div id="turboBar"></div></div>
</div>
<div class="overlay" id="start">
<div class="card">
<h1>EMBER CANYON<span>PROCEDURAL CANYON RACER</span></h1>
<p class="sub">
<b>A / D</b> bank and strafe · <b>W / S</b> pitch up to climb, dive to accelerate<br>
<b>SPACE / SHIFT</b> turbo · graze walls and gates for <b>near-miss</b> multipliers<br>
Collect the gold orbs to refill <b>energy</b> — it drains constantly.
</p>
<button id="startBtn">LAUNCH</button>
<div class="hint">ENTER / SPACE TO START · TOUCH: HOLD TO BOOST, DRAG TO STEER</div>
</div>
</div>
<div class="overlay hidden" id="over">
<div class="card">
<h1>CANYON LOST<span>SIGNAL TERMINATED</span></h1>
<div class="stats">
<div class="stat big"><label>FINAL SCORE</label><div id="fScore">0</div></div>
<div class="stat"><label>MAX SPEED</label><div id="fSpeed">0 KPH</div></div>
<div class="stat"><label>MAX MULTIPLIER</label><div id="fMult">1.0×</div></div>
<div class="stat"><label>NEAR MISSES</label><div id="fNear">0</div></div>
<div class="stat"><label>DISTANCE</label><div id="fDist">0 KM</div></div>
</div>
<div class="reason" id="fReason"></div>
<button id="restartBtn">RESTART</button>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/build/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/AfterimageShader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/Pass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/MaskPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/AfterimagePass.js"></script>
<script>
'use strict';
if (typeof THREE === 'undefined') {
document.body.innerHTML = '<div style="padding:40px;font-family:sans-serif;color:#fff">Three.js failed to load from CDN.</div>';
throw new Error('Three.js missing');
}
// ---------- constants & helpers ----------
const CL = 60; // chunk length
const AHEAD = 340, BEHIND = 70;
const PR = 0.6; // craft collision radius
const FLOOR = -6, CEIL = 16, CEIL_PLAYER = 9;
const NEAR = 1.1; // near-miss clearance
const MAXM = 12;
const clamp = (v, a, b) => (v < a ? a : (v > b ? b : v));
const rnd = (a, b) => a + Math.random() * (b - a);
// ---------- state ----------
const S = {
alive: false, started: false, score: 0, energy: 100, turbo: 20, mult: 1, multTimer: 0, nmCool: 0, wallCool: 0,
maxV: 0, maxMult: 1, nears: 0, dist: 0, shake: 0, boosting: false, t: 0, overTimer: 0, reason: ''
};
const P = { x: 0, y: 1, z: 0, vx: 0, vy: 0, v: 52, roll: 0, pitch: 0 };
const keys = {};
const touch = { on: false, boost: false, x: 0, y: 0 };
const difficulty = () => clamp(S.score / 7000, 0, 1);
// ---------- renderer / scene ----------
const canvas = document.getElementById('c');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0xc0643f, 60, 260);
const camera = new THREE.PerspectiveCamera(66, innerWidth / innerHeight, 0.1, 1000);
const camSmooth = new THREE.Vector3(0, 3, 8);
// gradient sky dome (vertex colored, no textures)
(function () {
const g = new THREE.SphereGeometry(600, 24, 14);
const pos = g.attributes.position;
const cols = [];
const top = new THREE.Color(0x1c0a33), mid = new THREE.Color(0x6d2f56);
const hor = new THREE.Color(0xf08a4b), low = new THREE.Color(0x140a1f);
const c = new THREE.Color();
for (let i = 0; i < pos.count; i++) {
const t = pos.getY(i) / 600;
if (t >= 0) {
if (t < 0.22) c.copy(hor).lerp(mid, t / 0.22);
else c.copy(mid).lerp(top, Math.min(1, (t - 0.22) / 0.78));
} else {
c.copy(hor).lerp(low, Math.min(1, -t * 3));
}
cols.push(c.r, c.g, c.b);
}
g.setAttribute('color', new THREE.Float32BufferAttribute(cols, 3));
const m = new THREE.Mesh(g, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.BackSide, fog: false, depthWrite: false }));
m.renderOrder = -10;
scene.add(m);
})();
function makeHaloTexture() {
const cv = document.createElement('canvas');
cv.width = cv.height = 128;
const x = cv.getContext('2d');
const g = x.createRadialGradient(64, 64, 0, 64, 64, 64);
g.addColorStop(0, 'rgba(255,255,255,1)');
g.addColorStop(0.2, 'rgba(255,255,255,.5)');
g.addColorStop(1, 'rgba(255,255,255,0)');
x.fillStyle = g;
x.fillRect(0, 0, 128, 128);
return new THREE.CanvasTexture(cv);
}
const haloTex = makeHaloTexture();
// sun disc + halo (placed far ahead along a fixed direction)
const SUN_DIR = new THREE.Vector3(0.25, 0.3, -0.92).normalize();
const sunMesh = new THREE.Mesh(new THREE.IcosahedronGeometry(26, 1), new THREE.MeshBasicMaterial({ color: 0xffd9a0, fog: false }));
scene.add(sunMesh);
const sunHalo = new THREE.Sprite(new THREE.SpriteMaterial({ map: haloTex, color: 0xff9a4a, blending: THREE.AdditiveBlending, transparent: true, depthWrite: false, fog: false }));
sunHalo.scale.set(340, 340, 1);
scene.add(sunHalo);
// lights: warm low sun with long shadows, purple hemisphere fill
scene.add(new THREE.HemisphereLight(0xffb37a, 0x2a1438, 0.85));
const sunLight = new THREE.DirectionalLight(0xffa35c, 1.9);
sunLight.castShadow = true;
sunLight.shadow.mapSize.set(2048, 2048);
{
const sc = sunLight.shadow.camera;
sc.left = -45; sc.right = 45; sc.top = 45; sc.bottom = -45; sc.near = 1; sc.far = 260;
sc.updateProjectionMatrix();
sunLight.shadow.bias = -0.0008;
sunLight.shadow.normalBias = 0.03;
}
scene.add(sunLight);
scene.add(sunLight.target);
// floor (receives long canyon shadows); follows player along z
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(900, 900),
new THREE.MeshStandardMaterial({ color: 0x3a2347, roughness: 1, metalness: 0, flatShading: true })
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = FLOOR;
floor.receiveShadow = true;
scene.add(floor);
// shared materials & geometries (only wall geometry is per-chunk)
const M = {
gate: new THREE.MeshStandardMaterial({ color: 0xa04a3c, roughness: 0.8, flatShading: true, emissive: 0x2a0a06 }),
orb: new THREE.MeshStandardMaterial({ color: 0xffe08a, emissive: 0xffa53a, emissiveIntensity: 2.6, roughness: 0.2, flatShading: true }),
craftBody: new THREE.MeshStandardMaterial({ color: 0xe6e1f2, roughness: 0.3, metalness: 0.7, flatShading: true }),
craftDark: new THREE.MeshStandardMaterial({ color: 0x2e2140, roughness: 0.5, metalness: 0.4, flatShading: true }),
canopy: new THREE.MeshStandardMaterial({ color: 0x6fe3ff, emissive: 0x1a6a88, roughness: 0.2, flatShading: true }),
engine: new THREE.MeshBasicMaterial({ color: 0xffc27a }),
monolith: new THREE.MeshStandardMaterial({ color: 0xb9b0cf, roughness: 0.4, metalness: 0.35, flatShading: true })
};
const boxGeo = new THREE.BoxGeometry(1, 1, 1);
const orbGeo = new THREE.IcosahedronGeometry(0.42, 0);
const engineGeo = new THREE.SphereGeometry(0.22, 8, 6);
const orbHaloMat = new THREE.SpriteMaterial({ map: haloTex, color: 0xffa040, blending: THREE.AdditiveBlending, transparent: true, depthWrite: false });
const wallMat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.92, metalness: 0, flatShading: true, side: THREE.DoubleSide });
// ---------- player craft (low-poly) ----------
const craft = new THREE.Group();
craft.rotation.order = 'YXZ';
scene.add(craft);
{
const body = new THREE.Mesh(new THREE.ConeGeometry(0.36, 2.2, 4), M.craftBody);
body.rotation.x = -Math.PI / 2; // tip points -Z (forward)
body.castShadow = true;
craft.add(body);
const wing = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.06, 0.8), M.craftDark);
wing.position.set(0, 0, 0.3);
wing.castShadow = true;
craft.add(wing);
const fin = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.7, 0.7), M.craftBody);
fin.position.set(0, 0.36, 0.55);
craft.add(fin);
const canopy = new THREE.Mesh(new THREE.OctahedronGeometry(0.28, 0), M.canopy);
canopy.position.set(0, 0.24, -0.3);
canopy.scale.set(1, 0.7, 1.6);
craft.add(canopy);
const eng = new THREE.Mesh(engineGeo, M.engine);
eng.position.set(0, 0, 1.15);
craft.add(eng);
}
const engineHalo = new THREE.Sprite(new THREE.SpriteMaterial({ map: haloTex, color: 0xff9040, blending: THREE.AdditiveBlending, transparent: true, depthWrite: false }));
engineHalo.position.set(0, 0, 1.3);
engineHalo.scale.set(1.6, 1.6, 1);
craft.add(engineHalo);
// ---------- particle systems ----------
// engine trail: ring buffer of recent nozzle positions, fading by index
const TR = 48;
const trPos = new Float32Array(TR * 3);
const trCol = new Float32Array(TR * 3);
for (let i = 0; i < TR; i++) {
const f = Math.pow(1 - i / TR, 1.5);
trCol[i * 3] = f; trCol[i * 3 + 1] = f * 0.5; trCol[i * 3 + 2] = f * 0.22;
}
const trGeo = new THREE.BufferGeometry();
trGeo.setAttribute('position', new THREE.BufferAttribute(trPos, 3));
trGeo.setAttribute('color', new THREE.BufferAttribute(trCol, 3));
const trail = new THREE.Points(trGeo, new THREE.PointsMaterial({ size: 0.55, vertexColors: true, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false }));
trail.frustumCulled = false;
scene.add(trail);
// dust / debris: fixed pool that streams past the player (wraps, no allocation)
const DU = 320;
const duPos = new Float32Array(DU * 3);
const duSpd = new Float32Array(DU);
for (let i = 0; i < DU; i++) {
duPos[i * 3] = rnd(-18, 18);
duPos[i * 3 + 1] = rnd(-5, 14);
duPos[i * 3 + 2] = rnd(-330, 20);
duSpd[i] = rnd(0.7, 1.3);
}
const duGeo = new THREE.BufferGeometry();
duGeo.setAttribute('position', new THREE.BufferAttribute(duPos, 3));
const dustMat = new THREE.PointsMaterial({ size: 0.24, color: 0xffd7a8, transparent: true, opacity: 0.6, blending: THREE.AdditiveBlending, depthWrite: false });
const dust = new THREE.Points(duGeo, dustMat);
dust.frustumCulled = false;
scene.add(dust);
// crash sparks (fixed pool)
const SP_N = 90;
const spPos = new Float32Array(SP_N * 3);
const spVel = new Float32Array(SP_N * 3);
const spGeo = new THREE.BufferGeometry();
spGeo.setAttribute('position', new THREE.BufferAttribute(spPos, 3));
const spMat = new THREE.PointsMaterial({ color: 0xffb050, size: 0.4, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false });
const sparks = new THREE.Points(spGeo, spMat);
sparks.frustumCulled = false;
scene.add(sparks);
let sparkLife = 0;
function burst(x, y, z) {
for (let i = 0; i < SP_N; i++) {
spPos[i * 3] = x; spPos[i * 3 + 1] = y; spPos[i * 3 + 2] = z;
const a = Math.random() * Math.PI * 2, s = rnd(8, 36);
spVel[i * 3] = Math.cos(a) * s;
spVel[i * 3 + 1] = Math.sin(a) * s * 0.7 + rnd(0, 8);
spVel[i * 3 + 2] = rnd(-10, 10);
}
sparkLife = 1.6;
}
function updateSparks(dt) {
if (sparkLife <= 0) { spMat.opacity = 0; return; }
sparkLife -= dt;
const damp = Math.exp(-dt * 2.5);
for (let i = 0; i < SP_N * 3; i++) {
spVel[i] *= damp;
spPos[i] += spVel[i] * dt;
}
spMat.opacity = clamp(sparkLife, 0, 1);
spGeo.attributes.position.needsUpdate = true;
}
// ---------- post-processing: bloom + speed-driven motion blur ----------
const composer = new THREE.EffectComposer(renderer);
composer.addPass(new THREE.RenderPass(scene, camera));
const bloom = new THREE.UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), 0.9, 0.5, 0.55);
composer.addPass(bloom);
const afterimage = new THREE.AfterimagePass(0.0);
afterimage.renderToScreen = true;
composer.addPass(afterimage);
// ---------- procedural audio (Web Audio, no files) ----------
let AC = null, sfxBus = null, noiseBuf = null, eng = {};
function initAudio() {
if (AC) { if (AC.state === 'suspended') AC.resume(); return; }
AC = new (window.AudioContext || window.webkitAudioContext)();
sfxBus = AC.createGain();
sfxBus.gain.value = 0.5;
sfxBus.connect(AC.destination);
noiseBuf = AC.createBuffer(1, AC.sampleRate * 2, AC.sampleRate);
const nd = noiseBuf.getChannelData(0);
for (let i = 0; i < nd.length; i++) nd[i] = Math.random() * 2 - 1;
// engine hum: two oscillators through a lowpass
const o1 = AC.createOscillator(); o1.type = 'sawtooth'; o1.frequency.value = 48;
const o2 = AC.createOscillator(); o2.type = 'triangle'; o2.frequency.value = 72;
const lp = AC.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 300; lp.Q.value = 3;
const eg = AC.createGain(); eg.gain.value = 0;
o1.connect(lp); o2.connect(lp); lp.connect(eg); eg.connect(sfxBus);
o1.start(); o2.start();
// wind: looping band-passed noise tied to speed
const ns = AC.createBufferSource(); ns.buffer = noiseBuf; ns.loop = true;
const bp = AC.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 800; bp.Q.value = 0.7;
const wg = AC.createGain(); wg.gain.value = 0;
ns.connect(bp); bp.connect(wg); wg.connect(sfxBus);
ns.start();
eng = { o1, o2, lp, eg, wg };
}
function tone(fA, fB, dur, type, vol) {
if (!AC) return;
const t = AC.currentTime;
const o = AC.createOscillator(), g = AC.createGain();
o.type = type;
o.frequency.setValueAtTime(fA, t);
o.frequency.exponentialRampToValueAtTime(fB, t + dur);
g.gain.setValueAtTime(vol, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(g); g.connect(sfxBus);
o.start(t); o.stop(t + dur + 0.05);
}
function noiseFx(dur, f0, f1, vol, type) {
if (!AC) return;
const t = AC.currentTime;
const s = AC.createBufferSource(); s.buffer = noiseBuf;
const f = AC.createBiquadFilter(); f.type = type || 'bandpass'; f.Q.value = 1.2;
f.frequency.setValueAtTime(f0, t);
f.frequency.exponentialRampToValueAtTime(f1, t + dur);
const g = AC.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(vol, t + dur * 0.25);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
s.connect(f); f.connect(g); g.connect(sfxBus);
s.start(t, Math.random() * 1.0); s.stop(t + dur + 0.05);
}
const sfx = {
ping() { tone(1100, 2300, 0.22, 'sine', 0.22); tone(1650, 3400, 0.3, 'triangle', 0.07); },
whoosh(s) { noiseFx(0.5, 380 + s * 300, 2600, 0.3 * (0.6 + s * 0.4)); },
crash() { noiseFx(1.3, 1400, 70, 0.8, 'lowpass'); tone(170, 26, 1.0, 'sawtooth', 0.35); }
};
function updateAudio() {
if (!AC || !eng.o1) return;
const t = AC.currentTime, v = P.v;
const f = 44 + v * 0.55 + (S.boosting ? 20 : 0);
eng.o1.frequency.setTargetAtTime(f, t, 0.08);
eng.o2.frequency.setTargetAtTime(f * 1.5, t, 0.08);
eng.lp.frequency.setTargetAtTime(220 + v * 5, t, 0.1);
eng.eg.gain.setTargetAtTime(S.alive ? 0.09 + (S.boosting ? 0.05 : 0) : 0, t, 0.2);
eng.wg.gain.setTargetAtTime(S.alive ? clamp((v - 55) / 120, 0, 1) * 0.18 : 0, t, 0.2);
}
// ---------- world generation (chunked; analytic canyon so walls are seam-free) ----------
const chunks = [];
const chunkByK = new Map();
let nextK = 0;
let activeOrbs = [];
const KNOT = 6; // z spacing of wall profile knots (CL divisible by KNOT)
const NK = CL / KNOT + 1;
const PN = 5; // profile points per wall cross-section
function hash(x) { const s = Math.sin(x * 12.9898) * 43758.5453; return s - Math.floor(s); }
function canyonBase(z) { return 9.5 + 2.4 * Math.sin(z * 0.021) + 1.4 * Math.sin(z * 0.0573 + 1.7); }
function wallAt(z, c) {
const t = clamp((c.zS - z) / CL, 0, 1);
return canyonBase(z) - c.pinch * Math.sin(Math.PI * t);
}
function canyonW(z) {
const c = chunkByK.get(Math.floor(-z / CL));
return c ? wallAt(z, c) : canyonBase(z);
}
const WALL_BASE = [new THREE.Color(0x2b1538), new THREE.Color(0x7e3a58), new THREE.Color(0xe0804a), new THREE.Color(0x3b1f4f), new THREE.Color(0x2a1538)];
function buildWallGeo(c) {
const count = 2 * NK * PN;
const pos = new Float32Array(count * 3), col = new Float32Array(count * 3);
const idx = [];
for (let si = 0; si < 2; si++) {
const s = si === 0 ? -1 : 1;
for (let n = 0; n < NK; n++) {
const z = c.zS - n * KNOT;
const W = wallAt(z, c);
const offs = [1.1 + 0.5 * Math.sin(z * 0.37 + si), 0, 0.9 + 0.5 * Math.sin(z * 0.53 + si * 2), 38, 38];
const ys = [FLOOR - 1.2, FLOOR + 8 + 2.2 * Math.sin(z * 0.13 + si), CEIL + 0.5 * Math.sin(z * 0.2), CEIL + 0.5, FLOOR - 1.2];
for (let p = 0; p < PN; p++) {
const i = (si * NK + n) * PN + p;
pos[i * 3] = s * (W + offs[p]);
pos[i * 3 + 1] = ys[p];
pos[i * 3 + 2] = z;
const j = 0.85 + 0.3 * hash(n * 7 + p * 13 + si * 31 + c.k * 17);
col[i * 3] = WALL_BASE[p].r * j;
col[i * 3 + 1] = WALL_BASE[p].g * j;
col[i * 3 + 2] = WALL_BASE[p].b * j;
}
}
for (let n = 0; n < NK - 1; n++) for (let p = 0; p < PN - 1; p++) {
const a = (si * NK + n) * PN + p, b = a + 1;
const d = (si * NK + n + 1) * PN + p, e = d + 1;
idx.push(a, b, d, b, e, d);
}
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
g.setAttribute('color', new THREE.BufferAttribute(col, 3));
g.setIndex(idx);
g.computeVertexNormals();
return g;
}
function makeBoxMesh(b, mat) {
const m = new THREE.Mesh(boxGeo, mat);
m.scale.set(b.x1 - b.x0, b.y1 - b.y0, b.z1 - b.z0);
m.position.set((b.x0 + b.x1) / 2, (b.y0 + b.y1) / 2, (b.z0 + b.z1) / 2);
m.castShadow = true;
m.receiveShadow = true;
return m;
}
function insideAnyBox(x, y, z, c, pad) {
for (const b of c.boxes) {
if (x > b.x0 - pad && x < b.x1 + pad && y > b.y0 - pad && y < b.y1 + pad && z > b.z0 - pad && z < b.z1 + pad) return true;
}
return false;
}
function genChunk(k) {
const zS = -k * CL, zE = zS - CL;
const d = difficulty();
const c = { k, zS, zE, group: new THREE.Group(), boxes: [], orbs: [], pinch: 0, wall: null };
chunkByK.set(k, c);
// pattern selection; pinch = sculpted canyon narrowing (never below ~4.5 half-width)
let pattern = 'open';
if (k >= 2) {
const r = Math.random();
pattern = r < 0.22 ? 'pinch' : (r < 0.5 ? 'mono' : (r < 0.75 ? 'gate' : 'mix'));
if (pattern === 'pinch') {
let minBase = Infinity;
for (let i = 0; i <= 6; i++) minBase = Math.min(minBase, canyonBase(zS - CL * i / 6));
c.pinch = clamp(rnd(2.2, 3.0) + d * 0.6, 0, Math.max(0, minBase - 4.5));
}
}
c.wall = new THREE.Mesh(buildWallGeo(c), wallMat);
c.wall.castShadow = true;
c.wall.receiveShadow = true;
c.group.add(c.wall);
let n = 0;
if (k >= 2) n = 1 + (Math.random() < 0.35 + d * 0.5 ? 1 : 0) + (d > 0.5 && Math.random() < 0.5 ? 1 : 0);
const gates = [];
for (let i = 0; i < n; i++) {
const zc = zS - CL * (i + 1) / (n + 1) + rnd(-3, 3);
const W = wallAt(zc, c);
let wantGate;
if (pattern === 'gate') wantGate = Math.random() < 0.8;
else if (pattern === 'pinch') wantGate = Math.random() < 0.7;
else if (pattern === 'mix') wantGate = Math.random() < 0.5;
else wantGate = Math.random() < 0.25 + d * 0.3;
if (wantGate) {
// tight squeeze: frame spanning the canyon with a rectangular hole
const gw = Math.max(2.5, 3.5 - d * 1.0);
const gr = Math.max(0, W - gw / 2 - 1.5);
const gx = rnd(-gr, gr);
const gy = rnd(-2.4, 2.8);
const gd = 1.6;
const z0 = zc - gd / 2, z1 = zc + gd / 2;
const L = -W - 2, R = W + 2, TOP = CEIL + 4, BOT = FLOOR - 2;
const pieces = [
{ x0: L, x1: gx - gw / 2, y0: BOT, y1: TOP },
{ x0: gx + gw / 2, x1: R, y0: BOT, y1: TOP },
{ x0: gx - gw / 2, x1: gx + gw / 2, y0: gy + gw / 2, y1: TOP },
{ x0: gx - gw / 2, x1: gx + gw / 2, y0: BOT, y1: gy - gw / 2 }
];
const owner = { nm: false, kind: 'gate' };
for (const p of pieces) {
if (p.x1 - p.x0 < 0.05 || p.y1 - p.y0 < 0.05) continue;
const b = { x0: p.x0, x1: p.x1, y0: p.y0, y1: p.y1, z0, z1, owner };
c.boxes.push(b);
c.group.add(makeBoxMesh(b, M.gate));
}
gates.push({ gx, gy, zc });
} else {
// floating monolith: must bank around it
const mw = rnd(2.8, 3.6), md = 2.4, mh = rnd(6, 9);
const yb = rnd(-3.8, 0.8);
const range = Math.max(0, W - mw / 2 - 1.4);
const mx = rnd(-range, range);
const b = { x0: mx - mw / 2, x1: mx + mw / 2, y0: yb, y1: Math.min(CEIL, yb + mh), z0: zc - md / 2, z1: zc + md / 2, owner: { nm: false, kind: 'mono' } };
c.boxes.push(b);
c.group.add(makeBoxMesh(b, M.monolith));
}
}
// energy orbs (some sit inside gate holes = risky reward)
let oc = (Math.random() < 0.5 + d * 0.1 ? 1 : 0) + (Math.random() < 0.2 ? 1 : 0);
if (k < 2) oc = 1;
for (let j = 0; j < oc; j++) {
let x, y, z;
if (gates.length && Math.random() < 0.5) {
const g = gates[0];
x = g.gx; y = g.gy; z = g.zc;
} else {
const W = wallAt(zS - CL / 2, c);
x = rnd(-W * 0.6, W * 0.6);
y = rnd(-2.5, 3);
z = rnd(zE + 8, zS - 8);
if (insideAnyBox(x, y, z, c, 0.9)) continue;
}
const mesh = new THREE.Mesh(orbGeo, M.orb);
mesh.position.set(x, y, z);
const h = new THREE.Sprite(orbHaloMat);
h.scale.set(3.2, 3.2, 1);
mesh.add(h);
c.group.add(mesh);
const o = { mesh, x, y, z, chunk: c, taken: false };
c.orbs.push(o);
activeOrbs.push(o);
}
scene.add(c.group);
chunks.push(c);
}
function removeChunk(c) {
scene.remove(c.group);
c.wall.geometry.dispose(); // per-chunk GPU buffer; boxes & materials are shared
for (const o of c.orbs) o.taken = true;
activeOrbs = activeOrbs.filter(o => o.chunk !== c);
chunkByK.delete(c.k);
}
function ensureChunks() {
while (-(nextK + 1) * CL > P.z - AHEAD) { genChunk(nextK); nextK++; }
for (let i = chunks.length - 1; i >= 0; i--) {
const c = chunks[i];
if (c.zE > P.z + BEHIND) { removeChunk(c); chunks.splice(i, 1); }
}
}
function resetWorld() {
for (const c of chunks) { scene.remove(c.group); c.wall.geometry.dispose(); }
chunks.length = 0;
chunkByK.clear();
activeOrbs = [];
nextK = 0;
ensureChunks();
}
// ---------- gameplay logic ----------
function nearMiss(clear, kind) {
S.nears++;
const closeness = 1 - clamp(clear / NEAR, 0, 1);
S.mult = Math.min(MAXM, S.mult + 0.25 + 0.6 * closeness);
S.maxMult = Math.max(S.maxMult, S.mult);
S.multTimer = 3.2;
S.score += Math.round(120 * S.mult);
S.turbo = Math.min(100, S.turbo + 6);
S.shake = Math.max(S.shake, 0.35);
S.nmCool = 0.35;
sfx.whoosh(closeness);
const el = document.getElementById('mult');
el.classList.remove('pulse'); void el.offsetWidth; el.classList.add('pulse');
const nm = document.getElementById('nm');
nm.textContent = closeness > 0.6 ? 'SKIN GRAZE' : (kind === 'gate' ? 'TIGHT SQUEEZE' : 'NEAR MISS');
nm.classList.remove('show'); void nm.offsetWidth; nm.classList.add('show');
}
function crash(reason) {
if (!S.alive) return;
S.alive = false;
S.reason = reason;
S.overTimer = 1.6;
S.boosting = false;
sfx.crash();
burst(P.x, P.y, P.z);
S.shake = 1.2;
craft.visible = false;
}
function checkWorld(dt) {
const W = canyonW(P.z);
const wallClear = Math.min(P.x + W, W - P.x) - PR;
if (wallClear < 0) { crash('WALL IMPACT'); return; }
if (P.y - PR < FLOOR) { crash('GROUND IMPACT'); return; }
// obstacle boxes: sphere vs AABB. Track the closest graze this frame.
let best = Infinity, bestOwner = null, bestKind = '';
for (const c of chunks) {
for (const b of c.boxes) {
if (P.z < b.z0 - 3 || P.z > b.z1 + 3) continue;
const cx = clamp(P.x, b.x0, b.x1), cy = clamp(P.y, b.y0, b.y1), cz = clamp(P.z, b.z0, b.z1);
const dx = P.x - cx, dy = P.y - cy, dz = P.z - cz;
const clear = Math.sqrt(dx * dx + dy * dy + dz * dz) - PR;
if (clear < 0) { crash(b.owner.kind === 'gate' ? 'SQUEEZED THROUGH FRAME' : 'MONOLITH IMPACT'); return; }
if (clear < NEAR && !b.owner.nm && clear < best) { best = clear; bestOwner = b.owner; bestKind = b.owner.kind; }
}
}
if (bestOwner && S.nmCool <= 0) {
bestOwner.nm = true;
nearMiss(best, bestKind);
}
// canyon wall graze (rate limited)
S.wallCool = Math.max(0, S.wallCool - dt);
if (wallClear < NEAR && S.wallCool <= 0 && S.nmCool <= 0) {
S.wallCool = 0.9;
nearMiss(wallClear, 'wall');
}
if (wallClear < 0.5) S.shake = Math.max(S.shake, 0.25);
// orbs: refill energy + turbo
for (let i = activeOrbs.length - 1; i >= 0; i--) {
const o = activeOrbs[i];
const dx = P.x - o.x, dy = P.y - o.y, dz = P.z - o.z;
if (dx * dx + dy * dy + dz * dz < (PR + 0.55) * (PR + 0.55)) {
o.taken = true;
o.chunk.group.remove(o.mesh);
activeOrbs.splice(i, 1);
S.energy = Math.min(100, S.energy + 22);
S.turbo = Math.min(100, S.turbo + 8);
S.score += Math.round(60 * S.mult);
burst_orb();
}
}
}
// orb pickup: flash the orb's halo briefly (no per-pickup allocation)
function burst_orb() {
sfx.ping();
}
function update(dt) {
S.t += dt;
const left = keys.KeyA || keys.ArrowLeft, right = keys.KeyD || keys.ArrowRight;
const up = keys.KeyW || keys.ArrowUp, down = keys.KeyS || keys.ArrowDown;
let ix = (right ? 1 : 0) - (left ? 1 : 0);
let iy = (up ? 1 : 0) - (down ? 1 : 0);
let wantBoost = !!(keys.Space || keys.ShiftLeft || keys.ShiftRight);
if (touch.on) { ix = touch.x; iy = touch.y * 0.8; wantBoost = touch.boost; }
ix = clamp(ix, -1, 1); iy = clamp(iy, -1, 1);
if (S.alive && S.started) {
const boosting = wantBoost && S.turbo > 0.5;
S.boosting = boosting;
const d = difficulty();
// flight model: lateral/vertical velocity lags input (weight)
P.vx += (ix * 22 - P.vx) * (1 - Math.exp(-dt * 3.0));
P.x += P.vx * dt;
P.roll += (ix * 0.8 - P.roll) * (1 - Math.exp(-dt * 5));
P.pitch += (iy * 0.42 - P.pitch) * (1 - Math.exp(-dt * 4));
const vyT = Math.sin(P.pitch) * P.v * 0.55;
P.vy += (vyT - P.vy) * (1 - Math.exp(-dt * 3.5));
P.y += P.vy * dt;
if (P.y > CEIL_PLAYER) { P.y = CEIL_PLAYER; P.vy = Math.min(P.vy, 0); }
// pitch-based acceleration: diving speeds up, climbing bleeds speed
let vBase = 50 + d * 36 - Math.abs(ix) * 5;
if (boosting) vBase += 42;
vBase = Math.min(vBase, 140);
P.v += (vBase - P.v) * (1 - Math.exp(-dt * 0.9));
P.v += (Math.max(0, -P.pitch) * 34 - Math.max(0, P.pitch) * 22) * dt;
P.v = clamp(P.v, 25, 150);
P.z -= P.v * dt;
S.dist += P.v * dt;
S.maxV = Math.max(S.maxV, P.v);
// turbo meter: spend while boosting, build on sustained straights
if (boosting) S.turbo = Math.max(0, S.turbo - 26 * dt);
else if (P.v > 85) S.turbo = Math.min(100, S.turbo + (Math.abs(ix) < 0.25 ? 5 : 2) * dt);
// constant energy decay (rises with difficulty and boosting)
S.energy -= (2.8 + d * 2.2 + (boosting ? 2.5 : 0)) * dt;
if (S.energy <= 0) { S.energy = 0; crash('OUT OF ENERGY'); }
// multiplier decay + score
if (S.multTimer > 0) S.multTimer -= dt;
else if (S.mult > 1) S.mult = Math.max(1, S.mult - dt * 0.5);
S.score += P.v * 0.5 * dt * S.mult;
S.nmCool = Math.max(0, S.nmCool - dt);
ensureChunks();
if (S.alive) checkWorld(dt);
} else {
S.boosting = false;
if (!S.alive && S.started && S.overTimer > 0) {
S.overTimer -= dt;
if (S.overTimer <= 0) showOver();
}
ensureChunks();
}
for (const c of chunks) for (const o of c.orbs) if (!o.taken) o.mesh.rotation.y += dt * 2.2;
updateSparks(dt);
visuals(dt);
hud();
updateAudio();
}
// ---------- visuals ----------
const _tmpV = new THREE.Vector3();
const _lookV = new THREE.Vector3();
let sunPulse = 0;
function visuals(dt) {
// craft transform: pitch from input, banking from lateral input, yaw toward travel
craft.position.set(P.x, P.y, P.z);
craft.rotation.set(P.pitch * 0.7, -P.vx * 0.018, -P.roll * 1.0);
craft.visible = S.alive;
const boostK = S.boosting ? 1 : 0;
engineHalo.scale.setScalar(1.6 + boostK * 1.2 + Math.sin(S.t * 40) * 0.1);
engineHalo.material.opacity = 0.8 + boostK * 0.2;
// engine trail: shift ring history, insert current nozzle point
for (let i = TR - 1; i > 0; i--) {
trPos[i * 3] = trPos[(i - 1) * 3];
trPos[i * 3 + 1] = trPos[(i - 1) * 3 + 1];
trPos[i * 3 + 2] = trPos[(i - 1) * 3 + 2];
}
trPos[0] = P.x; trPos[1] = P.y; trPos[2] = P.z + 1.3;
if (!S.alive) { for (let i = 0; i < TR * 3; i++) trPos[i] = 0; }
trGeo.attributes.position.needsUpdate = true;
trail.material.size = 0.45 + (S.boosting ? 0.35 : 0);
// dust: static in world, wrapped around player to create streaming motion
const span = 350;
for (let i = 0; i < DU; i++) {
let z = duPos[i * 3 + 2];
if (z > P.z + 22) z -= span;
else if (z < P.z - 330) z += span;
duPos[i * 3 + 2] = z;
}
duGeo.attributes.position.needsUpdate = true;
dustMat.size = 0.22 + clamp((P.v - 50) / 100, 0, 1) * 0.25;
dustMat.opacity = 0.35 + clamp((P.v - 50) / 90, 0, 1) * 0.5;
// sun & light follow the view / player
sunMesh.position.copy(camera.position).addScaledVector(SUN_DIR, 500);
sunHalo.position.copy(sunMesh.position);
sunLight.position.set(P.x + SUN_DIR.x * 120, P.y + SUN_DIR.y * 120, P.z + SUN_DIR.z * 120);
sunLight.target.position.set(P.x, FLOOR, P.z - 40);
sunLight.target.updateMatrixWorld();
floor.position.z = P.z;
floor.position.x = P.x;
// sky dome follows camera
scene.children.forEach(ch => { if (ch.geometry && ch.geometry.type === 'SphereGeometry') ch.position.copy(camera.position); });
// camera chase rig
const speedK = clamp((P.v - 50) / 90, 0, 1);
const boostK2 = S.boosting ? 1 : 0;
_tmpV.set(P.x * 0.7 - P.roll * 1.5, P.y + 2.3 + speedK * 0.2, P.z + 9.2 - speedK * 0.4);
camSmooth.lerp(_tmpV, 1 - Math.exp(-dt * 7));
// camera shake: walls close, boost, crashes
S.shake = Math.max(0, S.shake - dt * 2.4);
const sh = S.shake * S.shake;
camera.position.set(
camSmooth.x + (Math.random() - 0.5) * sh * 1.8,
camSmooth.y + (Math.random() - 0.5) * sh * 1.8 + boostK2 * (Math.random() - 0.5) * 0.12,
camSmooth.z
);
_lookV.set(P.x * 0.8, P.y * 0.85 + 0.3, P.z - 16);
camera.lookAt(_lookV);
// FOV widens with turbo / boost
const targetFov = 66 + boostK2 * 14 + speedK * 4 + (S.alive ? 0 : -6);
camera.fov += (targetFov - camera.fov) * (1 - Math.exp(-dt * 4));
camera.updateProjectionMatrix();
// motion blur (afterimage damp) and bloom scale with speed
afterimage.uniforms['damp'].value = S.alive ? clamp((P.v - 60) / 120, 0, 1) * 0.5 : 0;
bloom.strength = 0.85 + boostK2 * 0.35 + (S.mult > 3 ? 0.1 : 0);
// orb hue pulse
sunPulse += dt;
orbHaloMat.opacity = 0.7 + 0.25 * Math.sin(sunPulse * 3);
// fog tint: darker in deep canyon/high difficulty
scene.fog.far = 260 - difficulty() * 60;
}
// ---------- HUD ----------
const el = {
score: document.getElementById('scoreVal'), speedBar: document.getElementById('speedBar'), speedNum: document.getElementById('speedNum'),
mult: document.getElementById('multVal'), energyBar: document.getElementById('energyBar'), turboBar: document.getElementById('turboBar')
};
let lastScoreTxt = '';
function hud() {
const sTxt = String(Math.floor(S.score)).padStart(6, '0');
if (sTxt !== lastScoreTxt) { el.score.textContent = sTxt; lastScoreTxt = sTxt; }
el.speedBar.style.width = (clamp((P.v - 25) / 125, 0, 1) * 100).toFixed(1) + '%';
el.speedNum.textContent = Math.round(P.v * 3.2) + ' KPH';
el.mult.textContent = S.mult.toFixed(1) + '×';
el.energyBar.style.width = clamp(S.energy, 0, 100).toFixed(1) + '%';
el.energyBar.classList.toggle('low', S.energy < 25 && S.alive);
el.turboBar.style.width = clamp(S.turbo, 0, 100).toFixed(1) + '%';
el.turboBar.classList.toggle('ready', S.turbo >= 100);
}
function showOver() {
document.getElementById('fScore').textContent = Math.floor(S.score).toLocaleString();
document.getElementById('fSpeed').textContent = Math.round(S.maxV * 3.2) + ' KPH';
document.getElementById('fMult').textContent = S.maxMult.toFixed(1) + '×';
document.getElementById('fNear').textContent = S.nears;
document.getElementById('fDist').textContent = (S.dist / 1000 * 1.0).toFixed(2) + ' KM';
document.getElementById('fReason').textContent = S.reason;
document.getElementById('over').classList.remove('hidden');
}
// ---------- flow: start / restart ----------
function resetGame() {
Object.assign(S, { alive: true, started: true, score: 0, energy: 100, turbo: 20, mult: 1, multTimer: 0, nmCool: 0, wallCool: 0,
maxV: 0, maxMult: 1, nears: 0, dist: 0, shake: 0, boosting: false, overTimer: 0, reason: '' });
Object.assign(P, { x: 0, y: 1, z: 0, vx: 0, vy: 0, v: 52, roll: 0, pitch: 0 });
camSmooth.set(0, 3.3, 9.2);
craft.visible = true;
sparkLife = 0; spMat.opacity = 0;
resetWorld();
document.getElementById('start').classList.add('hidden');
document.getElementById('over').classList.add('hidden');
initAudio();
}
document.getElementById('startBtn').addEventListener('click', () => { if (!S.alive && !S.started) resetGame(); });
document.getElementById('restartBtn').addEventListener('click', () => resetGame());
// ---------- input ----------
const STOP = new Set(['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ShiftLeft', 'ShiftRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD']);
addEventListener('keydown', e => {
if (STOP.has(e.code)) e.preventDefault();
keys[e.code] = true;
if ((e.code === 'Enter' || e.code === 'Space') && !e.repeat) {
if (!S.started) resetGame();
else if (!S.alive && S.overTimer <= 0 && !document.getElementById('over').classList.contains('hidden')) resetGame();
}
});
addEventListener('keyup', e => { keys[e.code] = false; });
addEventListener('blur', () => { for (const k in keys) keys[k] = false; });
// touch: drag to steer (relative to touch-down), hold to boost
let tStart = null;
canvas.addEventListener('pointerdown', e => {
if (e.pointerType !== 'touch') return;
touch.on = true; touch.boost = true; tStart = { x: e.clientX, y: e.clientY };
if (!S.started) resetGame();
});
canvas.addEventListener('pointermove', e => {
if (!touch.on || !tStart) return;
touch.x = clamp((e.clientX - tStart.x) / 90, -1, 1);
touch.y = clamp(-(e.clientY - tStart.y) / 90, -1, 1);
});
const endTouch = () => { touch.on = false; touch.boost = false; touch.x = 0; touch.y = 0; tStart = null; };
canvas.addEventListener('pointerup', endTouch);
canvas.addEventListener('pointercancel', endTouch);
// ---------- resize ----------
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
composer.setSize(innerWidth, innerHeight);
bloom.setSize(innerWidth, innerHeight);
});
// ---------- main loop ----------
let last = performance.now();
function frame(now) {
requestAnimationFrame(frame);
const dt = Math.min(0.033, (now - last) / 1000);
last = now;
update(dt);
composer.render();
}
// initial world so the backdrop isn't empty behind the title card
ensureChunks();
camSmooth.set(0, 3.3, 9.2);
visuals(0.016);
requestAnimationFrame(frame);
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"e3f2dc09-fd7a-4dfd-a3dd-ac4476a9ccfc","tokensIn":917620,"tokensOut":431883,"tokensTotal":1349503,"cost":0.23504799999999998,"turns":18,"toolCalls":23,"failedToolCalls":1,"timestamp":"2026-10-08T02:31:09.453Z"} -->