<!DOCTYPE html>

<html lang="en">

<head>

<meta charset="UTF-8">

<title>Golden Hour — Drift Prototype v0.4</title>

<link rel="preconnect" href="https://fonts.googleapis.com">

<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=IBM+Plex+Mono:wght@300;400&display=swap" rel="stylesheet">

<style>

  :root {

    --warm: rgba(255, 235, 210, 0.92);

    --warm-dim: rgba(255, 220, 190, 0.55);

    --accent: #ffb070;

  }

  * { box-sizing: border-box; }

  html, body {

    margin: 0; padding: 0; overflow: hidden;

    background: #1a0d12;

    font-family: 'IBM Plex Mono', monospace;

    height: 100%;

    -webkit-font-smoothing: antialiased;

  }

  canvas { display: block; }


  #title {

    position: fixed; top: 24px; left: 28px;

    color: var(--warm);

    font-family: 'Instrument Serif', serif;

    font-style: italic; font-size: 26px;

    letter-spacing: 0.5px;

    pointer-events: none; line-height: 1;

    text-shadow: 0 2px 24px rgba(0,0,0,0.4);

  }

  #title .sub {

    display: block;

    font-family: 'IBM Plex Mono', monospace;

    font-style: normal; font-size: 9.5px;

    letter-spacing: 3px; opacity: 0.55;

    margin-top: 6px; text-transform: uppercase;

  }


  #hud {

    position: fixed; bottom: 28px; left: 28px;

    color: var(--warm);

    pointer-events: none;

    text-shadow: 0 2px 16px rgba(0,0,0,0.45);

  }

  #hud .row { display: flex; align-items: baseline; gap: 0; }

  .speed {

    font-family: 'IBM Plex Mono', monospace;

    font-weight: 300; font-size: 56px;

    letter-spacing: -1px; line-height: 1;

    font-variant-numeric: tabular-nums;

  }

  .unit {

    font-size: 10px; letter-spacing: 3px;

    opacity: 0.5; margin-left: 8px;

    text-transform: uppercase;

  }

  .gear {

    font-family: 'IBM Plex Mono', monospace;

    font-size: 22px; letter-spacing: 1px;

    color: var(--accent);

    margin-left: 22px; opacity: 0.92;

    font-variant-numeric: tabular-nums;

  }

  #rpmTrack {

    width: 280px; height: 8px;

    background: rgba(255, 230, 200, 0.10);

    border-radius: 2px; margin-top: 10px;

    overflow: visible;

    position: relative;

  }

  #rpmBar {

    height: 100%; width: 0%;

    background: linear-gradient(90deg, #ffd5a0, #ffb070);

    border-radius: 2px;

    transition: width 0.04s linear, background 0.2s ease;

    box-shadow: 0 0 12px rgba(255, 176, 112, 0.25);

  }

  #upshiftTick {

    position: absolute;

    top: -3px;

    width: 2px;

    height: 14px;

    background: rgba(255, 230, 200, 0.55);

    pointer-events: none;

    border-radius: 1px;

  }

  #rpmTrack .redline {

    position: absolute;

    top: 0;

    right: 0;

    width: 12%;

    height: 100%;

    background: rgba(255, 80, 80, 0.18);

    border-radius: 0 2px 2px 0;

    pointer-events: none;

  }

  #shiftFlash {

    position: fixed;

    top: 80px;

    left: 50%;

    transform: translateX(-50%) translateY(-20px);

    color: var(--accent);

    font-family: 'IBM Plex Mono', monospace;

    font-size: 32px;

    font-weight: 300;

    letter-spacing: 6px;

    opacity: 0;

    pointer-events: none;

    text-shadow: 0 0 30px rgba(255, 176, 112, 0.7), 0 2px 12px rgba(0,0,0,0.4);

    transition: opacity 0.28s ease, transform 0.28s ease;

  }

  #shiftFlash.up {

    opacity: 1;

    transform: translateX(-50%) translateY(0);

  }

  #shiftFlash.down {

    opacity: 1;

    transform: translateX(-50%) translateY(0);

    color: #ffc380;

  }

  .gear.shifting {

    color: #fff4d0;

    text-shadow: 0 0 18px rgba(255, 220, 180, 0.9);

  }

  #viewMode {

    margin-top: 10px;

    font-size: 10px; letter-spacing: 4px;

    color: var(--warm-dim);

    text-transform: uppercase;

    transition: color 0.3s ease;

  }

  #viewMode.drift { color: var(--accent); }


  #drift {

    position: fixed; top: 80px; left: 50%;

    transform: translateX(-50%);

    color: var(--accent);

    font-family: 'Instrument Serif', serif;

    font-style: italic; font-size: 42px;

    letter-spacing: 4px; opacity: 0;

    transition: opacity 0.35s ease;

    pointer-events: none;

    text-shadow: 0 0 30px rgba(255, 176, 112, 0.55), 0 2px 12px rgba(0,0,0,0.4);

    text-align: center;

  }

  #drift.active { opacity: 1; }

  #drift .timer {

    display: block;

    font-family: 'IBM Plex Mono', monospace;

    font-style: normal; font-size: 11px;

    letter-spacing: 4px; opacity: 0.65;

    margin-top: 8px; color: var(--warm);

  }


  #controls {

    position: fixed; bottom: 28px; right: 28px;

    color: var(--warm-dim);

    font-size: 10.5px; letter-spacing: 1.2px;

    line-height: 2; text-align: right;

    pointer-events: none; text-transform: uppercase;

  }

  #controls kbd {

    font-family: 'IBM Plex Mono', monospace;

    background: rgba(255, 230, 200, 0.08);

    border: 1px solid rgba(255, 230, 200, 0.18);

    padding: 2px 6px; border-radius: 3px;

    margin-right: 6px; font-size: 10px;

    color: var(--warm);

  }


  #vignette {

    position: fixed; inset: 0;

    pointer-events: none;

    background: radial-gradient(ellipse at center, transparent 50%, rgba(40, 15, 25, 0.45) 100%);

    z-index: 1;

  }


  #loading {

    position: fixed; inset: 0;

    display: flex; align-items: center; justify-content: center;

    background: #1a0d12; color: var(--warm);

    font-family: 'Instrument Serif', serif;

    font-style: italic; font-size: 22px;

    letter-spacing: 1px;

    transition: opacity 0.6s ease; z-index: 100;

  }

  #loading.hidden { opacity: 0; pointer-events: none; }


  #respawnFlash {

    position: fixed; inset: 0;

    background: radial-gradient(ellipse at center, rgba(255, 220, 180, 0.35), rgba(40, 15, 25, 0.0) 60%);

    pointer-events: none;

    opacity: 0;

    transition: opacity 0.18s ease;

    z-index: 50;

  }

  #respawnFlash.active { opacity: 1; }

  #respawnLabel {

    position: fixed;

    top: 42%;

    left: 50%;

    transform: translate(-50%, -50%);

    color: var(--warm);

    font-family: 'Instrument Serif', serif;

    font-style: italic;

    font-size: 26px;

    letter-spacing: 2px;

    opacity: 0;

    pointer-events: none;

    text-shadow: 0 2px 20px rgba(0,0,0,0.5);

    transition: opacity 0.18s ease;

    z-index: 51;

  }

  #respawnLabel.active { opacity: 0.9; }


  /* === CAR SELECT MENU === */

  #menu {

    position: fixed; inset: 0;

    background: linear-gradient(to bottom, rgba(26, 13, 18, 0.92), rgba(40, 20, 30, 0.92));

    backdrop-filter: blur(12px);

    z-index: 200;

    display: flex; flex-direction: column;

    align-items: center; justify-content: center;

    transition: opacity 0.5s ease;

  }

  #menu.hidden { opacity: 0; pointer-events: none; }

  #menuTitle {

    font-family: 'Instrument Serif', serif;

    font-style: italic;

    font-size: 64px;

    color: var(--warm);

    letter-spacing: 1px;

    margin-bottom: 4px;

    text-shadow: 0 4px 30px rgba(255, 180, 130, 0.3);

  }

  #menuSub {

    font-family: 'IBM Plex Mono', monospace;

    font-size: 10px;

    letter-spacing: 5px;

    color: var(--warm-dim);

    text-transform: uppercase;

    margin-bottom: 56px;

  }

  #carRow {

    display: flex;

    gap: 18px;

    margin-bottom: 44px;

    max-width: 1100px;

    flex-wrap: wrap;

    justify-content: center;

  }

  .carCard {

    width: 180px;

    background: rgba(255, 230, 200, 0.05);

    border: 1px solid rgba(255, 230, 200, 0.12);

    border-radius: 6px;

    padding: 18px 16px;

    cursor: pointer;

    transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;

    color: var(--warm);

    text-align: left;

    font-family: 'IBM Plex Mono', monospace;

    user-select: none;

  }

  .carCard:hover {

    background: rgba(255, 230, 200, 0.08);

    border-color: rgba(255, 230, 200, 0.25);

    transform: translateY(-3px);

  }

  .carCard.selected {

    border-color: var(--accent);

    background: rgba(255, 176, 112, 0.10);

    box-shadow: 0 0 30px rgba(255, 176, 112, 0.15);

  }

  .carSwatch {

    width: 100%;

    height: 60px;

    border-radius: 4px;

    margin-bottom: 14px;

    position: relative;

    overflow: hidden;

  }

  .carSwatch::after {

    /* Subtle highlight stripe to fake a paint sheen */

    content: '';

    position: absolute;

    inset: 0;

    background: linear-gradient(135deg, rgba(255,255,255,0.18) 0%, transparent 40%, transparent 60%, rgba(0,0,0,0.18) 100%);

  }

  .carName {

    font-family: 'Instrument Serif', serif;

    font-style: italic;

    font-size: 20px;

    letter-spacing: 0.5px;

    line-height: 1.1;

    margin-bottom: 4px;

  }

  .carInspired {

    font-size: 9px;

    letter-spacing: 2px;

    color: var(--warm-dim);

    text-transform: uppercase;

    margin-bottom: 12px;

  }

  .carTagline {

    font-size: 10px;

    letter-spacing: 1.5px;

    color: var(--accent);

    text-transform: uppercase;

    margin-bottom: 12px;

  }

  .carStats {

    font-size: 10px;

    color: var(--warm-dim);

    line-height: 1.7;

  }

  .carStat { display: flex; justify-content: space-between; }

  .carStat .val { color: var(--warm); font-variant-numeric: tabular-nums; }

  #menuStart {

    font-family: 'IBM Plex Mono', monospace;

    font-size: 11px;

    letter-spacing: 5px;

    color: var(--warm);

    background: transparent;

    border: 1px solid rgba(255, 230, 200, 0.3);

    padding: 14px 36px;

    border-radius: 3px;

    cursor: pointer;

    text-transform: uppercase;

    transition: all 0.2s ease;

    font-weight: 300;

  }

  #menuStart:hover {

    background: rgba(255, 176, 112, 0.15);

    border-color: var(--accent);

    color: var(--accent);

  }

  #menuHint {

    margin-top: 22px;

    font-size: 10px;

    letter-spacing: 3px;

    color: var(--warm-dim);

    text-transform: uppercase;

  }


  #audioHint {

    position: fixed; top: 50%; left: 50%;

    transform: translate(-50%, -50%);

    color: var(--warm); opacity: 0;

    font-family: 'Instrument Serif', serif;

    font-style: italic; font-size: 18px;

    letter-spacing: 1px;

    pointer-events: none;

    transition: opacity 0.5s ease;

    text-shadow: 0 2px 16px rgba(0,0,0,0.6);

  }

  #audioHint.show { opacity: 0.85; }

</style>

</head>

<body>


<div id="loading">golden hour</div>

<div id="audioHint">press any key to begin</div>


<div id="menu">

  <div id="menuTitle">Golden Hour</div>

  <div id="menuSub">choose your machine</div>

  <div id="carRow"></div>

  <button id="menuStart">drive</button>

  <div id="menuHint">← → to browse · enter to start</div>

</div>


<div id="title">

  Golden Hour

  <span class="sub">drift · prototype v0.4</span>

</div>


<div id="hud">

  <div class="row">

    <span class="speed" id="speed">0</span><span class="unit">km/h</span>

    <span class="gear" id="gear">G1</span>

  </div>

  <div id="rpmTrack">

    <div id="rpmBar"></div>

    <div id="upshiftTick"></div>

    <div class="redline"></div>

  </div>

  <div id="viewMode">chase cam</div>

</div>


<div id="shiftFlash">▲ UP</div>


<div id="drift">

  DRIFT

  <span class="timer" id="combo">0.0s</span>

</div>


<div id="controls">

  <div><kbd>W</kbd> / <kbd>↑</kbd> accelerate</div>

  <div><kbd>S</kbd> / <kbd>↓</kbd> brake / reverse</div>

  <div><kbd>A</kbd> <kbd>D</kbd> / <kbd>←</kbd> <kbd>→</kbd> steer</div>

  <div><kbd>SPACE</kbd> handbrake</div>

  <div><kbd>C</kbd> change camera</div>

  <div><kbd>R</kbd> reset</div>

</div>


<div id="vignette"></div>

<div id="respawnFlash"></div>

<div id="respawnLabel">returning to road</div>


<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>

<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>

<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>

<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>

<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>


<script>

// ====================================================================

//  GOLDEN HOUR v0.4

//  + Infinite chunked road (slowroads.io-style)

//  + Road-snap drift assist (car stays on the road during slides)

//  + Reworked audio (proper engine, audible drift skid)

//  + Tunnels removed; mountains remain as distant ring

// ====================================================================


const scene = new THREE.Scene();

const camera = new THREE.PerspectiveCamera(68, innerWidth / innerHeight, 0.1, 3000);

const renderer = new THREE.WebGLRenderer({ antialias: true });

renderer.setSize(innerWidth, innerHeight);

renderer.setPixelRatio(Math.min(devicePixelRatio, 2));

renderer.shadowMap.enabled = true;

renderer.shadowMap.type = THREE.PCFSoftShadowMap;

renderer.outputEncoding = THREE.sRGBEncoding;

renderer.toneMapping = THREE.ACESFilmicToneMapping;

renderer.toneMappingExposure = 1.05;

document.body.appendChild(renderer.domElement);


// ====================================================================

//  POST: radial motion blur

// ====================================================================

const RadialBlurShader = {

  uniforms: { tDiffuse: { value: null }, blurStrength: { value: 0.0 } },

  vertexShader: `

    varying vec2 vUv;

    void main(){

      vUv = uv;

      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);

    }`,

  fragmentShader: `

    uniform sampler2D tDiffuse;

    uniform float blurStrength;

    varying vec2 vUv;

    void main(){

      vec2 center = vec2(0.5, 0.5);

      vec2 dir = vUv - center;

      float dist = length(dir);

      float strength = blurStrength * smoothstep(0.05, 0.7, dist);

      vec4 sum = vec4(0.0);

      float total = 0.0;

      const int SAMPLES = 12;

      for (int i = 0; i < SAMPLES; i++) {

        float t = float(i) / float(SAMPLES - 1) - 0.5;

        float off = t * strength * 0.10;

        vec2 uv = vUv - dir * off;

        float w = 1.0 - abs(t) * 1.2;

        sum += texture2D(tDiffuse, uv) * w;

        total += w;

      }

      gl_FragColor = sum / total;

    }`,

};

const composer = new THREE.EffectComposer(renderer);

composer.addPass(new THREE.RenderPass(scene, camera));

const radialBlurPass = new THREE.ShaderPass(RadialBlurShader);

composer.addPass(radialBlurPass);


// ====================================================================

//  SKY / SUN / FOG

// ====================================================================

scene.fog = new THREE.FogExp2(0xff9560, 0.0042);


const skyMat = new THREE.ShaderMaterial({

  uniforms: {

    topColor:     { value: new THREE.Color(0x3a1f3f) },

    midColor:     { value: new THREE.Color(0xff6a3a) },

    horizonColor: { value: new THREE.Color(0xffc580) },

    groundColor:  { value: new THREE.Color(0x6a2535) },

  },

  vertexShader: `

    varying vec3 vWorldPos;

    void main(){

      vec4 wp = modelMatrix * vec4(position, 1.0);

      vWorldPos = wp.xyz;

      gl_Position = projectionMatrix * viewMatrix * wp;

    }`,

  fragmentShader: `

    uniform vec3 topColor, midColor, horizonColor, groundColor;

    varying vec3 vWorldPos;

    void main(){

      float h = normalize(vWorldPos).y;

      vec3 col;

      if (h >= 0.0) {

        float t = smoothstep(0.0, 0.55, h);

        vec3 lower = mix(horizonColor, midColor, smoothstep(0.0, 0.18, h));

        col = mix(lower, topColor, t);

      } else {

        col = mix(horizonColor, groundColor, smoothstep(0.0, -0.25, h));

      }

      gl_FragColor = vec4(col, 1.0);

    }`,

  side: THREE.BackSide, depthWrite: false,

});

const skyMesh = new THREE.Mesh(new THREE.SphereGeometry(1500, 32, 16), skyMat);

scene.add(skyMesh);


const sun = new THREE.Mesh(

  new THREE.CircleGeometry(55, 48),

  new THREE.MeshBasicMaterial({ color: 0xfff0c0, transparent: true, opacity: 0.95 })

);

sun.position.set(-80, 30, -900);

scene.add(sun);


const sunGlow = new THREE.Mesh(

  new THREE.CircleGeometry(140, 48),

  new THREE.MeshBasicMaterial({ color: 0xffa860, transparent: true, opacity: 0.25,

    blending: THREE.AdditiveBlending, depthWrite: false })

);

sunGlow.position.copy(sun.position).add(new THREE.Vector3(0, 0, 1));

scene.add(sunGlow);


// ====================================================================

//  LIGHTING

// ====================================================================

scene.add(new THREE.AmbientLight(0xffb088, 0.55));


const sunLight = new THREE.DirectionalLight(0xffc090, 1.15);

sunLight.position.set(-60, 70, -120);

sunLight.castShadow = true;

sunLight.shadow.mapSize.set(2048, 2048);

{

  const sc = sunLight.shadow.camera;

  sc.near = 1; sc.far = 240;

  sc.left = -60; sc.right = 60; sc.top = 60; sc.bottom = -60;

}

sunLight.shadow.bias = -0.0005;

scene.add(sunLight);


const rimLight = new THREE.DirectionalLight(0x6a4488, 0.35);

rimLight.position.set(80, 30, 100);

scene.add(rimLight);


// ====================================================================

//  GROUND (large fixed plane — fine since sky/fog hide the edges)

// ====================================================================

const ground = new THREE.Mesh(

  new THREE.PlaneGeometry(6000, 6000, 1, 1),

  new THREE.MeshLambertMaterial({ color: 0x9a5a3c })

);

ground.rotation.x = -Math.PI / 2;

ground.receiveShadow = true;

scene.add(ground);


// ====================================================================

//  DISTANT MOUNTAINS (one-shot ring, road-agnostic — they stay far away)

// ====================================================================

function buildDistantMountains(radius, count, heightMul, color) {

  const g = new THREE.Group();

  for (let i = 0; i < count; i++) {

    const angle = (i / count) * Math.PI * 2 + (Math.random() - 0.5) * 0.05;

    const dist = radius + Math.random() * 200;

    const h = (50 + Math.random() * 90) * heightMul;

    const r = 70 + Math.random() * 100;

    const m = new THREE.Mesh(

      new THREE.ConeGeometry(r, h, 5 + (i % 3)),

      new THREE.MeshBasicMaterial({ color })

    );

    m.position.set(Math.cos(angle) * dist, h / 2 - 2, Math.sin(angle) * dist);

    g.add(m);

  }

  return g;

}

const mtnNear = buildDistantMountains(900, 60, 1.0, 0x4a1f30);

const mtnFar  = buildDistantMountains(1300, 50, 0.7, 0x6a2a3a);

scene.add(mtnNear);

scene.add(mtnFar);

// We'll re-center these around the car each frame so they always feel "out there"


// ====================================================================

//  INFINITE ROAD — chunked centerline + meshes

// ====================================================================

// Strategy: maintain a buffer of centerline points generated by walking

// an arc/straight event chain forward. Each chunk owns its meshes and

// scenery, and gets disposed when it's far behind the car.


const ROAD_WIDTH = 22;

const ROAD_HALF = ROAD_WIDTH / 2;

const CHUNK_TARGET_LENGTH = 220;  // meters per chunk (roughly)

const POINT_STEP = 4;             // meters between centerline samples

const CHUNKS_AHEAD = 8;           // how many chunks to keep generated ahead

const CHUNKS_BEHIND = 2;          // how many chunks to keep behind


// "Pen" state — like a turtle that walks forward emitting points.

// We now use CONTINUOUS curvature driven by smooth noise, instead of

// discrete (straight/turn) events. This gives organic flowing curves,

// and a "return to forward" bias prevents the road from looping back

// on itself or crossing.

const roadPen = {

  x: 0, z: 0, heading: 0,

  distance: 0,        // total distance walked — used as the noise input

};


// 1D value noise — smoothed random — gives organically varying curvature.

// We precompute a long array of random values at fixed spacing, then

// interpolate smoothly between them. Two octaves layered.

const NOISE_SPACING_A = 90;    // meters between low-frequency noise samples (big sweepers)

const NOISE_SPACING_B = 28;    // meters between high-frequency samples (minor wiggle)

const noiseA = []; // low freq

const noiseB = []; // high freq

function getNoiseValue(arr, index) {

  while (arr.length <= index + 1) arr.push(Math.random() * 2 - 1);

  return arr[index];

}

function smoothNoise(arr, position, spacing) {

  const idx = Math.floor(position / spacing);

  const frac = (position / spacing) - idx;

  const a = getNoiseValue(arr, idx);

  const b = getNoiseValue(arr, idx + 1);

  // smoothstep interpolation

  const t = frac * frac * (3 - 2 * frac);

  return a * (1 - t) + b * t;

}


// Curvature target at the pen's current distance, in rad/m.

// The road's HEADING is soft-clamped to ±60° from the spawn forward direction.

// This single constraint is what prevents the road from doubling back or

// crossing itself: no matter how far it wanders sideways, it always has a

// net forward bias.

const CURVATURE_MAX     = 0.032;      // 1/0.032 = 31m radius — sweeper / normal corner

const HAIRPIN_CURVATURE = 0.055;      // 1/0.055 = 18m radius — tight hairpin

const FAIRMONT_CURVATURE= 0.040;      // 1/0.040 = 25m radius — Monaco Fairmont style

const HEADING_CLAMP_RAD = Math.PI / 2;   // ±90° max heading deviation from forward

const HEADING_SOFT_BAND = Math.PI / 5;   // 36° soft band


// Hairpin scheduling state — independent of the curvature noise

let nextHairpinAt = 600;

let currentHairpin = null; // { kind, direction, remaining, peak }


function targetCurvatureAt(distance, currentHeading) {

  // -- BASE: continuous smooth curvature from noise --

  const a = smoothNoise(noiseA, distance, NOISE_SPACING_A);

  const b = smoothNoise(noiseB, distance, NOISE_SPACING_B);

  let c = (a * 0.60 + b * 0.40) * CURVATURE_MAX;


  // -- HAIRPIN / FAIRMONT INJECTION --

  if (!currentHairpin && distance >= nextHairpinAt) {

    // 30% chance it's a full Fairmont 180° turn, 70% a normal hairpin

    const isFairmont = Math.random() < 0.30;

    if (isFairmont) {

      // 180° at 25m radius needs ~80m of constant curvature

      // (arc length = radius × angle in rad = 25 × π ≈ 78m)

      currentHairpin = {

        kind: 'fairmont',

        direction: Math.random() < 0.5 ? -1 : 1,

        remaining: 75 + Math.random() * 15,  // 75–90m → ~170–205°

        peak: FAIRMONT_CURVATURE,

      };

    } else {

      currentHairpin = {

        kind: 'hairpin',

        direction: Math.random() < 0.5 ? -1 : 1,

        remaining: 35 + Math.random() * 20,

        peak: HAIRPIN_CURVATURE * (0.85 + Math.random() * 0.25),

      };

    }

  }

  if (currentHairpin) {

    c = currentHairpin.direction * currentHairpin.peak;

    currentHairpin.remaining -= POINT_STEP;

    if (currentHairpin.remaining <= 0) {

      // Fairmonts are rarer — schedule next one further out

      const nextDelay = currentHairpin.kind === 'fairmont'

        ? 500 + Math.random() * 400

        : 350 + Math.random() * 350;

      currentHairpin = null;

      nextHairpinAt = distance + nextDelay;

    }

    return c; // hairpin override — skip heading clamp during the turn

  }


  // -- HEADING CLAMP: prevent the road from crossing itself (forward-only) --

  let h = currentHeading;

  while (h >  Math.PI) h -= 2 * Math.PI;

  while (h < -Math.PI) h += 2 * Math.PI;

  const limit = HEADING_CLAMP_RAD;

  const softStart = limit - HEADING_SOFT_BAND;

  if (Math.abs(h) > softStart) {

    const t = THREE.MathUtils.clamp((Math.abs(h) - softStart) / HEADING_SOFT_BAND, 0, 1);

    const restoreC = -Math.sign(h) * CURVATURE_MAX * (0.4 + t * 1.4);

    c = c * (1 - t) + restoreC * t;

    if (Math.abs(h) >= limit) c = -Math.sign(h) * CURVATURE_MAX * 1.6;

  }


  return Math.max(-HAIRPIN_CURVATURE * 1.4, Math.min(HAIRPIN_CURVATURE * 1.4, c));

}


// Minimum distance the road must keep from any earlier, non-adjacent segment

// to prevent crossings. Set comfortably larger than the road width (22m) plus

// the scenery buffer.

const SELF_AVOID_RADIUS = 45;

// Within how many indices back to consider points "adjacent" (i.e. not a

// real crossing risk — just the immediate trailing road behind us).

const SELF_AVOID_IGNORE_BACK = 80; // 80 points × 4m = 320m of trailing road ignored


// Walk the pen one POINT_STEP forward, applying smooth curvature plus

// emergency self-avoidance: if the next point would land too close to an

// earlier, non-adjacent road segment, bend the curvature away.

function advancePen() {

  let curvature = targetCurvatureAt(roadPen.distance, roadPen.heading);


  // Lookahead: where will the new point end up?

  // First, what would the new heading be after this step?

  let newHeading = roadPen.heading + curvature * POINT_STEP;

  let newX = roadPen.x + Math.sin(newHeading) * POINT_STEP;

  let newZ = roadPen.z + Math.cos(newHeading) * POINT_STEP;


  // -- COLLISION AVOIDANCE --

  // Scan the spatial grid in a neighborhood around (newX, newZ).

  // If any earlier road point (with index < nextChunkIdx - SELF_AVOID_IGNORE_BACK)

  // is within SELF_AVOID_RADIUS, we have a conflict.

  if (ROAD_GRID && nextChunkIdx > SELF_AVOID_IGNORE_BACK) {

    const cells = Math.ceil(SELF_AVOID_RADIUS / ROAD_GRID_CELL);

    const gx0 = Math.floor(newX / ROAD_GRID_CELL);

    const gz0 = Math.floor(newZ / ROAD_GRID_CELL);

    const recentCutoff = nextChunkIdx - SELF_AVOID_IGNORE_BACK;

    let bestConflict = null; // { px, pz, idx, d2 }

    let bestD2 = SELF_AVOID_RADIUS * SELF_AVOID_RADIUS;

    for (let dz = -cells; dz <= cells; dz++) {

      for (let dx = -cells; dx <= cells; dx++) {

        const arr = ROAD_GRID.get(roadGridKey(gx0 + dx, gz0 + dz));

        if (!arr) continue;

        for (const p of arr) {

          if (p.idx >= recentCutoff) continue; // ignore recent trail

          const ddx = p.x - newX, ddz = p.z - newZ;

          const d2 = ddx*ddx + ddz*ddz;

          if (d2 < bestD2) {

            bestD2 = d2;

            bestConflict = { px: p.x, pz: p.z, idx: p.idx, d2 };

          }

        }

      }

    }


    if (bestConflict) {

      // Determine which way to bend AWAY from the conflict.

      // Vector from conflict point to (would-be) new position.

      const awayX = newX - bestConflict.px;

      const awayZ = newZ - bestConflict.pz;

      // Cross product (in y) of forward direction × awayDirection tells us

      // whether we should turn LEFT (positive curvature) or RIGHT (negative).

      // Forward direction at current heading:

      const fwdX = Math.sin(roadPen.heading);

      const fwdZ = Math.cos(roadPen.heading);

      // 2D cross: fwd × away = fwdX*awayZ - fwdZ*awayX

      // Positive = away vector is to the LEFT of forward → turn left (positive curvature)

      const cross = fwdX * awayZ - fwdZ * awayX;

      const avoidSign = cross > 0 ? 1 : -1;


      // Strength: stronger the closer we are. At touch distance, max curvature.

      const proximity = 1 - Math.sqrt(bestConflict.d2) / SELF_AVOID_RADIUS; // 0..1

      const avoidCurvature = avoidSign * HAIRPIN_CURVATURE * 1.3 * Math.max(0.4, proximity);


      // Override the base curvature when avoidance is needed (don't blend —

      // we need to bend HARD and fast to escape a near miss).

      curvature = avoidCurvature;


      // Recompute the new heading and position with the override

      newHeading = roadPen.heading + curvature * POINT_STEP;

      newX = roadPen.x + Math.sin(newHeading) * POINT_STEP;

      newZ = roadPen.z + Math.cos(newHeading) * POINT_STEP;


      // Also: cancel any active hairpin event — collision avoidance takes

      // priority, and a hairpin in the middle of an avoidance might cause

      // a new conflict.

      if (currentHairpin) {

        currentHairpin = null;

        nextHairpinAt = roadPen.distance + 250 + Math.random() * 250;

      }

    }

  }


  roadPen.heading = newHeading;

  roadPen.x = newX;

  roadPen.z = newZ;

  roadPen.distance += POINT_STEP;

  return new THREE.Vector3(roadPen.x, 0, roadPen.z);

}


// Master point buffer. We never delete from the front (so indices stay stable);

// we just drop references when a chunk is removed. The spatial grid covers active points only.

const ROAD_POINTS = [];            // all generated centerline points

const chunks = [];                 // { startIdx, endIdx, meshes:[], sceneryGroup }

let nextChunkIdx = 0;              // index that the next chunk should start at


// Spatial grid for "where is the road near (x,z)?"

const ROAD_GRID = new Map();

const ROAD_GRID_CELL = 25;

function roadGridKey(gx, gz) { return gx + ',' + gz; }

function roadGridAdd(p, idx) {

  const gx = Math.floor(p.x / ROAD_GRID_CELL);

  const gz = Math.floor(p.z / ROAD_GRID_CELL);

  const key = roadGridKey(gx, gz);

  let arr = ROAD_GRID.get(key);

  if (!arr) { arr = []; ROAD_GRID.set(key, arr); }

  arr.push({ x: p.x, z: p.z, idx });

}

function roadGridRemove(p, idx) {

  const gx = Math.floor(p.x / ROAD_GRID_CELL);

  const gz = Math.floor(p.z / ROAD_GRID_CELL);

  const key = roadGridKey(gx, gz);

  const arr = ROAD_GRID.get(key);

  if (!arr) return;

  const i = arr.findIndex(e => e.idx === idx);

  if (i >= 0) arr.splice(i, 1);

  if (arr.length === 0) ROAD_GRID.delete(key);

}

function nearestRoadPoint(x, z, searchRadius) {

  const r = searchRadius || 60;

  const cells = Math.ceil(r / ROAD_GRID_CELL);

  const gx0 = Math.floor(x / ROAD_GRID_CELL);

  const gz0 = Math.floor(z / ROAD_GRID_CELL);

  let bestD2 = Infinity, bestIdx = -1;

  for (let dz = -cells; dz <= cells; dz++) {

    for (let dx = -cells; dx <= cells; dx++) {

      const arr = ROAD_GRID.get(roadGridKey(gx0 + dx, gz0 + dz));

      if (!arr) continue;

      for (const p of arr) {

        const dxp = p.x - x, dzp = p.z - z;

        const d2 = dxp*dxp + dzp*dzp;

        if (d2 < bestD2) { bestD2 = d2; bestIdx = p.idx; }

      }

    }

  }

  return { d2: bestD2, idx: bestIdx };

}


// Like nearestRoadPoint, but prefers road points whose tangent aligns with

// the car's heading direction. Prevents the assist from latching onto the

// wrong segment when the road folds near itself.

function nearestRoadPointAligned(x, z, headingX, headingZ, searchRadius) {

  const r = searchRadius || 60;

  const cells = Math.ceil(r / ROAD_GRID_CELL);

  const gx0 = Math.floor(x / ROAD_GRID_CELL);

  const gz0 = Math.floor(z / ROAD_GRID_CELL);

  let bestScore = Infinity, bestIdx = -1, bestD2 = Infinity;

  for (let dz = -cells; dz <= cells; dz++) {

    for (let dx = -cells; dx <= cells; dx++) {

      const arr = ROAD_GRID.get(roadGridKey(gx0 + dx, gz0 + dz));

      if (!arr) continue;

      for (const p of arr) {

        const dxp = p.x - x, dzp = p.z - z;

        const d2 = dxp*dxp + dzp*dzp;

        if (d2 > r * r) continue;

        // Compute tangent at this point (cheap version: use neighbors)

        const prev = ROAD_POINTS[p.idx - 1];

        const next = ROAD_POINTS[p.idx + 1];

        let tx, tz;

        if (prev && next) { tx = next.x - prev.x; tz = next.z - prev.z; }

        else if (next)    { tx = next.x - p.x;    tz = next.z - p.z;    }

        else if (prev)    { tx = p.x - prev.x;    tz = p.z - prev.z;    }

        else              { tx = 0; tz = 1; }

        const tlen = Math.hypot(tx, tz) || 1;

        tx /= tlen; tz /= tlen;

        // Alignment: 1 = same direction, -1 = opposite.

        // Only penalize points whose tangent goes the WRONG way (align < 0).

        // Mid-drift, the car heading rotates significantly relative to road

        // tangent, but we still want to grab the right segment, so we're

        // forgiving of moderate misalignment.

        const align = tx * headingX + tz * headingZ; // -1..1

        const alignPenalty = align < -0.1 ? (-0.1 - align) * 12000 : 0;

        const score = d2 + alignPenalty;

        if (score < bestScore) {

          bestScore = score;

          bestIdx = p.idx;

          bestD2 = d2;

        }

      }

    }

  }

  return { d2: bestD2, idx: bestIdx };

}


// Tangent / perpendicular at an index into ROAD_POINTS (returns nulls if out of range)

function tanPerpAt(i) {

  const p = ROAD_POINTS[i];

  if (!p) return { t: null, p: null };

  let prev = ROAD_POINTS[i - 1];

  let next = ROAD_POINTS[i + 1];

  let t;

  if (prev && next) t = next.clone().sub(prev);

  else if (next)    t = next.clone().sub(p);

  else if (prev)    t = p.clone().sub(prev);

  else              t = new THREE.Vector3(0, 0, 1);

  t.normalize();

  const perp = new THREE.Vector3(-t.z, 0, t.x);

  return { t, p: perp };

}


// ---- Chunk builder ----

// Builds asphalt ribbon, edge lines, dashed centerline, posts, and scenery

// for the given index range [startIdx, endIdx).

function buildChunkMeshes(startIdx, endIdx) {

  const meshes = [];

  if (endIdx - startIdx < 2) return { meshes, sceneryGroup: new THREE.Group() };


  const Y_ASPHALT = 0.02;

  const Y_LINE    = 0.028;


  // --- Asphalt ribbon ---

  const asPos = [], asIdx = [];

  for (let i = startIdx; i < endIdx; i++) {

    const { p: perp } = tanPerpAt(i);

    const c = ROAD_POINTS[i];

    const L = c.clone().addScaledVector(perp,  ROAD_HALF);

    const R = c.clone().addScaledVector(perp, -ROAD_HALF);

    asPos.push(L.x, Y_ASPHALT, L.z, R.x, Y_ASPHALT, R.z);

  }

  const ringCount = endIdx - startIdx;

  for (let i = 0; i < ringCount - 1; i++) {

    const a = i * 2, b = i * 2 + 1, c = (i + 1) * 2, d = (i + 1) * 2 + 1;

    asIdx.push(a, c, b, b, c, d);

  }

  const asGeo = new THREE.BufferGeometry();

  asGeo.setAttribute('position', new THREE.Float32BufferAttribute(asPos, 3));

  asGeo.setIndex(asIdx);

  asGeo.computeVertexNormals();

  const asphalt = new THREE.Mesh(

    asGeo,

    new THREE.MeshLambertMaterial({ color: 0x1f1612 })

  );

  asphalt.receiveShadow = true;

  scene.add(asphalt);

  meshes.push(asphalt);


  // --- Edge lines ---

  function buildEdgeLine(sideSign, width) {

    const pos = [], idx = [];

    const inset = ROAD_HALF - 0.35;

    for (let i = startIdx; i < endIdx; i++) {

      const { p: perp } = tanPerpAt(i);

      const c = ROAD_POINTS[i];

      const inner = c.clone().addScaledVector(perp, sideSign * (inset - width));

      const outer = c.clone().addScaledVector(perp, sideSign * inset);

      pos.push(inner.x, Y_LINE, inner.z, outer.x, Y_LINE, outer.z);

    }

    for (let i = 0; i < ringCount - 1; i++) {

      const a = i*2, b = i*2+1, c = (i+1)*2, d = (i+1)*2+1;

      idx.push(a, c, b, b, c, d);

    }

    const g = new THREE.BufferGeometry();

    g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));

    g.setIndex(idx);

    const m = new THREE.Mesh(g, new THREE.MeshBasicMaterial({ color: 0xf5e3c0 }));

    scene.add(m); meshes.push(m);

  }

  buildEdgeLine(1, 0.22);

  buildEdgeLine(-1, 0.22);


  // --- Dashed centerline ---

  const dashPos = [], dashIdx = [];

  const dashHalf = 0.13;

  const dashOn = 2, dashOff = 2;

  const cycle = dashOn + dashOff;

  let v = 0;

  // Use the global index to keep the dash pattern continuous across chunks

  for (let i = startIdx; i < endIdx - 1; i++) {

    if (i % cycle >= dashOn) continue;

    const { p: perp }  = tanPerpAt(i);

    const { p: perp2 } = tanPerpAt(i + 1);

    const p1 = ROAD_POINTS[i], p2 = ROAD_POINTS[i + 1];

    const lA = p1.clone().addScaledVector(perp,   dashHalf);

    const rA = p1.clone().addScaledVector(perp,  -dashHalf);

    const lB = p2.clone().addScaledVector(perp2,  dashHalf);

    const rB = p2.clone().addScaledVector(perp2, -dashHalf);

    dashPos.push(lA.x, Y_LINE, lA.z, rA.x, Y_LINE, rA.z,

                 lB.x, Y_LINE, lB.z, rB.x, Y_LINE, rB.z);

    dashIdx.push(v, v+2, v+1, v+1, v+2, v+3);

    v += 4;

  }

  if (dashPos.length) {

    const g = new THREE.BufferGeometry();

    g.setAttribute('position', new THREE.Float32BufferAttribute(dashPos, 3));

    g.setIndex(dashIdx);

    const m = new THREE.Mesh(g, new THREE.MeshBasicMaterial({ color: 0xf0d090 }));

    scene.add(m); meshes.push(m);

  }


  // --- Roadside reflector posts (instanced) ---

  const postStep = 6;

  const postCount = Math.ceil(ringCount / postStep) * 2 + 2;

  if (postCount > 0) {

    const postMesh = new THREE.InstancedMesh(

      new THREE.CylinderGeometry(0.09, 0.09, 1.1, 6),

      new THREE.MeshLambertMaterial({ color: 0xffe4b0 }),

      postCount

    );

    postMesh.castShadow = true;

    const dummy = new THREE.Object3D();

    let pi = 0;

    for (let i = startIdx; i < endIdx; i += postStep) {

      const { p: perp } = tanPerpAt(i);

      const c = ROAD_POINTS[i];

      for (const s of [-1, 1]) {

        const px = c.x + perp.x * s * (ROAD_HALF + 0.6);

        const pz = c.z + perp.z * s * (ROAD_HALF + 0.6);

        dummy.position.set(px, 0.55, pz);

        dummy.updateMatrix();

        if (pi < postCount) postMesh.setMatrixAt(pi++, dummy.matrix);

      }

    }

    postMesh.count = pi;

    postMesh.instanceMatrix.needsUpdate = true;

    scene.add(postMesh);

    meshes.push(postMesh);

  }


  // --- Desert scenery for this chunk ---

  const sceneryGroup = new THREE.Group();

  const cactusMat = new THREE.MeshLambertMaterial({ color: 0x3d5a3a });

  const trunkGeo  = new THREE.CylinderGeometry(0.28, 0.32, 2.6, 8);

  const armGeo    = new THREE.CylinderGeometry(0.22, 0.22, 1.1, 8);

  const treeMat = new THREE.MeshLambertMaterial({ color: 0x5a3a26 });

  const trunkTreeGeo = new THREE.CylinderGeometry(0.18, 0.28, 2.4, 6);

  const branchGeo    = new THREE.CylinderGeometry(0.08, 0.12, 1.2, 5);

  const rockMat = new THREE.MeshLambertMaterial({ color: 0x6b4030 });

  const rockGeo = new THREE.DodecahedronGeometry(0.6, 0);


  function makeCactus() {

    const g = new THREE.Group();

    const trunk = new THREE.Mesh(trunkGeo, cactusMat);

    trunk.position.y = 1.3; trunk.castShadow = true;

    g.add(trunk);

    const arms = Math.floor(Math.random() * 3);

    for (let i = 0; i < arms; i++) {

      const arm = new THREE.Mesh(armGeo, cactusMat);

      const side = Math.random() < 0.5 ? -1 : 1;

      arm.position.set(side * 0.4, 1.6 + Math.random() * 0.5, 0);

      arm.rotation.z = side * (Math.PI / 3);

      arm.castShadow = true;

      g.add(arm);

    }

    g.rotation.y = Math.random() * Math.PI * 2;

    return g;

  }

  function makeTree() {

    const g = new THREE.Group();

    const trunk = new THREE.Mesh(trunkTreeGeo, treeMat);

    trunk.position.y = 1.2; trunk.castShadow = true;

    g.add(trunk);

    const branches = 2 + Math.floor(Math.random() * 3);

    for (let i = 0; i < branches; i++) {

      const br = new THREE.Mesh(branchGeo, treeMat);

      const ang = Math.random() * Math.PI * 2;

      br.position.set(Math.cos(ang) * 0.2, 1.8 + Math.random() * 0.6, Math.sin(ang) * 0.2);

      br.rotation.z = (Math.random() - 0.5) * 1.4;

      br.rotation.y = ang;

      g.add(br);

    }

    g.rotation.y = Math.random() * Math.PI * 2;

    return g;

  }

  function makeRock() {

    const r = new THREE.Mesh(rockGeo, rockMat);

    r.scale.setScalar(0.6 + Math.random() * 1.4);

    r.position.y = 0.3;

    r.rotation.set(Math.random(), Math.random(), Math.random());

    r.castShadow = true;

    return r;

  }


  // Sandstone pillar (tall, close-to-road, drives motion blur perception)

  const pillarMat = new THREE.MeshLambertMaterial({ color: 0x7a4a32 });

  function makePillar() {

    const g = new THREE.Group();

    // Stacked uneven cylinders for a weathered look

    const segments = 2 + Math.floor(Math.random() * 3);

    let y = 0;

    for (let s = 0; s < segments; s++) {

      const r1 = 0.7 + Math.random() * 0.5;

      const r2 = r1 * (0.85 + Math.random() * 0.2);

      const h = 1.4 + Math.random() * 1.5;

      const cyl = new THREE.Mesh(

        new THREE.CylinderGeometry(r2, r1, h, 8),

        pillarMat

      );

      cyl.position.y = y + h / 2;

      cyl.castShadow = true;

      g.add(cyl);

      y += h * 0.92;

    }

    g.rotation.y = Math.random() * Math.PI * 2;

    return g;

  }


  // Warning sign / mile marker — close to road, taller than ground scatter

  const signPoleMat = new THREE.MeshLambertMaterial({ color: 0x6a6a6a });

  const signBoardMat = new THREE.MeshBasicMaterial({ color: 0xf5d090 });

  function makeSign() {

    const g = new THREE.Group();

    const pole = new THREE.Mesh(

      new THREE.CylinderGeometry(0.08, 0.08, 2.4, 6),

      signPoleMat

    );

    pole.position.y = 1.2;

    pole.castShadow = true;

    g.add(pole);

    const board = new THREE.Mesh(

      new THREE.BoxGeometry(0.95, 0.6, 0.04),

      signBoardMat

    );

    board.position.y = 2.0;

    g.add(board);

    g.rotation.y = Math.random() * Math.PI * 2;

    return g;

  }


  // -- DENSE SCATTER (more objects = stronger sense of speed) --

  // Two passes: a close-in pass with taller objects, a far pass with low ground scatter

  for (let i = startIdx; i < endIdx; i++) {

    const c = ROAD_POINTS[i];

    const { p: perp } = tanPerpAt(i);


    // CLOSE BAND: taller objects, right at the road shoulder. Higher hit rate.

    // Only every 2nd index to avoid total clutter.

    if (i % 2 === 0) {

      for (const side of [-1, 1]) {

        if (Math.random() > 0.40) continue;

        const off = ROAD_HALF + 3 + Math.random() * 4;

        const x = c.x + perp.x * side * off + (Math.random() - 0.5) * 2;

        const z = c.z + perp.z * side * off + (Math.random() - 0.5) * 2;

        const { d2 } = nearestRoadPoint(x, z, ROAD_HALF + 5);

        if (d2 < (ROAD_HALF + 2) * (ROAD_HALF + 2)) continue;

        const roll = Math.random();

        let obj;

        if (roll < 0.45)      obj = makeCactus();

        else if (roll < 0.70) obj = makePillar();

        else if (roll < 0.88) obj = makeTree();

        else                  obj = makeSign();

        obj.position.x = x;

        obj.position.z = z;

        sceneryGroup.add(obj);

      }

    }


    // FAR BAND: low ground scatter further out. Less dense, just visual texture.

    if (i % 4 === 0) {

      for (const side of [-1, 1]) {

        if (Math.random() > 0.55) continue;

        const off = ROAD_HALF + 10 + Math.random() * 20;

        const x = c.x + perp.x * side * off + (Math.random() - 0.5) * 6;

        const z = c.z + perp.z * side * off + (Math.random() - 0.5) * 6;

        const { d2 } = nearestRoadPoint(x, z, ROAD_HALF + 8);

        if (d2 < (ROAD_HALF + 2) * (ROAD_HALF + 2)) continue;

        const roll = Math.random();

        let obj;

        if (roll < 0.45)      obj = makeRock();

        else if (roll < 0.75) obj = makeCactus();

        else                  obj = makePillar();

        obj.position.x = x;

        obj.position.z = z;

        sceneryGroup.add(obj);

      }

    }

  }

  scene.add(sceneryGroup);


  return { meshes, sceneryGroup };

}


function disposeChunk(chunk) {

  for (const m of chunk.meshes) {

    scene.remove(m);

    m.geometry?.dispose?.();

  }

  // Free the road-grid entries for all points EXCEPT the last one, which is

  // shared with the next chunk (1-vertex overlap eliminates seam gaps).

  for (let i = chunk.startIdx; i < chunk.endIdx - 1; i++) {

    const p = ROAD_POINTS[i];

    if (p) roadGridRemove(p, i);

    ROAD_POINTS[i] = null;

  }

  scene.remove(chunk.sceneryGroup);

  chunk.sceneryGroup.traverse(o => { o.geometry?.dispose?.(); });

}


function generateNextChunk() {

  // Overlap by 1 with the previous chunk to eliminate seam gaps.

  // The previous chunk's last vertex is at index nextChunkIdx-1; we re-use it

  // as the FIRST vertex of this new chunk, which means consecutive quads

  // share an edge and there's no visible gap.

  const startIdx = Math.max(0, nextChunkIdx - 1);

  const targetSteps = Math.ceil(CHUNK_TARGET_LENGTH / POINT_STEP);

  for (let i = 0; i < targetSteps; i++) {

    const p = advancePen();

    ROAD_POINTS[nextChunkIdx] = p;

    roadGridAdd(p, nextChunkIdx);

    nextChunkIdx++;

  }

  const endIdx = nextChunkIdx;

  const { meshes, sceneryGroup } = buildChunkMeshes(startIdx, endIdx);

  chunks.push({ startIdx, endIdx, meshes, sceneryGroup });

}


// Initialise: spawn entry straight + a few chunks ahead

function initRoad() {

  // Start pen 200m behind spawn so the car has road behind it.

  // Pre-seed noise sample [0] so the first stretch is essentially straight

  // (otherwise the road can start mid-curve).

  noiseA[0] = 0; noiseB[0] = 0;

  noiseA[1] = 0.1; noiseB[1] = 0.1;

  roadPen.x = 0; roadPen.z = -200; roadPen.heading = 0; roadPen.distance = 0;

  // Walk 200m of near-straight entry + ~80m more

  const entrySteps = Math.ceil(280 / POINT_STEP);

  for (let i = 0; i < entrySteps; i++) {

    const p = advancePen();

    ROAD_POINTS[nextChunkIdx] = p;

    roadGridAdd(p, nextChunkIdx);

    nextChunkIdx++;

  }

  const { meshes, sceneryGroup } = buildChunkMeshes(0, nextChunkIdx);

  chunks.push({ startIdx: 0, endIdx: nextChunkIdx, meshes, sceneryGroup });


  for (let i = 0; i < CHUNKS_AHEAD; i++) generateNextChunk();

}

initRoad();


// Tracks the car's monotonic progress along the road. Only ever moves

// FORWARD (toward higher indices). This prevents the streamer from

// getting confused by geometric proximity to old segments.

let carRoadIdx = 0;


// Respawn state — kicks in if the player has been far from the road for

// long enough that they're clearly lost (not just a wide drift).

const RESPAWN_DISTANCE = 70;        // meters from road centerline

const RESPAWN_HOLD_TIME = 1.5;      // seconds of being lost before respawn

let lostTimer = 0;

let respawning = false;             // brief flag during teleport for fade


function attemptRespawn() {

  // Find nearest road point with a broad search

  const near = nearestRoadPoint(carState.position.x, carState.position.z, 600);

  if (near.idx < 0) return;

  const roadP = ROAD_POINTS[near.idx];

  if (!roadP) return;

  const { t: roadTan } = tanPerpAt(near.idx);

  if (!roadTan) return;


  // Teleport to the road point, facing along the tangent

  carState.position.set(roadP.x, 0, roadP.z);

  carState.heading = Math.atan2(roadTan.x, roadTan.z);

  // Cut speed to half — feels less jarring than dropping to zero, and

  // keeps the engine note from suddenly idling

  const halfSpeed = carState.velocity.length() * 0.5;

  setBodyAxes(carState.heading);

  carState.velocity.copy(_forward).multiplyScalar(Math.min(halfSpeed, 18));

  carState.driftTime = 0;

  carState.steerInput = 0;

  // Reset the road index to land on the new spot (only allow forward)

  if (near.idx > carRoadIdx) carRoadIdx = near.idx;

  lostTimer = 0;

  // Trigger a brief respawn flash

  respawning = true;

  const flashEl = document.getElementById('respawnFlash');

  const labelEl = document.getElementById('respawnLabel');

  if (flashEl) flashEl.classList.add('active');

  if (labelEl) labelEl.classList.add('active');

  setTimeout(() => {

    respawning = false;

    if (flashEl) flashEl.classList.remove('active');

    if (labelEl) labelEl.classList.remove('active');

  }, 450);

}


// Per-frame: ensure chunks ahead of car, dispose chunks far behind

function streamRoad() {

  // Find which road index the car is currently nearest to.

  // BUT: only allow this to advance forward, never jump backward.

  // We search in a small window AHEAD of the current carRoadIdx.

  const SEARCH_AHEAD = 60;  // how many indices ahead to scan

  const SEARCH_BEHIND = 4;  // small tolerance for slight reverse (off-road wiggle)

  const lo = Math.max(0, carRoadIdx - SEARCH_BEHIND);

  const hi = Math.min(nextChunkIdx, carRoadIdx + SEARCH_AHEAD);

  let bestIdx = -1, bestD2 = Infinity;

  for (let i = lo; i < hi; i++) {

    const p = ROAD_POINTS[i];

    if (!p) continue;

    const dx = p.x - carState.position.x;

    const dz = p.z - carState.position.z;

    const d2 = dx*dx + dz*dz;

    if (d2 < bestD2) { bestD2 = d2; bestIdx = i; }

  }


  // If we didn't find anything in the window, fall back to a broader search.

  // This handles initial spawn and edge cases.

  if (bestIdx < 0) {

    const near = nearestRoadPoint(carState.position.x, carState.position.z, 400);

    if (near.idx < 0) return;

    bestIdx = near.idx;

  }


  // Only advance forward. If we somehow ended up at a lower idx, ignore it.

  if (bestIdx > carRoadIdx) carRoadIdx = bestIdx;


  const carIdx = carRoadIdx;

  const targetAheadPoints = Math.ceil((CHUNKS_AHEAD * CHUNK_TARGET_LENGTH) / POINT_STEP);


  // --- GENERATE AHEAD ---

  let generatedThisFrame = 0;

  while (

    (nextChunkIdx - 1 - carIdx) < targetAheadPoints &&

    generatedThisFrame < 2

  ) {

    generateNextChunk();

    generatedThisFrame++;

  }


  // --- DISPOSE BEHIND ---

  // Only dispose chunks well behind the car. Always keep at least 2 chunks alive.

  const disposeThresholdPoints = CHUNKS_BEHIND * Math.ceil(CHUNK_TARGET_LENGTH / POINT_STEP);

  while (chunks.length > 2) {

    const c = chunks[0];

    if (c.endIdx + disposeThresholdPoints < carIdx) {

      disposeChunk(c);

      chunks.shift();

    } else break;

  }

}


// ====================================================================

//  CAR

// ====================================================================

function buildCar() {

  const g = new THREE.Group();


  const body = new THREE.Mesh(

    new THREE.BoxGeometry(1.9, 0.55, 4.2),

    new THREE.MeshPhongMaterial({ color: 0xd83040, shininess: 90, specular: 0x553030 })

  );

  body.position.y = 0.55; body.castShadow = true;

  body.userData.role = 'body';

  g.add(body);


  const cabin = new THREE.Mesh(

    new THREE.BoxGeometry(1.6, 0.5, 1.9),

    new THREE.MeshPhongMaterial({ color: 0x1a1a22, shininess: 110, specular: 0x444466 })

  );

  cabin.position.set(0, 1.1, -0.2); cabin.castShadow = true;

  g.add(cabin);


  const hood = new THREE.Mesh(

    new THREE.BoxGeometry(1.7, 0.06, 1.4),

    new THREE.MeshPhongMaterial({ color: 0xb02838 })

  );

  hood.position.set(0, 0.87, 1.0);

  hood.userData.role = 'accent';

  g.add(hood);


  const hlMat = new THREE.MeshBasicMaterial({ color: 0xfff4d0 });

  [-0.65, 0.65].forEach(x => {

    const hl = new THREE.Mesh(new THREE.BoxGeometry(0.45, 0.18, 0.12), hlMat);

    hl.position.set(x, 0.7, 2.1);

    g.add(hl);

  });


  const tlMat = new THREE.MeshBasicMaterial({ color: 0xff3030 });

  [-0.7, 0.7].forEach(x => {

    const tl = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.12, 0.08), tlMat);

    tl.position.set(x, 0.7, -2.1);

    g.add(tl);

  });


  const wheelGeo = new THREE.CylinderGeometry(0.42, 0.42, 0.32, 18);

  const wheelMat = new THREE.MeshPhongMaterial({ color: 0x111111, shininess: 30 });

  const wheels = [];

  [[ 0.95, 1.35, true], [-0.95, 1.35, true], [ 0.95, -1.35, false], [-0.95, -1.35, false]]

    .forEach(([x, z, isFront]) => {

      const pivot = new THREE.Group();

      pivot.position.set(x, 0.42, z);

      const w = new THREE.Mesh(wheelGeo, wheelMat);

      w.rotation.z = Math.PI / 2; w.castShadow = true;

      pivot.add(w);

      pivot.userData = { wheel: w, isFront };

      g.add(pivot);

      wheels.push(pivot);

    });


  g.userData.wheels = wheels;

  return g;

}

const car = buildCar();

scene.add(car);


// Update the car's body color from a car config

function recolorCar(carConfig) {

  if (!car) return;

  car.traverse(child => {

    if (!child.isMesh) return;

    if (child.userData.role === 'body') {

      child.material.color.setHex(carConfig.color);

    } else if (child.userData.role === 'accent') {

      // Mix: 70% body color + 30% accent, for the hood stripe

      const bodyC = new THREE.Color(carConfig.color);

      const accentC = new THREE.Color(carConfig.accentColor || carConfig.color);

      const mixed = bodyC.clone().lerp(accentC, 0.6);

      child.material.color.copy(mixed);

    }

  });

}


// ====================================================================

//  PHYSICS

// ====================================================================

const carState = {

  position: new THREE.Vector3(0, 0, 0),

  velocity: new THREE.Vector3(0, 0, 0),

  heading: 0,

  steerAngle: 0,

  driftTime: 0,

  steerInput: 0,

  gear: 1,

  rpm: 900,

  shiftTimer: 0,        // counts down during a shift; throttle cut while > 0

  shiftDirection: 0,    // +1 up, -1 down, 0 idle — used for visual cue

};


// ====================================================================

//  CAR CATALOG — five distinct profiles with their own physics + audio

// ====================================================================

// Each car has its own driving feel: top speed, acceleration curve,

// grip, gearbox, engine character. Selected via the title menu before play.

const CARS = [

  {

    id: 'gt3rs',

    name: 'Aragon GT3 RS',

    inspiration: '911 GT3 RS',

    tagline: 'precision · flat-six',

    color: 0xe2e2e6,

    accentColor: 0xd83040,

    accel: 9.5,

    maxForward: 86,

    gripNormal: 6.4,

    gripHandbrake: 0.95,

    gearRatios: [3.9, 2.8, 2.1, 1.65, 1.30, 1.05, 0.85],

    finalDrive: 3.6,

    rpmIdle: 1000,

    rpmRedline: 8800,

    rpmShiftUp: 8100,

    rpmShiftDown: 4600,

    cylinders: 6,

  },

  {

    id: 'm4csl',

    name: 'Anvil M4 CSL',

    inspiration: 'BMW M4 CSL',

    tagline: 'balanced · inline-six',

    color: 0x1a3242,

    accentColor: 0xc8a464,

    accel: 8.6,

    maxForward: 82,

    gripNormal: 6.0,

    gripHandbrake: 0.9,

    gearRatios: [4.0, 2.9, 2.2, 1.70, 1.35, 1.10, 0.90, 0.74],

    finalDrive: 3.5,

    rpmIdle: 900,

    rpmRedline: 7800,

    rpmShiftUp: 7200,

    rpmShiftDown: 4200,

    cylinders: 6,

  },

  {

    id: 'laferrari',

    name: 'Solera Stradale',

    inspiration: 'LaFerrari',

    tagline: 'screamer · v12',

    color: 0xb30a17,

    accentColor: 0x111111,

    accel: 10.4,

    maxForward: 92,

    gripNormal: 5.8,

    gripHandbrake: 0.85,

    gearRatios: [4.1, 2.95, 2.25, 1.75, 1.40, 1.12, 0.92],

    finalDrive: 3.7,

    rpmIdle: 1100,

    rpmRedline: 9000,

    rpmShiftUp: 8500,

    rpmShiftDown: 5000,

    cylinders: 12,

  },

  {

    id: 'huayra',

    name: 'Zephyr Aetós',

    inspiration: 'Pagani Huayra',

    tagline: 'floaty · twin-turbo v12',

    color: 0xb29560,

    accentColor: 0x2a221a,

    accel: 9.8,

    maxForward: 88,

    gripNormal: 5.4,         // floatier — more drift-prone

    gripHandbrake: 0.75,

    gearRatios: [3.8, 2.7, 2.05, 1.60, 1.28, 1.05, 0.86],

    finalDrive: 3.8,

    rpmIdle: 900,

    rpmRedline: 6800,        // turbo limit

    rpmShiftUp: 6300,

    rpmShiftDown: 3600,

    cylinders: 12,

  },

  {

    id: 'jesko',

    name: 'Helix Apex',

    inspiration: 'Koenigsegg Jesko',

    tagline: 'explosive · twin-turbo v8',

    color: 0x141420,

    accentColor: 0x65c8ff,

    accel: 11.6,             // brutal

    maxForward: 100,         // ~360 km/h

    gripNormal: 5.6,         // twitchy

    gripHandbrake: 0.9,

    gearRatios: [4.2, 3.1, 2.4, 1.9, 1.50, 1.18, 0.95, 0.78],

    finalDrive: 3.7,

    rpmIdle: 1000,

    rpmRedline: 8200,

    rpmShiftUp: 7600,

    rpmShiftDown: 4400,

    cylinders: 8,

  },

];


let CURRENT_CAR = CARS[0]; // default before menu selection


// Per-car physics state — written by applyCar(), read by physics code.

// These were const before; now they're let so the menu can swap cars.

let ACCEL              = 9;

let MAX_FORWARD        = 86;

let GRIP_NORMAL        = 6.0;

let GRIP_HANDBRAKE     = 0.9;

let GEAR_RATIOS        = [4.2, 3.0, 2.3, 1.85, 1.5, 1.22, 1.0, 0.82];

let FINAL_DRIVE        = 3.6;

let RPM_IDLE           = 900;

let RPM_REDLINE        = 8500;

let RPM_SHIFT_UP       = 7800;

let RPM_SHIFT_DOWN     = 4500;

let ENGINE_CYLINDERS   = 8;


function applyCar(car) {

  CURRENT_CAR = car;

  ACCEL = car.accel;

  MAX_FORWARD = car.maxForward;

  GRIP_NORMAL = car.gripNormal;

  GRIP_HANDBRAKE = car.gripHandbrake;

  GEAR_RATIOS = car.gearRatios.slice();

  FINAL_DRIVE = car.finalDrive;

  RPM_IDLE = car.rpmIdle;

  RPM_REDLINE = car.rpmRedline;

  RPM_SHIFT_UP = car.rpmShiftUp;

  RPM_SHIFT_DOWN = car.rpmShiftDown;

  ENGINE_CYLINDERS = car.cylinders;

  // Reposition the upshift tick on the rev bar to match the new car

  const tick = document.getElementById('upshiftTick');

  if (tick) {

    const frac = (RPM_SHIFT_UP - RPM_IDLE) / (RPM_REDLINE - RPM_IDLE);

    tick.style.left = (frac * 100).toFixed(1) + '%';

  }

  // Update the car's body color

  recolorCar(car);

  // Reset the car so RPM idles correctly at the new redline range

  if (typeof resetCar === 'function') resetCar();

}


// Apply default before everything else runs

// (CURRENT_CAR is already CARS[0], values match — this is for ordering)


// Universal physics constants (same for all cars)

const BRAKE_FORCE        = 32;

const REVERSE_ACCEL      = 6;

const MAX_REVERSE        = -10;

const COAST_DRAG_FORWARD = 1.8;

const STEER_RATE         = 1.9;

const STEER_FACTOR_MIN   = 0.45;

const STEER_INPUT_RAMP   = 5.5;

const STEER_INPUT_RELEASE= 7.0;


// Road-snap assist (active near road edges) — pulls heading toward road

// direction and pushes velocity back toward centerline. Stronger on corner

// exits when the player eases off the wheel.

const SNAP_HEADING_K = 2.8;

const SNAP_LATERAL_K = 1.8;


// Smoothed target tangent for the assist (persists frame-to-frame).

// Reset to null when the assist disengages.

let assistTangentSmoothed = null;


// 8-speed gearbox values are per-car (see applyCar above).

// WHEEL_RADIUS and SHIFT_DURATION are universal.

const WHEEL_RADIUS   = 0.42;


// Gear-shift feel: power cut during shift, plus visual/audio cues

const SHIFT_DURATION = 0.18;   // seconds power is cut during a shift


const _forward = new THREE.Vector3();

const _right   = new THREE.Vector3();

function setBodyAxes(h) {

  _forward.set(Math.sin(h), 0, Math.cos(h));

  _right.set(Math.cos(h), 0, -Math.sin(h));

}


const keys = {};

let audioHintEl = null;

addEventListener('keydown', e => {

  // Don't process driving inputs while the menu is up

  if (typeof gameStarted !== 'undefined' && !gameStarted) return;

  if (['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault();

  if (e.repeat) return;

  initAudio();

  if (audioHintEl) audioHintEl.classList.remove('show');

  keys[e.code] = true;

  if (e.code === 'KeyR') resetCar();

  if (e.code === 'KeyC') cycleView();

});

addEventListener('keyup', e => { keys[e.code] = false; });


function resetCar() {

  carState.position.set(0, 0, 0);

  carState.velocity.set(0, 0, 0);

  carState.heading = 0;

  carState.steerAngle = 0;

  carState.driftTime = 0;

  carState.steerInput = 0;

  carState.gear = 1;

  carState.rpm = RPM_IDLE;

  carState.shiftTimer = 0;

  carState.shiftDirection = 0;

  carRoadIdx = 0;

}


// Heading-difference helper (returns -PI..PI)

function angleDelta(a, b) {

  let d = a - b;

  while (d >  Math.PI) d -= 2 * Math.PI;

  while (d < -Math.PI) d += 2 * Math.PI;

  return d;

}


function updatePhysics(dt) {

  const throttle = (keys.KeyW || keys.ArrowUp) ? 1 : 0;

  const brake    = (keys.KeyS || keys.ArrowDown) ? 1 : 0;

  const steerRaw = ((keys.KeyA || keys.ArrowLeft) ? 1 : 0) - ((keys.KeyD || keys.ArrowRight) ? 1 : 0);

  const handbrake= keys.Space ? 1 : 0;


  // Smoothed steering input (the big stress fix from last round)

  if (Math.sign(steerRaw) === Math.sign(carState.steerInput) || carState.steerInput === 0) {

    const k = steerRaw === 0 ? STEER_INPUT_RELEASE : STEER_INPUT_RAMP;

    carState.steerInput += (steerRaw - carState.steerInput) * (1 - Math.exp(-k * dt));

  } else {

    carState.steerInput += (steerRaw - carState.steerInput) * (1 - Math.exp(-9 * dt));

  }

  const steerIn = carState.steerInput;


  setBodyAxes(carState.heading);


  let fwdVel = carState.velocity.dot(_forward);

  let latVel = carState.velocity.dot(_right);


  // Shift timer counts down — during a shift, throttle is cut (the "punch" feel)

  if (carState.shiftTimer > 0) {

    carState.shiftTimer = Math.max(0, carState.shiftTimer - dt);

    if (carState.shiftTimer === 0) carState.shiftDirection = 0;

  }

  const shifting = carState.shiftTimer > 0;


  // Gear-aware acceleration — lower gear = more wheel torque = harder pull.

  // Multiplier is the current gear ratio normalized so gear 3 (~mid) ≈ 1.0x

  const gearMul = GEAR_RATIOS[carState.gear - 1] / GEAR_RATIOS[2];

  // Near redline, torque drops off (you're past the powerband)

  const rpmFracForTorque = Math.max(0, Math.min(1, (carState.rpm - RPM_IDLE) / (RPM_REDLINE - RPM_IDLE)));

  const torqueCurve = rpmFracForTorque < 0.85

    ? 0.55 + rpmFracForTorque * 0.65          // climbs through powerband

    : 1.20 - (rpmFracForTorque - 0.85) * 1.8; // falls off past 0.85 redline

  const effectiveAccel = ACCEL * gearMul * Math.max(0.35, torqueCurve);


  if (throttle && !shifting) {

    fwdVel += effectiveAccel * dt;

  } else if (brake) {

    if (fwdVel > 0.5) fwdVel -= BRAKE_FORCE * dt;

    else              fwdVel -= REVERSE_ACCEL * dt;

  } else {

    const drag = COAST_DRAG_FORWARD * dt;

    if (fwdVel > 0)      fwdVel = Math.max(0, fwdVel - drag);

    else if (fwdVel < 0) fwdVel = Math.min(0, fwdVel + drag);

  }

  fwdVel = Math.max(MAX_REVERSE, Math.min(MAX_FORWARD, fwdVel));


  // ------------------------------------------------------------------

  // SPEED-DEPENDENT GRIP — real cars can't corner at 200 km/h on tight roads.

  // Cornering force scales as v²/r, so the effective grip envelope falls

  // off with speed. Trying to corner too fast → natural understeer/slide.

  // ------------------------------------------------------------------

  // At low speed (≤22 m/s, ~80 km/h): full grip.

  // From there it falls off so by 50 m/s (~180 km/h) grip is ~55%.

  const speedNow = Math.hypot(fwdVel, latVel);

  const gripFalloff = THREE.MathUtils.clamp(1.0 - Math.max(0, speedNow - 22) * 0.018, 0.45, 1.0);

  const grip = (handbrake ? GRIP_HANDBRAKE : GRIP_NORMAL) * gripFalloff;

  latVel *= Math.exp(-grip * dt);


  // Drift detection (preliminary — used for snap assist)

  const preDriftAngle = Math.atan2(Math.abs(latVel), Math.max(1, Math.abs(fwdVel)));

  const preSpeed = Math.hypot(fwdVel, latVel);

  const isDrifting = preDriftAngle > 0.35 && preSpeed > 8;


  // ------------------------------------------------------------------

  // ROAD-SNAP ASSIST — Asphalt-style, safe edition.

  // Two operations, both always toward stability — never amplifying:

  //   1. Gently rotate heading toward the road tangent.

  //   2. Damp the component of velocity perpendicular to the road.

  // Damping can only REDUCE lateral motion, never amplify or flip it.

  // This avoids the slingshot/sideways-fling bugs from earlier attempts.

  //

  // Target tangent is smoothed frame-to-frame so sudden nearest-point

  // jumps (when the road curves near itself) can't yank the car.

  // ------------------------------------------------------------------

  const handbrakeCommit = handbrake && preSpeed > 8;

  const wantAssist = isDrifting || handbrakeCommit;


  if (wantAssist && ROAD_POINTS.length) {

    const near = nearestRoadPointAligned(

      carState.position.x, carState.position.z,

      _forward.x, _forward.z,

      70

    );

    if (near.idx >= 0) {

      const roadP = ROAD_POINTS[near.idx];

      const { t: roadTan, p: roadPerp } = tanPerpAt(near.idx);

      if (roadTan && roadP) {

        const offX = carState.position.x - roadP.x;

        const offZ = carState.position.z - roadP.z;

        const lateralOffset = offX * roadPerp.x + offZ * roadPerp.z;

        const absOff = Math.abs(lateralOffset);


        // Off-road kill switch — assist fades out past the road edge

        const onRoadFactor = THREE.MathUtils.clamp(

          1 - (absOff - ROAD_HALF) / 6,

          0, 1

        );


        if (onRoadFactor > 0) {

          // Smooth the target tangent across frames so sudden jumps in the

          // "nearest road point" don't cause velocity slingshots.

          const tanHeading = Math.atan2(roadTan.x, roadTan.z);

          if (assistTangentSmoothed === null) {

            assistTangentSmoothed = tanHeading;

          } else {

            // Smooth via shortest angle

            const dT = angleDelta(tanHeading, assistTangentSmoothed);

            // Reject huge jumps (>90°): keep previous, don't follow

            if (Math.abs(dT) < Math.PI / 2) {

              assistTangentSmoothed += dT * (1 - Math.exp(-6 * dt));

            } else {

              // Big jump means the assist grabbed a different segment — reset

              assistTangentSmoothed = tanHeading;

            }

          }

          const safeTanHeading = assistTangentSmoothed;


          // -- HEADING ROTATION (gentle) --

          const dH = angleDelta(safeTanHeading, carState.heading);

          // Only rotate heading if we're within ±60° of the target — don't try

          // to spin the car 180° if something is weird

          if (Math.abs(dH) < Math.PI * 0.6) {

            const headingK = 2.4 * onRoadFactor;

            carState.heading += dH * (1 - Math.exp(-headingK * dt));

          }


          // -- LATERAL VELOCITY DAMPING (safe) --

          // Decompose world velocity into (along road tangent, perp to road).

          // Damp the perpendicular component. This can never amplify or flip

          // velocity — it only ever reduces sideways motion.

          const vWorldX = _forward.x * fwdVel + _right.x * latVel;

          const vWorldZ = _forward.z * fwdVel + _right.z * latVel;

          const tx = Math.sin(safeTanHeading), tz = Math.cos(safeTanHeading);

          const px = -tz, pz = tx; // perpendicular to tangent

          const vAlongTan  = vWorldX * tx + vWorldZ * tz;

          const vPerpToRoad= vWorldX * px + vWorldZ * pz;


          // Damp the perpendicular component, but only if it's pointing AWAY

          // from the road centerline (i.e. dragging the car off). Don't damp

          // if it's pointing toward the centerline (recovery is OK).

          const perpAwayFromCenter = (Math.sign(lateralOffset) === Math.sign(vPerpToRoad)) ? 1 : 0;

          const dampedPerp = perpAwayFromCenter

            ? vPerpToRoad * Math.exp(-2.5 * onRoadFactor * dt)

            : vPerpToRoad;


          // Reconstruct world velocity from (along, perp) in road frame

          const newVx = vAlongTan * tx + dampedPerp * px;

          const newVz = vAlongTan * tz + dampedPerp * pz;


          // Convert back to car frame using current (post-heading-update) body axes

          setBodyAxes(carState.heading);

          fwdVel = newVx * _forward.x + newVz * _forward.z;

          latVel = newVx * _right.x   + newVz * _right.z;

        }

      }

    } else {

      assistTangentSmoothed = null; // reset when no road found

    }

  } else {

    assistTangentSmoothed = null; // reset when assist disengages

  }

  // ------------------------------------------------------------------


  setBodyAxes(carState.heading); // refresh after snap

  carState.velocity.copy(_forward).multiplyScalar(fwdVel).addScaledVector(_right, latVel);


  const speedAbs = Math.abs(fwdVel) + Math.abs(latVel) * 0.4;

  const speedFactor = Math.max(STEER_FACTOR_MIN, Math.min(1.0, speedAbs / 12));

  const moveSign = fwdVel >= 0 ? 1 : -1;

  carState.heading += steerIn * STEER_RATE * speedFactor * dt * moveSign;


  const targetSteer = steerIn * 0.5;

  carState.steerAngle += (targetSteer - carState.steerAngle) * Math.min(1, dt * 9);


  carState.position.addScaledVector(carState.velocity, dt);


  // Gear + RPM

  const wheelRpm = (Math.abs(fwdVel) / (2 * Math.PI * WHEEL_RADIUS)) * 60;

  let targetRpm = wheelRpm * FINAL_DRIVE * GEAR_RATIOS[carState.gear - 1];


  // Upshift on redline

  if (targetRpm > RPM_SHIFT_UP && carState.gear < GEAR_RATIOS.length && !shifting) {

    carState.gear++;

    carState.shiftTimer = SHIFT_DURATION;

    carState.shiftDirection = 1;

    targetRpm = wheelRpm * FINAL_DRIVE * GEAR_RATIOS[carState.gear - 1];

  }

  // Downshift — happens early now (4500 rpm). When braking hard, we also

  // aggressively pre-downshift to keep RPM in the powerband for the exit.

  else if (carState.gear > 1 && !shifting) {

    const brakeForceDown = brake && carState.gear > 2;

    const downshiftThreshold = brakeForceDown ? 5800 : RPM_SHIFT_DOWN;

    if (targetRpm < downshiftThreshold) {

      carState.gear--;

      carState.shiftTimer = SHIFT_DURATION * 0.7;

      carState.shiftDirection = -1;

      targetRpm = wheelRpm * FINAL_DRIVE * GEAR_RATIOS[carState.gear - 1];

    }

  }


  if (throttle && targetRpm < 2200) targetRpm = 2200 + Math.random() * 200;

  targetRpm = Math.max(RPM_IDLE, Math.min(RPM_REDLINE, targetRpm));

  const rpmK = shifting ? 15 : 7;

  carState.rpm += (targetRpm - carState.rpm) * (1 - Math.exp(-rpmK * dt));


  const finalDriftAngle = Math.atan2(Math.abs(latVel), Math.max(1, Math.abs(fwdVel)));

  const speed = carState.velocity.length();

  const finalDrift = finalDriftAngle > 0.35 && speed > 8;

  if (finalDrift) carState.driftTime += dt;

  else            carState.driftTime = Math.max(0, carState.driftTime - dt * 1.8);


  return { fwdVel, latVel, speed, isDrifting: finalDrift, throttle, brake, handbrake,

           driftAngle: finalDriftAngle };

}


// ====================================================================

//  AUDIO — engine via cylinder firing pulses + exhaust resonator.

//  Skid via high-band noise with rapid AM modulation (the "skreee" stutter).

// ====================================================================

const audio = {

  ctx: null, master: null, started: false,

  engine: null, skid: null,

};


// ENGINE_CYLINDERS is per-car, set by applyCar() above


function initAudio() {

  if (audio.started) return;

  audio.started = true;

  const AC = window.AudioContext || window.webkitAudioContext;

  if (!AC) return;

  audio.ctx = new AC();

  audio.master = audio.ctx.createGain();

  audio.master.gain.value = 0.55;

  audio.master.connect(audio.ctx.destination);


  const sr = audio.ctx.sampleRate;


  // ----- ENGINE PULSE TRAIN -----

  // Real engines fire cylinders at (RPM * cylinders) / 120 Hz.

  // We synthesize a stream of short impulses, then filter through a

  // tuned resonator (modeling the exhaust pipe). This is what gives

  // engines their characteristic rumble — pure tones sound alien.

  const PULSE_BASE_HZ = 100;

  const PULSE_PERIOD_SAMPLES = Math.round(sr / PULSE_BASE_HZ);

  const PULSE_BUFFER_LEN = PULSE_PERIOD_SAMPLES * 8;

  const pulseBuf = audio.ctx.createBuffer(1, PULSE_BUFFER_LEN, sr);

  {

    const d = pulseBuf.getChannelData(0);

    for (let p = 0; p < 8; p++) {

      const offset = p * PULSE_PERIOD_SAMPLES;

      // Slight per-firing variation so it doesn't sound mechanical

      const amp = 0.85 + Math.random() * 0.15;

      for (let i = 0; i < PULSE_PERIOD_SAMPLES; i++) {

        const t = i / PULSE_PERIOD_SAMPLES;

        const env = Math.exp(-t * 25) * amp;

        d[offset + i] = env * (1 + 0.4 * Math.sin(t * 80)) - 0.3 * env;

      }

    }

  }

  const pulse = audio.ctx.createBufferSource();

  pulse.buffer = pulseBuf; pulse.loop = true; pulse.start();


  // Resonators — model the exhaust pipe + chamber resonances

  const reso1 = audio.ctx.createBiquadFilter();

  reso1.type = 'bandpass'; reso1.frequency.value = 220; reso1.Q.value = 8;

  const reso2 = audio.ctx.createBiquadFilter();

  reso2.type = 'peaking'; reso2.frequency.value = 600; reso2.Q.value = 3; reso2.gain.value = 6;

  const lp = audio.ctx.createBiquadFilter();

  lp.type = 'lowpass'; lp.frequency.value = 2200; lp.Q.value = 0.7;


  const engineGain = audio.ctx.createGain();

  engineGain.gain.value = 0.0;


  pulse.connect(reso1);

  reso1.connect(reso2);

  reso2.connect(lp);

  lp.connect(engineGain);

  engineGain.connect(audio.master);


  // ----- INTAKE / TURBULENCE -----

  // Adds the "roar" under heavy throttle

  const intakeBuf = audio.ctx.createBuffer(1, sr * 2, sr);

  {

    const d = intakeBuf.getChannelData(0);

    let last = 0;

    for (let i = 0; i < d.length; i++) {

      const w = Math.random() * 2 - 1;

      last = 0.85 * last + 0.15 * w; // brownish

      d[i] = last * 2.2;

    }

  }

  const intake = audio.ctx.createBufferSource();

  intake.buffer = intakeBuf; intake.loop = true; intake.start();

  const intakeFilt = audio.ctx.createBiquadFilter();

  intakeFilt.type = 'bandpass'; intakeFilt.frequency.value = 350; intakeFilt.Q.value = 1.4;

  const intakeGain = audio.ctx.createGain();

  intakeGain.gain.value = 0;

  intake.connect(intakeFilt);

  intakeFilt.connect(intakeGain);

  intakeGain.connect(audio.master);


  audio.engine = {

    pulse, reso1, reso2, lp, gain: engineGain,

    intake, intakeFilt, intakeGain,

    PULSE_BASE_HZ,

  };


  // ----- SKID / TIRE SCREECH -----

  // High bandpass + an LFO that modulates amplitude rapidly (~22 Hz).

  // The AM gives the "skreee-skreee" stutter that distinguishes screech

  // from smooth hiss (which sounds like sand or wind).

  const skidNoiseBuf = audio.ctx.createBuffer(1, sr * 2, sr);

  {

    const d = skidNoiseBuf.getChannelData(0);

    for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;

  }

  const skidNoise = audio.ctx.createBufferSource();

  skidNoise.buffer = skidNoiseBuf; skidNoise.loop = true; skidNoise.start();


  const skidHP = audio.ctx.createBiquadFilter();

  skidHP.type = 'highpass'; skidHP.frequency.value = 1800; skidHP.Q.value = 0.7;

  const skidBP = audio.ctx.createBiquadFilter();

  skidBP.type = 'bandpass'; skidBP.frequency.value = 3000; skidBP.Q.value = 4;

  const skidPK = audio.ctx.createBiquadFilter();

  skidPK.type = 'peaking'; skidPK.frequency.value = 4500; skidPK.Q.value = 3.5; skidPK.gain.value = 14;


  const skidLFO = audio.ctx.createOscillator();

  skidLFO.type = 'sine'; skidLFO.frequency.value = 22;

  const skidLFOGain = audio.ctx.createGain();

  skidLFOGain.gain.value = 0;


  const skidGain = audio.ctx.createGain();

  skidGain.gain.value = 0.0;


  skidLFO.connect(skidLFOGain);

  skidLFOGain.connect(skidGain.gain); // LFO adds to base gain → AM modulation

  skidLFO.start();


  skidNoise.connect(skidHP);

  skidHP.connect(skidBP);

  skidBP.connect(skidPK);

  skidPK.connect(skidGain);

  skidGain.connect(audio.master);


  audio.skid = { source: skidNoise, gain: skidGain, lfo: skidLFO, lfoGain: skidLFOGain };

}


// Short percussive "thunk" for each gear shift.

function playShiftThunk(direction) {

  if (!audio.started || !audio.ctx) return;

  const ctx = audio.ctx;

  const now = ctx.currentTime;


  const osc = ctx.createOscillator();

  osc.type = 'sine';

  const startFreq = direction > 0 ? 160 : 110;

  osc.frequency.setValueAtTime(startFreq, now);

  osc.frequency.exponentialRampToValueAtTime(startFreq * 0.45, now + 0.18);


  const gain = ctx.createGain();

  gain.gain.setValueAtTime(0.0, now);

  gain.gain.linearRampToValueAtTime(0.35, now + 0.008);

  gain.gain.exponentialRampToValueAtTime(0.001, now + 0.22);


  osc.connect(gain);

  gain.connect(audio.master);

  osc.start(now);

  osc.stop(now + 0.25);


  const sr = ctx.sampleRate;

  const buf = ctx.createBuffer(1, Math.floor(sr * 0.06), sr);

  const d = buf.getChannelData(0);

  for (let i = 0; i < d.length; i++) {

    const t = i / d.length;

    d[i] = (Math.random() * 2 - 1) * Math.exp(-t * 18);

  }

  const click = ctx.createBufferSource();

  click.buffer = buf;

  const clickFilt = ctx.createBiquadFilter();

  clickFilt.type = 'bandpass';

  clickFilt.frequency.value = direction > 0 ? 2400 : 1400;

  clickFilt.Q.value = 1.5;

  const clickGain = ctx.createGain();

  clickGain.gain.value = direction > 0 ? 0.18 : 0.12;

  click.connect(clickFilt);

  clickFilt.connect(clickGain);

  clickGain.connect(audio.master);

  click.start(now);

}


function updateAudio(dt, phys) {

  if (!audio.started || !audio.ctx) return;

  const now = audio.ctx.currentTime;


  // Engine pulse rate = (RPM × cylinders) / 120 Hz

  const firingHz = (carState.rpm * ENGINE_CYLINDERS) / 120;

  const targetRate = firingHz / audio.engine.PULSE_BASE_HZ;

  audio.engine.pulse.playbackRate.setTargetAtTime(targetRate, now, 0.04);


  const rpmFrac = Math.max(0, Math.min(1, (carState.rpm - RPM_IDLE) / (RPM_REDLINE - RPM_IDLE)));


  // Brightening character with RPM

  audio.engine.reso1.frequency.setTargetAtTime(180 + rpmFrac * 280, now, 0.05);

  audio.engine.reso2.frequency.setTargetAtTime(550 + rpmFrac * 700, now, 0.06);

  audio.engine.lp.frequency.setTargetAtTime(1400 + rpmFrac * 3000, now, 0.08);


  // Engine gain — throttle gives the engaged "growl"

  const throttleBonus = phys.throttle ? 0.18 : 0;

  let targetEngineGain = 0.20 + rpmFrac * 0.35 + throttleBonus;


  // Power cut during shift — quick dip (clutch out feel)

  if (carState.shiftTimer > 0) targetEngineGain *= 0.35;

  audio.engine.gain.gain.setTargetAtTime(targetEngineGain, now, 0.05);


  // Intake roar

  const intakeTarget = phys.throttle ? rpmFrac * 0.18 : rpmFrac * 0.04;

  audio.engine.intakeGain.gain.setTargetAtTime(intakeTarget, now, 0.08);

  audio.engine.intakeFilt.frequency.setTargetAtTime(280 + rpmFrac * 600, now, 0.08);


  // ----- SKID -----

  const handbrakeAtSpeed = keys.Space && phys.speed > 6;

  let skidTarget = 0;

  if (phys.isDrifting) {

    const intensity = Math.min(1, (phys.driftAngle - 0.35) / 0.6);

    skidTarget = 0.32 + intensity * 0.50;

  } else if (handbrakeAtSpeed) {

    skidTarget = 0.22;

  }

  // LFO modulates skidGain so the noise stutters at ~22 Hz instead of a flat hiss.

  // Base gain is half the target; LFO swings 0 → target.

  audio.skid.lfoGain.gain.setTargetAtTime(skidTarget * 0.5, now, 0.04);

  audio.skid.gain.gain.setTargetAtTime(skidTarget * 0.5, now, skidTarget > 0 ? 0.04 : 0.18);

  audio.skid.lfo.frequency.setTargetAtTime(18 + skidTarget * 12, now, 0.1);

}


// ====================================================================

//  TIRE SMOKE

// ====================================================================

const SMOKE_MAX = 380;

const smokePool = [];

const smokeActive = [];

const smokeGeo = new THREE.SphereGeometry(0.32, 6, 6);


function smokeGet() {

  if (smokePool.length) return smokePool.pop();

  return new THREE.Mesh(

    smokeGeo,

    new THREE.MeshBasicMaterial({ transparent: true, depthWrite: false })

  );

}

function spawnSmoke(pos, intensity) {

  if (smokeActive.length >= SMOKE_MAX) return;

  const m = smokeGet();

  m.position.copy(pos);

  m.position.x += (Math.random() - 0.5) * 0.4;

  m.position.z += (Math.random() - 0.5) * 0.4;

  m.material.color.setHSL(0.08, 0.25, 0.78 + Math.random() * 0.1);

  m.material.opacity = 0.55 + intensity * 0.25;

  const life = 1.2 + Math.random() * 0.8;

  m.userData.life = life;

  m.userData.maxLife = life;

  m.userData.vy = 0.35 + Math.random() * 0.5;

  m.userData.vx = (Math.random() - 0.5) * 0.8;

  m.userData.vz = (Math.random() - 0.5) * 0.8;

  m.userData.scale0 = 0.45 + Math.random() * 0.25;

  m.scale.setScalar(m.userData.scale0);

  scene.add(m);

  smokeActive.push(m);

}

function updateSmoke(dt) {

  for (let i = smokeActive.length - 1; i >= 0; i--) {

    const p = smokeActive[i];

    p.userData.life -= dt;

    if (p.userData.life <= 0) {

      scene.remove(p);

      smokeActive.splice(i, 1);

      smokePool.push(p);

      continue;

    }

    p.position.x += p.userData.vx * dt;

    p.position.y += p.userData.vy * dt;

    p.position.z += p.userData.vz * dt;

    p.userData.vy *= 0.96;

    p.userData.vx *= 0.94;

    p.userData.vz *= 0.94;

    const t = p.userData.life / p.userData.maxLife;

    p.material.opacity = t * 0.7;

    p.scale.setScalar(p.userData.scale0 * (1 + (1 - t) * 2.4));

  }

}


// ====================================================================

//  CAMERA

// ====================================================================

const VIEW_MODES = ['CHASE', 'HOOD', 'CINEMATIC'];

let viewIndex = 0;

function cycleView() { viewIndex = (viewIndex + 1) % VIEW_MODES.length; }


const _tmpPos  = new THREE.Vector3();

const _tmpLook = new THREE.Vector3();


function computeViewCam(mode, phys) {

  setBodyAxes(carState.heading);

  const cp = carState.position;

  switch (mode) {

    case 'CHASE': {

      // Distance/height interpolate with speed:

      // At 0 km/h: 4.5m back, 2.8m up — chill cinematic

      // At top speed: 2.6m back, 2.0m up — visceral closeup

      // FOV stays constant so the car visibly GROWS as speed increases.

      const speedFrac = Math.min(1, phys ? phys.speed / MAX_FORWARD : 0);

      const behind = 4.5 - speedFrac * 1.9;

      const above  = 2.8 - speedFrac * 0.8;

      const lookAhead = 1.5 - speedFrac * 0.6;

      const lookUp   = 1.1 - speedFrac * 0.4;

      _tmpPos.set(cp.x - _forward.x * behind, cp.y + above, cp.z - _forward.z * behind);

      _tmpLook.set(cp.x + _forward.x * lookAhead, cp.y + lookUp, cp.z + _forward.z * lookAhead);

      return { pos: _tmpPos.clone(), look: _tmpLook.clone(), fov: 68, fovSpeedAdd: 0 };

    }

    case 'HOOD': {

      const front = 1.6, height = 0.95;

      _tmpPos.set(cp.x + _forward.x * front, cp.y + height, cp.z + _forward.z * front);

      _tmpLook.set(cp.x + _forward.x * 30, cp.y + 1.5, cp.z + _forward.z * 30);

      return { pos: _tmpPos.clone(), look: _tmpLook.clone(), fov: 75, fovSpeedAdd: 10 };

    }

    case 'CINEMATIC': {

      const behind = 16, above = 2.6, sideOff = 5;

      _tmpPos.set(

        cp.x - _forward.x * behind + _right.x * sideOff,

        cp.y + above,

        cp.z - _forward.z * behind + _right.z * sideOff

      );

      _tmpLook.set(cp.x, cp.y + 0.9, cp.z);

      return { pos: _tmpPos.clone(), look: _tmpLook.clone(), fov: 50, fovSpeedAdd: 7 };

    }

  }

}


let lockedDriftSide = 1;

function computeDriftCam() {

  setBodyAxes(carState.heading);

  const cp = carState.position;

  const sideOff = 4.5 * lockedDriftSide;

  const back = 6.5;

  const height = 1.55;

  _tmpPos.set(

    cp.x + _right.x * sideOff - _forward.x * back,

    cp.y + height,

    cp.z + _right.z * sideOff - _forward.z * back

  );

  _tmpLook.set(cp.x - _forward.x * 0.2, cp.y + 0.6, cp.z - _forward.z * 0.2);

  return { pos: _tmpPos.clone(), look: _tmpLook.clone(), fov: 50 };

}


const camPos  = new THREE.Vector3(0, 4.5, -9);

const camLook = new THREE.Vector3();

let   driftWeight = 0;

camera.position.copy(camPos);


function updateCamera(dt, phys) {

  const wantsDrift = phys.isDrifting && carState.driftTime > 0.25;

  const target = wantsDrift ? 1 : 0;


  if (target === 1 && driftWeight < 0.15) {

    const s = Math.sign(phys.latVel);

    if (s !== 0) lockedDriftSide = -s;

  }


  const k = target > driftWeight

    ? (1 - Math.exp(-7.5 * dt))

    : (1 - Math.exp(-2.8 * dt));

  driftWeight += (target - driftWeight) * k;


  const v = computeViewCam(VIEW_MODES[viewIndex], phys);

  const d = computeDriftCam();

  const pos  = v.pos.lerp(d.pos, driftWeight);

  const look = v.look.lerp(d.look, driftWeight);


  const followK = 1 - Math.exp(-3.8 * dt);

  camPos.lerp(pos, followK);

  const lookK = 1 - Math.exp(-5.5 * dt);

  camLook.lerp(look, lookK);

  camera.position.copy(camPos);

  camera.lookAt(camLook);


  const speedFrac = Math.min(1, phys.speed / MAX_FORWARD);

  const vFov = v.fov + v.fovSpeedAdd * speedFrac;

  const targetFov = vFov * (1 - driftWeight) + d.fov * driftWeight;

  const fovK = 1 - Math.exp(-6 * dt);

  camera.fov += (targetFov - camera.fov) * fovK;

  camera.updateProjectionMatrix();


  const vm = document.getElementById('viewMode');

  if (driftWeight > 0.35) {

    vm.classList.add('drift');

    vm.textContent = 'drift cam';

  } else {

    vm.classList.remove('drift');

    vm.textContent = VIEW_MODES[viewIndex].toLowerCase() + ' cam';

  }

}


// ====================================================================

//  CAR VISUAL + HUD

// ====================================================================

const speedHud = document.getElementById('speed');

const driftHud = document.getElementById('drift');

const comboHud = document.getElementById('combo');

const gearHud  = document.getElementById('gear');

const rpmBar   = document.getElementById('rpmBar');

const shiftFlash = document.getElementById('shiftFlash');

audioHintEl    = document.getElementById('audioHint');

// Don't show the audio hint — the menu replaces it

// setTimeout(() => audioHintEl && audioHintEl.classList.add('show'), 900);


// === CAR SELECT MENU ===

const menuEl = document.getElementById('menu');

const carRowEl = document.getElementById('carRow');

const menuStartBtn = document.getElementById('menuStart');

let menuSelectedIdx = 0;

let gameStarted = false;


function hexToCss(h) {

  return '#' + h.toString(16).padStart(6, '0');

}


function buildMenuCards() {

  carRowEl.innerHTML = '';

  CARS.forEach((car, idx) => {

    const card = document.createElement('div');

    card.className = 'carCard';

    if (idx === menuSelectedIdx) card.classList.add('selected');

    card.dataset.idx = idx;

    // Top speed estimate (km/h): maxForward * 3.6

    const topKph = Math.round(car.maxForward * 3.6);

    card.innerHTML = `

      <div class="carSwatch" style="background:${hexToCss(car.color)}"></div>

      <div class="carName">${car.name}</div>

      <div class="carInspired">inspired · ${car.inspiration}</div>

      <div class="carTagline">${car.tagline}</div>

      <div class="carStats">

        <div class="carStat"><span>top</span><span class="val">${topKph} km/h</span></div>

        <div class="carStat"><span>gears</span><span class="val">${car.gearRatios.length}</span></div>

        <div class="carStat"><span>redline</span><span class="val">${car.rpmRedline}</span></div>

        <div class="carStat"><span>engine</span><span class="val">${car.cylinders}-cyl</span></div>

      </div>

    `;

    card.addEventListener('click', () => {

      menuSelectedIdx = idx;

      updateMenuSelection();

    });

    card.addEventListener('dblclick', startGame);

    carRowEl.appendChild(card);

  });

}


function updateMenuSelection() {

  const cards = carRowEl.querySelectorAll('.carCard');

  cards.forEach((c, i) => {

    c.classList.toggle('selected', i === menuSelectedIdx);

  });

}


function startGame() {

  if (gameStarted) return;

  gameStarted = true;

  initAudio(); // user gesture happened, safe to start audio

  applyCar(CARS[menuSelectedIdx]);

  menuEl.classList.add('hidden');

}


menuStartBtn.addEventListener('click', startGame);


// Menu keyboard nav (only while menu is up)

addEventListener('keydown', e => {

  if (gameStarted) return;

  if (e.code === 'ArrowLeft' || e.code === 'KeyA') {

    menuSelectedIdx = (menuSelectedIdx - 1 + CARS.length) % CARS.length;

    updateMenuSelection();

    e.preventDefault();

  } else if (e.code === 'ArrowRight' || e.code === 'KeyD') {

    menuSelectedIdx = (menuSelectedIdx + 1) % CARS.length;

    updateMenuSelection();

    e.preventDefault();

  } else if (e.code === 'Enter' || e.code === 'Space') {

    startGame();

    e.preventDefault();

  }

});


buildMenuCards();

// Apply default car so colors/HUD show correctly even before menu confirm

applyCar(CARS[0]);


// Position the upshift tick: where on the rev bar does the auto-shift happen?

{

  const upshiftFrac = (RPM_SHIFT_UP - RPM_IDLE) / (RPM_REDLINE - RPM_IDLE);

  const tick = document.getElementById('upshiftTick');

  if (tick) tick.style.left = (upshiftFrac * 100).toFixed(1) + '%';

}


// Track gear changes for HUD flash + shift "thunk" audio cue

let prevGear = carState.gear;

let shiftFlashHideAt = 0;


function triggerShiftFlash(direction) {

  if (!shiftFlash) return;

  shiftFlash.textContent = direction > 0 ? '▲ UP' : '▼ DOWN';

  shiftFlash.classList.remove('up', 'down');

  // reflow trick so the animation re-triggers

  void shiftFlash.offsetWidth;

  shiftFlash.classList.add(direction > 0 ? 'up' : 'down');

  shiftFlashHideAt = performance.now() + 280;

  gearHud.classList.add('shifting');

  setTimeout(() => gearHud.classList.remove('shifting'), 200);

}


function updateCarVisual(dt, fwdVel) {

  car.position.copy(carState.position);

  car.rotation.y = carState.heading;

  const spin = fwdVel / 0.42 * dt;

  car.userData.wheels.forEach(p => {

    p.userData.wheel.rotation.x += spin;

    if (p.userData.isFront) p.rotation.y = carState.steerAngle;

  });

  const inHood = VIEW_MODES[viewIndex] === 'HOOD' && driftWeight < 0.4;

  car.visible = !inHood;

}


function updateHUD(speed, isDrifting) {

  speedHud.textContent = Math.round(speed * 3.6);

  if (isDrifting && carState.driftTime > 0.25) {

    driftHud.classList.add('active');

    comboHud.textContent = carState.driftTime.toFixed(1) + 's';

  } else {

    driftHud.classList.remove('active');

  }


  // Gear change detection — fires the visual flash + audio thunk

  if (carState.gear !== prevGear) {

    const dir = carState.gear > prevGear ? 1 : -1;

    triggerShiftFlash(dir);

    playShiftThunk(dir);

    prevGear = carState.gear;

  }

  // Hide shift flash after its timer

  if (shiftFlashHideAt && performance.now() > shiftFlashHideAt) {

    shiftFlash.classList.remove('up', 'down');

    shiftFlashHideAt = 0;

  }


  gearHud.textContent = 'G' + carState.gear;

  const frac = Math.min(1, Math.max(0, (carState.rpm - RPM_IDLE) / (RPM_REDLINE - RPM_IDLE)));

  rpmBar.style.width = (frac * 100).toFixed(1) + '%';

  if (carState.shiftTimer > 0) {

    // Dim/desaturate the bar during a shift — visual cue of clutch disengage

    rpmBar.style.background = 'linear-gradient(90deg, #b08560, #806050)';

  } else if (frac > 0.88) {

    rpmBar.style.background = 'linear-gradient(90deg, #ffb070, #ff5050)';

  } else {

    rpmBar.style.background = 'linear-gradient(90deg, #ffd5a0, #ffb070)';

  }

}


// ====================================================================

//  MAIN LOOP

// ====================================================================

let lastTime = performance.now();

function animate(now) {

  requestAnimationFrame(animate);

  const dt = Math.min(0.05, (now - lastTime) / 1000);

  lastTime = now;


  const phys = updatePhysics(dt);

  updateCarVisual(dt, phys.fwdVel);


  // Smoke during real drifts or handbrake-at-speed

  const handbrakeAtSpeed = keys.Space && phys.speed > 6;

  if (phys.isDrifting || handbrakeAtSpeed) {

    const latI = Math.min(1, Math.abs(phys.latVel) / 9);

    const intensity = Math.max(latI, handbrakeAtSpeed ? 0.45 : 0);

    if (Math.random() < intensity * 32 * dt) {

      setBodyAxes(carState.heading);

      [-0.95, 0.95].forEach(xOff => {

        const wp = carState.position.clone()

          .addScaledVector(_right, xOff)

          .addScaledVector(_forward, -1.35);

        wp.y = 0.25;

        spawnSmoke(wp, intensity);

      });

    }

  }


  updateSmoke(dt);

  updateCamera(dt, phys);

  updateHUD(phys.speed, phys.isDrifting);

  updateAudio(dt, phys);

  streamRoad();


  // Respawn-when-lost: if the car is far from the road for a sustained

  // period, gently teleport it back. Brief excursions (wide drifts off

  // the shoulder) are tolerated thanks to the hold timer.

  {

    const roadP = ROAD_POINTS[carRoadIdx];

    if (roadP) {

      const dx = carState.position.x - roadP.x;

      const dz = carState.position.z - roadP.z;

      const distFromRoad = Math.hypot(dx, dz);

      if (distFromRoad > RESPAWN_DISTANCE) {

        lostTimer += dt;

        if (lostTimer >= RESPAWN_HOLD_TIME) attemptRespawn();

      } else {

        lostTimer = 0;

      }

    }

  }


  // Keep distant mountains "out there" — re-center around the car (XZ only)

  mtnNear.position.set(carState.position.x, 0, carState.position.z);

  mtnFar.position.set(carState.position.x, 0, carState.position.z);

  // Keep sky and ground centered too

  skyMesh.position.set(carState.position.x, 0, carState.position.z);

  ground.position.set(carState.position.x, 0, carState.position.z);

  // Sun stays "infinitely far away" — also re-center XZ

  const sunOffset = new THREE.Vector3(-80, 30, -900);

  sun.position.set(carState.position.x + sunOffset.x, sunOffset.y, carState.position.z + sunOffset.z);

  sunGlow.position.copy(sun.position).add(new THREE.Vector3(0, 0, 1));


  // Motion blur

  const speedFrac = Math.min(1, phys.speed / MAX_FORWARD);

  const blurTarget = Math.pow(speedFrac, 1.3) * 0.85;

  const cur = radialBlurPass.uniforms.blurStrength.value;

  radialBlurPass.uniforms.blurStrength.value = cur + (blurTarget - cur) * (1 - Math.exp(-3.5 * dt));


  composer.render();

}


addEventListener('resize', () => {

  camera.aspect = innerWidth / innerHeight;

  camera.updateProjectionMatrix();

  renderer.setSize(innerWidth, innerHeight);

  composer.setSize(innerWidth, innerHeight);

});


requestAnimationFrame(t => { lastTime = t; animate(t); });

setTimeout(() => document.getElementById('loading').classList.add('hidden'), 500);

</script>

</body>

</html>