Canyon Run
Minimax m3 · middle
◈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
Human review · Jul 17, 2026 · rubric 1
Historical path-based review. Association with these exact bytes is unverified.
- Visual design
- 4 / 5
- UX / feel
- 3 / 5
- Works
- 2 / 5
- Prompt spirit
- 2 / 5
- Delight
- 4 / 5
Wow factor because of visuals and ship design, but basically unplayable
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 55,415 bytes · SHA-256 453b4dee7264
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SUNRIDGE // Canyon Racer</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700;900&family=Rajdhani:wght@300;500;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0210; font-family: 'Orbitron', sans-serif; color: #fff; }
canvas { display: block; }
#hud {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 10;
text-shadow: 0 0 8px rgba(255,107,53,0.6);
}
/* Top-left score */
#score-panel { position: absolute; top: 22px; left: 26px; }
#score-label { font-size: 10px; letter-spacing: 4px; opacity: 0.6; font-weight: 400; }
#score-value { font-size: 32px; font-weight: 900; letter-spacing: 2px; color: #ffe4b5;
text-shadow: 0 0 12px #ff6b35, 0 0 24px rgba(255,107,53,0.4); }
/* Top-right altitude/speed info */
#info-panel { position: absolute; top: 22px; right: 26px; text-align: right; }
.info-row { font-size: 11px; letter-spacing: 2px; opacity: 0.7; margin-bottom: 4px; font-weight: 500; }
.info-val { font-size: 18px; font-weight: 700; color: #b0e0ff; text-shadow: 0 0 8px #00aaff; }
/* Center multiplier */
#multiplier-wrap { position: absolute; top: 60px; left: 50%; transform: translateX(-50%); text-align: center; }
#multiplier { font-size: 56px; font-weight: 900; line-height: 1;
background: linear-gradient(180deg, #fff 0%, #ff6b35 100%);
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
filter: drop-shadow(0 0 12px rgba(255,107,53,0.8));
transition: transform 0.08s ease-out; display: inline-block; }
#multiplier.pulse { transform: scale(1.4); }
#multiplier-label { font-size: 9px; letter-spacing: 5px; opacity: 0.5; margin-top: 2px; }
/* Bottom energy bar */
#energy-container {
position: absolute; bottom: 36px; left: 50%; transform: translateX(-50%);
width: 340px; height: 6px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,107,53,0.35);
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%, 4px 50%);
}
#energy-bar {
height: 100%; width: 100%;
background: linear-gradient(90deg, #ff2266 0%, #ff6b35 40%, #ffcc00 100%);
box-shadow: 0 0 12px #ff6b35, inset 0 0 6px rgba(255,255,255,0.3);
transition: width 0.15s linear;
}
#energy-label { position: absolute; bottom: 48px; left: 50%; transform: translateX(-50%);
font-size: 9px; letter-spacing: 4px; opacity: 0.6; }
/* Bottom-right speedometer */
#speedo-wrap { position: absolute; bottom: 50px; right: 26px; }
#speedo-label { font-size: 9px; letter-spacing: 3px; opacity: 0.5; margin-bottom: 4px; text-align: right; }
#speedo-bar { width: 220px; height: 2px; background: rgba(255,255,255,0.12); position: relative; }
#speedo-fill { position: absolute; left: 0; top: 0; height: 100%;
background: linear-gradient(90deg, #00ddff, #00ffff);
box-shadow: 0 0 8px #00ffff; transition: width 0.1s linear; }
#speedo-tick { position: absolute; top: -3px; width: 1px; height: 8px; background: rgba(255,107,53,0.5); }
/* Turbo meter */
#turbo-wrap { position: absolute; bottom: 30px; right: 26px; }
#turbo-label { font-size: 9px; letter-spacing: 3px; opacity: 0.5; margin-bottom: 4px; text-align: right; color: #ff5599; }
#turbo-bar { width: 220px; height: 3px; background: rgba(255,255,255,0.08); position: relative; overflow: hidden; }
#turbo-fill { position: absolute; left: 0; top: 0; height: 100%; width: 0%;
background: linear-gradient(90deg, #ff2266, #ff66cc);
box-shadow: 0 0 10px #ff2266; transition: width 0.1s linear; }
#turbo-ready { font-size: 10px; color: #ff66cc; opacity: 0; letter-spacing: 2px; margin-top: 3px; text-align: right;
text-shadow: 0 0 8px #ff2266; animation: none; }
#turbo-ready.show { opacity: 1; animation: blink 0.8s infinite; }
@keyframes blink { 0%,50% { opacity: 1; } 51%,100% { opacity: 0.3; } }
/* Near-miss popup */
#nearmiss-popup { position: absolute; left: 50%; top: 45%; transform: translate(-50%, -50%);
font-size: 28px; font-weight: 900; letter-spacing: 3px; color: #ffee00;
text-shadow: 0 0 16px #ff8800, 0 0 32px #ff4400;
opacity: 0; pointer-events: none; transition: opacity 0.3s, transform 0.5s; }
#nearmiss-popup.show { opacity: 1; transform: translate(-50%, -80%); }
/* Overlays */
.overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center; z-index: 20;
background: radial-gradient(ellipse at center, rgba(20,5,40,0.85) 0%, rgba(5,0,15,0.95) 100%);
backdrop-filter: blur(8px); }
.overlay.hidden { display: none; }
.panel { text-align: center; max-width: 600px; padding: 40px; }
.title { font-size: 64px; font-weight: 900; letter-spacing: 8px; margin-bottom: 8px;
background: linear-gradient(180deg, #ffeeaa 0%, #ff6b35 50%, #aa1144 100%);
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
filter: drop-shadow(0 0 20px rgba(255,107,53,0.6)); }
.subtitle { font-family: 'Rajdhani', sans-serif; font-size: 16px; letter-spacing: 6px; opacity: 0.6; margin-bottom: 40px; }
.controls { font-family: 'Rajdhani', sans-serif; font-size: 15px; line-height: 2; opacity: 0.8; margin-bottom: 40px; }
.controls span { color: #00ddff; font-weight: 700; }
.btn { font-family: 'Orbitron', sans-serif; font-size: 16px; font-weight: 700; letter-spacing: 4px;
padding: 16px 40px; background: transparent; color: #ff6b35; border: 1px solid #ff6b35;
cursor: pointer; pointer-events: auto; transition: all 0.2s; text-transform: uppercase; }
.btn:hover { background: #ff6b35; color: #0a0210; box-shadow: 0 0 24px #ff6b35; }
.gameover-stats { display: flex; gap: 40px; justify-content: center; margin: 30px 0 40px; }
.stat { text-align: center; }
.stat-label { font-size: 10px; letter-spacing: 3px; opacity: 0.5; margin-bottom: 8px; }
.stat-value { font-size: 36px; font-weight: 900; color: #ffe4b5; text-shadow: 0 0 12px #ff6b35; }
/* Damage flash */
#damage-flash { position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: radial-gradient(ellipse at center, transparent 40%, rgba(255,0,50,0.4) 100%);
opacity: 0; pointer-events: none; z-index: 9; transition: opacity 0.3s; }
#damage-flash.show { opacity: 1; }
/* Vignette */
#vignette { position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: radial-gradient(ellipse at center, transparent 50%, rgba(0,0,0,0.6) 100%);
pointer-events: none; z-index: 8; }
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div id="vignette"></div>
<div id="damage-flash"></div>
<div id="hud">
<div id="score-panel">
<div id="score-label">SCORE</div>
<div id="score-value">0</div>
</div>
<div id="info-panel">
<div class="info-row">ALT <span class="info-val" id="alt-val">0</span></div>
<div class="info-row">VEL <span class="info-val" id="vel-val">0</span></div>
</div>
<div id="multiplier-wrap">
<div id="multiplier">×1.0</div>
<div id="multiplier-label">PROXIMITY MULTIPLIER</div>
</div>
<div id="energy-label">FUSION CORE</div>
<div id="energy-container"><div id="energy-bar"></div></div>
<div id="speedo-wrap">
<div id="speedo-label">VELOCITY</div>
<div id="speedo-bar"><div id="speedo-fill"></div><div id="speedo-tick" style="left:75%"></div></div>
</div>
<div id="turbo-wrap">
<div id="turbo-label">TURBO</div>
<div id="turbo-bar"><div id="turbo-fill"></div></div>
<div id="turbo-ready">▲ BOOST READY [SPACE] ▲</div>
</div>
<div id="nearmiss-popup">NEAR MISS</div>
</div>
<div id="start-screen" class="overlay">
<div class="panel">
<div class="title">SUNRIDGE</div>
<div class="subtitle">CANYON RACER</div>
<div class="controls">
<span>A</span> / <span>D</span> BANK LEFT / RIGHT<br>
<span>W</span> / <span>S</span> PITCH UP / DOWN<br>
<span>SPACE</span> ACTIVATE TURBO BOOST<br>
<span>R</span> RESTART
</div>
<div style="font-family:'Rajdhani'; font-size:13px; opacity:0.5; margin-bottom:30px; letter-spacing:2px;">
SKIM THE CLIFFS • CHASE THE LIGHT • STAY FUELED
</div>
<button class="btn" id="start-btn">INITIATE</button>
</div>
</div>
<div id="gameover-screen" class="overlay hidden">
<div class="panel">
<div class="title" style="font-size:48px;">SYSTEM FAILURE</div>
<div class="subtitle">CRAFT DESTROYED</div>
<div class="gameover-stats">
<div class="stat"><div class="stat-label">FINAL SCORE</div><div class="stat-value" id="final-score">0</div></div>
<div class="stat"><div class="stat-label">MAX VELOCITY</div><div class="stat-value" id="max-speed">0</div></div>
<div class="stat"><div class="stat-label">DISTANCE</div><div class="stat-value" id="distance">0</div></div>
</div>
<button class="btn" id="restart-btn">REINITIATE</button>
</div>
</div>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
// ============================================================
// CONSTANTS
// ============================================================
const CHUNK_LENGTH = 220;
const CHUNK_COUNT = 5;
const CHUNK_AHEAD = 3; // how many chunks ahead of player to keep
const SEGMENTS_PER_CHUNK = 44;
const HEIGHT_SEGS = 8;
const WALL_BOTTOM = -45;
const WALL_TOP = 60;
const BASE_HALF_WIDTH = 32;
const PLAYER_RADIUS = 1.8;
const COLLISION_MARGIN = 0.8;
const NEAR_MISS_DIST = 12;
const NEAR_MISS_NEAR = 4;
const BASE_SPEED = 95;
const MAX_SPEED_BOOST = 1.65;
const LATERAL_ACCEL = 70;
const VERTICAL_ACCEL = 55;
const LATERAL_DAMP = 2.5;
const VERTICAL_DAMP = 2.0;
const ROLL_LERP = 6;
const PITCH_LERP = 5;
const ENERGY_DECAY = 4.2; // per second
const ORB_ENERGY = 28;
const ORB_SCORE = 75;
const COLLISION_ENERGY_LOSS = 35;
const TURBO_MAX = 100;
const TURBO_GAIN_PER_SEC = 12;
const TURBO_COST_PER_SEC = 55;
// ============================================================
// GAME STATE
// ============================================================
const state = {
started: false,
gameOver: false,
score: 0,
multiplier: 1,
multiplierTimer: 0,
energy: 100,
turbo: 35,
boosting: false,
wasBoosting: false,
baseSpeed: BASE_SPEED,
currentSpeed: BASE_SPEED,
maxSpeed: 0,
distance: 0,
elapsed: 0,
cameraShake: 0,
shakeDecay: 0,
lastNearMissTime: 0,
nearMissFlash: 0,
nearMissText: 0,
speedLinesIntensity: 0,
};
// Player physics state
const player = {
x: 0, y: 18, z: 0,
vx: 0, vy: 0,
roll: 0, pitch: 0,
targetRoll: 0, targetPitch: 0,
};
// Will be set to pathX(0) on first frame in startGame
// Input
const keys = {};
let mouseDown = false;
// Three.js objects
let scene, camera, renderer, composer, bloomPass;
let playerGroup, playerMesh, cockpitMesh, engineGlow;
let voidPlane;
let sunMesh;
let sunLight;
// Chunks
const chunks = [];
// Game objects
const orbs = [];
const monoliths = [];
const speedParticles = [];
const trailParticles = [];
const burstParticles = [];
// Audio
let audioCtx = null;
let engineOsc = null, engineOsc2 = null, engineFilter = null, engineGain = null, engineGain2 = null;
let engineFilter2 = null;
// Seeded random
function mulberry32(seed) {
return function() {
let t = seed += 0x6D2B79F5;
t = Math.imul(t ^ t >>> 15, t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
// ============================================================
// PATH FUNCTIONS
// ============================================================
function pathX(z) {
return Math.sin(z * 0.0072) * 32
+ Math.sin(z * 0.0183 + 1.3) * 14
+ Math.cos(z * 0.0411 + 2.1) * 6
+ Math.sin(z * 0.0031) * 8;
}
function pathHalfWidth(z) {
// Range roughly 16 to 50
const base = BASE_HALF_WIDTH;
const wide = Math.sin(z * 0.0041) * 10;
const narrow = -Math.abs(Math.sin(z * 0.0193 + 0.7)) * 16;
const squeeze = -Math.pow(Math.max(0, Math.sin(z * 0.0067 + 1.8)), 4) * 14;
return Math.max(14, base + wide + narrow + squeeze);
}
function pathTangent(z) {
const dx = (pathX(z + 1) - pathX(z - 1)) * 0.5;
return Math.atan2(dx, 1);
}
// ============================================================
// AUDIO
// ============================================================
function initAudio() {
if (audioCtx) return;
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
// Engine - dual oscillator with filter
engineOsc = audioCtx.createOscillator();
engineOsc.type = 'sawtooth';
engineOsc.frequency.value = 55;
engineOsc2 = audioCtx.createOscillator();
engineOsc2.type = 'sine';
engineOsc2.frequency.value = 82;
engineFilter = audioCtx.createBiquadFilter();
engineFilter.type = 'lowpass';
engineFilter.frequency.value = 400;
engineFilter.Q.value = 3;
engineFilter2 = audioCtx.createBiquadFilter();
engineFilter2.type = 'lowpass';
engineFilter2.frequency.value = 250;
engineGain = audioCtx.createGain();
engineGain.gain.value = 0.0;
engineGain2 = audioCtx.createGain();
engineGain2.gain.value = 0.0;
const masterGain = audioCtx.createGain();
masterGain.gain.value = 0.35;
engineOsc.connect(engineFilter);
engineOsc2.connect(engineFilter2);
engineFilter.connect(engineGain);
engineFilter2.connect(engineGain2);
engineGain.connect(masterGain);
engineGain2.connect(masterGain);
masterGain.connect(audioCtx.destination);
engineOsc.start();
engineOsc2.start();
} catch(e) { console.warn('Audio init failed', e); }
}
function updateEngineSound() {
if (!audioCtx || !engineOsc) return;
const speedNorm = Math.max(0, Math.min(1, (state.currentSpeed - BASE_SPEED) / (BASE_SPEED * (MAX_SPEED_BOOST - 1))));
const baseFreq = 50 + speedNorm * 80;
engineOsc.frequency.setTargetAtTime(baseFreq, audioCtx.currentTime, 0.05);
engineOsc2.frequency.setTargetAtTime(baseFreq * 1.5, audioCtx.currentTime, 0.05);
engineFilter.frequency.setTargetAtTime(300 + speedNorm * 1200, audioCtx.currentTime, 0.05);
engineFilter2.frequency.setTargetAtTime(200 + speedNorm * 800, audioCtx.currentTime, 0.05);
const active = state.started && !state.gameOver;
const vol = active ? (0.10 + speedNorm * 0.07) : 0;
const vol2 = active ? (0.05 + speedNorm * 0.04) : 0;
engineGain.gain.setTargetAtTime(vol, audioCtx.currentTime, 0.1);
engineGain2.gain.setTargetAtTime(vol2, audioCtx.currentTime, 0.1);
}
function playCollect() {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(880, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(1760, audioCtx.currentTime + 0.08);
gain.gain.setValueAtTime(0.25, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.25);
osc.connect(gain); gain.connect(audioCtx.destination);
osc.start(); osc.stop(audioCtx.currentTime + 0.3);
// Harmonic
const osc2 = audioCtx.createOscillator();
const gain2 = audioCtx.createGain();
osc2.type = 'triangle';
osc2.frequency.setValueAtTime(1760, audioCtx.currentTime);
osc2.frequency.exponentialRampToValueAtTime(3520, audioCtx.currentTime + 0.08);
gain2.gain.setValueAtTime(0.12, audioCtx.currentTime);
gain2.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.2);
osc2.connect(gain2); gain2.connect(audioCtx.destination);
osc2.start(); osc2.stop(audioCtx.currentTime + 0.25);
}
function playNearMiss() {
if (!audioCtx) return;
const dur = 0.4;
const bufferSize = Math.floor(audioCtx.sampleRate * dur);
const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * (1 - i / bufferSize);
}
const noise = audioCtx.createBufferSource();
noise.buffer = buffer;
const filter = audioCtx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(3000, audioCtx.currentTime);
filter.frequency.exponentialRampToValueAtTime(400, audioCtx.currentTime + dur);
filter.Q.value = 4;
const gain = audioCtx.createGain();
gain.gain.setValueAtTime(0.35, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + dur);
noise.connect(filter); filter.connect(gain); gain.connect(audioCtx.destination);
noise.start();
}
function playBoost() {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(150, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(600, audioCtx.currentTime + 0.35);
const filter = audioCtx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(800, audioCtx.currentTime);
filter.frequency.exponentialRampToValueAtTime(2000, audioCtx.currentTime + 0.35);
gain.gain.setValueAtTime(0.2, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.45);
osc.connect(filter); filter.connect(gain); gain.connect(audioCtx.destination);
osc.start(); osc.stop(audioCtx.currentTime + 0.5);
}
function playCrash() {
if (!audioCtx) return;
const dur = 1.2;
const bufferSize = Math.floor(audioCtx.sampleRate * dur);
const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (audioCtx.sampleRate * 0.35));
}
const noise = audioCtx.createBufferSource();
noise.buffer = buffer;
const filter = audioCtx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(1500, audioCtx.currentTime);
filter.frequency.exponentialRampToValueAtTime(100, audioCtx.currentTime + dur);
const gain = audioCtx.createGain();
gain.gain.value = 0.5;
noise.connect(filter); filter.connect(gain); gain.connect(audioCtx.destination);
noise.start();
// Sub boom
const osc = audioCtx.createOscillator();
const g2 = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(80, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(20, audioCtx.currentTime + 0.6);
g2.gain.setValueAtTime(0.5, audioCtx.currentTime);
g2.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.7);
osc.connect(g2); g2.connect(audioCtx.destination);
osc.start(); osc.stop(audioCtx.currentTime + 0.8);
}
// ============================================================
// THREE.JS SETUP
// ============================================================
function initThree() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a0833);
scene.fog = new THREE.Fog(0xff7755, 90, 320);
camera = new THREE.PerspectiveCamera(72, window.innerWidth / window.innerHeight, 0.5, 800);
camera.position.set(0, 20, -10);
camera.lookAt(0, 18, 10);
renderer = new THREE.WebGLRenderer({
canvas: document.getElementById('canvas'),
antialias: true,
powerPreference: 'high-performance'
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
renderer.outputColorSpace = THREE.SRGBColorSpace;
// Post-processing
composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
0.85, 0.55, 0.55
);
composer.addPass(bloomPass);
composer.addPass(new OutputPass());
// Lights
const ambient = new THREE.AmbientLight(0x4a2060, 0.5);
scene.add(ambient);
sunLight = new THREE.DirectionalLight(0xff8855, 1.8);
sunLight.position.set(80, 40, 100);
sunLight.castShadow = true;
sunLight.shadow.mapSize.set(2048, 2048);
sunLight.shadow.camera.near = 10;
sunLight.shadow.camera.far = 400;
sunLight.shadow.camera.left = -100;
sunLight.shadow.camera.right = 100;
sunLight.shadow.camera.top = 100;
sunLight.shadow.camera.bottom = -100;
sunLight.shadow.bias = -0.0005;
sunLight.target.position.set(0, 0, 0);
scene.add(sunLight);
scene.add(sunLight.target);
const fillLight = new THREE.DirectionalLight(0x6066aa, 0.4);
fillLight.position.set(-60, 20, 50);
scene.add(fillLight);
const rimLight = new THREE.DirectionalLight(0xff3388, 0.6);
rimLight.position.set(0, 30, -100);
scene.add(rimLight);
// Sky dome with gradient
createSkyDome();
// Sun disc
createSun();
// Void plane (far below)
const voidGeom = new THREE.PlaneGeometry(2500, 2500);
const voidMat = new THREE.MeshPhongMaterial({ color: 0x080218, shininess: 0 });
voidPlane = new THREE.Mesh(voidGeom, voidMat);
voidPlane.rotation.x = -Math.PI / 2;
voidPlane.position.y = -80;
voidPlane.receiveShadow = true;
scene.add(voidPlane);
// Distant mountains
for (let i = 0; i < 30; i++) {
const angle = (i / 30) * Math.PI * 2;
const dist = 350 + Math.random() * 100;
const h = 50 + Math.random() * 80;
const w = 30 + Math.random() * 40;
const geom = new THREE.ConeGeometry(w, h, 4);
const mat = new THREE.MeshBasicMaterial({ color: 0x1a0830 });
const m = new THREE.Mesh(geom, mat);
m.position.set(Math.cos(angle) * dist, h/2 - 40, Math.sin(angle) * dist);
scene.add(m);
}
// Player
createPlayer();
}
function createSkyDome() {
const skyGeom = new THREE.SphereGeometry(500, 32, 16);
const skyMat = new THREE.ShaderMaterial({
uniforms: {
topColor: { value: new THREE.Color(0x0a0220) },
midColor: { value: new THREE.Color(0x6b1a4a) },
bottomColor: { value: new THREE.Color(0xff6b35) },
sunColor: { value: new THREE.Color(0xffcc66) },
sunDir: { value: new THREE.Vector3(0.3, 0.15, 1).normalize() }
},
vertexShader: `
varying vec3 vWorldPos;
void main() {
vec4 wp = modelMatrix * vec4(position, 1.0);
vWorldPos = wp.xyz;
gl_Position = projectionMatrix * viewMatrix * wp;
}
`,
fragmentShader: `
uniform vec3 topColor, midColor, bottomColor, sunColor, sunDir;
varying vec3 vWorldPos;
void main() {
vec3 dir = normalize(vWorldPos);
float h = dir.y;
vec3 col;
if (h > 0.0) {
float t = pow(h, 0.6);
col = mix(midColor, topColor, t);
} else {
col = mix(midColor, bottomColor, pow(-h, 0.4) * 0.7);
}
// Sun glow
float sunDot = max(0.0, dot(dir, sunDir));
col += sunColor * pow(sunDot, 12.0) * 0.6;
col += sunColor * pow(sunDot, 4.0) * 0.15;
// Horizon glow
float horizon = 1.0 - abs(h);
col += vec3(1.0, 0.5, 0.2) * pow(horizon, 6.0) * 0.3;
gl_FragColor = vec4(col, 1.0);
}
`,
side: THREE.BackSide,
depthWrite: false,
fog: false
});
const sky = new THREE.Mesh(skyGeom, skyMat);
scene.add(sky);
}
function createSun() {
const sunGeom = new THREE.CircleGeometry(40, 32);
const sunMat = new THREE.MeshBasicMaterial({
color: 0xffeeaa,
transparent: true,
opacity: 0.95,
fog: false
});
sunMesh = new THREE.Mesh(sunGeom, sunMat);
sunMesh.position.set(150, 50, 400);
scene.add(sunMesh);
// Sun glow ring
const glowGeom = new THREE.CircleGeometry(80, 32);
const glowMat = new THREE.ShaderMaterial({
uniforms: { color: { value: new THREE.Color(0xff7733) } },
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
fragmentShader: `uniform vec3 color; varying vec2 vUv; void main() { float d = length(vUv - 0.5) * 2.0; gl_FragColor = vec4(color, pow(1.0 - d, 3.0) * 0.6); }`,
transparent: true,
depthWrite: false,
fog: false
});
const glow = new THREE.Mesh(glowGeom, glowMat);
glow.position.copy(sunMesh.position);
glow.position.z -= 0.1;
scene.add(glow);
}
function createPlayer() {
playerGroup = new THREE.Group();
// Main body - low poly wedge using octahedron stretched
const bodyGeom = new THREE.OctahedronGeometry(1, 0);
bodyGeom.scale(0.7, 0.28, 1.7);
const bodyMat = new THREE.MeshPhongMaterial({
color: 0xc8d4e0,
flatShading: true,
shininess: 120,
specular: 0x6688aa
});
playerMesh = new THREE.Mesh(bodyGeom, bodyMat);
playerMesh.castShadow = true;
playerGroup.add(playerMesh);
// Wings
const wingMat = new THREE.MeshPhongMaterial({
color: 0x9aa8b8,
flatShading: true,
shininess: 80
});
// Right wing
const wgR = new THREE.BoxGeometry(2.2, 0.08, 1.2);
wgR.translate(1.2, -0.1, -0.5);
const wR = new THREE.Mesh(wgR, wingMat);
wR.castShadow = true;
playerGroup.add(wR);
// Left wing
const wgL = new THREE.BoxGeometry(2.2, 0.08, 1.2);
wgL.translate(-1.2, -0.1, -0.5);
const wL = new THREE.Mesh(wgL, wingMat);
wL.castShadow = true;
playerGroup.add(wL);
// Wing tips (angled fins)
const finGeomR = new THREE.BoxGeometry(0.08, 0.5, 0.8);
finGeomR.translate(2.2, 0.1, -0.4);
const finR = new THREE.Mesh(finGeomR, wingMat);
finR.castShadow = true;
playerGroup.add(finR);
const finGeomL = new THREE.BoxGeometry(0.08, 0.5, 0.8);
finGeomL.translate(-2.2, 0.1, -0.4);
const finL = new THREE.Mesh(finGeomL, wingMat);
finL.castShadow = true;
playerGroup.add(finL);
// Cockpit
const cockpitGeom = new THREE.SphereGeometry(0.3, 6, 4, 0, Math.PI * 2, 0, Math.PI / 2);
cockpitGeom.translate(0, 0.25, 0.3);
const cockpitMat = new THREE.MeshPhongMaterial({
color: 0x00ddff,
emissive: 0x00aadd,
emissiveIntensity: 0.7,
flatShading: true,
shininess: 100
});
cockpitMesh = new THREE.Mesh(cockpitGeom, cockpitMat);
playerGroup.add(cockpitMesh);
// Underside accent
const accentGeom = new THREE.BoxGeometry(0.4, 0.05, 2.5);
accentGeom.translate(0, -0.22, -0.2);
const accentMat = new THREE.MeshBasicMaterial({ color: 0x00ffff });
const accent = new THREE.Mesh(accentGeom, accentMat);
playerGroup.add(accent);
// Engine glow (two planes for stereo effect)
const eg1 = new THREE.PlaneGeometry(0.4, 0.25);
const em1 = new THREE.MeshBasicMaterial({ color: 0x00ffff, transparent: true, opacity: 0.95 });
engineGlow = new THREE.Mesh(eg1, em1);
engineGlow.position.set(0, 0, -1.7);
playerGroup.add(engineGlow);
const eg2 = new THREE.PlaneGeometry(0.6, 0.4);
const em2 = new THREE.MeshBasicMaterial({ color: 0x66ffff, transparent: true, opacity: 0.5 });
const engineGlow2 = new THREE.Mesh(eg2, em2);
engineGlow2.position.set(0, 0, -1.8);
playerGroup.add(engineGlow2);
playerGroup.position.set(0, 18, 0);
scene.add(playerGroup);
}
// ============================================================
// CHUNK GENERATION
// ============================================================
function createChunkGeometry(zStart, zEnd) {
const segments = SEGMENTS_PER_CHUNK;
const heightSegs = HEIGHT_SEGS;
const positionsL = [], colorsL = [], indicesL = [];
const positionsR = [], colorsR = [], indicesR = [];
for (let i = 0; i <= segments; i++) {
const t = i / segments;
const z = zStart + t * (zEnd - zStart);
const cx = pathX(z);
const hw = pathHalfWidth(z);
for (let j = 0; j <= heightSegs; j++) {
const ht = j / heightSegs;
const y = WALL_BOTTOM + ht * (WALL_TOP - WALL_BOTTOM);
// Left wall
const lNoise1 = Math.sin(z * 0.4 + j * 0.7) * 1.2;
const lNoise2 = Math.sin(z * 0.13 + j * 0.3 + 1.2) * 2.0;
const lx = cx - hw - lNoise1 - lNoise2 * 0.5;
positionsL.push(lx, y, z);
// Right wall
const rNoise1 = Math.cos(z * 0.35 + j * 0.5 + 2.1) * 1.2;
const rNoise2 = Math.sin(z * 0.11 + j * 0.4 + 0.8) * 2.0;
const rx = cx + hw + rNoise1 + rNoise2 * 0.5;
positionsR.push(rx, y, z);
// Color: dark base, orange highlight at top, with some variation
const colorBase = new THREE.Color(0x0a0218);
const colorMid = new THREE.Color(0x3a1240);
const colorHigh = new THREE.Color(0xff6b35);
const colorPeak = new THREE.Color(0xffcc88);
let c;
if (ht < 0.4) {
c = colorBase.clone().lerp(colorMid, ht / 0.4);
} else if (ht < 0.75) {
c = colorMid.clone().lerp(colorHigh, (ht - 0.4) / 0.35);
} else {
c = colorHigh.clone().lerp(colorPeak, (ht - 0.75) / 0.25);
}
// Random brightness variation
const v = 0.85 + Math.random() * 0.3;
c.multiplyScalar(v);
// Cracks/strata - horizontal bands
const strata = Math.sin(y * 0.5 + z * 0.02) * 0.5 + 0.5;
c.lerp(new THREE.Color(0xff5533), strata * 0.1 * (1 - ht));
colorsL.push(c.r, c.g, c.b);
colorsR.push(c.r * 0.95, c.g * 0.9, c.b);
}
}
// Build indices for quads
for (let i = 0; i < segments; i++) {
for (let j = 0; j < heightSegs; j++) {
const a = i * (heightSegs + 1) + j;
const b = a + 1;
const c = a + (heightSegs + 1);
const d = c + 1;
// Left wall: normal points right (+X), so CCW from right
indicesL.push(a, c, b);
indicesL.push(b, c, d);
// Right wall: normal points left (-X), so CCW from left
indicesR.push(a, b, c);
indicesR.push(b, d, c);
}
}
const geomL = new THREE.BufferGeometry();
geomL.setAttribute('position', new THREE.Float32BufferAttribute(positionsL, 3));
geomL.setAttribute('color', new THREE.Float32BufferAttribute(colorsL, 3));
geomL.setIndex(indicesL);
geomL.computeVertexNormals();
const geomR = new THREE.BufferGeometry();
geomR.setAttribute('position', new THREE.Float32BufferAttribute(positionsR, 3));
geomR.setAttribute('color', new THREE.Float32BufferAttribute(colorsR, 3));
geomR.setIndex(indicesR);
geomR.computeVertexNormals();
return { left: geomL, right: geomR };
}
function createChunk(index) {
const zStart = index * CHUNK_LENGTH;
const zEnd = zStart + CHUNK_LENGTH;
const group = new THREE.Group();
group.position.z = zStart;
const wallMat = new THREE.MeshPhongMaterial({
vertexColors: true,
flatShading: true,
shininess: 15
});
const geoms = createChunkGeometry(zStart, zEnd);
const leftMesh = new THREE.Mesh(geoms.left, wallMat);
leftMesh.receiveShadow = true;
leftMesh.castShadow = true;
group.add(leftMesh);
const rightMesh = new THREE.Mesh(geoms.right, wallMat);
rightMesh.receiveShadow = true;
rightMesh.castShadow = true;
group.add(rightMesh);
// Seeded random for chunk content
const seed = Math.floor((index + 1) * 9999);
const rng = mulberry32(seed);
// Add monoliths (obstacles) - not in first 2 chunks
const chunkMonoliths = [];
if (index >= 1) {
const numMonoliths = 2 + Math.floor(rng() * 4);
for (let i = 0; i < numMonoliths; i++) {
const mz = zStart + 20 + rng() * (CHUNK_LENGTH - 40);
const mx = pathX(mz) + (rng() - 0.5) * (pathHalfWidth(mz) * 1.4);
const my = -10 + rng() * 25;
const mh = 15 + rng() * 35;
const mw = 2 + rng() * 4;
const md = 2 + rng() * 4;
const isTall = rng() > 0.5;
const mGeom = isTall
? new THREE.BoxGeometry(mw, mh, md)
: new THREE.OctahedronGeometry(mw, 0);
const mMat = new THREE.MeshPhongMaterial({
color: 0x1a0825,
emissive: 0x441122,
emissiveIntensity: 0.4,
flatShading: true,
shininess: 40
});
const mMesh = new THREE.Mesh(mGeom, mMat);
mMesh.position.set(mx, my, mz);
mMesh.castShadow = true;
mMesh.receiveShadow = true;
group.add(mMesh);
// Glowing edge
if (rng() > 0.5) {
const edgeGeom = new THREE.BoxGeometry(mw * 1.02, mh * 1.02, md * 1.02);
const edgeMat = new THREE.MeshBasicMaterial({
color: 0xff3366,
wireframe: true,
transparent: true,
opacity: 0.6
});
const edge = new THREE.Mesh(edgeGeom, edgeMat);
edge.position.copy(mMesh.position);
group.add(edge);
}
chunkMonoliths.push({
mesh: mMesh,
x: mx, y: my, z: mz,
radius: Math.max(mw, md) * 0.7 + PLAYER_RADIUS,
triggered: false
});
}
}
monoliths.push(...chunkMonoliths);
// Add orbs
const chunkOrbs = [];
const numOrbs = 3 + Math.floor(rng() * 5);
for (let i = 0; i < numOrbs; i++) {
const oz = zStart + 15 + (i + rng()) * (CHUNK_LENGTH / numOrbs);
const hw = pathHalfWidth(oz);
const ox = pathX(oz) + (rng() - 0.5) * hw * 0.7;
const oy = 5 + rng() * 30;
const orb = createOrb(ox, oy, oz);
group.add(orb.mesh);
chunkOrbs.push(orb);
}
orbs.push(...chunkOrbs);
scene.add(group);
return { index, zStart, zEnd, group, leftMesh, rightMesh };
}
function createOrb(x, y, z) {
const geom = new THREE.IcosahedronGeometry(0.8, 0);
const mat = new THREE.MeshBasicMaterial({
color: 0xffee44
});
const mesh = new THREE.Mesh(geom, mat);
// Glow sprite
const glowGeom = new THREE.PlaneGeometry(4, 4);
const glowMat = new THREE.ShaderMaterial({
uniforms: { color: { value: new THREE.Color(0xffcc00) } },
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
fragmentShader: `uniform vec3 color; varying vec2 vUv; void main() { float d = length(vUv - 0.5) * 2.0; gl_FragColor = vec4(color, pow(1.0 - d, 2.5) * 0.8); }`,
transparent: true,
depthWrite: false
});
const glow = new THREE.Mesh(glowGeom, glowMat);
mesh.add(glow);
mesh.position.set(x, y, z);
return {
mesh,
x, y, z,
baseY: y,
phase: Math.random() * Math.PI * 2,
collected: false
};
}
function initChunks() {
for (let i = 0; i < CHUNK_COUNT; i++) {
chunks.push(createChunk(i));
}
}
function recycleChunks() {
// Find chunks behind the player and move them ahead
for (let chunk of chunks) {
if (player.z > chunk.zEnd + CHUNK_LENGTH) {
// Remove old content
// Remove monoliths and orbs from this chunk
for (let i = monoliths.length - 1; i >= 0; i--) {
if (monoliths[i].z >= chunk.zStart && monoliths[i].z < chunk.zEnd) {
monoliths.splice(i, 1);
}
}
for (let i = orbs.length - 1; i >= 0; i--) {
if (orbs[i].z >= chunk.zStart && orbs[i].z < chunk.zEnd && !orbs[i].collected) {
scene.remove(orbs[i].mesh);
orbs.splice(i, 1);
}
}
// Dispose geometries
chunk.leftMesh.geometry.dispose();
chunk.rightMesh.geometry.dispose();
scene.remove(chunk.group);
// Find max chunk index
const maxIndex = Math.max(...chunks.map(c => c.index)) + 1;
// Recreate
const newChunk = createChunk(maxIndex);
Object.assign(chunk, newChunk);
}
}
}
// ============================================================
// PARTICLE SYSTEMS
// ============================================================
function initParticles() {
// Speed lines - dust/debris flying past
for (let i = 0; i < 80; i++) {
const geom = new THREE.PlaneGeometry(0.08, 1.5 + Math.random() * 2);
const mat = new THREE.MeshBasicMaterial({
color: new THREE.Color().setHSL(0.08 + Math.random() * 0.05, 0.8, 0.6),
transparent: true,
opacity: 0.0,
side: THREE.DoubleSide,
depthWrite: false
});
const mesh = new THREE.Mesh(geom, mat);
scene.add(mesh);
speedParticles.push({
mesh,
x: 0, y: 0, z: 0,
speed: 1 + Math.random() * 0.5,
reset() {
this.x = player.x + (Math.random() - 0.5) * 120;
this.y = Math.random() * 50 - 10;
this.z = player.z + 100 + Math.random() * 200;
this.mesh.position.set(this.x, this.y, this.z);
this.mesh.material.opacity = 0;
this.mesh.scale.y = 0.5 + Math.random() * 1.5;
}
});
speedParticles[i].reset();
}
// Trail particles
for (let i = 0; i < 40; i++) {
const geom = new THREE.PlaneGeometry(0.6, 0.6);
const mat = new THREE.MeshBasicMaterial({
color: 0x00ffff,
transparent: true,
opacity: 0,
depthWrite: false
});
const mesh = new THREE.Mesh(geom, mat);
scene.add(mesh);
trailParticles.push({
mesh,
life: 0, maxLife: 0.6,
active: false
});
}
let trailIdx = 0;
window._emitTrail = function() {
const p = trailParticles[trailIdx];
p.mesh.position.set(
player.x + (Math.random() - 0.5) * 0.5,
player.y - 0.1,
player.z - 1.8
);
p.mesh.material.opacity = 0.8;
p.life = p.maxLife;
p.active = true;
p.mesh.scale.setScalar(0.8 + Math.random() * 0.4);
trailIdx = (trailIdx + 1) % trailParticles.length;
};
// Burst particles (for collection, collision)
for (let i = 0; i < 60; i++) {
const geom = new THREE.PlaneGeometry(0.3, 0.3);
const mat = new THREE.MeshBasicMaterial({
color: 0xffaa00,
transparent: true,
opacity: 0,
depthWrite: false
});
const mesh = new THREE.Mesh(geom, mat);
scene.add(mesh);
burstParticles.push({
mesh,
vx: 0, vy: 0, vz: 0,
life: 0, maxLife: 0.8,
active: false
});
}
let burstIdx = 0;
window._emitBurst = function(x, y, z, color, count) {
for (let i = 0; i < count; i++) {
const p = burstParticles[burstIdx];
p.mesh.position.set(x, y, z);
p.mesh.material.color.set(color);
p.vx = (Math.random() - 0.5) * 20;
p.vy = (Math.random() - 0.5) * 20;
p.vz = (Math.random() - 0.5) * 20;
p.life = p.maxLife;
p.maxLife = 0.4 + Math.random() * 0.6;
p.active = true;
p.mesh.material.opacity = 1;
p.mesh.scale.setScalar(0.5 + Math.random() * 0.8);
burstIdx = (burstIdx + 1) % burstParticles.length;
}
};
}
function updateParticles(dt) {
// Speed lines
const speedNorm = Math.max(0, Math.min(1, (state.currentSpeed - BASE_SPEED) / (BASE_SPEED * (MAX_SPEED_BOOST - 1))));
const intensity = Math.max(0.15, Math.min(1, speedNorm * 1.5));
for (let p of speedParticles) {
const distTraveled = state.currentSpeed * dt * p.speed;
p.z -= distTraveled;
if (p.z < player.z - 30) {
p.reset();
}
// Fade in/out
const dz = p.z - player.z;
let op = 0;
if (dz > 0 && dz < 200) {
op = Math.min(1, dz / 50) * intensity;
} else if (dz < 0 && dz > -30) {
op = (1 + dz / 30) * intensity;
}
p.mesh.material.opacity = op * 0.6;
// Stretch with speed
p.mesh.scale.y = 1 + speedNorm * 4;
}
// Trail
for (let p of trailParticles) {
if (!p.active) continue;
p.life -= dt;
if (p.life <= 0) {
p.active = false;
p.mesh.material.opacity = 0;
continue;
}
p.mesh.material.opacity = (p.life / p.maxLife) * 0.7;
p.mesh.scale.setScalar(p.life / p.maxLife * 1.2);
// Particles stay in place while player moves forward (creates trail effect)
}
// Emit trail
if (state.started && !state.gameOver) {
if (Math.random() < 0.8) window._emitTrail();
}
// Burst
for (let p of burstParticles) {
if (!p.active) continue;
p.life -= dt;
if (p.life <= 0) {
p.active = false;
p.mesh.material.opacity = 0;
continue;
}
p.mesh.position.x += p.vx * dt;
p.mesh.position.y += p.vy * dt;
p.mesh.position.z += p.vz * dt;
p.mesh.material.opacity = p.life / p.maxLife;
p.mesh.scale.setScalar(0.3 + p.life / p.maxLife * 0.7);
}
}
// ============================================================
// INPUT
// ============================================================
function initInput() {
window.addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'KeyR' && state.gameOver) restart();
if (e.code === 'Space') e.preventDefault();
});
window.addEventListener('keyup', e => { keys[e.code] = false; });
window.addEventListener('mousedown', () => { mouseDown = true; });
window.addEventListener('mouseup', () => { mouseDown = false; });
window.addEventListener('resize', onResize);
}
function onResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
bloomPass.setSize(window.innerWidth, window.innerHeight);
}
// ============================================================
// PLAYER UPDATE
// ============================================================
function updatePlayer(dt) {
if (!state.started || state.gameOver) return;
// Lateral control
if (keys['KeyA'] || keys['ArrowLeft']) {
player.targetRoll = 0.85;
player.vx -= LATERAL_ACCEL * dt;
} else if (keys['KeyD'] || keys['ArrowRight']) {
player.targetRoll = -0.85;
player.vx += LATERAL_ACCEL * dt;
} else {
player.targetRoll = 0;
}
// Vertical control
if (keys['KeyW'] || keys['ArrowUp']) {
player.targetPitch = -0.45;
player.vy += VERTICAL_ACCEL * dt;
} else if (keys['KeyS'] || keys['ArrowDown']) {
player.targetPitch = 0.45;
player.vy -= VERTICAL_ACCEL * dt;
} else {
player.targetPitch = 0;
}
// Boost
state.boosting = (keys['Space'] || mouseDown) && state.turbo > 0;
if (state.boosting) {
state.turbo = Math.max(0, state.turbo - TURBO_COST_PER_SEC * dt);
if (state.turbo === 0) state.boosting = false;
} else {
state.turbo = Math.min(TURBO_MAX, state.turbo + TURBO_GAIN_PER_SEC * dt);
}
// Play boost sound on rising edge
if (state.boosting && !state.wasBoosting) {
playBoost();
}
state.wasBoosting = state.boosting;
// Damping
player.vx *= Math.pow(0.5, dt * LATERAL_DAMP);
player.vy *= Math.pow(0.5, dt * VERTICAL_DAMP);
// Speed
const difficultyMult = 1 + Math.min(state.score / 8000, 0.8);
state.baseSpeed = BASE_SPEED * difficultyMult;
state.currentSpeed = state.boosting ? state.baseSpeed * MAX_SPEED_BOOST : state.baseSpeed;
state.maxSpeed = Math.max(state.maxSpeed, state.currentSpeed);
// Update position
player.x += player.vx * dt;
player.y += player.vy * dt;
player.z += state.currentSpeed * dt;
// Clamp vertical
player.y = Math.max(-15, Math.min(50, player.y));
// Smooth roll/pitch
player.roll += (player.targetRoll - player.roll) * Math.min(1, dt * ROLL_LERP);
player.pitch += (player.targetPitch - player.pitch) * Math.min(1, dt * PITCH_LERP);
// Yaw follows path tangent (slight lean into turns)
const targetYaw = -pathTangent(player.z) * 0.35;
playerGroup.rotation.y += (targetYaw - playerGroup.rotation.y) * Math.min(1, dt * 3);
// Apply visual transforms
playerGroup.position.set(player.x, player.y, player.z);
playerGroup.rotation.z = player.roll;
playerGroup.rotation.x = player.pitch;
// Engine glow scaling with speed
const speedRatio = state.currentSpeed / state.baseSpeed;
engineGlow.scale.set(1 + speedRatio * 0.5, 1 + speedRatio * 0.8, 1);
engineGlow.material.opacity = 0.7 + speedRatio * 0.3;
if (state.boosting) {
engineGlow.material.color.setHex(0xff66ff);
} else {
engineGlow.material.color.setHex(0x00ffff);
}
}
// ============================================================
// COLLISIONS & NEAR MISS
// ============================================================
function checkCollisions() {
if (!state.started || state.gameOver) return;
// Wall collision - check left and right wall at player Z
const cx = pathX(player.z);
const hw = pathHalfWidth(player.z);
const leftWallX = cx - hw;
const rightWallX = cx + hw;
// Approximate surface noise effect (walls can protrude inward up to ~2.2 units)
const surfaceAdjust = 2.5;
const distToLeft = (player.x - PLAYER_RADIUS) - (leftWallX + surfaceAdjust);
const distToRight = (rightWallX - surfaceAdjust) - (player.x + PLAYER_RADIUS);
const minWallDist = Math.min(distToLeft, distToRight);
// Collision
if (minWallDist < -COLLISION_MARGIN) {
onCollision('wall');
return;
}
// Near miss
if (minWallDist > 0 && minWallDist < NEAR_MISS_DIST) {
if (state.elapsed - state.lastNearMissTime > 0.3) {
onNearMiss(minWallDist, 'wall');
}
}
// Monolith collisions
for (let m of monoliths) {
const dz = m.z - player.z;
if (Math.abs(dz) > 30) continue;
const dx = m.x - player.x;
const dy = m.y - player.y;
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz);
if (dist < m.radius) {
onCollision('monolith');
return;
} else if (dist < m.radius + NEAR_MISS_DIST && !m.triggered) {
onNearMiss(dist - m.radius, 'monolith');
m.triggered = true;
}
// Reset triggered flag when passed
if (dz < -15) m.triggered = false;
}
// Orb collection
for (let i = orbs.length - 1; i >= 0; i--) {
const orb = orbs[i];
if (orb.collected) continue;
const dz = orb.z - player.z;
if (Math.abs(dz) > 10) continue;
const dx = orb.x - player.x;
const dy = orb.y - player.y;
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz);
if (dist < 3) {
collectOrb(orb, i);
}
}
}
function onNearMiss(distance, type) {
state.lastNearMissTime = state.elapsed;
const closeness = Math.max(0, NEAR_MISS_DIST - distance) / NEAR_MISS_DIST;
const bonus = Math.floor(50 + closeness * 250);
state.score += bonus * state.multiplier;
state.multiplier = Math.min(5, state.multiplier + 0.15 + closeness * 0.3);
state.multiplierTimer = 1.5;
// Visual feedback
state.nearMissFlash = 0.4;
state.nearMissText = 1.0;
state.cameraShake = Math.max(state.cameraShake, 0.15 + closeness * 0.3);
// Audio
playNearMiss();
// Multiplier pulse
const multEl = document.getElementById('multiplier');
multEl.classList.add('pulse');
setTimeout(() => multEl.classList.remove('pulse'), 100);
// Show popup
const popup = document.getElementById('nearmiss-popup');
popup.classList.add('show');
setTimeout(() => popup.classList.remove('show'), 400);
}
function collectOrb(orb, index) {
orb.collected = true;
state.energy = Math.min(100, state.energy + ORB_ENERGY);
state.score += ORB_SCORE * state.multiplier;
state.turbo = Math.min(TURBO_MAX, state.turbo + 8);
// Visual
window._emitBurst(orb.x, orb.y, orb.z, 0xffee44, 12);
// Audio
playCollect();
// Remove
scene.remove(orb.mesh);
orb.mesh.geometry.dispose();
orb.mesh.material.dispose();
orbs.splice(index, 1);
}
function onCollision(type) {
if (state.gameOver) return;
state.energy -= COLLISION_ENERGY_LOSS;
state.cameraShake = 0.8;
state.turbo = Math.max(0, state.turbo - 30);
// Visual
document.getElementById('damage-flash').classList.add('show');
setTimeout(() => document.getElementById('damage-flash').classList.remove('show'), 300);
// Burst
window._emitBurst(player.x, player.y, player.z, 0xff3366, 25);
playCrash();
if (state.energy <= 0) {
gameOver();
}
}
// ============================================================
// CAMERA
// ============================================================
function updateCamera(dt) {
if (!state.started) {
// Intro camera - slowly orbit
const t = state.elapsed * 0.2;
camera.position.set(
Math.cos(t) * 25,
25 + Math.sin(t * 0.5) * 5,
player.z - 15
);
camera.lookAt(player.x * 0.5, player.y, player.z + 20);
return;
}
// Follow camera
const targetX = player.x;
const targetY = player.y + 2.5;
const targetZ = player.z - 9;
const lerp = Math.min(1, dt * 5);
camera.position.x += (targetX - camera.position.x) * lerp;
camera.position.y += (targetY - camera.position.y) * lerp;
camera.position.z += (targetZ - camera.position.z) * Math.min(1, dt * 8);
// Look ahead and slightly toward velocity
const lookX = player.x + player.vx * 0.08;
const lookY = player.y + 0.5;
const lookZ = player.z + 15;
camera.lookAt(lookX, lookY, lookZ);
// Bank camera slightly with roll
camera.rotation.z = player.roll * 0.3;
// FOV widens with speed
const speedNorm = (state.currentSpeed - state.baseSpeed) / (state.baseSpeed * (MAX_SPEED_BOOST - 1));
const targetFOV = 72 + speedNorm * 28 + (state.boosting ? 5 : 0);
camera.fov += (targetFOV - camera.fov) * Math.min(1, dt * 2);
camera.updateProjectionMatrix();
// Camera shake
if (state.cameraShake > 0.01) {
const s = state.cameraShake;
camera.position.x += (Math.random() - 0.5) * s;
camera.position.y += (Math.random() - 0.5) * s;
camera.position.z += (Math.random() - 0.5) * s * 0.5;
state.cameraShake *= Math.pow(0.05, dt);
}
}
// ============================================================
// HUD UPDATE
// ============================================================
function updateHUD() {
document.getElementById('score-value').textContent = Math.floor(state.score).toLocaleString();
document.getElementById('alt-val').textContent = Math.floor(player.y + 50);
document.getElementById('vel-val').textContent = Math.floor(state.currentSpeed);
// Multiplier
document.getElementById('multiplier').textContent = '×' + state.multiplier.toFixed(1);
// Energy
document.getElementById('energy-bar').style.width = Math.max(0, state.energy) + '%';
// Speedometer
const speedPct = Math.min(100, (state.currentSpeed / (BASE_SPEED * MAX_SPEED_BOOST)) * 100);
document.getElementById('speedo-fill').style.width = speedPct + '%';
// Turbo
const turboPct = (state.turbo / TURBO_MAX) * 100;
document.getElementById('turbo-fill').style.width = turboPct + '%';
const turboReady = document.getElementById('turbo-ready');
if (turboPct > 50) {
turboReady.classList.add('show');
} else {
turboReady.classList.remove('show');
}
}
// ============================================================
// GAME FLOW
// ============================================================
function startGame() {
initAudio();
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
state.started = true;
state.gameOver = false;
state.score = 0;
state.multiplier = 1;
state.multiplierTimer = 0;
state.energy = 100;
state.turbo = 35;
state.boosting = false;
state.wasBoosting = false;
state.maxSpeed = 0;
state.elapsed = 0;
player.x = pathX(0); player.y = 18; player.z = 0;
player.vx = 0; player.vy = 0;
player.roll = 0; player.pitch = 0;
player.targetRoll = 0; player.targetPitch = 0;
document.getElementById('start-screen').classList.add('hidden');
document.getElementById('gameover-screen').classList.add('hidden');
}
function gameOver() {
state.gameOver = true;
state.boosting = false;
document.getElementById('final-score').textContent = Math.floor(state.score).toLocaleString();
document.getElementById('max-speed').textContent = Math.floor(state.maxSpeed);
document.getElementById('distance').textContent = Math.floor(player.z) + 'm';
setTimeout(() => {
document.getElementById('gameover-screen').classList.remove('hidden');
}, 800);
}
function restart() {
// Clean up chunks and objects
for (let chunk of chunks) {
chunk.leftMesh.geometry.dispose();
chunk.rightMesh.geometry.dispose();
scene.remove(chunk.group);
}
chunks.length = 0;
for (let orb of orbs) {
scene.remove(orb.mesh);
}
orbs.length = 0;
monoliths.length = 0;
initChunks();
startGame();
}
// ============================================================
// MAIN LOOP
// ============================================================
let lastTime = performance.now();
function animate() {
requestAnimationFrame(animate);
const now = performance.now();
const dt = Math.min(0.05, (now - lastTime) / 1000);
lastTime = now;
if (state.started && !state.gameOver) {
state.elapsed += dt;
// Difficulty scaling
const difficultyMult = 1 + Math.min(state.score / 8000, 0.8);
// Energy decay
state.energy -= ENERGY_DECAY * dt * difficultyMult;
if (state.energy <= 0) {
gameOver();
}
// Score (passive)
state.score += dt * 15 * state.multiplier;
// Multiplier decay
state.multiplierTimer -= dt;
if (state.multiplierTimer <= 0) {
state.multiplier = Math.max(1, state.multiplier - dt * 0.6);
}
// Update player
updatePlayer(dt);
// Check collisions
checkCollisions();
// Recycle chunks
recycleChunks();
} else if (!state.started) {
state.elapsed += dt;
}
// Always update camera (for intro orbit)
updateCamera(dt);
// Update orbs (rotation/animation)
for (let orb of orbs) {
if (!orb.collected) {
orb.mesh.rotation.y += dt * 3;
orb.mesh.rotation.x += dt * 1.5;
orb.mesh.position.y = orb.baseY + Math.sin(state.elapsed * 2 + orb.phase) * 0.8;
}
}
// Update void plane position
voidPlane.position.z = player.z;
voidPlane.position.x = player.x;
// Update sun light to follow player (for shadow camera)
if (sunLight) {
sunLight.position.set(player.x + 80, 40, player.z + 100);
sunLight.target.position.set(player.x, 0, player.z + 20);
sunLight.target.updateMatrixWorld();
}
// Update sun position (distant, follows)
if (sunMesh) {
sunMesh.position.z = player.z + 350;
sunMesh.position.x = player.x + 120;
sunMesh.lookAt(camera.position);
}
// Update particles
updateParticles(dt);
// Update engine sound
updateEngineSound();
// Update HUD
updateHUD();
// Render
composer.render();
}
// ============================================================
// INIT
// ============================================================
initThree();
initChunks();
initParticles();
initInput();
document.getElementById('start-btn').addEventListener('click', startGame);
document.getElementById('restart-btn').addEventListener('click', restart);
// Auto-pause audio when not focused
window.addEventListener('blur', () => {
if (engineGain) engineGain.gain.setTargetAtTime(0, audioCtx.currentTime, 0.1);
});
window.addEventListener('focus', () => {
if (engineGain && state.started && !state.gameOver) {
// Engine sound will resume naturally on next frame
}
});
animate();
</script>
</body>
</html>
<!-- agent-meta {"model":"minimax/minimax-m3","provider":"openrouter","persona":"middle","sessionId":"7e9f1761-aaf4-4904-aab2-6d809194e089","tokensIn":1595147,"tokensOut":57307,"tokensTotal":1652454,"turns":29,"toolCalls":28,"failedToolCalls":1,"timestamp":"2026-07-13T22:07:05.189Z"} -->