Canyon Run
Tiel Coder 35B A3B GGUF MTP · full

Browser check: Artifact runtime error. This is a technical check, not a quality review.
Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 49,117 bytes · SHA-256 bae7ffc22a7e
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>CANYON // HOURLINE — Procedural Canyon Racer</title>
<style>
:root{
--gold:#ffd166; --orange:#ff7b3d; --magenta:#ff4d8d;
--purple:#6a3db0; --deep:#1a0f2e; --silver:#c9bfe0;
}
*{ margin:0; padding:0; box-sizing:border-box; }
html,body{ width:100%; height:100%; overflow:hidden; background:#0d0718; font-family:"Segoe UI",Helvetica,Arial,sans-serif; }
#game{ position:fixed; inset:0; }
canvas{ display:block; }
/* ---------- HUD ---------- */
#hud{ position:fixed; inset:0; pointer-events:none; z-index:10; color:var(--silver);
text-shadow:0 2px 12px rgba(0,0,0,.6); }
.hud-row{ position:absolute; top:22px; display:flex; align-items:flex-start; gap:26px; }
#scoreWrap{ }
#scoreLabel{ font-size:11px; letter-spacing:4px; text-transform:uppercase; opacity:.65; }
#score{ font-size:34px; font-weight:700; letter-spacing:1px; color:#fff; }
#best{ font-size:12px; opacity:.55; letter-spacing:2px; margin-top:2px; }
#multiWrap{ text-align:right; transform:translateX(0); will-change:transform; }
#multiLabel{ font-size:11px; letter-spacing:4px; text-transform:uppercase; opacity:.6; text-align:right; }
#multiplier{ font-size:64px; font-weight:800; line-height:.9;
background:linear-gradient(180deg,#fff,var(--gold)); -webkit-background-clip:text; background-clip:text;
-webkit-text-fill-color:transparent; filter:drop-shadow(0 0 14px rgba(255,209,102,.5));
transition:transform .08s ease-out; }
#multi.pulse{ transform:scale(1.28); }
/* speedometer */
#speedo{ position:absolute; left:22px; bottom:22px; width:240px; }
#speedoTop{ display:flex; justify-content:space-between; font-size:11px; letter-spacing:3px; opacity:.7; margin-bottom:6px; }
#speedVal{ font-size:22px; font-weight:700; color:#fff; }
#speedTrack{ height:3px; width:100%; background:rgba(255,255,255,.14); border-radius:2px; overflow:hidden; }
#speedFill{ height:100%; width:0%; background:linear-gradient(90deg,var(--purple),var(--magenta),var(--gold));
box-shadow:0 0 12px rgba(255,123,61,.7); transition:width .1s linear; }
/* energy */
#energy{ position:absolute; right:22px; bottom:22px; width:240px; text-align:right; }
#energyTop{ display:flex; align-items:center; justify-content:flex-end; gap:10px; font-size:11px;
letter-spacing:3px; text-transform:uppercase; opacity:.7; margin-bottom:6px; }
#energyIcon{ width:12px; height:12px; border-radius:50%; background:var(--gold);
box-shadow:0 0 12px var(--gold); animation:pulseE 1.4s infinite; }
@keyframes pulseE{ 0%,100%{opacity:.5} 50%{opacity:1} }
#energyTrack{ height:8px; width:100%; background:rgba(255,255,255,.12); border-radius:4px; overflow:hidden; }
#energyFill{ height:100%; width:100%; background:linear-gradient(90deg,#ff3b6b,var(--gold));
box-shadow:0 0 16px rgba(255,209,102,.6); transition:width .12s linear; }
#energyFill.low{ background:linear-gradient(90deg,#ff1e3d,#ff6b3d); }
/* turbo */
#turbo{ position:absolute; left:50%; bottom:22px; transform:translateX(-50%); width:260px; text-align:center; }
#turboLabel{ font-size:10px; letter-spacing:5px; text-transform:uppercase; opacity:.6; margin-bottom:5px; }
#turboTrack{ height:5px; width:100%; background:rgba(255,255,255,.12); border-radius:3px; overflow:hidden; }
#turboFill{ height:100%; width:0%; background:linear-gradient(90deg,#4de3ff,var(--magenta)); transition:width .1s linear; }
#turbo.active #turboLabel{ color:var(--gold); opacity:1; animation:tbPulse .35s infinite; }
@keyframes tbPulse{ 0%,100%{opacity:.6} 50%{opacity:1} }
#boostHint{ position:absolute; left:50%; bottom:56px; transform:translateX(-50%);
font-size:11px; letter-spacing:3px; text-transform:uppercase; opacity:0; transition:opacity .3s; color:var(--silver); }
#boostHint.show{ opacity:.85; }
/* overlays */
.overlay{ position:fixed; inset:0; z-index:20; display:flex; flex-direction:column;
align-items:center; justify-content:center; text-align:center;
background:radial-gradient(ellipse at 50% 40%, rgba(40,18,64,.72), rgba(10,5,20,.94));
backdrop-filter:blur(3px); }
.hidden{ display:none !important; }
#title{ font-size:64px; font-weight:900; letter-spacing:8px; line-height:.95;
background:linear-gradient(180deg,#fff,var(--gold),var(--orange)); -webkit-background-clip:text; background-clip:text;
-webkit-text-fill-color:transparent; filter:drop-shadow(0 4px 30px rgba(255,123,61,.4)); }
#subtitle{ font-size:14px; letter-spacing:8px; text-transform:uppercase; opacity:.6; margin-top:8px; }
.card{ margin-top:34px; padding:26px 40px; border:1px solid rgba(255,255,255,.12); border-radius:14px;
background:rgba(20,10,34,.5); min-width:300px; }
.stat{ display:flex; justify-content:space-between; margin:10px 0; font-size:14px; letter-spacing:2px; }
.stat b{ color:#fff; font-size:20px; }
.stat span{ opacity:.6; text-transform:uppercase; letter-spacing:3px; font-size:12px; }
.btn{ margin-top:26px; pointer-events:auto; cursor:pointer; font-size:15px; letter-spacing:4px;
text-transform:uppercase; padding:16px 40px; border:none; border-radius:40px; color:#1a0f2e; font-weight:800;
background:linear-gradient(90deg,var(--gold),var(--orange)); box-shadow:0 8px 30px rgba(255,123,61,.5);
transition:transform .15s ease, box-shadow .15s ease; }
.btn:hover{ transform:translateY(-2px) scale(1.03); box-shadow:0 12px 40px rgba(255,123,61,.7); }
.btn:active{ transform:scale(.97); }
.controls{ margin-top:26px; font-size:12px; letter-spacing:2px; opacity:.55; line-height:2; }
.controls b{ color:var(--silver); font-weight:600; }
#crashFlash{ position:fixed; inset:0; z-index:15; background:radial-gradient(ellipse at center, rgba(255,80,40,.5), transparent 70%);
opacity:0; pointer-events:none; transition:opacity .12s; }
#vignette{ position:fixed; inset:0; z-index:9; pointer-events:none;
box-shadow:inset 0 0 180px 40px rgba(10,4,20,.65); }
</style>
</head>
<body>
<div id="game"></div>
<div id="vignette"></div>
<div id="crashFlash"></div>
<div id="hud" class="hidden">
<div class="hud-row">
<div id="scoreWrap">
<div id="scoreLabel">Score</div>
<div id="score">0</div>
<div id="best">BEST — 0</div>
</div>
<div id="multiWrap">
<div id="multiLabel">Multiplier</div>
<div id="multiplier">x1</div>
</div>
</div>
<div id="speedo">
<div id="speedoTop"><span>SPEED</span><span id="speedVal">0</span></div>
<div id="speedTrack"><div id="speedFill"></div></div>
</div>
<div id="energy">
<div id="energyTop"><span>Energy</span><div id="energyIcon"></div></div>
<div id="energyTrack"><div id="energyFill"></div></div>
</div>
<div id="turbo"><div id="turboLabel">Turbo</div><div id="turboTrack"><div id="turboFill"></div></div></div>
<div id="boostHint">Hold <b>Space</b> / Right-Click to Boost</div>
</div>
<div id="startScreen" class="overlay">
<div id="title">CANYON<br>// HOURLINE</div>
<div id="subtitle">Procedural Canyon Racer</div>
<div class="card">
<div class="stat"><span>Goal</span><b>Go far. Go fast.</b></div>
<div class="stat"><span>Mechanic</span><b>Near-miss → boost</b></div>
</div>
<button class="btn" id="startBtn">Launch</button>
<div class="controls">
<b>← → / A D</b> bank & steer<br>
<b>↑ ↓ / W S</b> climb & dive<br>
<b>SPACE</b> boost (needs Turbo)<br>
Collect <b>glowing orbs</b> to stay fueled · Clip walls to build multiplier
</div>
</div>
<div id="gameOverScreen" class="overlay hidden">
<div id="title" style="font-size:52px;">SIGNAL LOSS</div>
<div id="subtitle">Craft destroyed</div>
<div class="card">
<div class="stat"><span>Final Score</span><b id="finalScore">0</b></div>
<div class="stat"><span>Distance</span><b id="finalDist">0</b></div>
<div class="stat"><span>Max Speed</span><b id="finalSpeed">0</b></div>
<div class="stat"><span>Best</span><b id="finalBest">0</b></div>
</div>
<button class="btn" id="restartBtn">Re-launch</button>
</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
// ===================================================================
// CANYON // HOURLINE — single-file procedural canyon racer
// ===================================================================
import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
const container = document.getElementById('game');
// -------------------------------------------------------------------
// Renderer / Scene / Camera
// -------------------------------------------------------------------
const renderer = new THREE.WebGLRenderer({ 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.outputColorSpace = THREE.SRGBColorSpace;
container.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0x3a1c4a, 120, 900);
const camera = new THREE.PerspectiveCamera(72, window.innerWidth/window.innerHeight, 0.1, 3000);
camera.position.set(0, 8, -16);
// ---- Lighting (golden-hour sun) ----
const hemi = new THREE.HemisphereLight(0xffd9a0, 0x2a1440, 0.75);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xffb066, 2.1);
sun.position.set(-60, 90, 80);
sun.castShadow = true;
sun.shadow.camera.left = -120;
sun.shadow.camera.right = 120;
sun.shadow.camera.top = 120;
sun.shadow.camera.bottom = -120;
sun.shadow.camera.near = 10;
sun.shadow.camera.far = 400;
sun.shadow.mapSize.set(1024,1024);
sun.shadow.bias = -0.0004;
scene.add(sun);
scene.add(sun.target);
const rim = new THREE.DirectionalLight(0xff4d8d, 0.5);
rim.position.set(60, 30, -80);
scene.add(rim);
// ---- Sun disc on the horizon ----
const sunDiscGeo = new THREE.CircleGeometry(70, 32);
const sunDiscMat = new THREE.MeshBasicMaterial({
color:0xffd166, side:THREE.DoubleSide, transparent:true, opacity:0.95
});
const sunDisc = new THREE.Mesh(sunDiscGeo, sunDiscMat);
sunDisc.position.set(-140, 40, -1200);
sunDisc.lookAt(0,40,0);
scene.add(sunDisc);
const sunGlowGeo = new THREE.CircleGeometry(120, 32);
const sunGlowMat = new THREE.MeshBasicMaterial({ color:0xff7b3d, transparent:true, opacity:0.28, side:THREE.DoubleSide });
const sunGlow = new THREE.Mesh(sunGlowGeo, sunGlowMat);
sunGlow.copy(sunDisc); sunGlow.position.z -= 2; scene.add(sunGlow);
// ---- Gradient sky dome ----
const skyGeo = new THREE.SphereGeometry(2000, 32, 16);
const skyMat = new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite:false,
uniforms:{
topColor: {value:new THREE.Color(0x2a1050)},
midColor: {value:new THREE.Color(0x8a2f5e)},
bottomColor: {value:new THREE.Color(0xff8a3d)},
},
vertexShader:`varying vec3 vP; void main(){ vP=position; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
fragmentShader:`
varying vec3 vP; uniform vec3 topColor; uniform vec3 midColor; uniform vec3 bottomColor;
void main(){
float h = normalize(vP).y*0.5+0.5;
vec3 c = h<0.5 ? mix(bottomColor,midColor,h/0.5) : mix(midColor,topColor,(h-0.5)/0.5);
gl_FragColor=vec4(c,1.0);
}`
});
scene.add(new THREE.Mesh(skyGeo, skyMat));
// -------------------------------------------------------------------
// Post-processing: Render -> radial motion blur -> bloom
// -------------------------------------------------------------------
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const blurMat = new THREE.ShaderMaterial({
uniforms:{
tDiffuse:{value:null},
resolution:{value:new THREE.Vector2(window.innerWidth, window.innerHeight)},
strength:{value:0.0}
},
vertexShader:`varying vec2 vUv; void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
fragmentShader:`
varying vec2 vUv; uniform sampler2D tDiffuse; uniform float strength; uniform vec2 resolution;
void main(){
vec2 uv = vUv;
vec2 dir = uv - 0.5;
float d = length(dir);
vec4 col = vec4(0.0); float total = 0.0;
int N = 9;
for(int i=1;i<=N;i++){
float t = float(i)/float(N);
vec2 off = dir * d * strength * 0.012 * t;
vec4 s = texture2D(tDiffuse, uv - off);
float w = 1.0 - t*0.55;
col += s*w; total += w;
}
gl_FragColor = col/total;
}`
});
const blurPass = new ShaderPass(blurMat);
blurPass.material.uniforms.strength.value = 0.0;
composer.addPass(blurPass);
const bloom = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight), 0.75, 0.55, 0.55
);
composer.addPass(bloom);
// -------------------------------------------------------------------
// Palette / materials
// -------------------------------------------------------------------
const COL = {
base:new THREE.Color(0x4a2034),
mid:new THREE.Color(0xa8543b),
top:new THREE.Color(0xb79fce),
wallTop:new THREE.Color(0xd3c6ee),
monolith:new THREE.Color(0x7a4fb0),
orb:new THREE.Color(0xffd166),
craft:new THREE.Color(0xe8e2f2),
craftDark:new THREE.Color(0x3a2d55),
engine:new THREE.Color(0xff7b3d),
};
function wallColorAt(y, floorY, wallH){
const t = THREE.MathUtils.clamp((y-floorY)/wallH, 0, 1);
let c;
if(t<0.5) c = COL.base.clone().lerp(COL.mid, t/0.5);
else c = COL.mid.clone().lerp(COL.wallTop, (t-0.5)/0.5);
return c;
}
const wallMat = new THREE.MeshStandardMaterial({ vertexColors:true, flatShading:true, roughness:0.85, metalness:0.05 });
const floorMat = new THREE.MeshStandardMaterial({ vertexColors:true, flatShading:true, roughness:0.9, metalness:0.02 });
const monolithMat = new THREE.MeshStandardMaterial({ color:COL.monolith, flatShading:true, roughness:0.4, metalness:0.2, emissive:0x2a1545, emissiveIntensity:0.4 });
// -------------------------------------------------------------------
// Player craft (low-poly)
// -------------------------------------------------------------------
const craft = new THREE.Group();
let engineL, engineR;
(function buildCraft(){
const body = new THREE.Mesh(new THREE.BoxGeometry(2.0, 1.1, 6.0),
new THREE.MeshStandardMaterial({ color:COL.craft, flatShading:true, roughness:0.35, metalness:0.4 }));
body.castShadow = true; craft.add(body);
const nose = new THREE.Mesh(new THREE.ConeGeometry(1.0, 3.4, 4),
new THREE.MeshStandardMaterial({ color:COL.craft, flatShading:true, roughness:0.35, metalness:0.4 }));
nose.rotation.x = Math.PI/2; nose.rotation.y = Math.PI/4;
nose.position.z = 4.4; nose.castShadow=true; craft.add(nose);
const wingMat = new THREE.MeshStandardMaterial({ color:COL.craftDark, flatShading:true, roughness:0.5, metalness:0.3 });
const wingL = new THREE.Mesh(new THREE.BoxGeometry(5.2, 0.25, 2.6), wingMat);
wingL.position.set(-2.6, -0.1, 0.4); wingL.rotation.z = 0.05; wingL.castShadow=true; craft.add(wingL);
const wingR = wingL.clone(); wingR.position.x = 2.6; wingR.rotation.z = -0.05; craft.add(wingR);
const tailL = new THREE.Mesh(new THREE.BoxGeometry(2.2, 1.4, 0.3), wingMat);
tailL.position.set(-1.3, 0.5, -2.4); tailL.rotation.z = 0.25; craft.add(tailL);
const tailR = tailL.clone(); tailR.position.x = 1.3; tailR.rotation.z = -0.25; craft.add(tailR);
// engine pods (emissive)
const engMat = new THREE.MeshStandardMaterial({ color:0x222222, emissive:COL.engine, emissiveIntensity:2.2, flatShading:true });
const engL = new THREE.Mesh(new THREE.CylinderGeometry(0.5,0.7,1.6,8), engMat);
engL.rotation.x = Math.PI/2; engL.position.set(-1.0, 0, -3.4); craft.add(engL);
const engR = engL.clone(); engR.position.x = 1.0; craft.add(engR);
engineL = engL; engineR = engR;
// engine glow light
const glow = new THREE.PointLight(0xff7b3d, 2.0, 26, 2);
glow.position.set(0,0,-4.2); craft.add(glow);
})();
scene.add(craft);
// -------------------------------------------------------------------
// Particle systems: trail + speed streaks
// -------------------------------------------------------------------
const TRAIL_N = 260, STREAK_N = 340;
const trailGeo = new THREE.BufferGeometry();
const trailPos = new Float32Array(TRAIL_N*3);
const trailLife = new Float32Array(TRAIL_N);
trailGeo.setAttribute('position', new THREE.BufferAttribute(trailPos, 3));
const trailTex = makeSoftTex();
const trailMat = new THREE.PointsMaterial({
size:2.6, map:trailTex, transparent:true, depthWrite:false, blending:THREE.AdditiveBlending,
color:0xff9a4d, opacity:0.85, sizeAttenuation:true
});
const trailPts = new THREE.Points(trailGeo, trailMat);
trailPts.frustumCulled = false; scene.add(trailPts);
const trailState = [];
for(let i=0;i<TRAIL_N;i++) trailState.push({x:0,y:-999,z:0,vx:0,vy:0,vz:0,life:0,max:1});
const streakGeo = new THREE.BufferGeometry();
const streakPos = new Float32Array(STREAK_N*3);
streakGeo.setAttribute('position', new THREE.BufferAttribute(streakPos, 3));
const streakMat = new THREE.PointsMaterial({
size:0.9, map:trailTex, transparent:true, depthWrite:false, blending:THREE.AdditiveBlending,
color:0xbfa8ff, opacity:0.5, sizeAttenuation:true
});
const streakPts = new THREE.Points(streakGeo, streakMat);
streakPts.frustumCulled = false; scene.add(streakPts);
const streakState = [];
for(let i=0;i<STREAK_N;i++) streakState.push({});
function makeSoftTex(){
const c = document.createElement('canvas'); c.width=c.height=64;
const g = c.getContext('2d');
const grd = g.createRadialGradient(32,32,0,32,32,32);
grd.addColorStop(0,'rgba(255,255,255,1)');
grd.addColorStop(0.4,'rgba(255,255,255,0.6)');
grd.addColorStop(1,'rgba(255,255,255,0)');
g.fillStyle=grd; g.fillRect(0,0,64,64);
const t = new THREE.CanvasTexture(c); return t;
}
// -------------------------------------------------------------------
// Procedural generation (ring-based canyon)
// -------------------------------------------------------------------
const RING_SPACING = 22;
const CHUNK_RINGS = 12;
const FAR_X = 150;
const NEAR_MISS = 9.0; // world units from a wall = near miss
const PLAYER_R = 2.0;
let difficulty = 0;
const ringParams = {}; // index -> {cx,hw,floorY,wallH,z}
const chunks = []; // active chunk managers
const obstacles = []; // {ring,type,x,y,z,hx,hy,hz,radius,collected,nearTriggered,glow}
let nextRing = 0;
function ringNoise(n, s){
return Math.sin(n*0.037+s*1.0)*0.45 + Math.sin(n*0.0213+s*2.3)*0.35 + Math.sin(n*0.011+s*0.7)*0.20;
}
function genRingParams(n){
const d = difficulty;
const cxAmp = 16 + Math.min(46, d*7);
const cx = ringNoise(n,1.0)*cxAmp;
const wv = ringNoise(n,2.0);
const minHw = Math.max(14, 30 - d*2.2);
const maxHw = Math.max(minHw+10, 64 - d*5);
const hw = maxHw - (maxHw-minHw)*0.5*(1.0+wv);
const floorVar = ringNoise(n,3.0);
const floorY = -18 + floorVar*16;
const heightVar = ringNoise(n,4.0);
const wallH = 150 + heightVar*46;
return { cx, hw, floorY, wallH, z: n*RING_SPACING };
}
// accumulate wall geometry across a chunk for merging
function chunkGeoArrays(){
return {
left:{p:[], n:[], c:[]},
right:{p:[], n:[], c:[]},
floor:{p:[], n:[], c:[]}
};
}
function pushQuad(arr, a,b,c,d, normal){
// emit a quad using the given winding; normal is the (already-computed) geometric normal
const faces = [[0,1,2],[0,2,3]];
for(const f of faces){
for(const idx of f){
const p = (idx===0?a:idx===1?b:idx===2?c:d);
arr.p.push(p.x,p.y,p.z);
arr.n.push(normal.x,normal.y,normal.z);
const col = wallColorAt(p.y, arr._floorY, arr._wallH);
arr.c.push(col.r,col.g,col.b);
}
}
}
// helper to grab floorY/wallH of the ring a quad belongs to (same for both verts of a quad's near/far)
function floorYFor(arr,p){ return arr._floorY; }
function wallHFor(arr,p){ return arr._wallH; }
function addSegment(geo, which, ri, rn){
const g = geo[which];
if(which==='floor'){
const far = FAR_X;
const a = new THREE.Vector3(-far, ri.floorY, ri.z);
const b = new THREE.Vector3( far, ri.floorY, ri.z);
const c = new THREE.Vector3( far, rn.floorY, rn.z);
const d = new THREE.Vector3(-far, rn.floorY, rn.z);
const n = new THREE.Vector3().subVectors(b,a).cross(new THREE.Vector3().subVectors(c,a)).normalize();
g._floorY = ri.floorY; g._wallH = 1;
pushQuad(g,a,b,c,d,n);
} else {
// canyon wall quad: vertical slab at the passage edge, spanning near->far rings.
const innerX = which==='left'? ri.cx-ri.hw : ri.cx+ri.hw;
const innerXn = which==='left'? rn.cx-rn.hw : rn.cx+rn.hw;
const ty = ri.floorY+ri.wallH, ty2 = rn.floorY+rn.wallH;
const inward = which==='left'? 1 : -1; // inner face points toward the passage
const a = new THREE.Vector3(innerX, ri.floorY, ri.z);
const b = new THREE.Vector3(innerX, ty, ri.z);
const c = new THREE.Vector3(innerXn, ty2, rn.z);
const d = new THREE.Vector3(innerXn, rn.floorY, rn.z);
const n = new THREE.Vector3().subVectors(b,a).cross(new THREE.Vector3().subVectors(c,a)).normalize();
if (n.x * inward < 0){ const t2=c; c=d; d=t2; } // ensure inner face points inward (x always nonzero)
g._floorY = ri.floorY; g._wallH = ri.wallH;
pushQuad(g,a,b,c,d,n);
}
}
function makeChunk(startRing){
const group = new THREE.Group();
const geo = chunkGeoArrays();
const rings = [];
for(let k=0;k<=CHUNK_RINGS;k++){
const i = startRing+k;
rings.push(genRingParams(i));
ringParams[i] = rings[k];
}
for(let k=0;k<CHUNK_RINGS;k++){
const ri = rings[k];
const rn = rings[k+1];
addSegment(geo,'left', ri, rn);
addSegment(geo,'right', ri, rn);
addSegment(geo,'floor', ri, rn);
genObstaclesForRing(startRing+k, ri);
}
function buildMesh(geoArr){
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(geoArr.p),3));
g.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(geoArr.n),3));
g.setAttribute('color', new THREE.BufferAttribute(new Float32Array(geoArr.c),3));
return g;
}
const leftMesh = new THREE.Mesh(buildMesh(geo.left), wallMat);
const rightMesh = new THREE.Mesh(buildMesh(geo.right), wallMat);
const floorMesh = new THREE.Mesh(buildMesh(geo.floor), floorMat);
for(const m of [leftMesh,rightMesh,floorMesh]){ m.castShadow=false; m.receiveShadow=true; group.add(m); }
scene.add(group);
return { startRing, endRing:startRing+CHUNK_RINGS-1, group, leftMesh, rightMesh, floorMesh };
}
function genObstaclesForRing(i, ri){
if(i < 4) return; // safe start corridor
// orbs
const orbChance = 0.34 + Math.min(0.3, difficulty*0.03);
if(Math.random() < orbChance){
const off = (Math.random()-0.5)* Math.max(4, ri.hw*0.7);
const oy = ri.floorY + 12 + Math.random()*34;
addOrb(i, ri.cx+off, oy, ri.z);
// occasional line of orbs
if(Math.random()<0.4){
const dir = Math.random()<0.5?1:-1;
for(let s=1;s<=2;s++){
const ox = ri.cx + off + dir*s*10;
if(Math.abs(ox-ri.cx) < ri.hw-4){
addOrb(i, ox, oy + dir*s*3, ri.z + s*0);
}
}
}
}
// monoliths (floating pillars)
const monChance = 0.14 + Math.min(0.4, difficulty*0.05);
let count = 0;
if(Math.random() < monChance) count = 1 + (Math.random()<Math.min(0.7,difficulty*0.12)?1:0);
for(let c=0;c<count;c++){
// place offset to one side, leaving a gap to fly through
const side = Math.random()<0.5?-1:1;
const hx = Math.max(4, ri.hw* (0.22+Math.random()*0.16));
const gapCenter = ri.cx - side*(ri.hw*0.35);
const mx = gapCenter + side*(hx*0.6);
const my = ri.floorY + 14 + Math.random()*26;
const hy = 12 + Math.random()*16;
const hz = 4 + Math.random()*3;
if( ri.cx - (gapCenter) * side < ri.hw - hx) continue; // keep a gap
addMonolith(i, mx, my, ri.z, hx, hy, hz);
}
}
function addOrb(i,x,y,z){
obstacles.push({ ring:i, type:'orb', x,y,z, radius:3.2, collected:false, nearTriggered:false, glow:null });
}
function addMonolith(i,x,y,z,hx,hy,hz){
const m = new THREE.Mesh(new THREE.BoxGeometry(hx*2,hy*2,hz*2), monolithMat);
m.position.set(x,y,z); m.castShadow=true; m.receiveShadow=true;
m.rotation.y = Math.random()*Math.PI;
scene.add(m);
obstacles.push({ ring:i, type:'monolith', x,y,z, hx,hy,hz, mesh:m, glow:null });
}
// -------------------------------------------------------------------
// Game state
// -------------------------------------------------------------------
const state = {
pos:new THREE.Vector3(0,2,0),
vel:new THREE.Vector3(0,0,0),
speed:0, baseSpeed:0, maxSpeed:64,
roll:0, pitch:0, targetRoll:0, targetPitch:0,
energy:1.0, turbo:0.0, boosting:false,
score:0, multiplier:1, multTimer:0,
maxSpeedSeen:0, distance:0,
running:false, over:false,
shake:0, multPulse:0, fov:72
};
const input = { x:0, y:0, boost:false };
let prevWallClear = 1e9;
let bestScore = Number(localStorage.getItem('canyon_best')||0);
// -------------------------------------------------------------------
// Audio (Web Audio API procedural)
// -------------------------------------------------------------------
let AC=null, engineOsc,engineGain,engineFilter,engineSub;
let audioReady=false;
function initAudio(){
if(audioReady) return;
try{
AC = new (window.AudioContext||window.webkitAudioContext)();
const master = AC.createGain(); master.gain.value=0.9; master.connect(AC.destination);
// engine: saw + sub, filtered
engineOsc = AC.createOscillator(); engineOsc.type='sawtooth'; engineOsc.frequency.value=55;
engineSub = AC.createOscillator(); engineSub.type='sine'; engineSub.frequency.value=28;
engineFilter = AC.createBiquadFilter(); engineFilter.type='lowpass'; engineFilter.frequency.value=500; engineFilter.Q.value=1.5;
engineGain = AC.createGain(); engineGain.gain.value=0.0;
engineOsc.connect(engineFilter); engineSub.connect(engineFilter);
engineFilter.connect(engineGain); engineGain.connect(master);
engineOsc.start(); engineSub.start();
audioReady=true;
}catch(e){ audioReady=false; }
}
function sfxPing(){
if(!AC) return;
const o=AC.createOscillator(), g=AC.createGain();
o.type='triangle'; o.frequency.setValueAtTime(880,AC.currentTime);
o.frequency.exponentialRampToValueAtTime(1760,AC.currentTime+0.08);
g.gain.setValueAtTime(0.25,AC.currentTime);
g.gain.exponentialRampToValueAtTime(0.001,AC.currentTime+0.25);
o.connect(g); g.connect(AC.destination); o.start(); o.stop(AC.currentTime+0.26);
}
function sfxWhoosh(intensity){
if(!AC) return;
const b=AC.createBufferSource();
const len=AC.sampleRate*0.35; const buf=AC.createBuffer(1,len,AC.sampleRate);
const d=buf.getChannelData(0);
for(let i=0;i<len;i++) d[i]=(Math.random()*2-1)*(1-i/len);
b.buffer=buf;
const f=AC.createBiquadFilter(); f.type='bandpass'; f.frequency.value=600+intensity*900; f.Q.value=1.2;
const g=AC.createGain(); g.gain.setValueAtTime(0.18*intensity,AC.currentTime);
g.gain.exponentialRampToValueAtTime(0.001,AC.currentTime+0.32);
b.connect(f); f.connect(g); g.connect(AC.destination); b.start();
}
function sfxCrash(){
if(!AC) return;
const b=AC.createBufferSource();
const len=AC.sampleRate*0.7; const buf=AC.createBuffer(1,len,AC.sampleRate);
const d=buf.getChannelData(0);
for(let i=0;i<len;i++) d[i]=(Math.random()*2-1)*Math.pow(1-i/len,2);
b.buffer=buf;
const f=AC.createBiquadFilter(); f.type='lowpass'; f.frequency.setValueAtTime(1400,AC.currentTime);
f.frequency.exponentialRampToValueAtTime(120,AC.currentTime+0.6);
const g=AC.createGain(); g.gain.setValueAtTime(0.6,AC.currentTime);
g.gain.exponentialRampToValueAtTime(0.001,AC.currentTime+0.7);
b.connect(f); f.connect(g); g.connect(AC.destination); b.start();
const o=AC.createOscillator(); o.type='sine'; o.frequency.setValueAtTime(120,AC.currentTime);
o.frequency.exponentialRampToValueAtTime(40,AC.currentTime+0.6);
const og=AC.createGain(); og.gain.setValueAtTime(0.5,AC.currentTime);
og.gain.exponentialRampToValueAtTime(0.001,AC.currentTime+0.6);
o.connect(og); og.connect(AC.destination); o.start(); o.stop(AC.currentTime+0.7);
}
function sfxBoost(){
if(!AC) return;
const o=AC.createOscillator(); o.type='sawtooth'; o.frequency.setValueAtTime(80,AC.currentTime);
o.frequency.exponentialRampToValueAtTime(220,AC.currentTime+0.3);
const g=AC.createGain(); g.gain.setValueAtTime(0.2,AC.currentTime);
g.gain.exponentialRampToValueAtTime(0.001,AC.currentTime+0.5);
const f=AC.createBiquadFilter(); f.type='lowpass'; f.frequency.value=900;
o.connect(f); f.connect(g); g.connect(AC.destination); o.start(); o.stop(AC.currentTime+0.5);
}
// -------------------------------------------------------------------
// Game flow
// -------------------------------------------------------------------
const hud = document.getElementById('hud');
const startScreen = document.getElementById('startScreen');
const gameOverScreen = document.getElementById('gameOverScreen');
const el = {
score:document.getElementById('score'), best:document.getElementById('best'),
mult:document.getElementById('multiplier'), multiWrap:document.getElementById('multiWrap'),
speedVal:document.getElementById('speedVal'), speedFill:document.getElementById('speedFill'),
energyFill:document.getElementById('energyFill'), turboFill:document.getElementById('turboFill'),
turbo:document.getElementById('turbo'), boostHint:document.getElementById('boostHint'),
finalScore:document.getElementById('finalScore'), finalDist:document.getElementById('finalDist'),
finalSpeed:document.getElementById('finalSpeed'), finalBest:document.getElementById('finalBest'),
crashFlash:document.getElementById('crashFlash'),
};
el.best.textContent = 'BEST — '+Math.round(bestScore);
function resetGame(){
// clear old geometry
for(const ch of chunks){ scene.remove(ch.group); ch.leftMesh.geometry.dispose(); ch.rightMesh.geometry.dispose(); ch.floorMesh.geometry.dispose(); }
for(const o of obstacles){ if(o.mesh) scene.remove(o.mesh); }
chunks.length=0; obstacles.length=0;
for(const k in ringParams) delete ringParams[k];
nextRing=0;
state.pos.set(0,2,0); state.vel.set(0,0,0);
state.speed=0; state.baseSpeed=48; state.maxSpeed=64;
state.roll=0; state.pitch=0; state.targetRoll=0; state.targetPitch=0;
state.energy=1.0; state.turbo=0.0; state.boosting=false;
state.score=0; state.multiplier=1; state.multTimer=0;
state.maxSpeedSeen=0; state.distance=0;
state.shake=0; state.multPulse=0; state.fov=72;
difficulty=0; prevWallClear=1e9;
input.x=0; input.y=0; input.boost=false;
// clear particles
for(const p of trailState){ p.y=-999; p.life=0; }
for(let c=0;c<6;c++) makeChunk(nextRing);
nextRing = CHUNK_RINGS*6; // 72 — next chunk will start here
state.pos.z = 0;
craft.visible=true;
}
function startGame(){
initAudio(); if(AC&&AC.state==='suspended') AC.resume();
resetGame();
state.running=true; state.over=false;
startScreen.classList.add('hidden');
gameOverScreen.classList.add('hidden');
hud.classList.remove('hidden');
}
function gameOver(){
state.running=false; state.over=true; craft.visible=false;
sfxCrash();
if(state.score>bestScore){ bestScore=Math.round(state.score); localStorage.setItem('canyon_best',bestScore); }
el.finalScore.textContent = Math.round(state.score).toLocaleString();
el.finalDist.textContent = Math.round(state.distance)+' m';
el.finalSpeed.textContent = Math.round(state.maxSpeedSeen);
el.finalBest.textContent = Math.round(bestScore).toLocaleString();
el.best.textContent = 'BEST — '+Math.round(bestScore);
el.crashFlash.style.opacity='1';
setTimeout(()=>{ el.crashFlash.style.opacity='0'; }, 160);
setTimeout(()=>{ gameOverScreen.classList.remove('hidden'); }, 500);
}
document.getElementById('startBtn').addEventListener('click', startGame);
document.getElementById('restartBtn').addEventListener('click', startGame);
// -------------------------------------------------------------------
// Input
// -------------------------------------------------------------------
const keyMap = {
ArrowLeft:'left', ArrowRight:'right', ArrowUp:'up', ArrowDown:'down',
KeyA:'left', KeyD:'right', KeyW:'up', KeyS:'down'
};
const held = {};
window.addEventListener('keydown', e=>{
if(keyMap[e.code]) held[keyMap[e.code]]=true;
if(e.code==='Space'){ input.boost=true; e.preventDefault(); }
if((e.code==='Enter'||e.code==='Space') && !state.running){ startGame(); }
});
window.addEventListener('keyup', e=>{
if(keyMap[e.code]) held[keyMap[e.code]]=false;
if(e.code==='Space') input.boost=false;
});
renderer.domElement.addEventListener('contextmenu', e=>{ e.preventDefault(); input.boost=true; });
renderer.domElement.addEventListener('mouseup', e=>{ if(e.button===2) input.boost=false; });
// mouse steering
let mouseActive=false;
renderer.domElement.addEventListener('mousemove', e=>{
if(mouseActive){ input.x = THREE.MathUtils.clamp((e.clientX/window.innerWidth)*2-1,-1,1); input.y = THREE.MathUtils.clamp(-((e.clientY/window.innerHeight)*2-1),-1,1); }
});
renderer.domElement.addEventListener('mousedown', e=>{ if(e.button===0){ mouseActive=true; } });
window.addEventListener('mouseup', e=>{ if(e.button===0){ mouseActive=false; input.x=0; input.y=0; } });
function readInput(){
let ix=0, iy=0;
if(held.left) ix-=1; if(held.right) ix+=1;
if(held.up) iy+=1; if(held.down) iy-=1;
input.x = THREE.MathUtils.clamp(ix,-1,1);
input.y = THREE.MathUtils.clamp(iy,-1,1);
}
// -------------------------------------------------------------------
// Collision helpers
// -------------------------------------------------------------------
function passageAt(z){
const i = Math.floor(z/RING_SPACING);
const r0 = ringParams[i], r1 = ringParams[i+1];
if(!r0||!r1) return null;
const t = (z - r0.z)/RING_SPACING;
const cx = r0.cx + (r1.cx-r0.cx)*t;
const hw = r0.hw + (r1.hw-r0.hw)*t;
const floorY = r0.floorY + (r1.floorY-r0.floorY)*t;
return { cx, hw, floorY, left:cx-hw, right:cx+hw };
}
// -------------------------------------------------------------------
// Update loop
// -------------------------------------------------------------------
const clock = new THREE.Clock();
const _camTarget = new THREE.Vector3();
const _camPos = new THREE.Vector3();
function update(dt){
if(!state.running) return;
readInput();
difficulty = state.score/4000;
// ---- forward speed ramps with difficulty ----
state.maxSpeed = 66 + difficulty*52;
if(state.boosting){
state.speed = THREE.MathUtils.lerp(state.speed, state.maxSpeed*1.9, dt*2.2);
state.turbo = THREE.MathUtils.clamp(state.turbo - dt*0.55, 0, 1);
if(state.turbo<=0) state.boosting=false;
} else {
const target = state.maxSpeed;
state.baseSpeed = THREE.MathUtils.lerp(state.baseSpeed, target, dt*0.5);
state.speed = THREE.MathUtils.lerp(state.speed, state.baseSpeed, dt*2.5);
// sustain turbo when fast straightaways
if(state.speed > state.maxSpeed*0.86) state.turbo = THREE.MathUtils.clamp(state.turbo+dt*0.06, 0, 1);
}
// boost activation
if(input.boost && (state.turbo>=0.15 || state.turbo>=1.0)) state.boosting=true;
// ---- lateral + vertical dynamics (weighty) ----
const lateralAcc = 62, vertAcc = 46, damp = 3.2, grav = 6.0;
state.vel.x += input.x * lateralAcc * dt;
state.vel.y += (input.y * vertAcc - grav) * dt;
state.vel.x *= Math.exp(-damp*dt);
state.vel.y *= Math.exp(-damp*dt);
state.pos.x += state.vel.x * dt;
state.pos.y += state.vel.y * dt;
// ---- forward progress ----
state.pos.z += state.speed * dt;
state.distance = state.pos.z;
// ---- banking & pitch visuals ----
state.targetRoll = - THREE.MathUtils.clamp(state.vel.x, -1,1) * 1.05 - input.x*0.3;
state.targetPitch = THREE.MathUtils.clamp(state.vel.y, -1,1)*0.5 + (input.y*0.3);
state.roll += (state.targetRoll - state.roll) * Math.exp(-8*dt);
state.pitch += (state.targetPitch - state.pitch) * Math.exp(-8*dt);
// ---- canyon collision ----
const p = passageAt(state.pos.z);
let crash = false;
let minWall = 1e9;
if(p){
const clearLeft = state.pos.x - p.left;
const clearRight = p.right - state.pos.x;
const clearFloor = state.pos.y - p.floorY;
minWall = Math.min(clearLeft, clearRight, clearFloor);
if(clearLeft <= PLAYER_R || clearRight <= PLAYER_R || clearFloor <= PLAYER_R) crash = true;
} else crash = true;
// ---- obstacles ----
for(const o of obstacles){
if(o.collected) continue;
if(o.z < state.pos.z-30 || o.z > state.pos.z+70) continue;
const dx=state.pos.x-o.x, dy=state.pos.y-o.y, dz=state.pos.z-o.z;
const dist = Math.sqrt(dx*dx+dy*dy+dz*dz);
if(o.type==='orb'){
if(dist < o.radius+PLAYER_R){
o.collected=true;
state.energy = THREE.MathUtils.clamp(state.energy+0.26, 0, 1);
state.score += 60*state.multiplier;
state.turbo = THREE.MathUtils.clamp(state.turbo+0.18,0,1);
sfxPing();
spawnBurst(o.x,o.y,o.z,0xffd166);
} else if(!o.nearTriggered && dist < NEAR_MISS+PLAYER_R){
o.nearTriggered=true; triggerNearMiss(NEAR_MISS/dist);
}
} else { // monolith
const hx=o.hx-PLAYER_R, hy=o.hy-PLAYER_R, hz=o.hz-PLAYER_R;
const px=Math.abs(dx), py=Math.abs(dy), pz=Math.abs(dz);
let closest;
if(px<hx&&py<hy&&pz<hz){ closest=0; }
else {
const ex=Math.max(px-hx,0), ey=Math.max(py-hy,0), ez=Math.max(pz-hz,0);
closest=Math.sqrt(ex*ex+ey*ey+ez*ez);
}
if(closest<=0) crash=true;
else if(!o.nearTriggered && closest<=NEAR_MISS){ o.nearTriggered=true; triggerNearMiss(NEAR_MISS/closest); }
else if(closest>NEAR_MISS*1.6) o.nearTriggered=false;
}
}
// ---- near miss (walls) ----
if(!crash && minWall>0 && prevWallClear>NEAR_MISS && minWall<=NEAR_MISS){
triggerNearMiss(NEAR_MISS/minWall);
}
prevWallClear = minWall;
// ---- multiplier decay ----
if(state.multTimer>0){ state.multTimer-=dt; if(state.multTimer<=0) state.multiplier=1; }
// ---- energy decay ----
const drain = 0.055 + (state.speed/state.maxSpeed)*0.05 + (state.boosting?0.09:0);
state.energy -= drain*dt;
if(state.energy<=0){ state.energy=0; crash=true; }
// ---- scoring (distance) ----
state.score += state.speed*dt*0.15*state.multiplier;
if(state.speed>state.maxSpeedSeen) state.maxSpeedSeen=state.speed;
// ---- crash ----
if(crash){ gameOver(); return; }
// ---- mult pulse decay ----
if(state.multPulse>0) state.multPulse=Math.max(0,state.multPulse-dt*3);
// ---- camera FOV ----
const targetFov = state.boosting? 96 : 72 + (state.speed/state.maxSpeed)*6;
state.fov += (targetFov-state.fov)*Math.exp(-4*dt);
camera.fov = state.fov; camera.updateProjectionMatrix();
// ---- shake decay ----
if(state.shake>0) state.shake=Math.max(0,state.shake-dt*3.5);
updateParticles(dt);
updateCraftVisuals(dt);
updateHUD();
updateCamera(dt);
manageChunks();
}
let burstPool=[];
function spawnBurst(x,y,z,color){
// simple: flash a small orb mesh that expands & fades
if(burstPool.length>40) return;
const g=new THREE.Mesh(new THREE.SphereGeometry(1,8,8),
new THREE.MeshBasicMaterial({color, transparent:true, opacity:0.9, blending:THREE.AdditiveBlending, depthWrite:false}));
g.position.set(x,y,z); scene.add(g);
burstPool.push({mesh:g, life:0, max:0.5});
}
function updateBursts(dt){
for(let i=burstPool.length-1;i>=0;i--){
const b=burstPool[i]; b.life+=dt; const t=b.life/b.max;
b.mesh.scale.setScalar(1+t*5); b.mesh.material.opacity=0.9*(1-t);
if(b.life>=b.max){ scene.remove(b.mesh); b.mesh.geometry.dispose(); b.mesh.material.dispose(); burstPool.splice(i,1); }
}
}
function triggerNearMiss(intensity){
state.multiplier++;
state.multTimer = 2.4;
state.score += 80*state.multiplier;
state.turbo = THREE.MathUtils.clamp(state.turbo + 0.09*intensity, 0, 1);
state.shake = Math.min(1.2, state.shake + 0.28*intensity);
state.multPulse = 1;
sfxWhoosh(Math.min(1.6,intensity));
el.mult.classList.add('pulse');
setTimeout(()=>el.mult.classList.remove('pulse'), 90);
}
// -------------------------------------------------------------------
// Particles
// -------------------------------------------------------------------
function updateParticles(dt){
// trail from engines
const engineWorldL=new THREE.Vector3(), engineWorldR=new THREE.Vector3();
engineL.getWorldPosition(engineWorldL); engineR.getWorldPosition(engineWorldR);
for(let i=0;i<TRAIL_N;i++){
const p=trailState[i];
if(p.life<=0){
// respawn at an engine
const e = (i%2===0)?engineWorldL:engineWorldR;
p.x=e.x+(Math.random()-0.5)*0.6; p.y=e.y+(Math.random()-0.5)*0.6; p.z=e.z;
p.vx=(Math.random()-0.5)*2 - state.speed*0.0; p.vy=(Math.random()-0.5)*2; p.vz=-6-Math.random()*8;
p.max=0.6+Math.random()*0.6; p.life=p.max;
}
p.life-=dt;
p.x+=p.vx*dt; p.y+=p.vy*dt; p.z+=p.vz*dt;
trailPos[i*3]=p.x; trailPos[i*3+1]=p.y; trailPos[i*3+2]=p.z;
}
trailGeo.attributes.position.needsUpdate=true;
trailMat.opacity = 0.55 + (state.boosting?0.4:0);
// speed streaks — stay around player, respawn ahead as we pass
for(let i=0;i<STREAK_N;i++){
const s=streakState[i];
if(s.z < state.pos.z-8){
s.z = state.pos.z + 90 + Math.random()*620;
s.x = state.pos.x + (Math.random()*2-1)* (40 + Math.random()*60);
s.y = 8 + Math.random()*70;
}
streakPos[i*3]=s.x; streakPos[i*3+1]=s.y; streakPos[i*3+2]=s.z;
}
streakGeo.attributes.position.needsUpdate=true;
streakMat.opacity = 0.35 + (state.speed/state.maxSpeed)*0.5 + (state.boosting?0.3:0);
}
function updateCraftVisuals(dt){
craft.position.copy(state.pos);
craft.rotation.z = state.roll;
craft.rotation.x = state.pitch;
// engine pulse
const e = 1.6 + Math.sin(performance.now()*0.02)*0.4 + (state.boosting?1.6:0);
engineL.material.emissiveIntensity=e; engineR.material.emissiveIntensity=e;
}
function updateCamera(dt){
const speedFrac = state.speed/state.maxSpeed;
const behind = 11 + speedFrac*8 + (state.boosting?6:0);
const up = 6 + speedFrac*3;
_camTarget.set(state.pos.x*0.6, state.pos.y+2, state.pos.z+14);
const desired = new THREE.Vector3(state.pos.x*0.7, state.pos.y+up, state.pos.z-behind);
_camPos.lerp(desired, Math.min(1, 4*dt));
// shake
const sh = state.shake;
const sx = (Math.random()-0.5)*sh*1.6;
const sy = (Math.random()-0.5)*sh*1.6;
const sz = (Math.random()-0.5)*sh*1.0;
camera.position.set(_camPos.x+sx, _camPos.y+sy, _camPos.z+sz);
camera.lookAt(_camTarget.x, _camTarget.y, _camTarget.z+30);
// sun follows player in X to keep shadows sweeping the canyon
sun.position.set(state.pos.x-60, state.pos.y+110, state.pos.z+80);
sun.target.position.copy(state.pos);
sunDisc.position.set(state.pos.x-160, 44, state.pos.z-1200);
sunGlow.position.set(state.pos.x-160, 44, state.pos.z-1202);
}
// -------------------------------------------------------------------
// Chunk management (infinite generation)
// -------------------------------------------------------------------
function manageChunks(){
// ensure chunks ahead
const needRing = Math.floor((state.pos.z+900)/RING_SPACING);
while(nextRing*0 + (chunks.length? chunks[chunks.length-1].endRing:0) < needRing){
const first = chunks.length? chunks[chunks.length-1].endRing+1 : 0;
if(nextRing>0 && first>0 && first<=chunks[chunks.length-1].endRing+1){
// continue from where we left off
}
const startRing = chunks.length? chunks[chunks.length-1].endRing+1 : 0;
chunks.push(makeChunk(startRing));
nextRing = startRing+CHUNK_RINGS;
}
// remove chunks behind player
while(chunks.length && chunks[0].endRing*RING_SPACING < state.pos.z-220){
const ch=chunks.shift();
scene.remove(ch.group);
ch.leftMesh.geometry.dispose(); ch.rightMesh.geometry.dispose(); ch.floorMesh.geometry.dispose();
}
// cull passed obstacles
for(const o of obstacles){
if(o.collected && o.type==='orb' && o.z < state.pos.z-60){
if(o.glow){ scene.remove(o.glow); o.glow=null; }
}
if(o.type==='monolith' && o.z < state.pos.z-120){
scene.remove(o.mesh); o.mesh=null;
}
}
// trim old obstacles occasionally
while(obstacles.length && obstacles[0].z < state.pos.z-200) obstacles.shift();
}
// -------------------------------------------------------------------
// HUD
// -------------------------------------------------------------------
function updateHUD(){
el.score.textContent = Math.round(state.score).toLocaleString();
el.mult.textContent = 'x'+state.multiplier;
const pulse = state.multPulse>0.05 ? (1+state.multPulse*0.3) : 1;
el.mult.style.transform = 'scale('+pulse+')';
el.speedVal.textContent = Math.round(state.speed);
el.speedFill.style.width = Math.min(100,(state.speed/state.maxSpeed)*100)+'%';
el.energyFill.style.width = (state.energy*100)+'%';
el.energyFill.classList.toggle('low', state.energy<0.25);
el.turboFill.style.width = (state.turbo*100)+'%';
el.turbo.classList.toggle('active', state.boosting);
el.boostHint.classList.toggle('show', state.turbo>=0.15 && !state.boosting);
}
// -------------------------------------------------------------------
// Main loop
// -------------------------------------------------------------------
function animate(){
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
update(dt);
updateBursts(dt);
blurPass.material.uniforms.strength.value = (state.boosting?0.9: state.speed/state.maxSpeed*0.42);
bloom.strength = state.boosting? 1.05 : 0.72;
composer.render();
}
window.addEventListener('resize', ()=>{
camera.aspect = window.innerWidth/window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
blurMat.uniforms.resolution.value.set(window.innerWidth, window.innerHeight);
});
// intro idle camera
(function idle(){
if(state.running) return;
craft.visible=true;
craft.position.set(0,2,0); craft.rotation.set(0,0,0);
const t=performance.now()*0.0003;
camera.position.set(Math.sin(t)*8, 10+Math.sin(t*1.3)*3, -16+Math.cos(t)*4);
camera.lookAt(0,4,40);
blurPass.material.uniforms.strength.value=0;
composer.render();
requestAnimationFrame(idle);
})();
animate();
</script>
</body>
</html>
<!-- agent-meta {"model":"peculiar-ragdoll/Tiel-Coder-35B-A3B-GGUF-MTP","provider":"brigid","persona":"full","sessionId":"45c72804-dcfa-46a9-a509-13a6fe54da6b","tokensIn":7836601,"tokensOut":134305,"tokensTotal":7970906,"cost":0,"turns":77,"toolCalls":76,"failedToolCalls":5,"timestamp":"2026-09-04T20:22:49.023Z"} -->