Canyon Run
Grok 4.5 · legacy
◈Preview unavailable — try playing the result
Browser check: Preview unavailable or timed out. 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 56,795 bytes · SHA-256 5de88dc13d97
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>Canyon Racer — High-Speed Procedural Flight</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Rajdhani:wght@400;600;700&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%; overflow: hidden;
background: #0a0612;
font-family: 'Rajdhani', system-ui, sans-serif;
color: #f0e6d3;
cursor: none;
}
#c { display: block; width: 100%; height: 100%; }
/* HUD */
#hud {
position: fixed; inset: 0; pointer-events: none;
z-index: 10;
}
.hud-top {
position: absolute; top: 28px; left: 0; right: 0;
display: flex; justify-content: center; gap: 48px;
align-items: flex-start;
}
.stat {
text-align: center;
min-width: 120px;
}
.stat-label {
font-size: 11px; letter-spacing: 0.28em; text-transform: uppercase;
color: rgba(240,230,211,0.45); font-weight: 600;
}
.stat-value {
font-family: 'Orbitron', monospace;
font-size: 28px; font-weight: 700;
color: #ffc857;
text-shadow: 0 0 24px rgba(255,180,60,0.45);
line-height: 1.2;
}
#score-val { font-size: 36px; }
#mult-wrap {
transition: transform 0.12s ease-out, filter 0.12s;
}
#mult-wrap.pulse {
transform: scale(1.35);
filter: brightness(1.6) drop-shadow(0 0 12px #ff6b35);
}
#mult-val {
color: #ff6b35;
font-size: 42px;
}
#mult-val::after { content: '×'; font-size: 0.55em; opacity: 0.7; }
/* Energy bar */
.energy-wrap {
position: absolute; bottom: 42px; left: 50%;
transform: translateX(-50%);
width: min(340px, 70vw);
}
.energy-label {
font-size: 10px; letter-spacing: 0.3em; text-transform: uppercase;
color: rgba(240,230,211,0.4); text-align: center; margin-bottom: 6px;
}
.energy-track {
height: 6px; border-radius: 3px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,200,100,0.2);
overflow: hidden; position: relative;
}
.energy-fill {
height: 100%; width: 100%;
background: linear-gradient(90deg, #c44512, #ff8c2a, #ffd96a);
box-shadow: 0 0 16px rgba(255,140,40,0.7);
transition: width 0.08s linear;
border-radius: 3px;
}
.energy-fill.low {
background: linear-gradient(90deg, #5c1010, #e63946, #ff6b6b);
animation: energy-crit 0.5s ease-in-out infinite alternate;
}
@keyframes energy-crit {
from { opacity: 0.7; } to { opacity: 1; box-shadow: 0 0 20px #e63946; }
}
/* Speed bar */
.speed-wrap {
position: absolute; bottom: 72px; left: 50%;
transform: translateX(-50%);
width: min(340px, 70vw);
}
.speed-track {
height: 2px; background: rgba(255,255,255,0.06);
position: relative;
}
.speed-fill {
height: 100%;
background: linear-gradient(90deg, #4a6fa5, #a8c6f0, #ffffff);
box-shadow: 0 0 8px rgba(160,200,255,0.6);
transition: width 0.05s linear;
}
.turbo-seg {
position: absolute; top: -3px; right: 0;
width: 22%; height: 8px;
border-left: 1px solid rgba(255,255,255,0.15);
}
.turbo-fill {
position: absolute; top: -3px; right: 0;
height: 8px; width: 0%;
background: linear-gradient(90deg, transparent, #7df9ff, #ffffff);
box-shadow: 0 0 14px #7df9ff;
transition: width 0.08s linear;
}
/* Near-miss flash text */
#near-miss-fx {
position: absolute; top: 38%; left: 50%;
transform: translate(-50%, -50%);
font-family: 'Orbitron', monospace;
font-size: 22px; font-weight: 700; letter-spacing: 0.2em;
color: #7df9ff;
text-shadow: 0 0 30px #7df9ff;
opacity: 0; pointer-events: none;
transition: none;
}
#near-miss-fx.show {
animation: nm-pop 0.7s ease-out forwards;
}
@keyframes nm-pop {
0% { opacity: 0; transform: translate(-50%, -40%) scale(0.6); }
20% { opacity: 1; transform: translate(-50%, -50%) scale(1.15); }
100% { opacity: 0; transform: translate(-50%, -70%) scale(1); }
}
/* Boost indicator */
#boost-fx {
position: absolute; inset: 0;
border: 3px solid transparent;
pointer-events: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
#boost-fx.active {
border-color: rgba(125,249,255,0.35);
box-shadow: inset 0 0 80px rgba(125,249,255,0.12);
}
/* Overlay screens */
.overlay {
position: fixed; inset: 0; z-index: 20;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
background: radial-gradient(ellipse at center, rgba(20,10,30,0.85) 0%, rgba(5,2,12,0.96) 100%);
pointer-events: auto;
cursor: default;
transition: opacity 0.4s ease;
}
.overlay.hidden { opacity: 0; pointer-events: none; }
.title-main {
font-family: 'Orbitron', monospace;
font-weight: 900; font-size: clamp(28px, 6vw, 56px);
letter-spacing: 0.12em;
background: linear-gradient(135deg, #ffc857, #ff6b35, #c44569);
-webkit-background-clip: text; background-clip: text;
color: transparent;
text-shadow: none;
filter: drop-shadow(0 0 40px rgba(255,140,40,0.35));
margin-bottom: 8px;
}
.title-sub {
font-size: 13px; letter-spacing: 0.45em; text-transform: uppercase;
color: rgba(240,230,211,0.5); margin-bottom: 40px;
}
.btn {
font-family: 'Orbitron', monospace;
font-size: 14px; font-weight: 700; letter-spacing: 0.25em;
text-transform: uppercase;
padding: 16px 48px;
border: 1px solid rgba(255,200,100,0.45);
background: linear-gradient(180deg, rgba(255,140,40,0.18), rgba(255,100,30,0.05));
color: #ffc857;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 0 30px rgba(255,140,40,0.15);
}
.btn:hover {
background: linear-gradient(180deg, rgba(255,140,40,0.35), rgba(255,100,30,0.12));
border-color: #ffc857;
box-shadow: 0 0 50px rgba(255,140,40,0.35);
transform: translateY(-1px);
}
.controls-hint {
margin-top: 36px;
font-size: 13px; color: rgba(240,230,211,0.4);
text-align: center; line-height: 1.9; letter-spacing: 0.05em;
}
.controls-hint kbd {
display: inline-block;
padding: 2px 8px; margin: 0 2px;
border: 1px solid rgba(255,255,255,0.15);
border-radius: 3px;
font-family: 'Orbitron', monospace; font-size: 11px;
color: rgba(255,200,100,0.7);
background: rgba(255,255,255,0.04);
}
.go-stats {
display: grid; grid-template-columns: 1fr 1fr 1fr;
gap: 32px; margin: 28px 0 40px;
text-align: center;
}
.go-stat-val {
font-family: 'Orbitron', monospace;
font-size: 32px; font-weight: 700; color: #ffc857;
}
.go-stat-lbl {
font-size: 11px; letter-spacing: 0.25em; text-transform: uppercase;
color: rgba(240,230,211,0.4); margin-top: 4px;
}
#go-title {
font-family: 'Orbitron', monospace;
font-size: 42px; font-weight: 900; letter-spacing: 0.15em;
color: #e63946;
text-shadow: 0 0 40px rgba(230,57,70,0.5);
margin-bottom: 8px;
}
/* Crosshair / reticle */
#reticle {
position: fixed; width: 28px; height: 28px;
margin: -14px 0 0 -14px; pointer-events: none; z-index: 5;
border: 1px solid rgba(255,200,100,0.25);
border-radius: 50%;
transition: border-color 0.1s;
}
#reticle::before, #reticle::after {
content: ''; position: absolute; background: rgba(255,200,100,0.35);
}
#reticle::before { width: 1px; height: 10px; left: 50%; top: 50%; transform: translate(-50%,-50%); }
#reticle::after { height: 1px; width: 10px; left: 50%; top: 50%; transform: translate(-50%,-50%); }
#reticle.danger { border-color: rgba(255,80,60,0.7); }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="hud">
<div class="hud-top">
<div class="stat">
<div class="stat-label">Score</div>
<div class="stat-value" id="score-val">0</div>
</div>
<div class="stat" id="mult-wrap">
<div class="stat-label">Multiplier</div>
<div class="stat-value" id="mult-val">1.0</div>
</div>
<div class="stat">
<div class="stat-label">Speed</div>
<div class="stat-value" id="speed-val">0</div>
</div>
</div>
<div id="near-miss-fx">NEAR MISS</div>
<div id="boost-fx"></div>
<div class="speed-wrap">
<div class="speed-track">
<div class="speed-fill" id="speed-bar"></div>
<div class="turbo-seg"></div>
<div class="turbo-fill" id="turbo-bar"></div>
</div>
</div>
<div class="energy-wrap">
<div class="energy-label">Energy</div>
<div class="energy-track">
<div class="energy-fill" id="energy-bar"></div>
</div>
</div>
</div>
<div id="reticle"></div>
<!-- Title -->
<div class="overlay" id="title-screen">
<div class="title-main">CANYON RACER</div>
<div class="title-sub">High-Speed Procedural Flight</div>
<button class="btn" id="start-btn">Launch</button>
<div class="controls-hint">
<kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> / Arrows — Pitch & Bank<br/>
<kbd>SPACE</kbd> — Turbo Boost · <kbd>MOUSE</kbd> — Aim assist<br/>
Skim the walls. Collect energy. Never stop.
</div>
</div>
<!-- Game Over -->
<div class="overlay hidden" id="go-screen">
<div id="go-title">SIGNAL LOST</div>
<div class="title-sub">Craft destroyed</div>
<div class="go-stats">
<div>
<div class="go-stat-val" id="go-score">0</div>
<div class="go-stat-lbl">Final Score</div>
</div>
<div>
<div class="go-stat-val" id="go-maxspd">0</div>
<div class="go-stat-lbl">Max Speed</div>
</div>
<div>
<div class="go-stat-val" id="go-maxmult">1.0×</div>
<div class="go-stat-lbl">Peak Mult</div>
</div>
</div>
<button class="btn" id="restart-btn">Reboot</button>
</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/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';
// ═══════════════════════════════════════════════════════════
// CONFIG
// ═══════════════════════════════════════════════════════════
const CFG = {
CHUNK_LEN: 180,
CHUNK_AHEAD: 6,
CHUNK_BEHIND: 2,
CANYON_HALF: 28,
WALL_H: 55,
BASE_SPEED: 48,
MAX_SPEED: 160,
LATERAL_ACCEL: 95,
LATERAL_MAX: 42,
PITCH_ACCEL: 70,
PITCH_MAX: 28,
BANK_MAX: 0.55,
DRAG: 3.2,
ENERGY_MAX: 100,
ENERGY_DECAY: 4.2,
ENERGY_ORB: 18,
NEAR_MISS_DIST: 4.5,
NEAR_MISS_MULT_GAIN: 0.15,
MULT_DECAY: 0.35,
MULT_MAX: 12,
TURBO_MAX: 100,
TURBO_GAIN: 8,
TURBO_COST: 35,
TURBO_BOOST: 55,
COLLIDE_R: 1.15,
FOV_BASE: 70,
FOV_BOOST: 95,
SPAWN_GRACE: 1.6,
SAFE_OPENING: 70,
};
// Palette — cyber sunset
const PAL = {
skyTop: 0x2a1548,
skyBot: 0xe05a3a,
sun: 0xffb86b,
wallA: 0x6b4568,
wallB: 0x8a5470,
wallC: 0xa85f78,
craft: 0xe8e8ec,
craftAccent: 0xff6b35,
engine: 0x7df9ff,
orb: 0xffc857,
mono: 0x7a5a8a,
ground: 0x3a2848,
};
// ═══════════════════════════════════════════════════════════
// AUDIO (Web Audio — procedural)
// ═══════════════════════════════════════════════════════════
class AudioSys {
constructor() {
this.ctx = null;
this.master = null;
this.engine = null;
this.engineGain = null;
this.boosting = false;
}
init() {
if (this.ctx) return;
const AC = window.AudioContext || window.webkitAudioContext;
this.ctx = new AC();
this.master = this.ctx.createGain();
this.master.gain.value = 0.35;
this.master.connect(this.ctx.destination);
this._startEngine();
}
_startEngine() {
const c = this.ctx;
// layered saw + noise drone
const osc = c.createOscillator();
osc.type = 'sawtooth';
osc.frequency.value = 55;
const filt = c.createBiquadFilter();
filt.type = 'lowpass';
filt.frequency.value = 280;
filt.Q.value = 2;
this.engineGain = c.createGain();
this.engineGain.gain.value = 0.12;
osc.connect(filt);
filt.connect(this.engineGain);
this.engineGain.connect(this.master);
osc.start();
this.engine = { osc, filt };
// subtle high hum
const o2 = c.createOscillator();
o2.type = 'sine';
o2.frequency.value = 180;
const g2 = c.createGain();
g2.gain.value = 0.04;
o2.connect(g2); g2.connect(this.master);
o2.start();
this.engine.hum = o2;
}
setEngine(speedNorm, boosting) {
if (!this.engine) return;
const f = 50 + speedNorm * 90 + (boosting ? 40 : 0);
this.engine.osc.frequency.setTargetAtTime(f, this.ctx.currentTime, 0.08);
this.engine.filt.frequency.setTargetAtTime(220 + speedNorm * 600 + (boosting ? 400 : 0), this.ctx.currentTime, 0.1);
this.engineGain.gain.setTargetAtTime(0.08 + speedNorm * 0.14, this.ctx.currentTime, 0.1);
}
ping() {
if (!this.ctx) return;
const t = this.ctx.currentTime;
const o = this.ctx.createOscillator();
const g = this.ctx.createGain();
o.type = 'sine';
o.frequency.setValueAtTime(880, t);
o.frequency.exponentialRampToValueAtTime(1760, t + 0.08);
g.gain.setValueAtTime(0.22, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.25);
o.connect(g); g.connect(this.master);
o.start(t); o.stop(t + 0.26);
}
whoosh() {
if (!this.ctx) return;
const t = this.ctx.currentTime;
const buf = this.ctx.createBuffer(1, this.ctx.sampleRate * 0.25, this.ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / d.length, 1.5);
const src = this.ctx.createBufferSource();
src.buffer = buf;
const f = this.ctx.createBiquadFilter();
f.type = 'bandpass';
f.frequency.setValueAtTime(400, t);
f.frequency.exponentialRampToValueAtTime(2400, t + 0.18);
f.Q.value = 0.8;
const g = this.ctx.createGain();
g.gain.setValueAtTime(0.28, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.25);
src.connect(f); f.connect(g); g.connect(this.master);
src.start(t);
}
crash() {
if (!this.ctx) return;
const t = this.ctx.currentTime;
const buf = this.ctx.createBuffer(1, this.ctx.sampleRate * 0.5, this.ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / d.length, 0.7);
const src = this.ctx.createBufferSource();
src.buffer = buf;
const f = this.ctx.createBiquadFilter();
f.type = 'lowpass';
f.frequency.setValueAtTime(900, t);
f.frequency.exponentialRampToValueAtTime(80, t + 0.45);
const g = this.ctx.createGain();
g.gain.setValueAtTime(0.5, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.5);
src.connect(f); f.connect(g); g.connect(this.master);
src.start(t);
// thump
const o = this.ctx.createOscillator();
o.type = 'sine';
o.frequency.setValueAtTime(90, t);
o.frequency.exponentialRampToValueAtTime(25, t + 0.3);
const g2 = this.ctx.createGain();
g2.gain.setValueAtTime(0.4, t);
g2.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
o.connect(g2); g2.connect(this.master);
o.start(t); o.stop(t + 0.36);
}
boostStart() {
if (!this.ctx) return;
const t = this.ctx.currentTime;
const o = this.ctx.createOscillator();
o.type = 'sawtooth';
o.frequency.setValueAtTime(100, t);
o.frequency.exponentialRampToValueAtTime(400, t + 0.2);
const g = this.ctx.createGain();
g.gain.setValueAtTime(0.12, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
const f = this.ctx.createBiquadFilter();
f.type = 'highpass'; f.frequency.value = 200;
o.connect(f); f.connect(g); g.connect(this.master);
o.start(t); o.stop(t + 0.36);
}
}
const SFX = new AudioSys();
// ═══════════════════════════════════════════════════════════
// SEEDED RNG for chunk consistency
// ═══════════════════════════════════════════════════════════
function hashSeed(n) {
let x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x);
}
function rng(seed) {
let s = seed;
return () => {
s = (s * 16807 + 0.5) % 2147483647;
return (s - 0.5) / 2147483646;
};
}
// ═══════════════════════════════════════════════════════════
// THREE SETUP
// ═══════════════════════════════════════════════════════════
const canvas = document.getElementById('c');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.55;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
scene.background = new THREE.Color(PAL.skyTop);
scene.fog = new THREE.FogExp2(0x4a2038, 0.0016);
// Gradient sky via large dome
{
const skyGeo = new THREE.SphereGeometry(900, 32, 16);
const skyMat = new THREE.ShaderMaterial({
side: THREE.BackSide,
depthWrite: false,
uniforms: {
topColor: { value: new THREE.Color(PAL.skyTop) },
botColor: { value: new THREE.Color(PAL.skyBot) },
offset: { value: 0.1 },
exponent: { value: 0.55 },
},
vertexShader: `
varying vec3 vW;
void main() {
vec4 w = modelMatrix * vec4(position, 1.0);
vW = w.xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `
uniform vec3 topColor, botColor;
uniform float offset, exponent;
varying vec3 vW;
void main() {
float h = normalize(vW + vec3(0.0, offset, 0.0)).y;
float t = max(pow(max(h, 0.0), exponent), 0.0);
// warm horizon band
float band = smoothstep(0.0, 0.25, h) * (1.0 - smoothstep(0.15, 0.55, h));
vec3 col = mix(botColor, topColor, t);
col = mix(col, vec3(1.0, 0.45, 0.2), band * 0.55);
gl_FragColor = vec4(col, 1.0);
}`,
});
scene.add(new THREE.Mesh(skyGeo, skyMat));
}
// Lights — bright cyber-sunset (walls must read, not pure silhouette)
const ambient = new THREE.AmbientLight(0xc8a0b8, 0.95);
scene.add(ambient);
const sun = new THREE.DirectionalLight(PAL.sun, 2.4);
sun.position.set(-60, 90, -30);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.near = 10;
sun.shadow.camera.far = 400;
sun.shadow.camera.left = -120;
sun.shadow.camera.right = 120;
sun.shadow.camera.top = 120;
sun.shadow.camera.bottom = -120;
sun.shadow.bias = -0.0005;
sun.shadow.intensity = 0.55;
scene.add(sun);
// Fill from the open canyon so unlit faces aren't black
const fill = new THREE.DirectionalLight(0xffd0a8, 1.15);
fill.position.set(50, 40, 80);
scene.add(fill);
const rim = new THREE.DirectionalLight(0xb48cff, 0.85);
rim.position.set(40, 30, 60);
scene.add(rim);
const hemi = new THREE.HemisphereLight(0xffb070, 0x4a3060, 0.95);
scene.add(hemi);
// Soft warm key near the craft so corridor always reads
const pathLight = new THREE.PointLight(0xffa060, 2.2, 90, 1.4);
pathLight.position.set(0, 22, 0);
scene.add(pathLight);
// Camera
const camera = new THREE.PerspectiveCamera(CFG.FOV_BASE, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 8, 18);
// Post-processing: bloom + radial speed blur
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
0.45, 0.35, 0.82
);
composer.addPass(bloom);
const SpeedBlurShader = {
uniforms: {
tDiffuse: { value: null },
strength: { value: 0.0 },
},
vertexShader: /* glsl */`
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: /* glsl */`
uniform sampler2D tDiffuse;
uniform float strength;
varying vec2 vUv;
void main() {
vec2 center = vec2(0.5, 0.48);
vec2 dir = vUv - center;
float dist = length(dir);
vec2 stepDir = dir * strength * 0.018;
vec4 col = texture2D(tDiffuse, vUv);
col += texture2D(tDiffuse, vUv - stepDir);
col += texture2D(tDiffuse, vUv - stepDir * 2.0);
col += texture2D(tDiffuse, vUv - stepDir * 3.0);
col += texture2D(tDiffuse, vUv + stepDir);
gl_FragColor = col / 5.0;
// slight speed vignette toward edges
gl_FragColor.rgb *= 1.0 - dist * strength * 0.18;
}`,
};
const speedBlur = new ShaderPass(SpeedBlurShader);
composer.addPass(speedBlur);
// ═══════════════════════════════════════════════════════════
// PLAYER CRAFT
// ═══════════════════════════════════════════════════════════
function buildCraft() {
const g = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({
color: PAL.craft, metalness: 0.75, roughness: 0.28, flatShading: true,
});
const accentMat = new THREE.MeshStandardMaterial({
color: PAL.craftAccent, metalness: 0.5, roughness: 0.4, flatShading: true,
emissive: PAL.craftAccent, emissiveIntensity: 0.25,
});
const glowMat = new THREE.MeshStandardMaterial({
color: PAL.engine, emissive: PAL.engine, emissiveIntensity: 2.5,
metalness: 0.2, roughness: 0.4, flatShading: true,
});
// Fuselage
const body = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.45, 3.2), bodyMat);
body.castShadow = true;
g.add(body);
// Nose
const nose = new THREE.Mesh(new THREE.ConeGeometry(0.35, 1.4, 4), bodyMat);
nose.rotation.x = -Math.PI / 2;
nose.position.z = -2.1;
nose.castShadow = true;
g.add(nose);
// Wings
const wingGeo = new THREE.BoxGeometry(3.8, 0.08, 1.1);
const wing = new THREE.Mesh(wingGeo, bodyMat);
wing.position.set(0, 0, 0.3);
wing.castShadow = true;
g.add(wing);
// Wing tips
const tipL = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.5, 0.8), accentMat);
tipL.position.set(-1.9, 0.15, 0.2);
g.add(tipL);
const tipR = tipL.clone();
tipR.position.x = 1.9;
g.add(tipR);
// Cockpit
const cock = new THREE.Mesh(
new THREE.BoxGeometry(0.6, 0.35, 0.9),
new THREE.MeshStandardMaterial({
color: 0x1a1a2e, metalness: 0.9, roughness: 0.1,
emissive: 0x4a6fff, emissiveIntensity: 0.3, flatShading: true,
})
);
cock.position.set(0, 0.35, -0.4);
g.add(cock);
// Engine glow
const eng = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.3, 0.4), glowMat);
eng.position.set(0, 0, 1.7);
g.add(eng);
g.userData.engine = eng;
g.userData.glowMat = glowMat;
// Engine light
const engLight = new THREE.PointLight(PAL.engine, 2.5, 18);
engLight.position.set(0, 0, 2.2);
g.add(engLight);
g.userData.engLight = engLight;
return g;
}
const craft = buildCraft();
scene.add(craft);
// ═══════════════════════════════════════════════════════════
// PARTICLES — trail + speed dust
// ═══════════════════════════════════════════════════════════
function makeParticles(count, size, color, opacity) {
const geo = new THREE.BufferGeometry();
const pos = new Float32Array(count * 3);
const vel = new Float32Array(count * 3);
const life = new Float32Array(count);
for (let i = 0; i < count; i++) {
pos[i * 3] = 0; pos[i * 3 + 1] = 0; pos[i * 3 + 2] = 0;
life[i] = Math.random();
}
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
const mat = new THREE.PointsMaterial({
color, size, transparent: true, opacity,
depthWrite: false, blending: THREE.AdditiveBlending, sizeAttenuation: true,
});
const pts = new THREE.Points(geo, mat);
pts.frustumCulled = false;
pts.userData = { vel, life, count };
return pts;
}
const trail = makeParticles(120, 0.28, 0x7df9ff, 0.7);
scene.add(trail);
const dust = makeParticles(200, 0.15, 0xffb347, 0.45);
scene.add(dust);
function updateTrail(dt, boosting) {
const pos = trail.geometry.attributes.position.array;
const { vel, life, count } = trail.userData;
const base = craft.position;
for (let i = 0; i < count; i++) {
life[i] -= dt * (boosting ? 2.5 : 1.6);
if (life[i] <= 0) {
life[i] = 1;
pos[i * 3] = base.x + (Math.random() - 0.5) * 0.6;
pos[i * 3 + 1] = base.y + (Math.random() - 0.5) * 0.3;
pos[i * 3 + 2] = base.z + 1.6 + Math.random() * 0.4;
vel[i * 3] = (Math.random() - 0.5) * 2;
vel[i * 3 + 1] = (Math.random() - 0.5) * 2;
vel[i * 3 + 2] = 8 + Math.random() * 10 + (boosting ? 20 : 0);
} else {
pos[i * 3] += vel[i * 3] * dt;
pos[i * 3 + 1] += vel[i * 3 + 1] * dt;
pos[i * 3 + 2] += vel[i * 3 + 2] * dt;
}
}
trail.geometry.attributes.position.needsUpdate = true;
trail.material.opacity = 0.4 + (boosting ? 0.5 : 0);
trail.material.size = boosting ? 0.4 : 0.22;
}
function updateDust(dt, speed) {
const pos = dust.geometry.attributes.position.array;
const { vel, life, count } = dust.userData;
const base = craft.position;
const dens = Math.min(1, speed / 80);
for (let i = 0; i < count; i++) {
life[i] -= dt * 1.8;
if (life[i] <= 0) {
if (Math.random() > dens * 0.7) { life[i] = 0.01; continue; }
life[i] = 0.5 + Math.random() * 0.5;
pos[i * 3] = base.x + (Math.random() - 0.5) * 50;
pos[i * 3 + 1] = base.y + (Math.random() - 0.5) * 30;
pos[i * 3 + 2] = base.z - 20 - Math.random() * 80;
vel[i * 3] = 0;
vel[i * 3 + 1] = 0;
vel[i * 3 + 2] = speed * 1.2;
} else {
pos[i * 3 + 2] += vel[i * 3 + 2] * dt;
}
}
dust.geometry.attributes.position.needsUpdate = true;
dust.material.opacity = 0.15 + dens * 0.35;
}
// ═══════════════════════════════════════════════════════════
// PROCEDURAL CANYON
// ═══════════════════════════════════════════════════════════
const wallMat = new THREE.MeshStandardMaterial({
color: PAL.wallA, metalness: 0.08, roughness: 0.72, flatShading: true,
emissive: 0x3a1838, emissiveIntensity: 0.22,
});
const wallMatB = new THREE.MeshStandardMaterial({
color: PAL.wallB, metalness: 0.08, roughness: 0.74, flatShading: true,
emissive: 0x4a2040, emissiveIntensity: 0.2,
});
const wallMatC = new THREE.MeshStandardMaterial({
color: PAL.wallC, metalness: 0.1, roughness: 0.7, flatShading: true,
emissive: 0x5a2850, emissiveIntensity: 0.18,
});
const monoMat = new THREE.MeshStandardMaterial({
color: PAL.mono, metalness: 0.25, roughness: 0.5, flatShading: true,
emissive: 0x402858, emissiveIntensity: 0.28,
});
const groundMat = new THREE.MeshStandardMaterial({
color: PAL.ground, metalness: 0.06, roughness: 0.9, flatShading: true,
emissive: 0x201528, emissiveIntensity: 0.12,
});
const orbMat = new THREE.MeshStandardMaterial({
color: PAL.orb, emissive: PAL.orb, emissiveIntensity: 2.2,
metalness: 0.3, roughness: 0.3, flatShading: true,
});
const colliders = []; // { box: Box3, mesh, type: 'wall'|'mono'|'orb', center, radius }
const chunks = new Map(); // zIndex -> group
function makeRockColumn(w, h, d, mat) {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat);
mesh.castShadow = true;
mesh.receiveShadow = true;
// slight random tilt in build
return mesh;
}
function generateChunk(zIndex, difficulty) {
const group = new THREE.Group();
group.userData.zIndex = zIndex;
const z0 = zIndex * CFG.CHUNK_LEN;
group.position.z = -z0;
const rand = rng(hashSeed(zIndex * 97.13 + 13.7) * 1e6);
const dens = Math.min(1, difficulty);
// Wider, calmer opening; tighten only after the player is airborne
const openEase = zIndex <= 1 ? 0 : Math.min(1, (zIndex - 1) / 3);
const half = CFG.CANYON_HALF * (1.08 - dens * 0.2 - openEase * 0.06);
const mats = [wallMat, wallMatB, wallMatC];
const localColliders = [];
// World-Z of chunk start is -z0; keep a clear tunnel at the very beginning
const worldZStart = z0; // magnitude along track
const avoidObstacles = worldZStart < CFG.SAFE_OPENING;
// Floor ribbon
const floor = new THREE.Mesh(
new THREE.BoxGeometry(half * 2 + 40, 1.5, CFG.CHUNK_LEN + 2),
groundMat
);
floor.position.set(0, -1.2, -CFG.CHUNK_LEN / 2);
floor.receiveShadow = true;
group.add(floor);
// Meandering canyon center — start straight for honest spawn clearance
const meander = zIndex === 0
? 0
: Math.sin(zIndex * 0.7) * 8 + Math.cos(zIndex * 0.35) * 5;
const wallSegments = 6;
for (let s = 0; s < wallSegments; s++) {
const frac = (s + 0.5) / wallSegments;
const zLocal = -frac * CFG.CHUNK_LEN;
// Deterministic jitter so visuals & colliders match (dampened in opening)
const jitterScale = zIndex === 0 ? 0.25 : 1;
const jL = (hashSeed(zIndex * 31.7 + s * 3.1) - 0.5) * 7 * jitterScale;
const jR = (hashSeed(zIndex * 41.3 + s * 5.9) - 0.5) * 7 * jitterScale;
// Enforce a minimum clear half-width around the flight corridor
const minClear = zIndex === 0 ? 18 : 14;
const leftInner = Math.min(-minClear, -half + meander + jL);
const rightInner = Math.max(minClear, half + meander + jR);
const hL = CFG.WALL_H * (0.6 + hashSeed(zIndex + s * 2.2) * 0.55);
const hR = CFG.WALL_H * (0.6 + hashSeed(zIndex + s * 3.7) * 0.55);
const thick = 10 + hashSeed(zIndex * 7 + s) * 16;
const depth = CFG.CHUNK_LEN / wallSegments + 3;
// Left wall block (inner face at leftInner)
const left = makeRockColumn(thick, hL, depth, mats[s % 3]);
left.position.set(leftInner - thick / 2, hL / 2 - 0.5, zLocal);
left.rotation.z = (hashSeed(zIndex + s + 0.1) - 0.5) * 0.06;
group.add(left);
// Only colliders on dedicated slabs — big tilted wall AABBs were killing spawns
// Right wall block (inner face at rightInner)
const right = makeRockColumn(thick, hR, depth, mats[(s + 1) % 3]);
right.position.set(rightInner + thick / 2, hR / 2 - 0.5, zLocal);
right.rotation.z = (hashSeed(zIndex + s + 0.2) - 0.5) * 0.06;
group.add(right);
// Jagged ridge spikes (visual only — high up on wall crowns)
if (hashSeed(zIndex * 9 + s) > 0.4) {
const spike = makeRockColumn(thick * 0.45, hL * 0.32, depth * 0.35, mats[0]);
spike.position.set(leftInner - thick * 0.4, hL + hL * 0.05, zLocal);
group.add(spike);
}
if (hashSeed(zIndex * 11 + s) > 0.4) {
const spike = makeRockColumn(thick * 0.45, hR * 0.32, depth * 0.35, mats[1]);
spike.position.set(rightInner + thick * 0.4, hR + hR * 0.05, zLocal);
group.add(spike);
}
// Inner-face slab colliders (axis-aligned, reliable near-miss + crash)
const colH = Math.max(hL, hR) * 1.15;
const colD = depth + 1;
const leftCol = new THREE.Mesh(
new THREE.BoxGeometry(2.4, colH, colD),
new THREE.MeshBasicMaterial({ visible: false })
);
leftCol.position.set(leftInner - 1.2, colH / 2 - 0.5, zLocal);
group.add(leftCol);
localColliders.push({ mesh: leftCol, type: 'wall' });
const rightCol = new THREE.Mesh(
new THREE.BoxGeometry(2.4, colH, colD),
new THREE.MeshBasicMaterial({ visible: false })
);
rightCol.position.set(rightInner + 1.2, colH / 2 - 0.5, zLocal);
group.add(rightCol);
localColliders.push({ mesh: rightCol, type: 'wall' });
}
// Floating monoliths — never in the opening stretch, and not on the flight line early on
if (!avoidObstacles) {
const monoCount = Math.floor(1 + dens * 3 + rand() * 2);
for (let i = 0; i < monoCount; i++) {
const mw = 2 + rand() * 4;
const mh = 10 + rand() * 28;
const md = 2 + rand() * 4;
const mono = new THREE.Mesh(new THREE.BoxGeometry(mw, mh, md), monoMat);
// Keep monoliths off the dead-center line a bit
const sideBias = rand() > 0.5 ? 1 : -1;
const mx = meander + sideBias * (6 + rand() * half * 0.55);
const my = 5 + rand() * 16;
// Bias obstacles deeper into the chunk (never flush with chunk near-edge)
const mz = -(18 + rand() * (CFG.CHUNK_LEN - 28));
mono.position.set(mx, my, mz);
mono.rotation.y = rand() * Math.PI * 0.5;
mono.castShadow = true;
mono.receiveShadow = true;
group.add(mono);
localColliders.push({ mesh: mono, type: 'mono' });
if (rand() > 0.65) {
const s2 = new THREE.Mesh(
new THREE.BoxGeometry(mw * 0.55, mh * 0.35, md * 0.55),
mats[Math.floor(rand() * 3)]
);
s2.position.set(mx + sideBias * (2 + rand() * 3), my + mh * 0.35, mz + (rand() - 0.5) * 3);
s2.castShadow = true;
group.add(s2);
localColliders.push({ mesh: s2, type: 'mono' });
}
}
}
// Tight squeezes — paired pillars with optional overhead beam
if (!avoidObstacles && rand() < 0.35 + dens * 0.35) {
const gap = Math.max(4.8, 7.0 - dens * 1.6);
const cx = meander + (rand() - 0.5) * 5;
const cz = -(CFG.CHUNK_LEN * (0.35 + rand() * 0.45));
const ph = 16 + rand() * 18;
for (const side of [-1, 1]) {
const p = new THREE.Mesh(new THREE.BoxGeometry(3.2, ph, 3.2), wallMatC);
p.position.set(cx + side * (gap / 2 + 1.6), ph / 2 + 1, cz);
p.castShadow = true;
group.add(p);
localColliders.push({ mesh: p, type: 'mono' });
}
if (rand() > 0.4) {
const beam = new THREE.Mesh(new THREE.BoxGeometry(gap + 7, 2.2, 2.8), monoMat);
beam.position.set(cx, 11 + rand() * 6, cz);
beam.castShadow = true;
group.add(beam);
localColliders.push({ mesh: beam, type: 'mono' });
}
}
// Energy orbs — keep first cluster ahead of the craft, not in its face
const orbCount = 2 + Math.floor(rand() * 3) + (dens > 0.5 ? 1 : 0);
for (let i = 0; i < orbCount; i++) {
const orb = new THREE.Mesh(new THREE.OctahedronGeometry(0.85, 0), orbMat.clone());
const nearWall = rand() > 0.3;
const minZ = (zIndex === 0) ? 25 : 8;
const oz = -(minZ + rand() * (CFG.CHUNK_LEN - minZ - 4));
const oy = 4 + rand() * 12;
let ox;
if (nearWall) {
const side = rand() > 0.5 ? 1 : -1;
ox = meander + side * (half * 0.55) + (rand() - 0.5) * 2.5;
} else {
ox = meander + (rand() - 0.5) * half * 0.55;
}
orb.position.set(ox, oy, oz);
orb.userData.spin = 1.5 + rand() * 2;
const ol = new THREE.PointLight(PAL.orb, 1.2, 14);
orb.add(ol);
group.add(orb);
localColliders.push({ mesh: orb, type: 'orb', collected: false });
}
// Distant decorative ridges (no collision)
for (let i = 0; i < 3; i++) {
const ridge = new THREE.Mesh(
new THREE.BoxGeometry(20 + rand() * 30, 8 + rand() * 20, 15 + rand() * 20),
mats[Math.floor(rand() * 3)]
);
ridge.position.set(
(rand() > 0.5 ? 1 : -1) * (half + 30 + rand() * 40),
5 + rand() * 15,
-rand() * CFG.CHUNK_LEN
);
ridge.rotation.y = rand() * 0.5;
group.add(ridge);
}
group.userData.colliders = localColliders;
group.userData.meander = meander;
group.userData.half = half;
return group;
}
function worldBox(mesh) {
mesh.updateWorldMatrix(true, false);
return new THREE.Box3().setFromObject(mesh);
}
function refreshColliders() {
colliders.length = 0;
for (const [, ch] of chunks) {
for (const c of ch.userData.colliders) {
if (c.type === 'orb' && c.collected) continue;
const box = worldBox(c.mesh);
const center = new THREE.Vector3();
const size = new THREE.Vector3();
box.getCenter(center);
box.getSize(size);
colliders.push({
box, center, size,
radius: Math.max(size.x, size.y, size.z) * 0.5,
type: c.type,
ref: c,
mesh: c.mesh,
});
}
}
}
function updateChunks(playerZ, score) {
const idx = Math.floor(-playerZ / CFG.CHUNK_LEN);
const difficulty = Math.min(1, score / 25000 + (-playerZ) / 8000);
// spawn ahead
for (let i = idx - CFG.CHUNK_BEHIND; i <= idx + CFG.CHUNK_AHEAD; i++) {
if (i < 0) continue;
if (!chunks.has(i)) {
const ch = generateChunk(i, difficulty);
scene.add(ch);
chunks.set(i, ch);
}
}
// cull far behind
for (const [k, ch] of chunks) {
if (k < idx - CFG.CHUNK_BEHIND - 1) {
scene.remove(ch);
ch.traverse(o => {
if (o.geometry) o.geometry.dispose();
// materials are shared mostly — skip dispose shared mats
});
chunks.delete(k);
}
}
refreshColliders();
return difficulty;
}
// ═══════════════════════════════════════════════════════════
// GAME STATE
// ═══════════════════════════════════════════════════════════
const keys = {};
const mouse = { x: 0, y: 0, active: false };
const state = {
mode: 'title', // title | play | dead
pos: new THREE.Vector3(0, 8, 0),
vel: new THREE.Vector3(0, 0, 0), // lateral x, vertical y, forward is separate
speed: CFG.BASE_SPEED,
bank: 0,
pitch: 0,
energy: CFG.ENERGY_MAX,
turbo: 0,
boosting: false,
score: 0,
mult: 1,
maxMult: 1,
maxSpeed: 0,
shake: 0,
nearMissCD: 0,
lastNear: 0,
camOff: new THREE.Vector3(0, 5.5, 14),
time: 0,
difficulty: 0,
grace: 0,
};
function disposeChunk(ch) {
scene.remove(ch);
ch.traverse(o => {
if (o.geometry) o.geometry.dispose();
// only dispose materials we cloned per-mesh (orbs)
if (o.isMesh && o.material && o.material !== wallMat && o.material !== wallMatB &&
o.material !== wallMatC && o.material !== monoMat && o.material !== groundMat &&
o.material !== orbMat && o.material) {
// orb uses orbMat.clone()
if (o.material.emissive && o.material.color && o.material !== craft.userData.glowMat) {
try { o.material.dispose(); } catch (_) {}
}
}
});
}
function resetGame() {
state.pos.set(0, 8, 0);
state.vel.set(0, 0, 0);
state.speed = CFG.BASE_SPEED;
state.bank = 0;
state.pitch = 0;
state.energy = CFG.ENERGY_MAX;
state.turbo = 40; // a little boost charge so launch feels alive
state.boosting = false;
state.score = 0;
state.mult = 1;
state.maxMult = 1;
state.maxSpeed = 0;
state.shake = 0;
state.nearMissCD = 0;
state.time = 0;
state.difficulty = 0;
state.grace = CFG.SPAWN_GRACE;
craft.position.copy(state.pos);
craft.rotation.set(0, 0, 0);
// clear chunks completely so title-idle geometry can't linger
for (const [, ch] of chunks) disposeChunk(ch);
chunks.clear();
colliders.length = 0;
updateChunks(0, 0);
// force matrix/world bound refresh after adding fresh chunks
craft.updateMatrixWorld(true);
refreshColliders();
// snap camera so first frame isn't looking at old title path
camera.position.set(0, 13, 16);
camera.lookAt(0, 8, -18);
camera.fov = CFG.FOV_BASE;
camera.updateProjectionMatrix();
updateHUD();
}
// ═══════════════════════════════════════════════════════════
// INPUT
// ═══════════════════════════════════════════════════════════
window.addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'Space') e.preventDefault();
});
window.addEventListener('keyup', e => { keys[e.code] = false; });
window.addEventListener('mousemove', e => {
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
mouse.active = true;
const ret = document.getElementById('reticle');
ret.style.left = e.clientX + 'px';
ret.style.top = e.clientY + 'px';
});
window.addEventListener('mousedown', () => { if (state.mode === 'play') keys['Space'] = true; });
window.addEventListener('mouseup', () => { keys['Space'] = false; });
document.getElementById('start-btn').addEventListener('click', () => {
SFX.init();
document.getElementById('title-screen').classList.add('hidden');
resetGame();
state.mode = 'play';
});
document.getElementById('restart-btn').addEventListener('click', () => {
document.getElementById('go-screen').classList.add('hidden');
resetGame();
state.mode = 'play';
SFX.init();
});
// ═══════════════════════════════════════════════════════════
// PHYSICS + COLLISION
// ═══════════════════════════════════════════════════════════
const _playerBox = new THREE.Box3();
const _tmp = new THREE.Vector3();
function getInputAxes() {
let ix = 0, iy = 0;
if (keys['KeyA'] || keys['ArrowLeft']) ix -= 1;
if (keys['KeyD'] || keys['ArrowRight']) ix += 1;
if (keys['KeyW'] || keys['ArrowUp']) iy += 1;
if (keys['KeyS'] || keys['ArrowDown']) iy -= 1;
// mouse assist (soft)
if (mouse.active) {
ix += mouse.x * 0.85;
iy += mouse.y * 0.55;
}
ix = THREE.MathUtils.clamp(ix, -1, 1);
iy = THREE.MathUtils.clamp(iy, -1, 1);
return { ix, iy };
}
function physics(dt) {
const { ix, iy } = getInputAxes();
// Difficulty ramps base speed
const targetBase = CFG.BASE_SPEED + state.difficulty * 55 + Math.min(40, state.score / 800);
if (!state.boosting) {
state.speed += (targetBase - state.speed) * (1 - Math.exp(-dt * 1.2));
}
// Turbo
const wantBoost = keys['Space'] && state.turbo > 5;
if (wantBoost && !state.boosting) {
state.boosting = true;
SFX.boostStart();
}
if (state.boosting) {
if (!keys['Space'] || state.turbo <= 0) {
state.boosting = false;
} else {
state.turbo = Math.max(0, state.turbo - CFG.TURBO_COST * dt);
state.speed += (targetBase + CFG.TURBO_BOOST - state.speed) * (1 - Math.exp(-dt * 4));
}
} else {
// charge turbo on speed / straight
const straight = 1 - Math.min(1, Math.abs(ix) + Math.abs(iy) * 0.5);
state.turbo = Math.min(CFG.TURBO_MAX, state.turbo + CFG.TURBO_GAIN * straight * (state.speed / 60) * dt);
}
// Lateral / vertical momentum
state.vel.x += ix * CFG.LATERAL_ACCEL * dt;
state.vel.y += iy * CFG.PITCH_ACCEL * dt;
// drag
state.vel.x *= Math.exp(-CFG.DRAG * dt);
state.vel.y *= Math.exp(-CFG.DRAG * dt);
state.vel.x = THREE.MathUtils.clamp(state.vel.x, -CFG.LATERAL_MAX, CFG.LATERAL_MAX);
state.vel.y = THREE.MathUtils.clamp(state.vel.y, -CFG.PITCH_MAX, CFG.PITCH_MAX);
// Pitch-based slight speed carry (dive -> faster)
const pitchBonus = THREE.MathUtils.clamp(-state.vel.y * 0.35, -4, 12);
const forward = state.speed + pitchBonus;
// Integrate
state.pos.x += state.vel.x * dt;
state.pos.y += state.vel.y * dt;
state.pos.z -= forward * dt;
// Soft altitude clamp
state.pos.y = THREE.MathUtils.clamp(state.pos.y, 1.5, 42);
// Soft canyon confines (push inward before hard collide)
// Approx using nearby wall colliders handled below
// Visual bank / pitch
const targetBank = -ix * CFG.BANK_MAX - state.vel.x * 0.012;
const targetPitch = -state.vel.y * 0.025 + iy * 0.15;
state.bank += (targetBank - state.bank) * (1 - Math.exp(-dt * 8));
state.pitch += (targetPitch - state.pitch) * (1 - Math.exp(-dt * 7));
craft.position.copy(state.pos);
craft.rotation.z = state.bank;
craft.rotation.x = state.pitch;
craft.rotation.y = -state.vel.x * 0.004;
// Engine glow intensity
const gn = 1.5 + (state.speed / CFG.MAX_SPEED) * 2 + (state.boosting ? 2.5 : 0);
craft.userData.glowMat.emissiveIntensity = gn;
craft.userData.engLight.intensity = 1.5 + (state.boosting ? 4 : 0) + state.speed / 40;
// Energy decay
state.energy -= CFG.ENERGY_DECAY * (1 + state.difficulty * 0.4) * dt;
if (state.boosting) state.energy -= CFG.ENERGY_DECAY * 0.5 * dt;
// Score from distance + mult
state.score += forward * state.mult * 0.35 * dt;
state.maxSpeed = Math.max(state.maxSpeed, state.speed);
// Mult decay toward 1
if (state.nearMissCD <= 0) {
state.mult = Math.max(1, state.mult - CFG.MULT_DECAY * dt);
} else {
state.nearMissCD -= dt;
}
state.maxMult = Math.max(state.maxMult, state.mult);
// speed clamp visual
state.speed = Math.min(state.speed, CFG.MAX_SPEED + 20);
}
function collisions(dt) {
const pr = CFG.COLLIDE_R;
const px = state.pos.x, py = state.pos.y, pz = state.pos.z;
let closestWall = 999;
let hit = false;
const invuln = state.grace > 0;
for (let i = colliders.length - 1; i >= 0; i--) {
const c = colliders[i];
// cheap reject on Z / X
if (Math.abs(c.center.z - pz) > 30) continue;
if (Math.abs(c.center.x - px) > 40) continue;
if (c.type === 'orb') {
const d = c.center.distanceTo(state.pos);
if (d < 2.4 && !c.ref.collected) {
c.ref.collected = true;
c.mesh.visible = false;
if (c.mesh.children) {
c.mesh.children.forEach(ch => { if (ch.isLight) ch.intensity = 0; });
}
state.energy = Math.min(CFG.ENERGY_MAX, state.energy + CFG.ENERGY_ORB);
state.turbo = Math.min(CFG.TURBO_MAX, state.turbo + 12);
state.score += 50 * state.mult;
SFX.ping();
}
continue;
}
// AABB vs sphere (craft)
const bx = c.box;
// Guard against inverted / empty boxes from unready matrices
if (!isFinite(bx.min.x) || !isFinite(bx.max.x) || bx.isEmpty()) continue;
const cx = THREE.MathUtils.clamp(px, bx.min.x, bx.max.x);
const cy = THREE.MathUtils.clamp(py, bx.min.y, bx.max.y);
const cz = THREE.MathUtils.clamp(pz, bx.min.z, bx.max.z);
const dx = px - cx, dy = py - cy, dz = pz - cz;
const distSq = dx * dx + dy * dy + dz * dz;
if (distSq < pr * pr) {
if (!invuln) {
hit = true;
break;
}
// during grace: gently nudge out instead of killing
continue;
}
const dist = Math.sqrt(Math.max(distSq, 0));
if (dist < closestWall) closestWall = dist;
}
// Near-miss (skip during grace so multiplier doesn't fake-stack in tunnel)
if (!invuln && closestWall < CFG.NEAR_MISS_DIST && closestWall > pr) {
const proximity = 1 - (closestWall - pr) / (CFG.NEAR_MISS_DIST - pr);
const gain = CFG.NEAR_MISS_MULT_GAIN * proximity * (1 + state.difficulty) * dt * 18;
state.mult = Math.min(CFG.MULT_MAX, state.mult + gain);
state.shake = Math.max(state.shake, proximity * 0.35);
state.turbo = Math.min(CFG.TURBO_MAX, state.turbo + proximity * 14 * dt);
if (proximity > 0.55 && state.nearMissCD <= 0.05) {
if (performance.now() - state.lastNear > 420) {
state.lastNear = performance.now();
const fx = document.getElementById('near-miss-fx');
fx.textContent = proximity > 0.85 ? 'RAZOR EDGE' : 'NEAR MISS';
fx.classList.remove('show');
void fx.offsetWidth;
fx.classList.add('show');
SFX.whoosh();
document.getElementById('mult-wrap').classList.remove('pulse');
void document.getElementById('mult-wrap').offsetWidth;
document.getElementById('mult-wrap').classList.add('pulse');
}
state.nearMissCD = 0.35;
}
document.getElementById('reticle').classList.toggle('danger', proximity > 0.4);
} else {
document.getElementById('reticle').classList.remove('danger');
}
return hit;
}
// ═══════════════════════════════════════════════════════════
// CAMERA
// ═══════════════════════════════════════════════════════════
function updateCamera(dt) {
const boostT = state.boosting ? 1 : 0;
const targetFov = THREE.MathUtils.lerp(CFG.FOV_BASE, CFG.FOV_BOOST, boostT * 0.85 + (state.speed - CFG.BASE_SPEED) / 200);
camera.fov += (targetFov - camera.fov) * (1 - Math.exp(-dt * 3));
camera.updateProjectionMatrix();
// chase cam with lag
const back = 12 + state.speed * 0.04;
const up = 4.2 + Math.abs(state.bank) * 1.5;
const ideal = _tmp.set(
state.pos.x * 0.85 - state.vel.x * 0.08,
state.pos.y + up,
state.pos.z + back
);
camera.position.x += (ideal.x - camera.position.x) * (1 - Math.exp(-dt * 5));
camera.position.y += (ideal.y - camera.position.y) * (1 - Math.exp(-dt * 4));
camera.position.z += (ideal.z - camera.position.z) * (1 - Math.exp(-dt * 6));
// look ahead
const look = new THREE.Vector3(
state.pos.x + state.vel.x * 0.12,
state.pos.y + 0.5,
state.pos.z - 18
);
camera.lookAt(look);
// shake
if (state.shake > 0.001) {
camera.position.x += (Math.random() - 0.5) * state.shake;
camera.position.y += (Math.random() - 0.5) * state.shake * 0.7;
state.shake *= Math.exp(-dt * 8);
}
if (state.boosting) {
state.shake = Math.max(state.shake, 0.12);
}
}
// ═══════════════════════════════════════════════════════════
// HUD
// ═══════════════════════════════════════════════════════════
function updateHUD() {
document.getElementById('score-val').textContent = Math.floor(state.score).toLocaleString();
document.getElementById('mult-val').textContent = state.mult.toFixed(1);
document.getElementById('speed-val').textContent = Math.floor(state.speed);
const ePct = THREE.MathUtils.clamp(state.energy / CFG.ENERGY_MAX, 0, 1) * 100;
const eBar = document.getElementById('energy-bar');
eBar.style.width = ePct + '%';
eBar.classList.toggle('low', state.energy < 28);
const sPct = THREE.MathUtils.clamp((state.speed - 20) / (CFG.MAX_SPEED - 20), 0, 1) * 100;
document.getElementById('speed-bar').style.width = sPct + '%';
document.getElementById('turbo-bar').style.width = (state.turbo / CFG.TURBO_MAX * 22) + '%';
document.getElementById('boost-fx').classList.toggle('active', state.boosting);
}
function gameOver() {
state.mode = 'dead';
SFX.crash();
document.getElementById('go-score').textContent = Math.floor(state.score).toLocaleString();
document.getElementById('go-maxspd').textContent = Math.floor(state.maxSpeed);
document.getElementById('go-maxmult').textContent = state.maxMult.toFixed(1) + '×';
document.getElementById('go-screen').classList.remove('hidden');
}
// ═══════════════════════════════════════════════════════════
// SPIN ORBS
// ═══════════════════════════════════════════════════════════
function animateOrbs(dt) {
for (const [, ch] of chunks) {
for (const c of ch.userData.colliders) {
if (c.type === 'orb' && !c.collected && c.mesh.visible) {
c.mesh.rotation.y += (c.mesh.userData.spin || 2) * dt;
c.mesh.rotation.x += dt * 0.8;
c.mesh.position.y += Math.sin(state.time * 3 + c.mesh.position.z) * 0.01;
}
}
}
}
// ═══════════════════════════════════════════════════════════
// RESIZE
// ═══════════════════════════════════════════════════════════
window.addEventListener('resize', () => {
const w = window.innerWidth, h = window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
composer.setSize(w, h);
bloom.resolution.set(w, h);
});
// ═══════════════════════════════════════════════════════════
// MAIN LOOP
// ═══════════════════════════════════════════════════════════
let last = performance.now();
// bootstrap a couple chunks even on title
updateChunks(0, 0);
// idle drift on title
function titleIdle(dt) {
state.time += dt;
craft.position.set(Math.sin(state.time * 0.4) * 3, 8 + Math.sin(state.time * 0.7) * 1.2, -state.time * 12);
craft.rotation.z = Math.sin(state.time * 0.5) * 0.2;
craft.rotation.x = Math.sin(state.time * 0.3) * 0.08;
camera.position.set(craft.position.x * 0.5, craft.position.y + 5, craft.position.z + 16);
camera.lookAt(craft.position.x, craft.position.y, craft.position.z - 20);
updateChunks(craft.position.z, 0);
updateTrail(dt, false);
updateDust(dt, 40);
animateOrbs(dt);
}
function frame(now) {
requestAnimationFrame(frame);
let dt = (now - last) / 1000;
last = now;
dt = Math.min(dt, 0.05);
state.time += dt;
if (state.mode === 'title') {
titleIdle(dt);
composer.render();
return;
}
if (state.mode === 'play') {
if (state.grace > 0) state.grace = Math.max(0, state.grace - dt);
state.difficulty = updateChunks(state.pos.z, state.score);
physics(dt);
// refresh colliders frequently — moved geometry needs fresh world boxes
refreshColliders();
const crashed = collisions(dt);
// energy only kills after a frame of real play (never on the Launch frame)
if (crashed || (state.energy <= 0 && state.grace <= 0 && state.time > 0.25)) {
gameOver();
}
updateCamera(dt);
updateTrail(dt, state.boosting);
updateDust(dt, state.speed);
animateOrbs(dt);
updateHUD();
const speedNorm = THREE.MathUtils.clamp(state.speed / CFG.MAX_SPEED, 0, 1);
SFX.setEngine(speedNorm, state.boosting);
// bloom + motion blur scale with speed / boost
bloom.strength = 0.38 + (state.boosting ? 0.35 : 0) + state.mult * 0.015;
const blurAmt = THREE.MathUtils.clamp(
(state.speed - CFG.BASE_SPEED) / 90 + (state.boosting ? 0.55 : 0),
0, 1.35
);
speedBlur.uniforms.strength.value = blurAmt;
// sun + path light follow for readable lighting along the run
sun.position.set(
state.pos.x - 60,
90,
state.pos.z - 30
);
sun.target.position.copy(state.pos);
sun.target.updateMatrixWorld();
pathLight.position.set(state.pos.x, state.pos.y + 16, state.pos.z - 12);
} else if (state.mode === 'dead') {
// slow drift cam
camera.position.y += dt * 0.5;
updateTrail(dt, false);
}
composer.render();
}
requestAnimationFrame(frame);
</script>
</body>
</html>