Canyon Run
Minimax m3 · full
◈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
- 3 / 5
- Prompt spirit
- 4 / 5
- Delight
- 4 / 5
imperfect but beautiful; inverted steering controls
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 57,676 bytes · SHA-256 ab41c569325c
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>CANYON RACER</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
body { font-family: 'Helvetica Neue', 'Arial', sans-serif; color: #fff; user-select: none; }
#game { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; }
canvas { display: block; }
#hud { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
.hud-corner { position: absolute; padding: 24px 32px; }
.hud-label { font-size: 9px; letter-spacing: 5px; opacity: 0.45; font-weight: 300; margin-bottom: 6px; }
#score-block { top: 0; left: 0; }
#score-value { font-size: 38px; font-weight: 100; letter-spacing: 3px; line-height: 1; }
#score-value::after { content: ''; display: inline-block; width: 0; }
#energy-block { top: 0; right: 0; width: 260px; }
.energy-bar-bg { width: 100%; height: 3px; background: rgba(255,255,255,0.08); position: relative; overflow: hidden; }
.energy-bar { width: 100%; height: 100%; background: linear-gradient(90deg, #00e5ff, #b066ff); box-shadow: 0 0 14px #00e5ff, 0 0 4px #00e5ff; transform-origin: left; transition: transform 0.15s linear; }
.energy-num { font-size: 11px; letter-spacing: 3px; opacity: 0.6; margin-top: 4px; text-align: right; }
#multiplier-block { position: absolute; right: 32px; top: 50%; transform: translateY(-50%); text-align: right; }
#multiplier { font-size: 84px; font-weight: 200; font-style: italic; letter-spacing: -2px; line-height: 1; text-shadow: 0 0 30px rgba(255,140,40,0.6); transition: transform 0.12s ease-out, text-shadow 0.2s; }
#multiplier.pulse { transform: scale(1.25); text-shadow: 0 0 50px rgba(255,180,60,1), 0 0 20px rgba(255,100,20,0.8); }
#multiplier-label { font-size: 9px; letter-spacing: 5px; opacity: 0.45; margin-top: 6px; }
.mult-bar { width: 80px; height: 2px; background: rgba(255,255,255,0.1); margin-left: auto; margin-top: 8px; }
.mult-bar-fill { width: 100%; height: 100%; background: linear-gradient(90deg, #ff7722, #ffcc44); box-shadow: 0 0 6px #ff7722; transform-origin: right; transition: transform 0.1s linear; }
#speedo-block { position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); width: 520px; text-align: center; }
.speedo-track { width: 100%; height: 1px; background: rgba(255,255,255,0.15); position: relative; margin-top: 8px; }
.speedo-fill { position: absolute; left: 0; top: 0; height: 3px; background: linear-gradient(90deg, #ffaa22, #ffee66); box-shadow: 0 0 12px #ffaa22; width: 0%; transform: translateY(-1px); transition: width 0.1s linear; }
.speedo-tick { position: absolute; top: 4px; width: 1px; height: 6px; background: rgba(255,255,255,0.2); }
.speedo-tick.major { height: 10px; background: rgba(255,255,255,0.4); }
.speedo-num { font-size: 11px; letter-spacing: 4px; opacity: 0.5; }
.speedo-num .unit { opacity: 0.5; font-size: 9px; }
#turbo-block { position: absolute; bottom: 70px; left: 50%; transform: translateX(-50%); width: 200px; text-align: center; opacity: 0; transition: opacity 0.3s; }
#turbo-block.visible { opacity: 1; }
.turbo-track { width: 100%; height: 2px; background: rgba(255,255,255,0.08); margin-top: 4px; }
.turbo-fill { width: 0%; height: 100%; background: linear-gradient(90deg, #ff3300, #ffaa00); box-shadow: 0 0 10px #ff5500; transition: width 0.1s linear; }
.near-msg { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 14px; letter-spacing: 6px; opacity: 0; font-weight: 300; color: #ffdd66; text-shadow: 0 0 20px #ff8800; pointer-events: none; }
.near-msg.show { animation: nearMsg 0.6s ease-out; }
@keyframes nearMsg { 0% { opacity: 0; transform: translate(-50%, -50%) scale(0.8); } 30% { opacity: 1; transform: translate(-50%, -50%) scale(1.1); } 100% { opacity: 0; transform: translate(-50%, -60%) scale(1); } }
#boost-flash { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; opacity: 0; background: radial-gradient(ellipse at center, transparent 40%, rgba(255,170,40,0.3) 100%); }
#boost-flash.active { opacity: 1; }
#damage-flash { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; opacity: 0; background: radial-gradient(ellipse at center, transparent 30%, rgba(255,0,40,0.5) 100%); }
.overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; pointer-events: auto; z-index: 20; }
#intro { background: radial-gradient(ellipse at center, rgba(20,5,40,0.85) 0%, rgba(0,0,0,0.95) 100%); }
#intro h1 { font-size: 88px; font-weight: 100; letter-spacing: 16px; margin-bottom: 8px; background: linear-gradient(180deg, #ffdd88 0%, #ff6622 60%, #aa1144 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
#intro .subtitle { font-size: 13px; letter-spacing: 8px; opacity: 0.5; margin-bottom: 50px; }
.controls-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px 40px; margin-bottom: 50px; font-size: 11px; letter-spacing: 3px; }
.control-item { display: flex; align-items: center; gap: 12px; opacity: 0.7; }
.key { display: inline-block; padding: 6px 12px; border: 1px solid rgba(255,255,255,0.25); font-size: 10px; letter-spacing: 2px; min-width: 40px; text-align: center; }
.btn { background: transparent; border: 1px solid rgba(255,170,60,0.6); color: #ffaa44; padding: 16px 48px; font-size: 12px; letter-spacing: 6px; cursor: pointer; transition: all 0.2s; font-family: inherit; }
.btn:hover { background: #ffaa44; color: #000; box-shadow: 0 0 30px rgba(255,170,60,0.4); }
#game-over { background: rgba(0,0,0,0.9); display: none; }
#game-over.show { display: flex; }
#game-over h1 { font-size: 72px; font-weight: 100; letter-spacing: 10px; margin-bottom: 8px; color: #ff4444; text-shadow: 0 0 30px rgba(255,40,40,0.4); }
#game-over .subtitle { font-size: 11px; letter-spacing: 6px; opacity: 0.5; margin-bottom: 50px; }
.stats { display: flex; gap: 80px; margin-bottom: 50px; }
.stat { text-align: center; }
.stat-label { font-size: 9px; letter-spacing: 5px; opacity: 0.4; margin-bottom: 10px; }
.stat-value { font-size: 56px; font-weight: 200; color: #ffcc44; line-height: 1; }
.stat-value .small { font-size: 24px; opacity: 0.6; }
.scanline { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; background: repeating-linear-gradient(0deg, transparent 0px, transparent 2px, rgba(255,255,255,0.012) 2px, rgba(255,255,255,0.012) 4px); z-index: 5; }
</style>
</head>
<body>
<div id="game"></div>
<div id="boost-flash"></div>
<div id="damage-flash"></div>
<div class="scanline"></div>
<div id="hud">
<div class="hud-corner" id="score-block">
<div class="hud-label">SCORE</div>
<div id="score-value">0</div>
</div>
<div class="hud-corner" id="energy-block">
<div class="hud-label">ENERGY</div>
<div class="energy-bar-bg"><div class="energy-bar" id="energy-bar"></div></div>
<div class="energy-num" id="energy-num">100%</div>
</div>
<div id="multiplier-block">
<div id="multiplier">1.0×</div>
<div id="multiplier-label">RISK MULTIPLIER</div>
<div class="mult-bar"><div class="mult-bar-fill" id="mult-bar-fill"></div></div>
</div>
<div id="turbo-block">
<div class="hud-label" style="text-align:center;margin:0;">TURBO</div>
<div class="turbo-track"><div class="turbo-fill" id="turbo-fill"></div></div>
</div>
<div id="speedo-block">
<div class="speedo-num"><span id="speed-num">0</span> <span class="unit">M/S</span></div>
<div class="speedo-track">
<div class="speedo-fill" id="speedo-fill"></div>
</div>
</div>
<div class="near-msg" id="near-msg">NEAR MISS</div>
</div>
<div class="overlay" id="intro">
<h1>CANYON RACER</h1>
<div class="subtitle">A HIGH-SPEED PROCEDURAL DESCENT</div>
<div class="controls-grid">
<div class="control-item"><span class="key">A</span><span class="key">D</span><span>BANK</span></div>
<div class="control-item"><span class="key">W</span><span class="key">S</span><span>PITCH</span></div>
<div class="control-item"><span class="key">SPACE</span><span>BOOST</span></div>
<div class="control-item"><span class="key">SHIFT</span><span>BRAKE</span></div>
</div>
<button class="btn" id="start-btn">LAUNCH</button>
</div>
<div class="overlay" id="game-over">
<h1>END OF RUN</h1>
<div class="subtitle">THE CANYON CLAIMS ANOTHER</div>
<div class="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 SPEED</div>
<div class="stat-value"><span id="final-speed">0</span><span class="small"> M/S</span></div>
</div>
<div class="stat">
<div class="stat-label">DISTANCE</div>
<div class="stat-value"><span id="final-dist">0</span><span class="small"> M</span></div>
</div>
</div>
<button class="btn" id="restart-btn">RESTART</button>
</div>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
// ============================================================
// CONFIG
// ============================================================
const CFG = {
CHUNK_SIZE: 70,
NUM_CHUNKS: 14,
POINTS_PER_CHUNK: 14,
PLAYER_RADIUS: 1.1,
WALL_HEIGHT: 32,
CANYON_BASE_HALF: 22,
NEAR_MISS_RANGE: 4.0,
ORB_RADIUS: 0.9,
SEED: 12345,
};
const $ = (id) => document.getElementById(id);
// ============================================================
// SEEDED RANDOM
// ============================================================
function mulberry32(a) {
return function() {
a |= 0; a = a + 0x6D2B79F5 | 0;
let t = a;
t = Math.imul(t ^ t >>> 15, t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
const rng = mulberry32(CFG.SEED);
function noise1D(x) {
// smooth value noise
const i = Math.floor(x);
const f = x - i;
const h = (n) => { const s = Math.sin(n * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); };
const a = h(i), b = h(i + 1);
const u = f * f * (3 - 2 * f);
return a * (1 - u) + b * u;
}
function fbm(x) {
let v = 0, a = 1, f = 1, max = 0;
for (let i = 0; i < 4; i++) { v += noise1D(x * f) * a; max += a; a *= 0.5; f *= 2; }
return v / max;
}
// ============================================================
// CANYON SHAPE FUNCTIONS
// ============================================================
function getCanyonAt(t) {
// t = forward distance (positive going forward)
// centerline offset
const cx = Math.sin(t * 0.0042) * 9 + Math.sin(t * 0.0117) * 4 + Math.cos(t * 0.0023) * 5;
// base width with smooth variation
const baseHalf = CFG.CANYON_BASE_HALF + Math.sin(t * 0.008) * 4 - Math.cos(t * 0.006) * 2;
// squeeze regions (deterministic periodic narrows)
const squeezePhase = Math.sin(t * 0.0035 + 1.2);
const squeeze = squeezePhase > 0.78 ? (squeezePhase - 0.78) * 90 : 0;
const halfWidth = Math.max(8.5, baseHalf - squeeze);
// ground height undulation
const groundY = Math.sin(t * 0.007) * 0.6 + fbm(t * 0.02) * 0.4;
return { centerX: cx, halfWidth, groundY };
}
// ============================================================
// AUDIO ENGINE (Web Audio API procedural SFX)
// ============================================================
class AudioEngine {
constructor() { this.ctx = null; this.master = null; this.engineGain = null; this.engineOsc = null; this.engineOsc2 = null; }
init() {
if (this.ctx) return;
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
this.master = this.ctx.createGain();
this.master.gain.value = 0.5;
this.master.connect(this.ctx.destination);
// Continuous engine hum
this.engineOsc = this.ctx.createOscillator();
this.engineOsc.type = 'sawtooth';
this.engineOsc.frequency.value = 55;
this.engineOsc2 = this.ctx.createOscillator();
this.engineOsc2.type = 'square';
this.engineOsc2.frequency.value = 82;
const engineFilter = this.ctx.createBiquadFilter();
engineFilter.type = 'lowpass';
engineFilter.frequency.value = 800;
engineFilter.Q.value = 4;
this.engineGain = this.ctx.createGain();
this.engineGain.gain.value = 0;
this.engineOsc.connect(engineFilter);
this.engineOsc2.connect(engineFilter);
engineFilter.connect(this.engineGain);
this.engineGain.connect(this.master);
this.engineOsc.start();
this.engineOsc2.start();
}
setEngine(speed, boosting) {
if (!this.ctx) return;
const t = this.ctx.currentTime;
this.engineOsc.frequency.setTargetAtTime(50 + speed * 0.6, t, 0.08);
this.engineOsc2.frequency.setTargetAtTime(75 + speed * 0.9, t, 0.08);
this.engineGain.gain.setTargetAtTime(boosting ? 0.18 : 0.07, t, 0.1);
}
ping(freq = 1) {
if (!this.ctx) return;
const t = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const g = this.ctx.createGain();
osc.type = 'sine';
const base = 700 + freq * 200;
osc.frequency.setValueAtTime(base, t);
osc.frequency.exponentialRampToValueAtTime(base * 2, t + 0.08);
g.gain.setValueAtTime(0.25, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.18);
osc.connect(g); g.connect(this.master);
osc.start(t); osc.stop(t + 0.2);
// Harmonic
const osc2 = this.ctx.createOscillator();
const g2 = this.ctx.createGain();
osc2.type = 'triangle';
osc2.frequency.setValueAtTime(base * 1.5, t);
osc2.frequency.exponentialRampToValueAtTime(base * 3, t + 0.08);
g2.gain.setValueAtTime(0.1, t);
g2.gain.exponentialRampToValueAtTime(0.001, t + 0.15);
osc2.connect(g2); g2.connect(this.master);
osc2.start(t); osc2.stop(t + 0.16);
}
whoosh(intensity = 1) {
if (!this.ctx) return;
const t = this.ctx.currentTime;
const dur = 0.35;
const bufSize = Math.floor(this.ctx.sampleRate * dur);
const buf = this.ctx.createBuffer(1, bufSize, this.ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < bufSize; i++) {
data[i] = (Math.random() * 2 - 1) * (1 - i / bufSize);
}
const src = this.ctx.createBufferSource();
src.buffer = buf;
const filter = this.ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(3500, t);
filter.frequency.exponentialRampToValueAtTime(200, t + dur);
filter.Q.value = 3;
const g = this.ctx.createGain();
g.gain.setValueAtTime(0.4 * intensity, t);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
src.connect(filter); filter.connect(g); g.connect(this.master);
src.start(t);
}
boost() {
if (!this.ctx) return;
const t = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const g = this.ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(80, t);
osc.frequency.exponentialRampToValueAtTime(380, t + 0.35);
g.gain.setValueAtTime(0.15, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
osc.connect(g); g.connect(this.master);
osc.start(t); osc.stop(t + 0.4);
}
crash() {
if (!this.ctx) return;
const t = this.ctx.currentTime;
const dur = 0.9;
const bufSize = Math.floor(this.ctx.sampleRate * dur);
const buf = this.ctx.createBuffer(1, bufSize, this.ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < bufSize; i++) {
const env = Math.exp(-i / (bufSize * 0.25));
data[i] = (Math.random() * 2 - 1) * env;
}
const src = this.ctx.createBufferSource();
src.buffer = buf;
const filter = this.ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(2000, t);
filter.frequency.exponentialRampToValueAtTime(200, t + dur);
const g = this.ctx.createGain();
g.gain.value = 0.5;
src.connect(filter); filter.connect(g); g.connect(this.master);
src.start(t);
// Low boom
const boom = this.ctx.createOscillator();
const bg = this.ctx.createGain();
boom.type = 'sine';
boom.frequency.setValueAtTime(120, t);
boom.frequency.exponentialRampToValueAtTime(30, t + 0.4);
bg.gain.setValueAtTime(0.4, t);
bg.gain.exponentialRampToValueAtTime(0.001, t + 0.5);
boom.connect(bg); bg.connect(this.master);
boom.start(t); boom.stop(t + 0.5);
}
}
const audio = new AudioEngine();
// ============================================================
// GAME STATE
// ============================================================
const state = {
started: false,
gameOver: false,
score: 0,
multiplier: 1.0,
multDecayTimer: 0,
energy: 100,
maxEnergy: 100,
turbo: 0,
maxTurbo: 100,
speed: 55,
maxSpeed: 90,
baseSpeed: 55,
time: 0,
distance: 0,
maxSpeedReached: 0,
nearMissCooldown: 0,
cameraShake: 0,
prevMinDist: 999,
boostActive: false,
braking: false,
multiplierPulse: 0,
damageFlash: 0,
};
// ============================================================
// PLAYER
// ============================================================
const player = {
group: new THREE.Group(),
position: new THREE.Vector3(0, 5, 0),
yaw: 0,
pitch: 0,
roll: 0,
yawVel: 0,
pitchVel: 0,
forward: new THREE.Vector3(0, 0, -1),
velocity: new THREE.Vector3(0, 0, -55),
};
function createCraft() {
const g = new THREE.Group();
// Body - stretched octahedron with faceted shading
const bodyGeom = new THREE.OctahedronGeometry(1, 0);
bodyGeom.scale(0.55, 0.22, 1.6);
bodyGeom.translate(0, 0, 0.1);
const bodyMat = new THREE.MeshStandardMaterial({
color: 0xdde2ee, metalness: 0.85, roughness: 0.25, flatShading: true,
emissive: 0x223344, emissiveIntensity: 0.25,
});
const body = new THREE.Mesh(bodyGeom, bodyMat);
body.castShadow = true;
g.add(body);
// Top fin
const finShape = new THREE.Shape();
finShape.moveTo(0, 0);
finShape.lineTo(0, 0.5);
finShape.lineTo(1.0, -0.3);
finShape.lineTo(0, 0);
const finGeom = new THREE.ExtrudeGeometry(finShape, { depth: 0.08, bevelEnabled: false });
finGeom.translate(-0.5, 0, -0.04);
const finMat = new THREE.MeshStandardMaterial({
color: 0x556688, metalness: 0.9, roughness: 0.3, flatShading: true,
emissive: 0x112233, emissiveIntensity: 0.2,
});
const topFin = new THREE.Mesh(finGeom, finMat);
topFin.rotation.y = Math.PI / 2;
topFin.position.set(0, 0.15, 0.2);
topFin.castShadow = true;
g.add(topFin);
// Wings
const wingShape = new THREE.Shape();
wingShape.moveTo(0, 0);
wingShape.lineTo(2.2, -0.6);
wingShape.lineTo(2.0, 0.1);
wingShape.lineTo(0.4, 0.3);
wingShape.lineTo(0, 0);
const wingGeom = new THREE.ExtrudeGeometry(wingShape, { depth: 0.12, bevelEnabled: false });
wingGeom.translate(0, 0, -0.06);
const wingMat = new THREE.MeshStandardMaterial({
color: 0x99a8c4, metalness: 0.8, roughness: 0.35, flatShading: true,
emissive: 0x223355, emissiveIntensity: 0.15,
});
const lw = new THREE.Mesh(wingGeom, wingMat);
lw.rotation.y = Math.PI / 2;
lw.rotation.x = -0.15;
lw.position.set(-0.4, -0.02, 0.3);
lw.castShadow = true;
g.add(lw);
const rw = new THREE.Mesh(wingGeom, wingMat);
rw.rotation.y = -Math.PI / 2;
rw.scale.x = -1;
rw.rotation.x = 0.15;
rw.position.set(0.4, -0.02, 0.3);
rw.castShadow = true;
g.add(rw);
// Cockpit - small dark glassy shape
const cockGeom = new THREE.SphereGeometry(0.22, 8, 6, 0, Math.PI * 2, 0, Math.PI / 2);
cockGeom.scale(0.8, 0.8, 1.6);
cockGeom.translate(0, 0.12, -0.2);
const cockMat = new THREE.MeshStandardMaterial({
color: 0x112233, metalness: 0.9, roughness: 0.1, flatShading: true,
emissive: 0x004488, emissiveIntensity: 0.3,
});
const cock = new THREE.Mesh(cockGeom, cockMat);
g.add(cock);
// Engine glow (emissive sphere at rear)
const glowGeom = new THREE.SphereGeometry(0.28, 12, 8);
const glowMat = new THREE.MeshBasicMaterial({ color: 0x66eeff });
const glow = new THREE.Mesh(glowGeom, glowMat);
glow.position.set(0, 0, 1.5);
g.add(glow);
// Inner bright core
const coreGeom = new THREE.SphereGeometry(0.12, 8, 6);
const coreMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
const core = new THREE.Mesh(coreGeom, coreMat);
core.position.set(0, 0, 1.5);
g.add(core);
return g;
}
// ============================================================
// CHUNK / CANYON GENERATION
// ============================================================
const wallMaterial = new THREE.MeshStandardMaterial({
vertexColors: true,
flatShading: true,
roughness: 0.92,
metalness: 0.05,
});
const monolithMaterial = new THREE.MeshStandardMaterial({
color: 0x1a1830, metalness: 0.85, roughness: 0.3, flatShading: true,
emissive: 0x441166, emissiveIntensity: 0.4,
});
const monolithMaterial2 = new THREE.MeshStandardMaterial({
color: 0x2a1a30, metalness: 0.8, roughness: 0.35, flatShading: true,
emissive: 0x884422, emissiveIntensity: 0.35,
});
const groundMaterial = new THREE.MeshStandardMaterial({
color: 0x2a1408, roughness: 0.95, metalness: 0.0,
});
const chunks = new Map();
const allOrbs = []; // { pos, mesh, collected, phase }
const allMonoliths = []; // { pos, mesh, size, rotSpeed, axis }
function buildChunk(idx) {
const group = new THREE.Group();
group.userData.idx = idx;
const startT = idx * CFG.CHUNK_SIZE;
const endT = (idx + 1) * CFG.CHUNK_SIZE;
// ---- Walls ----
const positions = [];
const colors = [];
const indices = [];
const baseCol = new THREE.Color(0x2a1208);
const midCol = new THREE.Color(0x6a2810);
const topCol = new THREE.Color(0xcc6633);
const highlightCol = new THREE.Color(0xffaa55);
for (let side = 0; side < 2; side++) {
const sign = side === 0 ? -1 : 1; // -1 = left, +1 = right
const sunFactor = side === 1 ? 1.0 : 0.55; // right wall is sunlit
for (let i = 0; i <= CFG.POINTS_PER_CHUNK; i++) {
const t = startT + (i / CFG.POINTS_PER_CHUNK) * CFG.CHUNK_SIZE;
const z = -t;
const canyon = getCanyonAt(t);
const innerX = canyon.centerX + sign * canyon.halfWidth;
const outerX = innerX + sign * 7;
// Wall height with jagged variation
const heightBase = CFG.WALL_HEIGHT;
const heightVar = (fbm(t * 0.04) - 0.3) * 12;
const peak = fbm(t * 0.08 + side * 13) > 0.72 ? 6 + fbm(t * 0.15) * 8 : 0;
const height = heightBase + heightVar + peak;
// Slight tilt in/out for organic look
const outerShift = (fbm(t * 0.05 + side * 7) - 0.5) * 2.5;
// Vertices: 0=BI, 1=TI, 2=BO, 3=TO
positions.push(innerX, canyon.groundY, z);
positions.push(innerX, canyon.groundY + height, z);
positions.push(outerX + outerShift, canyon.groundY, z);
positions.push(outerX + outerShift, canyon.groundY + height * 0.85, z);
// Colors per vertex (bottom dark, top light, with sun factor)
const cBot = baseCol.clone().multiplyScalar(sunFactor * 0.7);
const cMid = midCol.clone().multiplyScalar(sunFactor);
const cTop = topCol.clone().multiplyScalar(sunFactor);
const cTopO = highlightCol.clone().multiplyScalar(sunFactor * 0.8);
// BI dark, TI mid-top, BO darkest, TO mid
colors.push(cBot.r, cBot.g, cBot.b);
colors.push(cTop.r * 0.8, cTop.g * 0.8, cTop.b * 0.8);
colors.push(cBot.r * 0.5, cBot.g * 0.5, cBot.b * 0.5);
colors.push(cMid.r, cMid.g, cMid.b);
}
}
const N = CFG.POINTS_PER_CHUNK + 1;
for (let side = 0; side < 2; side++) {
for (let i = 0; i < CFG.POINTS_PER_CHUNK; i++) {
const a = side * N * 4 + i * 4;
const b = side * N * 4 + (i + 1) * 4;
// Inner face
indices.push(a, a + 1, b + 1, a, b + 1, b);
// Top face
indices.push(a + 1, a + 3, b + 3, a + 1, b + 3, b + 1);
// Outer face (reverse winding)
indices.push(a + 3, a + 2, b + 2, a + 3, b + 2, b + 3);
}
}
const wallGeom = new THREE.BufferGeometry();
wallGeom.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
wallGeom.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
wallGeom.setIndex(indices);
wallGeom.computeVertexNormals();
const wallMesh = new THREE.Mesh(wallGeom, wallMaterial);
wallMesh.castShadow = true;
wallMesh.receiveShadow = true;
group.add(wallMesh);
// ---- Ground (canyon floor) ----
// Build a ground strip that follows canyon center
const gPositions = [];
const gColors = [];
const gIndices = [];
const gSegs = CFG.POINTS_PER_CHUNK;
for (let i = 0; i <= gSegs; i++) {
const t = startT + (i / gSegs) * CFG.CHUNK_SIZE;
const z = -t;
const canyon = getCanyonAt(t);
const halfW = canyon.halfWidth - 0.5;
// two vertices across, two in y
gPositions.push(canyon.centerX - halfW, canyon.groundY, z);
gPositions.push(canyon.centerX + halfW, canyon.groundY, z);
const tint = 0.8 + fbm(t * 0.1) * 0.4;
gColors.push(0.18 * tint, 0.08 * tint, 0.04 * tint);
gColors.push(0.18 * tint, 0.08 * tint, 0.04 * tint);
}
for (let i = 0; i < gSegs; i++) {
const a = i * 2, b = (i + 1) * 2;
gIndices.push(a, a + 1, b + 1, a, b + 1, b);
}
const gGeom = new THREE.BufferGeometry();
gGeom.setAttribute('position', new THREE.Float32BufferAttribute(gPositions, 3));
gGeom.setAttribute('color', new THREE.Float32BufferAttribute(gColors, 3));
gGeom.setIndex(gIndices);
gGeom.computeVertexNormals();
const gMesh = new THREE.Mesh(gGeom, groundMaterial);
gMesh.receiveShadow = true;
group.add(gMesh);
// ---- Orbs ----
const orbCount = 3 + Math.floor(rng() * 3);
for (let i = 0; i < orbCount; i++) {
const t = startT + (i + 0.3) / orbCount * CFG.CHUNK_SIZE + (rng() - 0.5) * 8;
const z = -t;
const canyon = getCanyonAt(t);
// offset: mostly center, sometimes to one side
let offX = 0;
const r = rng();
if (r < 0.45) offX = 0;
else if (r < 0.75) offX = (canyon.halfWidth - 3.5) * (rng() < 0.5 ? -1 : 1) * (0.4 + rng() * 0.6);
else offX = (canyon.halfWidth - 2.5) * (rng() < 0.5 ? -1 : 1);
const x = canyon.centerX + offX;
const y = 3.5 + rng() * 4;
const orb = createOrb();
orb.position.set(x, y, z);
group.add(orb);
allOrbs.push({ pos: orb.position, mesh: orb, collected: false, phase: rng() * Math.PI * 2 });
}
// ---- Monoliths ----
// Difficulty scales with distance
const diff = Math.min(1, startT / 4000);
const monoChance = 0.35 + diff * 0.3;
if (rng() < monoChance) {
const t = startT + CFG.CHUNK_SIZE * (0.3 + rng() * 0.4);
const z = -t;
const canyon = getCanyonAt(t);
const side = rng() < 0.5 ? -1 : 1;
const xOffset = canyon.halfWidth * (0.35 + rng() * 0.25);
const x = canyon.centerX + side * xOffset;
const y = (10 + rng() * 8) * (0.7 + diff * 0.5);
const type = rng() < 0.5 ? 0 : 1;
const mono = createMonolith(type);
mono.position.set(x, y, z);
mono.rotation.y = rng() * Math.PI * 2;
group.add(mono);
allMonoliths.push({ pos: mono.position, mesh: mono, type, rotSpeed: (rng() - 0.5) * 0.3, axis: rng() < 0.5 ? 'y' : 'x', size: 1 });
}
// ---- Floating obstacles (tight squeezes have floating blocks) ----
if (rng() < 0.2 + diff * 0.15) {
const t = startT + CFG.CHUNK_SIZE * (0.4 + rng() * 0.3);
const z = -t;
const canyon = getCanyonAt(t);
if (canyon.halfWidth < 16) {
// place floating block in center requiring bank
const x = canyon.centerX + (rng() - 0.5) * (canyon.halfWidth - 5);
const y = 4 + rng() * 5;
const block = createFloatingBlock();
block.position.set(x, y, z);
group.add(block);
allMonoliths.push({ pos: block.position, mesh: block, type: 2, rotSpeed: 0.2, axis: 'y', size: 1.5 });
}
}
return group;
}
function createOrb() {
const group = new THREE.Group();
// outer
const outer = new THREE.Mesh(
new THREE.IcosahedronGeometry(0.45, 1),
new THREE.MeshStandardMaterial({
color: 0xaaeeff, emissive: 0x00ddff, emissiveIntensity: 2.5,
flatShading: true, metalness: 0.2, roughness: 0.3,
})
);
group.add(outer);
// inner core
const core = new THREE.Mesh(
new THREE.IcosahedronGeometry(0.2, 0),
new THREE.MeshBasicMaterial({ color: 0xffffff })
);
group.add(core);
// glow ring
const ring = new THREE.Mesh(
new THREE.TorusGeometry(0.7, 0.04, 6, 16),
new THREE.MeshBasicMaterial({ color: 0x66ddff, transparent: true, opacity: 0.5 })
);
ring.rotation.x = Math.PI / 2;
group.add(ring);
return group;
}
function createMonolith(type) {
const group = new THREE.Group();
if (type === 0) {
// Tall hex prism
const h = 18;
const geom = new THREE.CylinderGeometry(0.8, 1.1, h, 6, 1);
const mesh = new THREE.Mesh(geom, monolithMaterial);
mesh.castShadow = true;
group.add(mesh);
// Top accent
const top = new THREE.Mesh(
new THREE.CylinderGeometry(0.3, 0.3, 1.5, 6),
new THREE.MeshBasicMaterial({ color: 0xff66cc })
);
top.position.y = h / 2 + 0.7;
group.add(top);
// Bottom accent
const bot = new THREE.Mesh(
new THREE.CylinderGeometry(0.3, 0.3, 1.5, 6),
new THREE.MeshBasicMaterial({ color: 0xff66cc })
);
bot.position.y = -h / 2 - 0.7;
group.add(bot);
} else {
// Wide floating block
const geom = new THREE.BoxGeometry(3, 4, 3);
const mesh = new THREE.Mesh(geom, monolithMaterial2);
mesh.castShadow = true;
group.add(mesh);
// Edge glow
const edges = new THREE.EdgesGeometry(geom);
const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0xff8844 }));
group.add(line);
}
return group;
}
function createFloatingBlock() {
const group = new THREE.Group();
const size = 2.5;
const geom = new THREE.BoxGeometry(size, size, size);
const mat = new THREE.MeshStandardMaterial({
color: 0x331a2a, metalness: 0.7, roughness: 0.4, flatShading: true,
emissive: 0xaa2200, emissiveIntensity: 0.3,
});
const mesh = new THREE.Mesh(geom, mat);
mesh.castShadow = true;
group.add(mesh);
const edges = new THREE.EdgesGeometry(geom);
const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0xffaa44 }));
group.add(line);
return group;
}
function updateChunks() {
const playerT = -player.position.z;
const currentIdx = Math.floor(playerT / CFG.CHUNK_SIZE);
const keepStart = currentIdx - 1;
const keepEnd = currentIdx + CFG.NUM_CHUNKS;
// Generate new chunks ahead
for (let i = keepStart; i <= keepEnd; i++) {
if (i < 0) continue;
if (!chunks.has(i)) {
const c = buildChunk(i);
scene.add(c);
chunks.set(i, c);
}
}
// Remove old chunks behind
for (const [idx, chunk] of chunks) {
if (idx < keepStart - 1) {
scene.remove(chunk);
chunk.traverse(obj => {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
if (Array.isArray(obj.material)) obj.material.forEach(m => m.dispose());
else obj.material.dispose();
}
});
// Remove orbs and monoliths from global lists
chunk.children.forEach(child => {
const orbIdx = allOrbs.findIndex(o => o.mesh === child);
if (orbIdx >= 0) allOrbs.splice(orbIdx, 1);
const monoIdx = allMonoliths.findIndex(m => m.mesh === child);
if (monoIdx >= 0) allMonoliths.splice(monoIdx, 1);
});
chunks.delete(idx);
}
}
}
// ============================================================
// PARTICLE SYSTEMS
// ============================================================
const TRAIL_MAX = 80;
const trailPositions = new Float32Array(TRAIL_MAX * 3);
const trailAlphas = new Float32Array(TRAIL_MAX);
const trailSizes = new Float32Array(TRAIL_MAX);
let trailHead = 0;
let trailGeom, trailPoints;
function initTrail() {
for (let i = 0; i < TRAIL_MAX; i++) {
trailPositions[i * 3] = 0;
trailPositions[i * 3 + 1] = -1000;
trailPositions[i * 3 + 2] = 0;
trailAlphas[i] = 0;
trailSizes[i] = 0;
}
trailGeom = new THREE.BufferGeometry();
trailGeom.setAttribute('position', new THREE.BufferAttribute(trailPositions, 3));
trailGeom.setAttribute('alpha', new THREE.BufferAttribute(trailAlphas, 1));
trailGeom.setAttribute('size', new THREE.BufferAttribute(trailSizes, 1));
// Custom shader for points
const trailMat = new THREE.ShaderMaterial({
uniforms: { color: { value: new THREE.Color(0x44ddff) } },
vertexShader: `
attribute float alpha;
attribute float size;
varying float vAlpha;
void main() {
vAlpha = alpha;
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = size * (300.0 / -mv.z);
gl_Position = projectionMatrix * mv;
}
`,
fragmentShader: `
uniform vec3 color;
varying float vAlpha;
void main() {
vec2 c = gl_PointCoord - 0.5;
float d = length(c);
if (d > 0.5) discard;
float a = smoothstep(0.5, 0.0, d) * vAlpha;
gl_FragColor = vec4(color, a);
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
trailPoints = new THREE.Points(trailGeom, trailMat);
scene.add(trailPoints);
}
function emitTrail(dt) {
const intensity = state.boostActive ? 3 : 1;
for (let i = 0; i < intensity; i++) {
const i3 = trailHead * 3;
trailPositions[i3] = player.position.x + (Math.random() - 0.5) * 0.3;
trailPositions[i3 + 1] = player.position.y + (Math.random() - 0.5) * 0.2;
trailPositions[i3 + 2] = player.position.z + 1.5;
trailAlphas[trailHead] = state.boostActive ? 1.2 : 0.9;
trailSizes[trailHead] = state.boostActive ? 12 : 7;
trailHead = (trailHead + 1) % TRAIL_MAX;
}
// Decay
for (let i = 0; i < TRAIL_MAX; i++) {
trailAlphas[i] = Math.max(0, trailAlphas[i] - dt * 2.5);
}
trailGeom.attributes.position.needsUpdate = true;
trailGeom.attributes.alpha.needsUpdate = true;
trailGeom.attributes.size.needsUpdate = true;
}
// Dust particles
const DUST_MAX = 140;
const dustPositions = new Float32Array(DUST_MAX * 3);
const dustSizes = new Float32Array(DUST_MAX);
const dustColors = new Float32Array(DUST_MAX * 3);
let dustGeom, dustPoints;
function initDust() {
for (let i = 0; i < DUST_MAX; i++) {
resetDust(i, true);
}
dustGeom = new THREE.BufferGeometry();
dustGeom.setAttribute('position', new THREE.BufferAttribute(dustPositions, 3));
dustGeom.setAttribute('size', new THREE.BufferAttribute(dustSizes, 1));
dustGeom.setAttribute('color', new THREE.BufferAttribute(dustColors, 3));
const dustMat = new THREE.ShaderMaterial({
vertexShader: `
attribute float size;
attribute vec3 color;
varying vec3 vColor;
void main() {
vColor = color;
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = size * (250.0 / -mv.z);
gl_Position = projectionMatrix * mv;
}
`,
fragmentShader: `
varying vec3 vColor;
void main() {
vec2 c = gl_PointCoord - 0.5;
float d = length(c);
if (d > 0.5) discard;
float a = smoothstep(0.5, 0.1, d) * 0.6;
gl_FragColor = vec4(vColor, a);
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
dustPoints = new THREE.Points(dustGeom, dustMat);
scene.add(dustPoints);
}
function resetDust(i, initial) {
const t = -player.position.z + (initial ? Math.random() * 200 : 0);
dustPositions[i * 3] = player.position.x + (Math.random() - 0.5) * 70;
dustPositions[i * 3 + 1] = Math.random() * 20 - 2;
dustPositions[i * 3 + 2] = player.position.z - 30 - Math.random() * 180;
dustSizes[i] = 1 + Math.random() * 2.5;
const tone = Math.random();
if (tone < 0.5) { dustColors[i*3] = 1.0; dustColors[i*3+1] = 0.5; dustColors[i*3+2] = 0.2; }
else if (tone < 0.85) { dustColors[i*3] = 0.9; dustColors[i*3+1] = 0.6; dustColors[i*3+2] = 0.3; }
else { dustColors[i*3] = 0.7; dustColors[i*3+1] = 0.5; dustColors[i*3+2] = 0.9; }
}
function updateDust() {
for (let i = 0; i < DUST_MAX; i++) {
if (dustPositions[i * 3 + 2] > player.position.z + 15) {
resetDust(i, false);
}
}
dustGeom.attributes.position.needsUpdate = true;
dustGeom.attributes.size.needsUpdate = true;
dustGeom.attributes.color.needsUpdate = true;
}
// Spark burst (for orb collection)
const bursts = []; // {pos, time, max, mesh}
function createBurst(pos, color) {
const count = 16;
const geom = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
const velocities = [];
for (let i = 0; i < count; i++) {
positions[i * 3] = pos.x;
positions[i * 3 + 1] = pos.y;
positions[i * 3 + 2] = pos.z;
velocities.push(new THREE.Vector3(
(Math.random() - 0.5) * 8,
(Math.random() - 0.5) * 8,
(Math.random() - 0.5) * 8
));
}
geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({
color, size: 0.4, transparent: true, opacity: 1,
blending: THREE.AdditiveBlending, depthWrite: false,
});
const pts = new THREE.Points(geom, mat);
scene.add(pts);
bursts.push({ pts, velocities, time: 0, max: 0.6 });
}
function updateBursts(dt) {
for (let i = bursts.length - 1; i >= 0; i--) {
const b = bursts[i];
b.time += dt;
const positions = b.pts.geometry.attributes.position.array;
for (let j = 0; j < b.velocities.length; j++) {
positions[j * 3] += b.velocities[j].x * dt;
positions[j * 3 + 1] += b.velocities[j].y * dt;
positions[j * 3 + 2] += b.velocities[j].z * dt;
b.velocities[j].multiplyScalar(0.92);
}
b.pts.geometry.attributes.position.needsUpdate = true;
b.pts.material.opacity = Math.max(0, 1 - b.time / b.max);
if (b.time >= b.max) {
scene.remove(b.pts);
b.pts.geometry.dispose();
b.pts.material.dispose();
bursts.splice(i, 1);
}
}
}
// ============================================================
// SCENE SETUP
// ============================================================
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0xff7744, 60, 220);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.5, 600);
camera.position.set(0, 8, 14);
// Sky
const skyGeom = new THREE.SphereGeometry(400, 32, 16);
const skyMat = new THREE.ShaderMaterial({
side: THREE.BackSide,
uniforms: {
topColor: { value: new THREE.Color(0x0a0220) },
midColor: { value: new THREE.Color(0x4a1144) },
horizonColor: { value: new THREE.Color(0xff5522) },
sunColor: { value: new THREE.Color(0xffcc88) },
sunDir: { value: new THREE.Vector3(0.35, 0.08, -1).normalize() },
},
vertexShader: `
varying vec3 vWorld;
void main() {
vec4 wp = modelMatrix * vec4(position, 1.0);
vWorld = wp.xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 topColor, midColor, horizonColor, sunColor, sunDir;
varying vec3 vWorld;
void main() {
vec3 dir = normalize(vWorld);
float h = dir.y;
vec3 col;
if (h > 0.0) {
col = mix(horizonColor, midColor, smoothstep(0.0, 0.4, h));
col = mix(col, topColor, smoothstep(0.3, 1.0, h));
} else {
col = mix(horizonColor, vec3(0.15, 0.05, 0.02), smoothstep(0.0, -0.3, h));
}
// Sun
float sunDot = max(0.0, dot(dir, sunDir));
col += sunColor * pow(sunDot, 64.0) * 1.5;
col += sunColor * pow(sunDot, 8.0) * 0.4;
col += vec3(1.0, 0.4, 0.1) * pow(sunDot, 2.0) * 0.15;
gl_FragColor = vec4(col, 1.0);
}
`,
});
const sky = new THREE.Mesh(skyGeom, skyMat);
sky.frustumCulled = false;
scene.add(sky);
// Sun (visual)
const sunGeom = new THREE.SphereGeometry(8, 16, 12);
const sunMat = new THREE.MeshBasicMaterial({ color: 0xffeecc });
const sunMesh = new THREE.Mesh(sunGeom, sunMat);
const sunPos = new THREE.Vector3(70, 8, -200);
sunMesh.position.copy(sunPos);
scene.add(sunMesh);
// Distant mountains
const mountainGroup = new THREE.Group();
for (let i = 0; i < 40; i++) {
const angle = (i / 40) * Math.PI * 2 + Math.random() * 0.1;
const dist = 280 + Math.random() * 60;
const h = 40 + Math.random() * 90;
const w = 30 + Math.random() * 30;
const mGeom = new THREE.ConeGeometry(w, h, 4 + Math.floor(Math.random() * 3));
const mMat = new THREE.MeshStandardMaterial({
color: new THREE.Color().setHSL(0.85 + Math.random() * 0.1, 0.5, 0.12 + Math.random() * 0.05),
flatShading: true, roughness: 1.0, metalness: 0,
});
const m = new THREE.Mesh(mGeom, mMat);
m.position.set(Math.cos(angle) * dist, h / 2 - 25, Math.sin(angle) * dist);
m.rotation.y = Math.random() * Math.PI;
mountainGroup.add(m);
}
scene.add(mountainGroup);
// Lights
const sunLight = new THREE.DirectionalLight(0xffaa66, 1.6);
sunLight.position.set(70, 30, 100);
sunLight.target.position.set(0, 0, -50);
sunLight.castShadow = true;
sunLight.shadow.mapSize.width = 2048;
sunLight.shadow.mapSize.height = 2048;
sunLight.shadow.camera.near = 1;
sunLight.shadow.camera.far = 250;
sunLight.shadow.camera.left = -80;
sunLight.shadow.camera.right = 80;
sunLight.shadow.camera.top = 80;
sunLight.shadow.camera.bottom = -80;
sunLight.shadow.bias = -0.0005;
sunLight.shadow.normalBias = 0.02;
scene.add(sunLight);
scene.add(sunLight.target);
const fillLight = new THREE.DirectionalLight(0x6644aa, 0.5);
fillLight.position.set(-50, 20, 50);
scene.add(fillLight);
const hemi = new THREE.HemisphereLight(0xff8855, 0x220a22, 0.4);
scene.add(hemi);
const ambient = new THREE.AmbientLight(0x442255, 0.3);
scene.add(ambient);
// Player-following light for path visibility
const playerLight = new THREE.PointLight(0xffaa66, 1.2, 35, 1.5);
playerLight.position.set(0, 8, 5);
scene.add(playerLight);
// Player craft
const craft = createCraft();
scene.add(craft);
craft.position.copy(player.position);
// Ground plane (extends infinitely with chunks)
const bigGround = new THREE.Mesh(
new THREE.PlaneGeometry(500, 500),
new THREE.MeshStandardMaterial({ color: 0x0a0402, roughness: 1.0, metalness: 0 })
);
bigGround.rotation.x = -Math.PI / 2;
bigGround.position.y = -3;
bigGround.receiveShadow = true;
scene.add(bigGround);
// ============================================================
// POST-PROCESSING
// ============================================================
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
renderer.outputColorSpace = THREE.SRGBColorSpace;
$('game').appendChild(renderer.domElement);
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 0.7, 0.4, 0.85);
composer.addPass(bloom);
// Vignette + chromatic aberration shader
const vignettePass = new ShaderPass({
uniforms: {
tDiffuse: { value: null },
offset: { value: 1.1 },
darkness: { value: 0.65 },
aberration: { value: 0.0012 },
},
vertexShader: `
varying vec2 vUv;
void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform float offset;
uniform float darkness;
uniform float aberration;
varying vec2 vUv;
void main() {
vec2 uv = vUv;
vec2 dir = uv - 0.5;
float dist = length(dir);
// Chromatic aberration
vec2 ca = dir * aberration;
float r = texture2D(tDiffuse, uv - ca).r;
float g = texture2D(tDiffuse, uv).g;
float b = texture2D(tDiffuse, uv + ca).b;
vec3 col = vec3(r, g, b);
// Vignette
float vig = smoothstep(offset, offset - 0.7, dist);
col *= mix(1.0 - darkness, 1.0, vig);
// Grain
float grain = fract(sin(dot(uv * 1000.0, vec2(12.9898, 78.233))) * 43758.5453);
col += (grain - 0.5) * 0.03;
gl_FragColor = vec4(col, 1.0);
}
`,
});
composer.addPass(vignettePass);
composer.addPass(new OutputPass());
// ============================================================
// INPUT
// ============================================================
const keys = {};
window.addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'Space' || ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) e.preventDefault();
});
window.addEventListener('keyup', e => { keys[e.code] = false; });
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
});
// ============================================================
// GAME FUNCTIONS
// ============================================================
function startGame() {
audio.init();
$('intro').style.display = 'none';
state.started = true;
state.gameOver = false;
state.score = 0;
state.multiplier = 1.0;
state.energy = 100;
state.turbo = 0;
state.speed = state.baseSpeed;
state.maxSpeed = 90;
state.maxSpeedReached = 0;
state.distance = 0;
state.time = 0;
state.nearMissCooldown = 0;
state.prevMinDist = 999;
player.position.set(0, 5, 0);
player.yaw = 0;
player.pitch = 0;
player.roll = 0;
player.yawVel = 0;
player.pitchVel = 0;
player.forward.set(0, 0, -1);
// Clear chunks and regenerate
for (const [idx, chunk] of chunks) {
scene.remove(chunk);
chunk.traverse(obj => {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
if (Array.isArray(obj.material)) obj.material.forEach(m => m.dispose());
else obj.material.dispose();
}
});
}
chunks.clear();
allOrbs.length = 0;
allMonoliths.length = 0;
updateChunks();
}
function gameOver() {
if (state.gameOver) return;
state.gameOver = true;
state.started = false;
audio.crash();
$('damage-flash').style.opacity = '1';
$('damage-flash').style.transition = 'opacity 0.1s';
setTimeout(() => {
$('damage-flash').style.transition = 'opacity 1s';
$('damage-flash').style.opacity = '0';
}, 100);
$('final-score').textContent = Math.floor(state.score);
$('final-speed').textContent = Math.floor(state.maxSpeedReached);
$('final-dist').textContent = Math.floor(state.distance);
$('game-over').classList.add('show');
}
function restartGame() {
$('game-over').classList.remove('show');
startGame();
}
$('start-btn').addEventListener('click', startGame);
$('restart-btn').addEventListener('click', restartGame);
function showNearMsg(text) {
const el = $('near-msg');
el.textContent = text;
el.classList.remove('show');
void el.offsetWidth;
el.classList.add('show');
}
// ============================================================
// UPDATE LOOP
// ============================================================
let lastTime = performance.now();
let prevPlayerZ = 0;
function update(dt) {
if (!state.started) return;
state.time += dt;
state.distance += state.speed * dt;
// ---- Player input → rotation ----
const yawInput = ((keys['ArrowRight'] || keys['KeyD']) ? 1 : 0) - ((keys['ArrowLeft'] || keys['KeyA']) ? 1 : 0);
const pitchInput = ((keys['ArrowUp'] || keys['KeyW']) ? 1 : 0) - ((keys['ArrowDown'] || keys['KeyS']) ? 1 : 0);
const boostInput = !!keys['Space'];
const brakeInput = !!keys['ShiftLeft'] || !!keys['ShiftRight'];
state.boostActive = boostInput && state.turbo > 0 && !brakeInput;
state.braking = brakeInput;
// Yaw and pitch with inertia
player.yawVel += yawInput * 2.4 * dt;
player.yawVel *= Math.pow(0.06, dt); // damping
player.yaw += player.yawVel * dt;
player.pitchVel += pitchInput * 1.8 * dt;
player.pitchVel *= Math.pow(0.06, dt);
player.pitch += player.pitchVel * dt;
player.pitch = Math.max(-0.55, Math.min(0.55, player.pitch));
// Roll (banking) follows yaw velocity
const targetRoll = -player.yawVel * 0.5;
player.roll += (targetRoll - player.roll) * Math.min(1, 8 * dt);
// ---- Speed ----
// Difficulty scaling
state.maxSpeed = 90 + Math.min(100, state.score * 0.015);
const targetSpeed = state.boostActive ? state.maxSpeed * 1.55 : (state.braking ? state.baseSpeed * 0.7 : state.maxSpeed);
const speedLerp = (targetSpeed - state.speed) * 2.0 * dt;
state.speed += speedLerp;
state.maxSpeedReached = Math.max(state.maxSpeedReached, state.speed);
// Turbo
if (state.boostActive) {
state.turbo = Math.max(0, state.turbo - 35 * dt);
} else {
state.turbo = Math.min(state.maxTurbo, state.turbo + (8 + state.speed * 0.08) * dt);
}
// ---- Apply movement ----
player.forward.set(0, 0, -1).applyEuler(new THREE.Euler(player.pitch, player.yaw, 0, 'YXZ'));
player.position.addScaledVector(player.forward, state.speed * dt);
// Constrain position
player.position.y = Math.max(1.5, Math.min(20, player.position.y));
const t = -player.position.z;
const canyon = getCanyonAt(t);
const maxX = canyon.centerX + canyon.halfWidth - CFG.PLAYER_RADIUS;
const minX = canyon.centerX - canyon.halfWidth + CFG.PLAYER_RADIUS;
if (player.position.x > maxX) player.position.x = maxX;
if (player.position.x < minX) player.position.x = minX;
// Update craft transform
craft.position.copy(player.position);
craft.rotation.set(player.pitch, player.yaw, player.roll, 'YXZ');
// ---- Energy decay ----
const energyDecay = (3.2 + state.maxSpeed * 0.025) * dt;
state.energy -= energyDecay;
if (state.energy <= 0) {
state.energy = 0;
gameOver();
return;
}
// ---- Multiplier decay ----
state.multiplier = Math.max(1.0, state.multiplier - 0.25 * dt);
state.multDecayTimer = Math.max(0, state.multDecayTimer - dt);
// ---- Score (base from distance) ----
state.score += state.speed * dt * 0.5 * state.multiplier;
// ---- Collisions ----
// Wall collision (X bounds)
const distToLeft = player.position.x - (canyon.centerX - canyon.halfWidth);
const distToRight = (canyon.centerX + canyon.halfWidth) - player.position.x;
const minDist = Math.min(distToLeft, distToRight);
if (minDist < CFG.PLAYER_RADIUS) {
gameOver();
return;
}
// Near-miss
state.nearMissCooldown = Math.max(0, state.nearMissCooldown - dt);
if (minDist < CFG.PLAYER_RADIUS + CFG.NEAR_MISS_RANGE) {
if (state.prevMinDist > minDist && state.nearMissCooldown <= 0) {
const intensity = 1 - (minDist - CFG.PLAYER_RADIUS) / CFG.NEAR_MISS_RANGE;
const multGain = 0.4 + intensity * 0.8;
state.multiplier = Math.min(12, state.multiplier + multGain);
state.cameraShake = Math.max(state.cameraShake, 0.15 + intensity * 0.4);
state.multiplierPulse = 0.2;
audio.whoosh(intensity);
showNearMsg(intensity > 0.7 ? 'CLOSE!' : (intensity > 0.4 ? 'NEAR MISS' : 'CLOSE CALL'));
state.nearMissCooldown = 0.25;
}
}
state.prevMinDist = minDist;
// Orb collection
for (const orb of allOrbs) {
if (orb.collected) continue;
const dx = player.position.x - orb.pos.x;
const dy = player.position.y - orb.pos.y;
const dz = player.position.z - orb.pos.z;
const d2 = dx * dx + dy * dy + dz * dz;
if (d2 < 2.5 * 2.5) {
orb.collected = true;
orb.mesh.visible = false;
state.energy = Math.min(state.maxEnergy, state.energy + 28);
state.turbo = Math.min(state.maxTurbo, state.turbo + 18);
state.score += 100 * state.multiplier;
state.cameraShake = Math.max(state.cameraShake, 0.1);
audio.ping(Math.random());
createBurst(orb.pos, 0x66eeff);
}
}
// Monolith collisions
for (const m of allMonoliths) {
const dx = player.position.x - m.pos.x;
const dy = player.position.y - m.pos.y;
const dz = player.position.z - m.pos.z;
const d2 = dx * dx + dy * dy + dz * dz;
const r = CFG.PLAYER_RADIUS + (m.size || 1) * 1.2;
if (d2 < r * r) {
gameOver();
return;
}
}
// ---- Camera ----
const idealOffset = new THREE.Vector3(0, 3, 12);
idealOffset.applyEuler(new THREE.Euler(0, player.yaw, player.roll * 0.5));
const idealPos = player.position.clone().add(idealOffset);
camera.position.lerp(idealPos, Math.min(1, 4 * dt));
const lookTarget = player.position.clone().addScaledVector(player.forward, 18);
camera.lookAt(lookTarget);
// Camera shake
state.cameraShake *= Math.pow(0.001, dt);
if (state.cameraShake > 0.01) {
camera.position.x += (Math.random() - 0.5) * state.cameraShake * 2;
camera.position.y += (Math.random() - 0.5) * state.cameraShake * 2;
}
// FOV
const targetFov = 75 + (state.boostActive ? 18 : 0) + Math.min(8, state.speed * 0.03);
camera.fov += (targetFov - camera.fov) * Math.min(1, 3 * dt);
camera.updateProjectionMatrix();
// Sun light follows player
sunLight.position.set(player.position.x + 70, 30, player.position.z + 100);
sunLight.target.position.set(player.position.x, 0, player.position.z - 50);
sunLight.target.updateMatrixWorld();
// Player light follows player (illuminates immediate area)
playerLight.position.set(player.position.x, player.position.y + 3, player.position.z + 2);
// Mountains follow
mountainGroup.position.x = player.position.x;
mountainGroup.position.z = player.position.z;
bigGround.position.x = player.position.x;
bigGround.position.z = player.position.z;
// Sky follows
sky.position.x = player.position.x;
sky.position.z = player.position.z;
sunMesh.position.set(player.position.x + 70, 8, player.position.z - 200);
// ---- Update particles ----
emitTrail(dt);
updateDust();
updateBursts(dt);
// ---- Animate orbs and monoliths ----
const time = state.time;
for (const orb of allOrbs) {
if (orb.collected) continue;
orb.mesh.rotation.y = time * 1.5 + orb.phase;
orb.mesh.rotation.x = time * 0.8;
orb.mesh.position.y = orb.pos.y + Math.sin(time * 2 + orb.phase) * 0.3;
orb.mesh.children[2].rotation.z = time * 2;
}
for (const m of allMonoliths) {
if (m.axis === 'y') m.mesh.rotation.y += m.rotSpeed * dt;
else m.mesh.rotation.x += m.rotSpeed * dt;
}
// ---- Update chunks ----
updateChunks();
// ---- Audio ----
audio.setEngine(state.speed, state.boostActive);
if (state.boostActive && Math.random() < 0.02) audio.boost();
// ---- HUD ----
updateHUD();
}
function updateHUD() {
$('score-value').textContent = Math.floor(state.score).toLocaleString();
$('energy-bar').style.transform = `scaleX(${state.energy / state.maxEnergy})`;
$('energy-num').textContent = Math.ceil(state.energy) + '%';
$('speed-num').textContent = Math.floor(state.speed);
const speedPct = Math.min(100, (state.speed / 200) * 100);
$('speedo-fill').style.width = speedPct + '%';
$('turbo-fill').style.width = Math.min(100, state.turbo) + '%';
$('turbo-block').classList.toggle('visible', state.turbo > 5 || state.boostActive);
const multEl = $('multiplier');
multEl.textContent = state.multiplier.toFixed(1) + '×';
if (state.multiplierPulse > 0) {
multEl.classList.add('pulse');
state.multiplierPulse -= 1/60;
} else {
multEl.classList.remove('pulse');
}
const multBarPct = ((state.multiplier - 1) / 11) * 100;
$('mult-bar-fill').style.transform = `scaleX(${multBarPct / 100})`;
$('boost-flash').classList.toggle('active', state.boostActive);
}
function animate() {
requestAnimationFrame(animate);
const now = performance.now();
let dt = (now - lastTime) / 1000;
lastTime = now;
if (dt > 0.1) dt = 0.1; // clamp
update(dt);
// Always update trail decay even when not started (subtle)
if (!state.started) {
for (let i = 0; i < TRAIL_MAX; i++) trailAlphas[i] = Math.max(0, trailAlphas[i] - dt * 2.5);
trailGeom.attributes.alpha.needsUpdate = true;
updateBursts(dt);
}
composer.render();
}
// ============================================================
// INIT
// ============================================================
function init() {
initTrail();
initDust();
// Generate initial chunks
for (let i = 0; i < CFG.NUM_CHUNKS; i++) {
const c = buildChunk(i);
scene.add(c);
chunks.set(i, c);
}
// First frame
composer.render();
animate();
}
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"minimax/minimax-m3","provider":"openrouter","persona":"full","sessionId":"7f8917e6-279e-45a7-9acb-11115df25b42","tokensIn":1845749,"tokensOut":61022,"tokensTotal":1906771,"turns":31,"toolCalls":30,"failedToolCalls":0,"timestamp":"2026-07-13T21:57:47.840Z"} -->