Canyon Run
Ornith 1.5 35B A3B · 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 47,873 bytes · SHA-256 33c1ff0bf172
<!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 FLOW — High-Speed Procedural Canyon Racer</title>
<style>
:root{
--gold:#ffb347; --orange:#ff7a18; --purple:#6a2c8f; --deep:#1a0b2e;
--cyan:#4df2ff; --pink:#ff4fa2;
}
*{ margin:0; padding:0; box-sizing:border-box; }
html,body{ width:100%; height:100%; overflow:hidden; background:#0a0516; }
body{ font-family:'Segoe UI',system-ui,-apple-system,sans-serif; color:#fff; user-select:none; }
#app{ position:fixed; inset:0; }
canvas{ display:block; }
/* ---------- HUD ---------- */
#hud{ position:fixed; inset:0; pointer-events:none; z-index:10; }
.hud-num{ font-weight:800; letter-spacing:2px; }
#scoreWrap{ position:absolute; left:26px; top:22px; }
#score{ font-size:40px; color:#fff; text-shadow:0 0 18px rgba(255,179,71,.7); }
#score .lbl{ font-size:12px; font-weight:600; letter-spacing:4px; color:#ffb347; opacity:.85; display:block; }
#best{ font-size:12px; color:#c9a9ff; margin-top:2px; letter-spacing:2px; opacity:.8; }
#multiWrap{ position:absolute; right:30px; top:18px; text-align:right; }
#multi{ font-size:64px; line-height:1; color:#4df2ff; text-shadow:0 0 24px rgba(77,242,255,.7); transition:transform .08s; }
#multi .lbl{ font-size:12px; font-weight:600; letter-spacing:5px; color:#4df2ff; opacity:.8; }
#multi.hot{ transform:scale(1.18); color:#fff; text-shadow:0 0 30px rgba(255,79,162,.9); }
#speedWrap{ position:absolute; left:50%; bottom:26px; transform:translateX(-50%); width:min(340px,56vw); text-align:center; }
#speedVal{ font-size:30px; font-weight:800; letter-spacing:2px; }
#speedVal .unit{ font-size:13px; font-weight:600; opacity:.7; letter-spacing:3px; margin-left:5px; }
#speedBar{ height:3px; margin:6px auto 0; width:100%; background:rgba(255,255,255,.12); border-radius:2px; overflow:hidden; }
#speedFill{ height:100%; width:0%; background:linear-gradient(90deg,#4df2ff,#ff4fa2); box-shadow:0 0 12px #4df2ff; }
/* energy bar bottom center-left */
#energyWrap{ position:absolute; left:26px; bottom:26px; width:min(240px,38vw); }
#energyLbl{ font-size:11px; letter-spacing:4px; font-weight:700; color:#ffb347; opacity:.85; margin-bottom:6px; }
#energyBar{ height:8px; width:100%; background:rgba(255,255,255,.12); border-radius:4px; overflow:hidden; }
#energyFill{ height:100%; width:100%; background:linear-gradient(90deg,#ff7a18,#ffb347); transition:width .1s linear; }
#energyFill.low{ background:linear-gradient(90deg,#ff2d55,#ff7a18); }
/* turbo meter */
#turboWrap{ position:absolute; right:26px; bottom:26px; width:14px; height:150px; background:rgba(255,255,255,.1); border-radius:8px; overflow:hidden; border:1px solid rgba(255,255,255,.12); }
#turboLbl{ position:absolute; right:24px; bottom:182px; font-size:10px; letter-spacing:3px; font-weight:700; color:#4df2ff; opacity:.8; }
#turboFill{ position:absolute; bottom:0; width:100%; height:0%; background:linear-gradient(0deg,#4df2ff,#ff4fa2); border-radius:8px; }
#turboFill.ready{ box-shadow:0 0 16px #4df2ff; animation:tpulse .8s infinite; }
@keyframes tpulse{ 50%{ opacity:.55; } }
#boostHint{ position:absolute; right:52px; bottom:182px; font-size:11px; letter-spacing:2px; color:#ff4fa2; opacity:0; transition:opacity .2s; }
#boostHint.show{ opacity:1; }
/* ---------- 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 center, rgba(40,15,70,.72), rgba(10,5,22,.94)); backdrop-filter:blur(3px); }
.overlay.hidden{ display:none; }
.title{ font-size:clamp(48px,11vw,120px); font-weight:900; letter-spacing:6px; line-height:.95;
background:linear-gradient(180deg,#fff, #ffb347 55%, #ff7a18); -webkit-background-clip:text; background-clip:text; color:transparent;
text-shadow:0 0 60px rgba(255,122,24,.35); }
.subtitle{ font-size:14px; letter-spacing:8px; color:#c9a9ff; margin-top:6px; text-transform:uppercase; }
.card{ margin-top:34px; padding:26px 34px; border-radius:14px; background:rgba(255,255,255,.05); border:1px solid rgba(255,255,255,.12); }
.stat{ display:flex; justify-content:space-between; gap:40px; font-size:15px; margin:8px 0; letter-spacing:1px; }
.stat b{ font-weight:800; }
.stat .v{ color:#ffb347; }
.btn{ pointer-events:auto; cursor:pointer; margin-top:26px; padding:16px 44px; font-size:18px; font-weight:800; letter-spacing:3px;
color:#1a0b2e; border:none; border-radius:40px; background:linear-gradient(90deg,#ffb347,#ff7a18);
box-shadow:0 8px 30px rgba(255,122,24,.5); transition:transform .12s, box-shadow .12s; }
.btn:hover{ transform:translateY(-2px) scale(1.03); box-shadow:0 12px 40px rgba(255,122,24,.7); }
.btn:active{ transform:scale(.97); }
.hint{ margin-top:24px; font-size:12px; letter-spacing:2px; color:#9b86c4; line-height:2; }
.hint kbd{ display:inline-block; padding:2px 8px; margin:0 2px; background:rgba(255,255,255,.12); border-radius:5px; font-family:inherit; font-weight:700; color:#fff; }
.gameover .title{ background:linear-gradient(180deg,#fff,#ff4fa2 60%,#ff7a18); -webkit-background-clip:text; background-clip:text; color:transparent; }
#pauseTag{ position:fixed; top:20px; left:50%; transform:translateX(-50%); z-index:15; font-size:12px; letter-spacing:6px;
color:#fff; opacity:.85; display:none; }
</style>
</head>
<body>
<div id="app"></div>
<!-- HUD -->
<div id="hud">
<div id="scoreWrap">
<span class="hud-num" id="score">0<span class="lbl">SCORE</span></span>
<div id="best">BEST 0</div>
</div>
<div id="multiWrap">
<span id="multi">x1<span class="lbl">NEAR</span></span>
</div>
<div id="energyWrap">
<div id="energyLbl">ENERGY</div>
<div id="energyBar"><div id="energyFill"></div></div>
</div>
<div id="turboLbl">TURBO</div>
<div id="boostHint">HOLD</div>
<div id="turboWrap"><div id="turboFill"></div></div>
<div id="speedWrap">
<span id="speedVal" class="hud-num">0</span><span class="unit">KM/H</span>
<div id="speedBar"><div id="speedFill"></div></div>
</div>
</div>
<div id="pauseTag">PAUSED</div>
<!-- Start -->
<div id="startScreen" class="overlay">
<div class="title">CANYON<br>FLOW</div>
<div class="subtitle">High-Speed Procedural Racer</div>
<div class="card">
<div class="stat"><span>Speed</span><b class="v">Faster = More Score</b></div>
<div class="stat"><span>Near-Miss</span><b class="v">Build Multiplier</b></div>
<div class="stat"><span>Energy</span><b class="v">Collect Orbs</b></div>
<div class="stat"><span>Turbo</span><b class="v">Hold to Boost</b></div>
</div>
<button class="btn" id="startBtn">LAUNCH</button>
<div class="hint">
MOVE: <kbd>Mouse</kbd> or <kbd>← →</kbd> / <kbd>A D</kbd> · BOOST: <kbd>Hold</kbd> <kbd>Space</kbd> / <kbd>W</kbd><br>
PAUSE: <kbd>P</kbd> · Collect glowing orbs before energy runs out.
</div>
</div>
<!-- Pause -->
<div id="pauseScreen" class="overlay hidden">
<div class="title" style="font-size:clamp(36px,8vw,72px)">PAUSED</div>
<button class="btn" id="resumeBtn">RESUME</button>
</div>
<!-- Game Over -->
<div id="overScreen" class="overlay gameover hidden">
<div class="title">WRECKED</div>
<div class="card">
<div class="stat"><span>Final Score</span><b class="v" id="finalScore">0</b></div>
<div class="stat"><span>Best Score</span><b class="v" id="finalBest">0</b></div>
<div class="stat"><span>Max Speed</span><b class="v" id="maxSpeed">0</b></div>
<div class="stat"><span>Distance</span><b class="v" id="distance">0</b></div>
</div>
<button class="btn" id="restartBtn">RETRY</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 { FXAAShader } from 'three/addons/shaders/FXAAShader.js';
/* ============================================================
CANYON FLOW — procedural canyon racer (Race the Sun inspired)
============================================================ */
// ---------- Globals ----------
let renderer, composer, scene, camera;
let skyMesh, sunMesh, sunGlow;
let player, engineGlowL, engineGlowR;
let bloomPass, fxaaPass;
let clock = new THREE.Clock();
// Game state
const STATE = { MENU:0, PLAY:1, PAUSE:2, OVER:3 };
let state = STATE.MENU;
let segLen = 90, segGap = 8, numSeg = 9;
let segments = [];
let obstacles = []; // { box, type, pos, half, nearMissed }
let orbs = []; // mesh + data
let nextSegZ = 0;
let spawnZ = 0;
// Player physics
const P = {
pos: new THREE.Vector3(0, 0, 0),
vel: new THREE.Vector3(),
bank: 0, // roll
pitch: 0, // visual pitch
yaw: 0, // horizontal heading offset
speed: 60, // forward speed
targetBank: 0,
targetPitch: 0,
};
// Gameplay tunables
let energy = 1.0;
let score = 0;
let best = Number(localStorage.getItem('canyonflow_best') || 0);
let mult = 1;
let multTimer = 0;
let turbo = 0; // 0..1
let boosting = false;
let maxSpeed = 0;
let distance = 0;
let difficulty = 0;
// Input
const input = { x: 0, boost: false };
let pointerX = 0;
let pointerY = 0;
// Audio
let audio = null;
// Particles
let trailSys, dustSys, streakSys;
// Camera shake
let shakeAmt = 0;
// DOM
const $ = id => document.getElementById(id);
const elScore = $('score'), elBest = $('best'), elMulti = $('multi');
const elSpeedVal = $('speedVal'), elSpeedFill = $('speedFill');
const elEnergyFill = $('energyFill'), elTurboFill = $('turboFill'), elTurboHint = $('boostHint');
const elFinalScore = $('finalScore'), elFinalBest = $('finalBest'), elMaxSpeed = $('maxSpeed'), elDistance = $('distance');
// ---------- Helpers ----------
function clamp(v,a,b){ return v<a?a:(v>b?b:v); }
function rand(a,b){ return a + Math.random()*(b-a); }
function lerp(a,b,t){ return a+(b-a)*t; }
const randInt = (a,b)=>Math.floor(rand(a,b+1));
// ============================================================
// INIT / SCENE
// ============================================================
function init(){
const app = $('app');
renderer = new THREE.WebGLRenderer({ antialias:true, powerPreference:'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
app.appendChild(renderer.domElement);
scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x2a0f3a, 0.0062);
camera = new THREE.PerspectiveCamera(78, window.innerWidth/window.innerHeight, 0.5, 1200);
camera.position.set(0, 5, 14);
// ---- Lights ----
const hemi = new THREE.HemisphereLight(0xffd9a0, 0x2a0f3a, 0.7);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xffa54a, 2.4);
sun.position.set(-0.4, 0.35, -1).normalize().multiplyScalar(200);
sun.castShadow = true;
sun.shadow.mapSize.set(1024,1024);
sun.shadow.camera.near = 10;
sun.shadow.camera.far = 500;
sun.shadow.camera.left = -120; sun.shadow.camera.right = 120;
sun.shadow.camera.top = 120; sun.shadow.camera.bottom = -120;
sun.shadow.bias = -0.0008;
scene.add(sun);
scene.add(sun.target);
const rim = new THREE.DirectionalLight(0xff4fa2, 0.6);
rim.position.set(0.6, 0.2, 1).normalize().multiplyScalar(120);
scene.add(rim);
buildSky();
buildSun();
buildPlayer();
buildParticles();
// ---- Post processing ----
composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 0.9, 0.7, 0.82);
composer.addPass(bloomPass);
fxaaPass = new ShaderPass(FXAAShader);
fxaaPass.material.uniforms['resolution'].value.set(1/window.innerWidth, 1/window.innerHeight);
composer.addPass(fxaaPass);
window.addEventListener('resize', onResize);
bindInput();
$('best').textContent = 'BEST ' + best;
}
function buildSky(){
const geo = new THREE.SphereGeometry(700, 32, 20);
const mat = new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite:false,
uniforms:{ },
vertexShader:`
varying vec3 vDir;
void main(){ vDir = normalize(position); gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
fragmentShader:`
varying vec3 vDir;
void main(){
float h = clamp(vDir.y*0.5+0.35, 0.0, 1.0);
vec3 low = vec3(0.10,0.05,0.20);
vec3 mid = vec3(0.55,0.18,0.42);
vec3 high = vec3(1.0,0.55,0.22);
vec3 top = vec3(0.18,0.08,0.36);
vec3 col = mix(low, mid, smoothstep(0.0,0.35,h));
col = mix(col, high, smoothstep(0.25,0.6,h));
col = mix(col, top, smoothstep(0.6,1.0,h));
gl_FragColor = vec4(col,1.0);
}`
});
skyMesh = new THREE.Mesh(geo, mat);
scene.add(skyMesh);
}
function buildSun(){
const sunGeo = new THREE.SphereGeometry(60, 32, 32);
const sunMat = new THREE.MeshBasicMaterial({ color:0xffd27a, fog:false });
sunMesh = new THREE.Mesh(sunGeo, sunMat);
sunMesh.position.set(-120, 60, -520);
scene.add(sunMesh);
// glow sprite
const glowTex = makeGlowTex();
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map:glowTex, color:0xffa040, transparent:true, depthWrite:false, blending:THREE.AdditiveBlending }));
sp.scale.set(320,320,1);
sp.position.copy(sunMesh.position);
sunGlow = sp;
scene.add(sp);
}
function makeGlowTex(){
const c = document.createElement('canvas'); c.width=c.height=128;
const g = c.getContext('2d');
const grd = g.createRadialGradient(64,64,0,64,64,64);
grd.addColorStop(0,'rgba(255,240,200,1)');
grd.addColorStop(0.3,'rgba(255,170,80,0.7)');
grd.addColorStop(0.7,'rgba(255,120,40,0.15)');
grd.addColorStop(1,'rgba(255,120,40,0)');
g.fillStyle=grd; g.fillRect(0,0,128,128);
return new THREE.CanvasTexture(c);
}
function buildPlayer(){
player = new THREE.Group();
// Body — sleek dart
const bodyMat = new THREE.MeshStandardMaterial({ color:0xd8dde8, flatShading:true, metalness:0.6, roughness:0.35 });
const nose = new THREE.ConeGeometry(0.42, 2.4, 4);
nose.rotateX(-Math.PI/2);
const noseM = new THREE.Mesh(nose, bodyMat); noseM.position.z = -1.5;
const fus = new THREE.BoxGeometry(0.7, 0.32, 2.2);
const fusM = new THREE.Mesh(fus, bodyMat);
const cockpit = new THREE.Mesh(new THREE.SphereGeometry(0.34, 6, 5), new THREE.MeshStandardMaterial({ color:0x2b3a55, emissive:0x1a2b45, metalness:0.9, roughness:0.2 }));
cockpit.scale.set(1,0.7,1.4); cockpit.position.set(0,0.22,-0.2);
// Main wings
const wingMat = new THREE.MeshStandardMaterial({ color:0xff7a18, flatShading:true, metalness:0.4, roughness:0.5, emissive:0x331200 });
const wingShape = new THREE.Shape();
wingShape.moveTo(0,0); wingShape.lineTo(2.6,-0.9); wingShape.lineTo(2.6,-1.3); wingShape.lineTo(0.2,-0.5); wingShape.closePath();
const wingGeo = new THREE.ExtrudeGeometry(wingShape, { depth:0.08, bevelEnabled:true, bevelThickness:0.03, bevelSize:0.04, bevelSegments:0 });
wingGeo.translate(0,0,0);
const wingR = new THREE.Mesh(wingGeo, wingMat); wingR.rotation.y = 0; wingR.position.set(0.15,0,0.2);
const wingL = new THREE.Mesh(wingGeo, wingMat); wingL.scale.x = -1; wingL.position.set(-0.15,0,0.2);
// Tail fins
const tailMat = new THREE.MeshStandardMaterial({ color:0xff4fa2, flatShading:true, metalness:0.3, roughness:0.5, emissive:0x330018 });
const tailGeo = new THREE.BoxGeometry(0.12, 0.9, 0.9);
const tail = new THREE.Mesh(tailGeo, tailMat); tail.position.set(0,0.55,1.1);
const tailGeo2 = new THREE.BoxGeometry(1.0, 0.12, 0.7);
const tailW = new THREE.Mesh(tailGeo2, tailMat); tailW.position.set(0,-0.05,1.2);
// Engine glows
const engMat = new THREE.MeshBasicMaterial({ color:0x4df2ff });
engineGlowL = new THREE.Mesh(new THREE.SphereGeometry(0.18,8,8), engMat);
engineGlowL.position.set(0.25,0,1.3);
engineGlowR = new THREE.Mesh(new THREE.SphereGeometry(0.18,8,8), engMat);
engineGlowR.position.set(-0.25,0,1.3);
player.add(noseM, fusM, cockpit, wingR, wingL, tail, tailW, engineGlowL, engineGlowR);
// Collision sphere
player.userData.coll = new THREE.Sphere(new THREE.Vector3(), 1.1);
player.userData.radius = 1.1;
scene.add(player);
}
// ============================================================
// PARTICLES
// ============================================================
function buildParticles(){
// Trail — engine exhaust behind player
trailSys = makeParticleField(60, 0x4df2ff, 0.9);
scene.add(trailSys.points);
trailSys.points.visible = false;
// Speed streaks — long lines flying past
streakSys = makeParticleField(80, 0xffffff, 2.4);
scene.add(streakSys.points);
streakSys.points.visible = false;
// Dust / debris
dustSys = makeParticleField(120, 0xffc88a, 1.6);
scene.add(dustSys.points);
dustSys.points.visible = false;
}
// Simple pooled point field. Returns { points, sys } where sys is the controller.
function makeParticleField(n, color, baseSize){
const geo = new THREE.BufferGeometry();
const pos = new Float32Array(n*3);
const life = new Float32Array(n);
const size = new Float32Array(n);
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setAttribute('customSize', new THREE.BufferAttribute(size, 1));
geo.setAttribute('life', new THREE.BufferAttribute(life, 1));
const mat = new THREE.ShaderMaterial({
transparent:true, depthWrite:false, blending:THREE.AdditiveBlending,
uniforms:{ globalScale:{ value: baseSize } },
vertexShader:`
attribute float customSize;
attribute float life;
uniform float globalScale;
varying float vLife;
void main(){
vLife = life;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = globalScale * customSize * (300.0 / -mvPosition.z);
gl_Position = projectionMatrix * mvPosition;
}`,
fragmentShader:`
varying float vLife;
void main(){
float d = distance(gl_PointCoord, vec2(0.5));
if(d > 0.5) discard;
float a = smoothstep(0.5, 0.0, d) * clamp(vLife, 0.0, 1.0);
gl_FragColor = vec4(1.0, 1.0, 1.0, a);
}`
});
const points = new THREE.Points(geo, mat);
points.frustumCulled = false;
const sys = {
points, geo, pos, life, size, n, next:0,
spawn(p, s){
const i = this.next;
this.pos[i*3]=p.x; this.pos[i*3+1]=p.y; this.pos[i*3+2]=p.z;
this.life[i]=s; this.size[i]=Math.min(s, 1);
this.next=(this.next+1)%this.n;
},
update(dt, fwd){
const arr = this.geo.attributes.position.array;
const sarr = this.geo.attributes.customSize.array;
const larr = this.geo.attributes.life.array;
for(let i=0;i<this.n;i++){
this.life[i]-=dt;
const s = Math.max(0, this.life[i]);
sarr[i] = this.size[i]*(0.4+s);
larr[i] = s;
// keep particles anchored relative to player (they don't move in world)
}
this.geo.attributes.position.needsUpdate=true;
this.geo.attributes.customSize.needsUpdate=true;
this.geo.attributes.life.needsUpdate=true;
}
};
return { points, sys };
}
function makeStreakField(n){
return makeParticleField(n, 0xffffff, 2.4);
}
// ============================================================
// PROCEDURAL CANYON GENERATION
// ============================================================
function segHalfWidths(z){
// canyon floor half-width (x) and ceiling gap (y from ground up)
const floorW = 42;
const gapY = 30;
return { floorW, gapY };
}
function makeSegment(z, diff){
const group = new THREE.Group();
group.position.z = z;
const d = Math.random();
let floorW=42, gapY=30, leftX=0, rightX=0;
if(d < 0.34){ // straight wide
floorW = rand(40,52); gapY = rand(28,36);
} else if(d < 0.62){ // tight squeeze (narrow)
floorW = rand(20,28); gapY = rand(20,26);
} else if(d < 0.82){ // high ceiling (dip)
floorW = rand(36,46); gapY = rand(38,48);
} else { // low ceiling (dive)
floorW = rand(34,44); gapY = rand(16,22);
}
// curve the canyon center
leftX = rand(-6,6);
rightX = leftX;
const seg = { group, z, floorW, gapY, leftX, obstacles:[], floorM:null, ceilM:null, wallLM:null, wallRM:null };
const matRock = (em)=> new THREE.MeshStandardMaterial({ color:0x3a2b52, flatShading:true, metalness:0.1, roughness:0.9, emissive:em||0x000000, emissiveIntensity:0.7 });
const matCeil = matRock(0x150a25);
const matWall = matRock(0x241030);
// Ground floor segments (two quads)
const floorMat = matRock(0x2a1640);
const leftFloorGeo = new THREE.PlaneGeometry(floorW+leftX, segLen, 1, 1);
const leftFloor = new THREE.Mesh(leftFloorGeo, floorMat);
leftFloor.rotation.x = -Math.PI/2;
leftFloor.position.set(-(floorW/2 + leftX/2), -12, 0);
leftFloor.receiveShadow = true;
group.add(leftFloor);
const rightFloorGeo = new THREE.PlaneGeometry(floorW+leftX, segLen, 1,1);
const rightFloor = new THREE.Mesh(rightFloorGeo, floorMat);
rightFloor.rotation.x = -Math.PI/2;
rightFloor.position.set((floorW/2 + leftX/2), -12, 0);
rightFloor.receiveShadow = true;
group.add(rightFloor);
// Left wall (vertical rock formation)
const wallH = 44;
const wallGeo = new THREE.PlaneGeometry(wallH, segLen, 1, 1);
const leftWall = new THREE.Mesh(wallGeo, matWall);
leftWall.rotation.y = Math.PI/2;
leftWall.position.set(-(floorW + leftX), 8, 0);
leftWall.castShadow = true; leftWall.receiveShadow = true;
group.add(leftWall); seg.wallLM = leftWall;
const rightWall = new THREE.Mesh(wallGeo, matWall);
rightWall.rotation.y = -Math.PI/2;
rightWall.position.set((floorW + leftX), 8, 0);
rightWall.castShadow = true; rightWall.receiveShadow = true;
group.add(rightWall); seg.wallRM = rightWall;
// Ceiling (with gap) — two ceiling slabs
const ceilGeo = new THREE.PlaneGeometry(floorW*0.9, segLen, 1,1);
const ceilL = new THREE.Mesh(ceilGeo, matCeil);
ceilL.rotation.x = Math.PI/2;
ceilL.position.set(-(floorW*0.45), gapY+6, 0);
ceilL.castShadow=true; ceilL.receiveShadow=true;
group.add(ceilL); seg.ceilLM = ceilL;
const ceilR = new THREE.Mesh(ceilGeo, matCeil);
ceilR.rotation.x = Math.PI/2;
ceilR.position.set((floorW*0.45), gapY+6, 0);
ceilR.castShadow=true; ceilR.receiveShadow=true;
group.add(ceilR); seg.ceilRM = ceilR;
scene.add(group);
segments.push(seg);
// Spawn obstacles for this segment
spawnObstaclesFor(seg, diff);
return seg;
}
function spawnObstaclesFor(seg, diff){
const zc = seg.z;
const cx = seg.leftX; // canyon center offset
const floorW = seg.floorW;
const gapY = seg.gapY;
// Determine how many obstacles
let count = 0;
const r = Math.random();
if(r < 0.25 + diff*0.3) count = 1;
else if(r < 0.55 + diff*0.2) count = 2;
for(let i=0;i<count;i++){
const typeRoll = Math.random();
let obs;
if(typeRoll < 0.45){
// Floating monolith
const w = rand(3,6), h = rand(4,10), d = rand(3,6);
const ox = cx + rand(-(floorW*0.55), floorW*0.55);
const oy = rand(-4, gapY*0.5);
obs = makeMonolith(w,h,d, ox, oy);
} else if(typeRoll < 0.75){
// Tight squeeze — pillar pair narrowing gap
const gap = rand(10, 16) * (1 - diff*0.3);
obs = makeSqueeze(gap, cx, floorW, gapY);
} else {
// Orb cluster (reward) — guaranteed sometimes
obs = makeOrb(cx, floorW, gapY);
seg.orb = obs;
}
if(obs){ seg.obstacles.push(obs); }
}
// ensure at least one orb roughly every few segments
if(!seg.orb && Math.random() < 0.5){
const o = makeOrb(cx, floorW, gapY);
if(o){ seg.orb = o; seg.obstacles.push(o); }
}
}
function makeMonolith(w,h,d,ox,oy){
const grp = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({ color:0x5a3a7a, flatShading:true, metalness:0.2, roughness:0.7, emissive:0x1a0a2e, emissiveIntensity:0.4 });
const box = new THREE.Mesh(new THREE.BoxGeometry(w,h,d), mat);
box.castShadow = true; grp.add(box);
grp.position.set(ox, oy, 0);
scene.add(grp);
const half = new THREE.Vector3(w/2+0.6, h/2+0.6, d/2+0.6);
return { grp, type:'monolith', half, pos: grp.position, mesh: box, w,h,d };
}
function makeSqueeze(gap, cx, floorW, gapY){
// two monoliths from floor and ceiling, leaving a horizontal gap between them
const grp = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({ color:0x6a3a8a, flatShading:true, metalness:0.2, roughness:0.7, emissive:0x1a0a2e, emissiveIntensity:0.4 });
const side = cx + rand(-4,4);
const gapCenterY = rand(2, gapY*0.6);
const monW = rand(4,6), monH = 20, monD = rand(4,7);
const belowH = gapCenterY - monH/2;
const aboveH = (gapY - gapCenterY - monH/2);
if(belowH > 3){
const b = new THREE.Mesh(new THREE.BoxGeometry(monW,belowH,monD), mat);
b.position.set(side, -12 + belowH/2, 0); b.castShadow=true; grp.add(b);
}
if(aboveH > 3){
const a = new THREE.Mesh(new THREE.BoxGeometry(monW,aboveH,monD), mat);
a.position.set(side, gapY + 6 - aboveH/2, 0); a.castShadow=true; grp.add(a);
}
grp.position.z = 0;
scene.add(grp);
const half = new THREE.Vector3(monW/2+0.8, monH/2+0.8, monD/2+0.8);
return { grp, type:'squeeze', half, pos: grp.position, mesh: grp.children[0], w:monW, h:monH, d:monD };
}
function makeOrb(cx, floorW, gapY){
const grp = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({ color:0x4df2ff, emissive:0x22d3ee, emissiveIntensity:1.6, flatShading:true, metalness:0.3, roughness:0.3 });
const geo = new THREE.IcosahedronGeometry(1.1, 0);
const m = new THREE.Mesh(geo, mat);
grp.add(m);
// glow sprite
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map:makeGlowTex(), color:0x66f7ff, transparent:true, blending:THREE.AdditiveBlending, depthWrite:false }));
sp.scale.set(7,7,1); grp.add(sp);
const oy = rand(-2, gapY*0.6);
const ox = cx + rand(-(floorW*0.55), floorW*0.55);
grp.position.set(ox, oy, 0);
scene.add(grp);
const orb = { grp, type:'orb', mesh:m, radius:1.6, pos:grp.position, spin:rand(1,2.5) };
return orb;
}
function ensureSegments(){
// generate ahead until we've covered player.z + margin
const margin = 400;
while(spawnZ < P.pos.z + margin){
makeSegment(spawnZ, difficulty);
spawnZ += segLen + segGap;
}
// remove behind
const cullZ = P.pos.z - 160;
for(let i=segments.length-1;i>=0;i--){
const s = segments[i];
if(s.z < cullZ){
scene.remove(s.group);
// dispose geometries/materials
s.group.traverse(o=>{ if(o.geometry) o.geometry.dispose(); if(o.material) o.material.dispose(); });
segments.splice(i,1);
}
}
}
// ============================================================
// COLLISION & NEAR-MISS
// ============================================================
function updateCollision(){
const pp = P.pos;
const pr = P.radius;
const nearSphere = new THREE.Sphere(pp.clone(), pr + 4.5); // near-miss threshold
for(const seg of segments){
for(const o of seg.obstacles){
const op = o.pos;
const dx = pp.x - op.x, dy = pp.y - op.y, dz = pp.z - op.z;
const rHalf = o.radius || 0;
// distance to box center (approx sphere vs box)
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz);
if(o.type==='orb'){
if(dist < pr + rHalf){
collectOrb(o, seg);
}
continue;
}
// Near-miss: within nearSphere but not colliding
if(!o.nearMissed && dist < (pr + rHalf + 3.2) && dist > (pr + rHalf)){
// check not inside box (collision handled below)
o.nearMissed = true;
registerNearMiss(o, clamp(dist,0,6));
}
// Collision: player sphere center within box half extents + player radius (approx)
const hx = o.half.x, hy = o.half.y, hz = o.half.z;
const insideX = Math.abs(dx) < hx;
const insideY = Math.abs(dy) < hy;
const insideZ = Math.abs(dz) < hz;
if(insideX && insideY && insideZ){
// collision!
gameOver();
return;
}
}
}
}
function registerNearMiss(obs, dist){
obs.nearMissed = true;
// closer => bigger multiplier boost
const boost = clamp((6 - dist)/6, 0.05, 1);
mult += 0.5 + boost*2;
mult = Math.round(mult*10)/10;
multTimer = 1.4;
score += Math.floor(mult * 10 * boost);
turbo = clamp(turbo + 0.12 + boost*0.15, 0, 1);
shakeAmt = Math.max(shakeAmt, 0.5 + boost*1.2);
if(audio) audio.whoosh(dist);
pulseMulti();
}
function collectOrb(orb, seg){
orb.collected = true;
energy = clamp(energy + 0.32, 0, 1);
score += 50 * Math.max(1, Math.floor(mult));
turbo = clamp(turbo + 0.28, 0, 1);
// pop effect
if(audio) audio.ping();
spawnBurst(orb.pos.clone(), 0x4df2ff);
// fade & remove
orb.grp.visible = false;
const idx = seg.obstacles.indexOf(orb);
if(idx>=0) seg.obstacles.splice(idx,1);
if(seg.orb===orb) seg.orb=null;
}
// burst particles (simple)
let bursts = [];
function spawnBurst(pos, color){
for(let i=0;i<10;i++){
const m = new THREE.Mesh(new THREE.TetrahedronGeometry(0.3), new THREE.MeshBasicMaterial({color}));
m.position.copy(pos);
m.userData.v = new THREE.Vector3(rand(-1,1),rand(-1,1),rand(-1,1)).normalize().multiplyScalar(rand(8,20));
m.userData.life = 0.7;
scene.add(m); bursts.push(m);
}
}
// ============================================================
// GAME FLOW
// ============================================================
function startGame(){
state = STATE.PLAY;
// reset
energy = 1.0; score = 0; mult = 1; multTimer = 0; turbo = 0; boosting=false;
maxSpeed = 0; distance = 0; difficulty = 0;
shakeAmt = 0;
P.pos.set(0,0,0); P.vel.set(0,0,0); P.bank=0; P.pitch=0; P.yaw=0; P.speed=60; P.targetBank=0; P.targetPitch=0;
pointerX=0; input.x=0;
// clear segments & obstacles
for(const s of segments){ scene.remove(s.group); s.group.traverse(o=>{ if(o.geometry)o.geometry.dispose(); if(o.material)o.material.dispose(); }); }
segments.length=0; obstacles.length=0; orbs.length=0;
bursts.forEach(b=>{ scene.remove(b.mesh); b.mesh.geometry.dispose(); b.mesh.material.dispose(); });
bursts.length=0;
spawnZ = 0;
ensureSegments();
$('startScreen').classList.add('hidden');
$('overScreen').classList.add('hidden');
$('pauseScreen').classList.add('hidden');
$('pauseTag').style.display='none';
if(audio) audio.start();
}
function gameOver(){
if(state!==STATE.PLAY) return;
state = STATE.OVER;
if(audio) audio.crash();
shakeAmt = 3.5;
P.speed = 0;
// update best
if(score>best){ best=score; localStorage.setItem('canyonflow_best', best); }
elFinalScore.textContent = score.toLocaleString();
elFinalBest.textContent = best.toLocaleString();
elMaxSpeed.textContent = Math.round(maxSpeed*3.6).toLocaleString();
elDistance.textContent = Math.round(distance)+'m';
$('best').textContent = 'BEST '+best.toLocaleString();
setTimeout(()=>$('overScreen').classList.remove('hidden'), 700);
}
function togglePause(){
if(state===STATE.PLAY){ state=STATE.PAUSE; $('pauseScreen').classList.remove('hidden'); $('pauseTag').style.display='block'; if(audio)audio.pauseSound(); }
else if(state===STATE.PAUSE){ state=STATE.PLAY; $('pauseScreen').classList.add('hidden'); $('pauseTag').style.display='none'; if(audio)audio.resumeSound(); }
}
// ============================================================
// UPDATE LOOP
// ============================================================
function update(dt){
if(dt>0.05) dt=0.05;
if(state===STATE.PLAY){
updatePlayerInput(dt);
updatePhysics(dt);
updateEnergy(dt);
updateDifficulty();
updateMultiplier(dt);
ensureSegments();
updateCollision();
updateParticles(dt);
updateCamera(dt);
updateHUD(dt);
} else {
// idle camera drift / decay shake
shakeAmt *= 0.9;
}
// particle fields update even when paused-ish (for trail)
renderLoop();
requestAnimationFrame(update);
}
function updatePlayerInput(dt){
// pointer-based steering (mouse) with keyboard fallback
let steer = input.x;
if(Math.abs(pointerX) > 0.02) steer = THREE.MathUtils.clamp(pointerX*1.6, -1, 1);
// keyboard
if(keys['ArrowLeft']||keys['a']) steer = -1;
if(keys['ArrowRight']||keys['d']) steer = 1;
P.targetBank = clamp(steer * 0.9, -0.9, 0.9);
// vertical steering (up/down) via mouse Y or arrow up/down
let vert = 0;
if(keys['ArrowUp']||keys['w']) vert -= 1;
if(keys['ArrowDown']) vert += 1;
if(Math.abs(pointerY) > 0.02) vert = -pointerY*1.2;
P.targetPitch = clamp(vert*0.5, -0.5, 0.5);
boosting = input.boost || (keys['Space']||keys['w']) && turbo>=1;
boosting = input.boost && turbo>=1;
}
function updatePhysics(dt){
// banking smoothing (weighty)
P.bank = lerp(P.bank, P.targetBank, 1 - Math.pow(0.001, dt));
P.pitch = lerp(P.pitch, P.targetPitch, 1 - Math.pow(0.002, dt));
// horizontal movement toward target
const steerForce = P.targetBank * 55;
P.vel.x += steerForce * dt;
P.vel.x *= (1 - 2.2*dt); // damping (weight)
P.vel.x = clamp(P.vel.x, -38, 38);
P.pos.x += P.vel.x * dt;
// vertical movement
const vertForce = -P.targetPitch * 40;
P.vel.y += vertForce * dt;
P.vel.y *= (1 - 2.0*dt);
P.pos.y += P.vel.y * dt;
// forward speed control
let targetSpeed = 62 + difficulty*22;
if(boosting){ targetSpeed = 150 + difficulty*30; turbo = clamp(turbo - dt*0.9, 0, 1); }
else { // recover turbo slowly when not boosting
turbo = clamp(turbo + dt*0.05, 0, 1);
}
P.speed = lerp(P.speed, targetSpeed, 1 - Math.pow(0.02, dt));
if(P.speed > maxSpeed) maxSpeed = P.speed;
// move player forward
P.pos.z += P.speed * dt;
distance += P.speed * dt;
// canyon bounds (walls/ceiling/floor) collision
const seg = segAt(P.pos.z);
let boundW = 60, boundY = 40, cx = 0;
if(seg){ boundW = seg.floorW + 6; cx = seg.leftX; boundY = seg.gapY + 4; }
// clamp player within canyon (this is a "wall hit" too, but we treat as soft clamp + damage-ish; keep it as bounds)
const maxX = cx + boundW/2 - 2;
const minX = cx - boundW/2 + 2;
if(P.pos.x > maxX){ P.pos.x = maxX; P.vel.x *= -0.3; shakeAmt=Math.max(shakeAmt,1.0); }
if(P.pos.x < minX){ P.pos.x = minX; P.vel.x *= -0.3; shakeAmt=Math.max(shakeAmt,1.0); }
if(P.pos.y > boundY){ P.pos.y = boundY; P.vel.y *= -0.3; }
if(P.pos.y < -11){ P.pos.y = -11; P.vel.y *= -0.3; }
// player mesh transform
player.position.copy(P.pos);
player.rotation.z = -P.bank; // roll
player.rotation.x = P.pitch*0.6; // slight pitch
player.rotation.y = -P.vel.x*0.01; // yaw into turn
// banking visual lean
player.rotation.z = THREE.MathUtils.lerp(player.rotation.z, -P.bank, 1);
// engine glow based on speed
const glow = 0.5 + clamp(P.speed/150,0,1)*1.2 + (boosting?1.5:0);
engineGlowL.material.color.setRGB(1, lerp(0.7,1,boosting), lerp(0.2,0.99,boosting));
engineGlowL.scale.setScalar(glow);
engineGlowR.scale.setScalar(glow);
// camera FOV widens with speed & boost
const targetFov = 78 + clamp((P.speed-60)/90,0,1)*22 + (boosting?18:0);
camera.fov = lerp(camera.fov, targetFov, 1 - Math.pow(0.05, dt));
camera.updateProjectionMatrix();
}
function updateEnergy(dt){
energy -= dt * 0.032 * (1 + difficulty*0.15); // faster decay at higher difficulty
energy = clamp(energy, 0, 1);
if(energy <= 0){ gameOver(); }
}
function updateMultiplier(dt){
multTimer -= dt;
if(multTimer <= 0){
mult -= 0.5*dt; // decay
if(mult < 1) mult = 1;
}
}
function updateDifficulty(){
difficulty = clamp(score / 2500, 0, 1);
}
function updateCamera(dt){
// chase camera behind & above player
const back = boosting ? 20 : 15;
const up = boosting ? 6 : 5;
const target = new THREE.Vector3(P.pos.x*0.6, P.pos.y*0.5 + up, P.pos.z - back);
camera.position.lerp(target, 1 - Math.pow(0.03, dt));
camera.lookAt(P.pos.x*0.7, P.pos.y*0.6, P.pos.z - 30);
// shake
if(shakeAmt > 0.001){
const s = shakeAmt * (boosting?1.4:1);
camera.position.x += rand(-1,1)*s*0.4;
camera.position.y += rand(-1,1)*s*0.4;
shakeAmt *= 1 - 3*dt;
}
}
function updateParticles(dt){
// trail behind engine
trailSys.points.visible = true;
const ep = P.pos.clone();
ep.z += 1.3;
if(trailSys.sys.next % 1 === 0 || Math.random()<0.8){
trailSys.sys.spawn(ep.clone(), rand(0.4,0.8));
}
// move trail points with player (they flow backward relative to world)
trailSys.points.position.set(0,0,P.pos.z);
trailSys.sys.update(dt);
// speed streaks
streakSys.points.visible = true;
streakSys.points.position.set(0,0,P.pos.z);
if(Math.random() < (boosting?0.9:0.35)){
streakSys.sys.spawn(new THREE.Vector3(P.pos.x+rand(-30,30), P.pos.y+rand(-20,20), P.pos.z-40));
}
streakSys.points.position.copy(trailSys.points.position);
streakSys.sys.update(dt);
// dust
dustSys.points.visible = true;
dustSys.points.position.set(0,0,P.pos.z);
if(Math.random() < (boosting?0.7:0.25)){
dustSys.sys.spawn(new THREE.Vector3(P.pos.x+rand(-40,40), rand(-12,20), P.pos.z-45));
}
dustSys.points.position.copy(trailSys.points.position);
dustSys.sys.update(dt);
// bursts
for(let i=bursts.length-1;i>=0;i--){
const b=bursts[i];
b.userData.life-=dt;
b.mesh.position.addScaledVector(b.userData.v, dt);
b.userData.v.y -= 15*dt;
b.mesh.rotation.x+=dt*5; b.mesh.rotation.y+=dt*5;
b.mesh.scale.setScalar(Math.max(0.01,b.userData.life*1.4));
if(b.userData.life<=0){ scene.remove(b.mesh); b.mesh.geometry.dispose(); b.mesh.material.dispose(); bursts.splice(i,1); }
}
}
function updateHUD(dt){
elScore.firstChild.textContent = score.toLocaleString();
elMulti.firstChild.textContent = 'x'+mult.toFixed(1);
const kph = Math.round(P.speed*3.6);
elSpeedVal.textContent = kph;
elSpeedFill.style.width = clamp(P.speed/180*100,0,100)+'%';
elEnergyFill.style.width = (energy*100).toFixed(0)+'%';
elEnergyFill.classList.toggle('low', energy<0.25);
elTurboFill.style.height = (turbo*100).toFixed(0)+'%';
elTurboFill.classList.toggle('ready', turbo>=1);
elTurboHint.classList.toggle('show', turbo>=1 && !boosting);
}
function pulseMulti(){
elMulti.parentElement.classList.add('hot');
setTimeout(()=>elMulti.parentElement.classList.remove('hot'), 120);
}
function segAt(z){
// find nearest segment
let best=null, bd=1e9;
for(const s of segments){ const d = Math.abs(s.z-z); if(d<bd){ bd=d; best=s; } }
return best;
}
// ============================================================
// RENDER
// ============================================================
function renderLoop(){
// animate sky/sun follow camera
if(skyMesh) skyMesh.position.copy(camera.position).multiplyScalar(0.3);
if(sunMesh) sunMesh.position.set(camera.position.x-120, 60, camera.position.z-520);
if(sunGlow) sunGlow.position.copy(sunMesh.position);
// animate orbs spin
for(const s of segments){
for(const o of s.obstacles){
if(o.type==='orb'){ o.mesh.rotation.y += o.spin*0.016; o.mesh.rotation.x += 0.01; }
}
}
composer.render();
}
// ============================================================
// INPUT
// ============================================================
const keys = {};
function bindInput(){
window.addEventListener('keydown', e=>{
keys[e.key]=true;
if(e.key===' '||e.key==='ArrowUp'||e.key==='ArrowDown') e.preventDefault();
if(e.key==='p'||e.key==='P') togglePause();
if((state===STATE.MENU||state===STATE.OVER) && (e.key===' '||e.key==='Enter')) startGame();
});
window.addEventListener('keyup', e=>{ keys[e.key]=false; });
window.addEventListener('pointermove', e=>{
pointerX = (e.clientX/window.innerWidth)*2 - 1;
pointerY = (e.clientY/window.innerHeight)*2 - 1;
});
renderer.domElement.addEventListener('pointerdown', e=>{
input.boost = true;
pointerX = (e.clientX/window.innerWidth)*2 - 1;
});
window.addEventListener('pointerup', e=>{ input.boost=false; });
// touch: left half steer left, right half steer right, tap = boost
window.addEventListener('touchstart', e=>{
input.boost = true;
const t = e.touches[0]; if(t){ pointerX = (t.clientX/window.innerWidth)*2-1; pointerY=(t.clientY/window.innerHeight)*2-1; }
}, {passive:true});
window.addEventListener('touchmove', e=>{
const t=e.touches[0]; if(t){ pointerX=(t.clientX/window.innerWidth)*2-1; pointerY=(t.clientY/window.innerHeight)*2-1; }
}, {passive:true});
window.addEventListener('touchend', ()=>{ input.boost=false; pointerX=0; pointerY=0; });
$('startBtn').addEventListener('click', startGame);
$('restartBtn').addEventListener('click', startGame);
$('resumeBtn').addEventListener('click', togglePause);
}
function onResize(){
camera.aspect = window.innerWidth/window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
fxaaPass.material.uniforms['resolution'].value.set(1/window.innerWidth, 1/window.innerHeight);
}
// ============================================================
// AUDIO (Web Audio API synthesis)
// ============================================================
class AudioEngine{
constructor(){
this.ctx=null; this.engine=null; this.engineGain=null; this.started=false;
this.noiseSrc=null; this.noiseGain=null; this.filter=null;
}
ensure(){
if(this.ctx) return;
const Ctx = window.AudioContext || window.webkitAudioContext;
this.ctx = new Ctx();
this.master = this.ctx.createGain(); this.master.gain.value=0.9;
this.master.connect(this.ctx.destination);
}
start(){
this.ensure();
if(this.started) return; this.started=true;
// engine hum: two detuned saws + sub
const g = this.ctx.createGain(); g.gain.value=0.0; g.connect(this.master); this.engineGain=g;
this.engine=this.ctx.createOscillator(); this.engine.type='sawtooth'; this.engine.frequency.value=62;
const e2=this.ctx.createOscillator(); e2.type='triangle'; e2.frequency.value=93;
const filt=this.ctx.createBiquadFilter(); filt.type='lowpass'; filt.frequency.value=600; filt.Q.value=2;
this.engine.connect(filt); e2.connect(filt); filt.connect(g);
this.engine.start(); e2.start();
this._e2=e2; this._filt=filt;
g.gain.linearRampToValueAtTime(0.18, this.ctx.currentTime+0.5);
// filter follows speed
this._engineUpdate();
// background noise whoosh bed
const ng=this.ctx.createGain(); ng.gain.value=0.05; ng.connect(this.master); this.noiseGain=ng;
this.noiseSrc=this.createNoise();
const nf=this.ctx.createBiquadFilter(); nf.type='bandpass'; nf.frequency.value=900; nf.Q.value=0.7;
this.noiseSrc.connect(nf); nf.connect(ng); this.noiseSrc.start();
this._nf=nf;
}
pauseSound(){ if(this.ctx && this.ctx.state==='running'){ this.ctx.suspend(); } }
resumeSound(){ if(this.ctx && this.ctx.state==='suspended'){ this.ctx.resume(); } }
_engineUpdate(){
if(!this.started) return;
const base = 62 + P.speed*0.35;
if(this.engine) this.engine.frequency.setTargetAtTime(base, this.ctx.currentTime, 0.08);
if(this._e2) this._e2.frequency.setTargetAtTime(base*1.5, this.ctx.currentTime, 0.08);
if(this._filt) this._filt.frequency.setTargetAtTime(500 + P.speed*6 + (input.boost?800:0), this.ctx.currentTime, 0.1);
if(this._nf) this._nf.frequency.setTargetAtTime(700 + P.speed*3, this.ctx.currentTime, 0.15);
requestAnimationFrame(()=>this._engineUpdate());
}
createNoise(){
const bufSize = this.ctx.sampleRate*2;
const buf = this.ctx.createBuffer(1, bufSize, this.ctx.sampleRate);
const d = buf.getChannelData(0);
for(let i=0;i<bufSize;i++) d[i]=Math.random()*2-1;
const src=this.ctx.createBufferSource(); src.buffer=buf; src.loop=true; return src;
}
ping(){
if(!this.ctx) return;
const t=this.ctx.currentTime;
const o=this.ctx.createOscillator(); o.type='sine'; o.frequency.value=880;
const g=this.ctx.createGain(); g.gain.setValueAtTime(0.0001,t);
o.connect(g); g.connect(this.master);
o.frequency.exponentialRampToValueAtTime(1760,t+0.08);
g.gain.exponentialRampToValueAtTime(0.25,t+0.01);
g.gain.exponentialRampToValueAtTime(0.0001,t+0.4);
o.start(t); o.stop(t+0.4);
// second harmonic
const o2=this.ctx.createOscillator(); o2.type='sine'; o2.frequency.value=1320;
const g2=this.ctx.createGain(); g2.gain.setValueAtTime(0.0001,t);
o2.connect(g2); g2.connect(this.master);
g2.gain.exponentialRampToValueAtTime(0.15,t+0.02);
g2.gain.exponentialRampToValueAtTime(0.0001,t+0.3);
o2.start(t); o2.stop(t+0.3);
}
whoosh(dist){
if(!this.ctx) return;
const t=this.ctx.currentTime;
const amt = clamp((6-dist)/6,0.3,1);
const src=this.createNoise();
const g=this.ctx.createGain(); g.gain.setValueAtTime(0.0001,t);
const f=this.ctx.createBiquadFilter(); f.type='bandpass'; f.frequency.value=1800; f.Q.value=1.2;
src.connect(f); f.connect(g); g.connect(this.master);
g.gain.exponentialRampToValueAtTime(0.2*amt,t+0.03);
g.gain.exponentialRampToValueAtTime(0.0001,t+0.35);
f.frequency.exponentialRampToValueAtTime(600,t+0.35);
src.start(t); src.stop(t+0.4);
}
boostStart(){
if(!this.ctx) return;
const t=this.ctx.currentTime;
const o=this.ctx.createOscillator(); o.type='sawtooth'; o.frequency.value=120;
const g=this.ctx.createGain(); g.gain.setValueAtTime(0.0001,t);
o.connect(g); g.connect(this.master);
o.frequency.exponentialRampToValueAtTime(400,t+0.3);
g.gain.exponentialRampToValueAtTime(0.2,t+0.05);
g.gain.exponentialRampToValueAtTime(0.0001,t+0.6);
o.start(t); o.stop(t+0.6);
}
crash(){
if(!this.ctx) return;
const t=this.ctx.currentTime;
// noise burst
const src=this.createNoise();
const g=this.ctx.createGain(); g.gain.setValueAtTime(0.4,t);
const f=this.ctx.createBiquadFilter(); f.type='lowpass'; f.frequency.setValueAtTime(2000,t); f.frequency.exponentialRampToValueAtTime(120,t+0.8);
src.connect(f); f.connect(g); g.connect(this.master);
g.gain.exponentialRampToValueAtTime(0.0001,t+1.2);
src.start(t); src.stop(t+1.2);
// low boom
const o=this.ctx.createOscillator(); o.type='sine'; o.frequency.setValueAtTime(90,t); o.frequency.exponentialRampToValueAtTime(30,t+0.9);
const og=this.ctx.createGain(); og.gain.setValueAtTime(0.5,t); og.gain.exponentialRampToValueAtTime(0.0001,t+1.0);
o.connect(og); og.connect(this.master); o.start(t); o.stop(t+1.0);
// cut engine
if(this.engineGain) this.engineGain.gain.setTargetAtTime(0.0001, t, 0.1);
}
}
// ============================================================
// BOOT
// ============================================================
init();
audio = new AudioEngine();
requestAnimationFrame(update);
</script>
</body>
</html>
<!-- agent-meta {"model":"bartowski/Ornith-1.5-35B-A3B","provider":"brigid","persona":"full","sessionId":"6b8907f2-63f1-42c5-a015-ee98ab9d3c04","tokensIn":4767866,"tokensOut":72554,"tokensTotal":4840420,"cost":0,"turns":66,"toolCalls":65,"failedToolCalls":12,"timestamp":"2026-08-23T20:27:19.608Z"} -->