import * as THREE from './vendor/three.module.min.js';
import {
  sfx, unlock, setBoostSound, setMusicMode,
  setMusicVolume, setSfxVolume, getVolumes, duckAudio, setAudioOverride,
} from './audio.js';
import { t, initI18n, loadLang, currentLang, languages } from './i18n.js';
import { heightAt, MAPS } from './maps.js';
import { FLAG_CODES, isFlagPattern, flagEmoji } from './flags.js';
import { camRig, steerFromDrag, STEER_RADIUS, validScheme } from './mobile.js';
import * as telegram from './telegram.js';
import * as portal from './portal.js';

if (portal.PORTAL) document.documentElement.classList.add('portal');
portal.initPortal().then(() => {
  portal.loadingStart();
  portal.watchSettings((s) => setAudioOverride('portal', s.muteAudio));
});

await initI18n();
telegram.initTelegram();

const INTERP_DELAY = 0.075;
// Trail sprouts from the rear wheel itself, topping out just above its axle.
const BIKE_SCALE = 1.15;
const TRAIL_H = 1.15;
const TRAIL_REAR = 1.5;
const MAX_TRAIL_PTS = 8192;
const PLAYER_COLORS = ['#00FFFF', '#00FF66', '#FF0055', '#FFE600', '#B000FF', '#FF6600'];
const SHAPES = ['blade', 'heavy', 'wing', 'overclock'];
const PATTERNS = ['solid', 'dashed', 'pulse', 'chevron', 'gold', 'rainbow', 'lightning', 'nyan'];
const validPattern = (p) => PATTERNS.includes(p) || isFlagPattern(p);
const FREE_SHAPES = ['blade', 'heavy', 'wing'];
const FREE_PATTERNS = ['solid', 'dashed'];
// mirrors server/store.js PRICES (any flag costs FLAG_PRICE neobits); league
// items carry the league name instead
const SHOP = {
  pulse: { cur: 'neobits', cost: 9000 }, chevron: { cur: 'neobits', cost: 9000 },
  rainbow: { cur: 'shards', cost: 450 }, lightning: { cur: 'shards', cost: 450 },
  nyan: { cur: 'shards', cost: 750 },
  gold: { league: 'CYBER GOLD' }, overclock: { league: 'OVERCLOCK PLATINUM' },
};
const shopDeal = (item) => SHOP[item] || (isFlagPattern(item) ? { cur: 'neobits', cost: 15000 } : null);
const CUR_ICON = { neobits: '◉', shards: '◆' };

const $ = (id) => document.getElementById(id);
const perfNow = () => performance.now() / 1000;
const touchDevice = matchMedia('(pointer: coarse)').matches || navigator.maxTouchPoints > 0;

// --- app state ----------------------------------------------------------------

const room = {
  code: null, private: false, state: null,
  members: new Map(),          // id -> {id, name, color, bot, wins}
  you: null,
  arena: 400,
  map: 'empty',
  size: 12,
  trailMaxArc: 420,
};
const match = {
  bikes: new Map(),            // id -> Bike
  timeOffset: null,            // matchTime - perfTime
  bounds: [0, 0, 400, 400],
  lastTime: 0,
  alarmDone: false,
};
let lobbyDeadline = null;      // perf-clock deadlines derived from server "secs"
let countdownDeadline = null;
let lastCountShown = null;
let spectateId = null;
let connected = false;
let pingMs = 0;
let firstFramePainted = false;
// control scheme: analog wheel (default on touch) or the classic ◀ ▶ pads.
// Declared up here with the rest of the module state because setRoomState()
// reads both and the file's later `let`s have bitten us with TDZ before.
let controlScheme = validScheme(localStorage.getItem('tron.controls') || 'wheel');
let hasSteered = localStorage.getItem('tron.steered') === '1';
// a shared room reaches us as /room/CODE on the web and ?startapp=CODE in the
// Mini App — both land on the same private room
let urlRoom = location.pathname.match(/^\/room\/([A-Za-z0-9]{1,8})$/)?.[1].toUpperCase()
  || telegram.startRoom() || null;
let tgConfig = null; // {packs, bot, app} once the server says Stars are live

// --- three.js scene -----------------------------------------------------------

// Mobile fill-rate is the usual bottleneck. Prefer adjustable resolution over
// fixed MSAA there so slow devices soften instead of missing frames.
const renderer = new THREE.WebGLRenderer({ canvas: $('game'), antialias: !touchDevice });
let baseDpr = Math.min(touchDevice ? 1.5 : 2, window.devicePixelRatio || 1);
let renderScale = 1;
renderer.setPixelRatio(baseDpr);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x050510);
scene.fog = new THREE.Fog(0x050510, 160, 520);
const camera = new THREE.PerspectiveCamera(62, 1, 0.5, 3200); // far enough for the sky dome

// chase-camera geometry for the current aspect; portrait phones get a wider,
// higher, further-back rig or they see nothing to their sides (see mobile.js)
let rig = camRig(1.78);

function resize() {
  // devicePixelRatio can change when a window moves between displays.
  baseDpr = Math.min(touchDevice ? 1.5 : 2, window.devicePixelRatio || 1);
  renderer.setPixelRatio(baseDpr * renderScale);
  renderer.setSize(innerWidth, innerHeight);
  camera.aspect = innerWidth / innerHeight;
  rig = camRig(camera.aspect);
  camera.updateProjectionMatrix();
  document.documentElement.style.setProperty('--vh', `${innerHeight}px`);
}
addEventListener('resize', resize);
addEventListener('orientationchange', () => setTimeout(resize, 120));
resize();

// --- environment: per-map floor, grid/terrain, skyline -----------------------

const env = { group: null, map: 'empty', arena: room.arena, center: room.arena / 2, gates: [] };
const groundH = (x, y) => heightAt(env.map, x, y);

// --- synthwave sky: star field + slatted moon, fog-immune -------------------

let moonTex = null;
function getMoonTexture() {
  if (moonTex) return moonTex;
  const cv = document.createElement('canvas');
  cv.width = cv.height = 512;
  const c2 = cv.getContext('2d');
  const grad = c2.createLinearGradient(0, 40, 0, 472);
  grad.addColorStop(0, '#ffe1f0');
  grad.addColorStop(0.45, '#ff6ec7');
  grad.addColorStop(1, '#7b2ff7');
  c2.fillStyle = grad;
  c2.beginPath();
  c2.arc(256, 256, 216, 0, Math.PI * 2);
  c2.fill();
  // classic slat cut-outs across the lower half
  c2.globalCompositeOperation = 'destination-out';
  let y = 280;
  for (const h of [6, 9, 12, 16, 21]) {
    c2.fillRect(0, y, 512, h);
    y += h + 26;
  }
  moonTex = new THREE.CanvasTexture(cv);
  return moonTex;
}

function addSky(g, C) {
  // two star layers on a dome way outside every arena
  for (const [count, size, opacity] of [[750, 1.6, 0.55], [180, 2.8, 0.95]]) {
    const pos = new Float32Array(count * 3);
    for (let i = 0; i < count; i++) {
      const az = Math.random() * Math.PI * 2;
      const el = Math.asin(Math.random()) * 0.94 + 0.03; // bias low, keep off the floor
      const r = 1500;
      pos.set([
        C + Math.cos(az) * Math.cos(el) * r,
        Math.sin(el) * r,
        C + Math.sin(az) * Math.cos(el) * r,
      ], i * 3);
    }
    const geo = new THREE.BufferGeometry();
    geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
    const mat = new THREE.PointsMaterial({
      color: 0xcfe0ff, size, sizeAttenuation: false,
      transparent: true, opacity, depthWrite: false, fog: false,
    });
    const stars = new THREE.Points(geo, mat);
    stars.frustumCulled = false;
    g.add(stars);
  }
  // the moon: soft glow behind a slatted disc
  const az = -2.35, el = 0.35, r = 1250;
  const mx = C + Math.cos(az) * Math.cos(el) * r;
  const my = Math.sin(el) * r;
  const mz = C + Math.sin(az) * Math.cos(el) * r;
  const glow = new THREE.Sprite(new THREE.SpriteMaterial({
    map: getGlowTexture(), color: 0xff6ec7, transparent: true, opacity: 0.5,
    depthWrite: false, blending: THREE.AdditiveBlending, fog: false,
  }));
  glow.scale.set(760, 760, 1);
  glow.position.set(mx, my, mz);
  g.add(glow);
  const moon = new THREE.Sprite(new THREE.SpriteMaterial({
    map: getMoonTexture(), transparent: true, depthWrite: false, fog: false,
  }));
  moon.scale.set(330, 330, 1);
  moon.position.set(mx, my, mz);
  g.add(moon);
}

// wireframe skyline ring + a dim far ring, lit windows, antennas
function addSkyline(g, C, palette) {
  const windowPts = [];
  const makeRing = (count, radMin, radSpread, opMin, opSpread, hMin, hSpread) => {
    for (let i = 0; i < count; i++) {
      const ang = (i / count) * Math.PI * 2 + Math.random() * 0.12;
      const rad = C + radMin + Math.random() * radSpread;
      const w = 10 + Math.random() * 24;
      const h = hMin + Math.random() * hSpread;
      const bx = C + Math.cos(ang) * rad;
      const bz = C + Math.sin(ang) * rad;
      const boxGeo = new THREE.BoxGeometry(w, h, w);
      const edges = new THREE.EdgesGeometry(boxGeo);
      boxGeo.dispose();
      const mat = new THREE.LineBasicMaterial({
        color: palette[Math.floor(Math.random() * palette.length)],
        transparent: true, opacity: opMin + Math.random() * opSpread,
      });
      const box = new THREE.LineSegments(edges, mat);
      box.position.set(bx, h / 2, bz);
      g.add(box);
      // lit windows sprinkled through the shell
      const lit = Math.floor(w * h * 0.02);
      for (let k = 0; k < lit; k++) {
        windowPts.push(
          bx + (Math.random() - 0.5) * w * 0.9,
          2 + Math.random() * (h - 3),
          bz + (Math.random() - 0.5) * w * 0.9,
        );
      }
      // some rooftops get an antenna with a red beacon
      if (Math.random() < 0.3) {
        const ah = 6 + Math.random() * h * 0.3;
        const lineGeo = new THREE.BufferGeometry().setFromPoints([
          new THREE.Vector3(bx, h, bz), new THREE.Vector3(bx, h + ah, bz),
        ]);
        g.add(new THREE.Line(lineGeo, new THREE.LineBasicMaterial({
          color: 0x8899bb, transparent: true, opacity: 0.5,
        })));
        windowPts.push(bx, h + ah, bz); // beacon rides the window cloud
      }
    }
  };
  makeRing(46, 65, 160, 0.28, 0.25, 18, 75);   // near ring (the classic one)
  makeRing(30, 240, 200, 0.10, 0.12, 40, 120); // dim giants further out
  if (windowPts.length) {
    const geo = new THREE.BufferGeometry();
    geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(windowPts), 3));
    const pts = new THREE.Points(geo, new THREE.PointsMaterial({
      color: 0xffe9a8, size: 1.8, sizeAttenuation: false,
      transparent: true, opacity: 0.75, depthWrite: false,
    }));
    pts.frustumCulled = false;
    g.add(pts);
  }
}

function buildEnvironment(map, arena) {
  if (env.group && env.map === map && env.arena === arena) return;
  if (env.group) {
    scene.remove(env.group);
    env.group.traverse((o) => { o.geometry?.dispose(); o.material?.dispose?.(); });
  }
  env.map = map;
  env.arena = arena;
  env.center = arena / 2;
  env.gates = [];
  const C = arena / 2;
  const g = env.group = new THREE.Group();

  const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(arena * 6, arena * 6),
    new THREE.MeshBasicMaterial({ color: 0x04040d }),
  );
  floor.rotation.x = -Math.PI / 2;
  floor.position.set(C, -0.08, C);
  g.add(floor);

  if (map === 'circuit') {
    // outrun racetrack: hot grid, low barrier walls, gates, checkered start
    const grid = new THREE.GridHelper(arena, 48, 0xb0329a, 0x461038);
    grid.material.transparent = true;
    grid.material.opacity = 0.75;
    grid.position.set(C, 0, C);
    g.add(grid);
    const walls2 = MAPS.circuit.obstacles || [];
    walls2.forEach((o, i) => {
      const w = o.hw * 2, d = o.hh * 2;
      const inner = i === 4; // the big island
      const h = inner ? 4 : 5.5;
      const boxGeo = new THREE.BoxGeometry(w, h, d);
      const solid = new THREE.Mesh(boxGeo, new THREE.MeshBasicMaterial({ color: 0x0a0616 }));
      solid.position.set(o.x, h / 2, o.y);
      g.add(solid);
      const edges = new THREE.LineSegments(
        new THREE.EdgesGeometry(boxGeo),
        new THREE.LineBasicMaterial({
          color: i < 4 ? 0x00ffff : i === 4 ? 0xff6ec7 : 0xff6600,
          transparent: true, opacity: 0.9,
        }),
      );
      edges.position.copy(solid.position);
      g.add(edges);
    });
    // gates: neon posts + beam; the start/finish line gets the checker strip
    (MAPS.circuit.gates || []).forEach((gate, gi) => {
      const finishG = gi === (MAPS.circuit.gates.length - 1);
      const color = finishG ? 0xffe600 : 0x00ffff;
      const gateGroup = new THREE.Group();
      for (const [px, py] of [[gate.x1, gate.y1], [gate.x2, gate.y2]]) {
        const post = new THREE.Mesh(
          new THREE.BoxGeometry(1.2, 11, 1.2),
          new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.9 }),
        );
        post.position.set(px, 5.5, py);
        gateGroup.add(post);
      }
      const beamGeo = new THREE.BufferGeometry().setFromPoints([
        new THREE.Vector3(gate.x1, 11, gate.y1), new THREE.Vector3(gate.x2, 11, gate.y2),
      ]);
      gateGroup.add(new THREE.Line(beamGeo, new THREE.LineBasicMaterial({
        color, transparent: true, opacity: 0.8,
      })));
      if (finishG) {
        const cv = document.createElement('canvas');
        cv.width = 64; cv.height = 8;
        const c2 = cv.getContext('2d');
        for (let i = 0; i < 16; i++) {
          for (let j = 0; j < 2; j++) {
            c2.fillStyle = (i + j) % 2 ? '#e8e8f0' : '#0a0a14';
            c2.fillRect(i * 4, j * 4, 4, 4);
          }
        }
        const tex = new THREE.CanvasTexture(cv);
        const strip = new THREE.Mesh(
          new THREE.PlaneGeometry(Math.hypot(gate.x2 - gate.x1, gate.y2 - gate.y1), 6),
          new THREE.MeshBasicMaterial({ map: tex, transparent: true, opacity: 0.85 }),
        );
        strip.rotation.x = -Math.PI / 2;
        strip.rotation.z = Math.atan2(gate.y2 - gate.y1, gate.x2 - gate.x1);
        strip.position.set(gate.mx, 0.06, gate.my);
        g.add(strip);
      }
      env.gates.push(gateGroup);
      g.add(gateGroup);
    });
  } else if (map === 'city') {
    // Night City: flat neon grid + solid building obstacles as glowing towers
    const grid = new THREE.GridHelper(arena, 40, 0x8a2a9a, 0x3a1055);
    grid.material.transparent = true;
    grid.material.opacity = 0.8;
    grid.position.set(C, 0, C);
    g.add(grid);
    const fine = new THREE.GridHelper(arena, 160, 0x2a0a44, 0x2a0a44);
    fine.material.transparent = true;
    fine.material.opacity = 0.5;
    fine.position.set(C, -0.02, C);
    g.add(fine);
    const towerCols = [0xff00ff, 0x00ffff, 0xffe600, 0xff0055, 0x4488ff];
    (MAPS.city.obstacles || []).forEach((o, i) => {
      const w = o.hw * 2, d = o.hh * 2;
      // thin rects are tunnel side walls: low barriers, not towers
      const thin = o.hw <= 3 || o.hh <= 3;
      const h = thin ? 9 : 26 + ((i * 37) % 44);
      const col = thin ? 0xff00ff : towerCols[i % towerCols.length];
      const boxGeo = new THREE.BoxGeometry(w, h, d);
      const solid = new THREE.Mesh(boxGeo, new THREE.MeshBasicMaterial({ color: 0x080814 }));
      solid.position.set(o.x, h / 2, o.y);
      g.add(solid);
      const edges = new THREE.LineSegments(
        new THREE.EdgesGeometry(boxGeo),
        new THREE.LineBasicMaterial({ color: col, transparent: true, opacity: 0.85 }),
      );
      edges.position.copy(solid.position);
      g.add(edges);
      // window bands up the tower face
      const bands = Math.floor(h / 9);
      for (let bnd = 1; bnd <= bands; bnd++) {
        const ring = new THREE.LineSegments(
          new THREE.EdgesGeometry(new THREE.PlaneGeometry(w, d).rotateX(-Math.PI / 2)),
          new THREE.LineBasicMaterial({ color: col, transparent: true, opacity: 0.28 }),
        );
        ring.position.set(o.x, (h / (bands + 1)) * bnd, o.y);
        g.add(ring);
      }
      // danger stripe at street level so the lethal footprint reads clearly
      const baseGeo = new THREE.PlaneGeometry(w + 1.6, d + 1.6).rotateX(-Math.PI / 2);
      const base = new THREE.LineSegments(
        new THREE.EdgesGeometry(baseGeo),
        new THREE.LineBasicMaterial({ color: 0xff0055, transparent: true, opacity: 0.8 }),
      );
      baseGeo.dispose();
      base.position.set(o.x, 0.12, o.y);
      g.add(base);
    });
    // avenue tunnels: translucent roof slab + glowing entrance frames
    for (const tn of MAPS.city.tunnels || []) {
      const w = tn.hw * 2, d = tn.hh * 2;
      const roofGeo = new THREE.BoxGeometry(w, 1.4, d);
      const roof = new THREE.Mesh(roofGeo, new THREE.MeshBasicMaterial({
        color: 0x140a20, transparent: true, opacity: 0.88,
      }));
      roof.position.set(tn.x, 10, tn.y);
      g.add(roof);
      const roofEdges = new THREE.LineSegments(
        new THREE.EdgesGeometry(roofGeo),
        new THREE.LineBasicMaterial({ color: 0xff00ff, transparent: true, opacity: 0.9 }),
      );
      roofEdges.position.copy(roof.position);
      g.add(roofEdges);
      // entrance arches at both open ends
      const ends = tn.dir === 'h'
        ? [[tn.x - tn.hw, tn.y], [tn.x + tn.hw, tn.y]]
        : [[tn.x, tn.y - tn.hh], [tn.x, tn.y + tn.hh]];
      for (const [ex, ey] of ends) {
        const frameGeo = tn.dir === 'h'
          ? new THREE.BoxGeometry(0.8, 10.5, d)
          : new THREE.BoxGeometry(w, 10.5, 0.8);
        const frame = new THREE.LineSegments(
          new THREE.EdgesGeometry(frameGeo),
          new THREE.LineBasicMaterial({ color: 0x00ffff, transparent: true, opacity: 0.85 }),
        );
        frameGeo.dispose();
        frame.position.set(ex, 5.25, ey);
        g.add(frame);
      }
    }
  } else if (map === 'ridge') {
    // heightfield terrain: dark solid surface + neon wireframe on top
    const seg = 112;
    const geo = new THREE.PlaneGeometry(arena, arena, seg, seg);
    geo.rotateX(-Math.PI / 2);
    const pos = geo.attributes.position;
    for (let i = 0; i < pos.count; i++) {
      pos.setY(i, heightAt('ridge', pos.getX(i) + C, pos.getZ(i) + C));
    }
    const solid = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ color: 0x04040d }));
    solid.position.set(C, -0.15, C);
    g.add(solid);
    const wire = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({
      color: 0x1e4488, wireframe: true, transparent: true, opacity: 0.55,
    }));
    wire.position.set(C, 0, C);
    g.add(wire);
    // arch gates: lethal pillar pairs topped with a beam, standing on terrain
    const pillars = MAPS.ridge.obstacles || [];
    for (let i = 0; i + 1 < pillars.length; i += 2) {
      const pair = [pillars[i], pillars[i + 1]];
      const tops = [];
      for (const o of pair) {
        const base = heightAt('ridge', o.x, o.y);
        const h = 10;
        const boxGeo = new THREE.BoxGeometry(o.hw * 2, h, o.hh * 2);
        const solid = new THREE.Mesh(boxGeo, new THREE.MeshBasicMaterial({ color: 0x080814 }));
        solid.position.set(o.x, base + h / 2, o.y);
        g.add(solid);
        const edges = new THREE.LineSegments(
          new THREE.EdgesGeometry(boxGeo),
          new THREE.LineBasicMaterial({ color: 0x00ffcc, transparent: true, opacity: 0.9 }),
        );
        edges.position.copy(solid.position);
        g.add(edges);
        tops.push(new THREE.Vector3(o.x, base + h, o.y));
      }
      const beamGeo = new THREE.BoxGeometry(
        Math.abs(tops[1].x - tops[0].x) + pair[0].hw * 2 || 2, 1.4,
        Math.abs(tops[1].z - tops[0].z) + pair[0].hh * 2 || 2,
      );
      const beam = new THREE.Mesh(beamGeo, new THREE.MeshBasicMaterial({
        color: 0x00ffcc, transparent: true, opacity: 0.55,
      }));
      beam.position.set(
        (tops[0].x + tops[1].x) / 2,
        Math.max(tops[0].y, tops[1].y) + 0.7,
        (tops[0].z + tops[1].z) / 2,
      );
      g.add(beam);
    }
  } else {
    const grid = new THREE.GridHelper(arena, 40, 0x3a7fe0, 0x1b3d7a);
    grid.material.transparent = true;
    grid.material.opacity = 0.85;
    grid.position.set(C, 0, C);
    g.add(grid);
    const fine = new THREE.GridHelper(arena, 160, 0x14305e, 0x14305e);
    fine.material.transparent = true;
    fine.material.opacity = 0.5;
    fine.position.set(C, -0.02, C);
    g.add(fine);
  }
  const grid2 = new THREE.GridHelper(arena * 6, 60, 0x0d2244, 0x0a1a33);
  grid2.position.set(C, -0.04, C);
  g.add(grid2);

  // neon skyline + starfield + synthwave moon on every map
  const palette = map === 'circuit'
    ? [0xff6ec7, 0xff6600, 0xb000ff, 0xffe600, 0xff0055, 0x00ffff]
    : [0x00ffff, 0xff0055, 0xb000ff, 0xff6600, 0x4488ff, 0x00ff66];
  addSkyline(g, C, palette);
  addSky(g, C);

  scene.fog.near = 160 * (arena / 400);
  scene.fog.far = 520 * (arena / 400);
  scene.add(g);
}

// arena bounds walls (move in when shrinking)
const wallMat = new THREE.MeshBasicMaterial({
  color: 0x00ffff, transparent: true, opacity: 0.07,
  side: THREE.DoubleSide, depthWrite: false,
});
const walls = [];
for (let i = 0; i < 4; i++) {
  const m = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), wallMat);
  walls.push(m);
  scene.add(m);
}
function layoutWalls(b) {
  const [x0, y0, x1, y1] = b;
  const h = 14, cy = h / 2;
  const w = x1 - x0, d = y1 - y0;
  walls[0].position.set((x0 + x1) / 2, cy, y0); walls[0].scale.set(w, h, 1);
  walls[1].position.set((x0 + x1) / 2, cy, y1); walls[1].scale.set(w, h, 1);
  walls[2].position.set(x0, cy, (y0 + y1) / 2); walls[2].rotation.y = Math.PI / 2; walls[2].scale.set(d, h, 1);
  walls[3].position.set(x1, cy, (y0 + y1) / 2); walls[3].rotation.y = Math.PI / 2; walls[3].scale.set(d, h, 1);
  const shrinking = x0 > 0.5;
  wallMat.color.setHex(shrinking ? 0xff0055 : 0x00ffff);
  wallMat.opacity = shrinking ? 0.16 : 0.07;
}
layoutWalls(match.bounds);

// --- bike + trail objects -----------------------------------------------------

function makeNameSprite(name, color) {
  const cv = document.createElement('canvas');
  cv.width = 256; cv.height = 64;
  const c2 = cv.getContext('2d');
  c2.font = '700 34px system-ui, sans-serif';
  c2.textAlign = 'center';
  c2.fillStyle = 'rgba(0,0,0,0.5)';
  const tw = Math.min(240, c2.measureText(name).width + 20);
  c2.fillRect(128 - tw / 2, 8, tw, 46);
  c2.fillStyle = color;
  c2.fillText(name, 128, 42);
  const tex = new THREE.CanvasTexture(cv);
  const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false }));
  spr.scale.set(9, 2.25, 1);
  spr.position.y = 4.6;
  return spr;
}

let glowTex = null;
function getGlowTexture() {
  if (glowTex) return glowTex;
  const cv = document.createElement('canvas');
  cv.width = cv.height = 128;
  const c2 = cv.getContext('2d');
  const g = c2.createRadialGradient(64, 64, 4, 64, 64, 64);
  g.addColorStop(0, 'rgba(255,255,255,0.9)');
  g.addColorStop(0.4, 'rgba(255,255,255,0.35)');
  g.addColorStop(1, 'rgba(255,255,255,0)');
  c2.fillStyle = g;
  c2.fillRect(0, 0, 128, 128);
  glowTex = new THREE.CanvasTexture(cv);
  return glowTex;
}
// initial build lives here: addSky needs the glow texture helper above
buildEnvironment('empty', room.arena);

// National flag trails (spec Task 7): flag texture on the wall, own neon glow.
// Drawn upside-up: trail uv.y=0 is the wall bottom, canvas y=0 is the top.
const FLAG_PAINTERS = {
  'flag-us'(c2, W, H) {
    for (let i = 0; i < 13; i++) {
      c2.fillStyle = i % 2 ? '#ffffff' : '#ff2244';
      c2.fillRect(0, (i / 13) * H, W, H / 13 + 1);
    }
    c2.fillStyle = '#2244ff';
    c2.fillRect(0, 0, W * 0.4, H * 7 / 13);
    c2.fillStyle = '#ffffff';
    for (let r = 0; r < 5; r++) {
      for (let s = 0; s < 6 - (r % 2); s++) {
        const x = W * 0.4 * ((s + 0.5 + (r % 2) * 0.5) / 6);
        const y = H * 7 / 13 * ((r + 0.5) / 5);
        c2.beginPath(); c2.arc(x, y, H * 0.022, 0, Math.PI * 2); c2.fill();
      }
    }
  },
  'flag-kg'(c2, W, H) {
    c2.fillStyle = '#ff2222';
    c2.fillRect(0, 0, W, H);
    const cx = W / 2, cy = H / 2, R = H * 0.34;
    c2.fillStyle = '#ffdd00';
    for (let i = 0; i < 40; i++) {
      const a = (i / 40) * Math.PI * 2;
      c2.beginPath();
      c2.moveTo(cx + Math.cos(a - 0.035) * R * 0.82, cy + Math.sin(a - 0.035) * R * 0.82);
      c2.lineTo(cx + Math.cos(a) * R * 1.18, cy + Math.sin(a) * R * 1.18);
      c2.lineTo(cx + Math.cos(a + 0.035) * R * 0.82, cy + Math.sin(a + 0.035) * R * 0.82);
      c2.fill();
    }
    c2.beginPath(); c2.arc(cx, cy, R * 0.82, 0, Math.PI * 2); c2.fill();
    // tunduk: red disc with crossed lath arcs
    c2.fillStyle = '#ff2222';
    c2.beginPath(); c2.arc(cx, cy, R * 0.62, 0, Math.PI * 2); c2.fill();
    c2.strokeStyle = '#ffdd00';
    c2.lineWidth = H * 0.035;
    c2.beginPath(); c2.arc(cx, cy, R * 0.45, 0, Math.PI * 2); c2.stroke();
    for (const rot of [-0.5, 0, 0.5]) {
      c2.beginPath();
      c2.ellipse(cx, cy, R * 0.45, R * 0.16, rot + Math.PI / 2, 0, Math.PI * 2);
      c2.stroke();
    }
  },
  'flag-es'(c2, W, H) {
    c2.fillStyle = '#ff2233'; c2.fillRect(0, 0, W, H);
    c2.fillStyle = '#ffcc00'; c2.fillRect(0, H * 0.25, W, H * 0.5);
  },
  'flag-de'(c2, W, H) {
    c2.fillStyle = '#333333'; c2.fillRect(0, 0, W, H / 3);
    c2.fillStyle = '#ff2222'; c2.fillRect(0, H / 3, W, H / 3);
    c2.fillStyle = '#ffcc00'; c2.fillRect(0, H * 2 / 3, W, H / 3);
  },
  'flag-jp'(c2, W, H) {
    c2.fillStyle = '#ffffff'; c2.fillRect(0, 0, W, H);
    c2.fillStyle = '#ff2244';
    c2.beginPath(); c2.arc(W / 2, H / 2, H * 0.3, 0, Math.PI * 2); c2.fill();
  },
  'flag-cn'(c2, W, H) {
    c2.fillStyle = '#ff2222'; c2.fillRect(0, 0, W, H);
    c2.fillStyle = '#ffdd00';
    const star = (x, y, r) => {
      c2.beginPath();
      for (let i = 0; i < 5; i++) {
        const a = -Math.PI / 2 + (i * 4 * Math.PI) / 5;
        c2[i ? 'lineTo' : 'moveTo'](x + Math.cos(a) * r, y + Math.sin(a) * r);
      }
      c2.fill();
    };
    star(W * 0.12, H * 0.28, H * 0.14);
    star(W * 0.24, H * 0.1, H * 0.05);
    star(W * 0.28, H * 0.24, H * 0.05);
    star(W * 0.28, H * 0.42, H * 0.05);
    star(W * 0.24, H * 0.55, H * 0.05);
  },
};

// Premium trails (Data Shards / league rewards) — drawn, not tinted.
const PREMIUM_PAINTERS = {
  gold(c2, W, H) {
    const g = c2.createLinearGradient(0, 0, 0, H);
    g.addColorStop(0, '#fff3b0');
    g.addColorStop(0.45, '#ffd700');
    g.addColorStop(1, '#8a5a00');
    c2.fillStyle = g;
    c2.fillRect(0, 0, W, H);
    c2.strokeStyle = 'rgba(255,255,255,.8)';
    c2.lineWidth = 5;
    for (const x of [-W / 2, 0, W / 2, W]) {
      c2.beginPath(); c2.moveTo(x, H); c2.lineTo(x + W / 2, 0); c2.stroke();
    }
  },
  rainbow(c2, W, H) {
    const cols = ['#ff2222', '#ff9900', '#ffee00', '#22cc44', '#2266ff', '#8833cc'];
    cols.forEach((col, i) => {
      c2.fillStyle = col;
      c2.fillRect(0, (i / 6) * H, W, H / 6 + 1);
    });
  },
  lightning(c2, W, H) {
    c2.strokeStyle = '#dff4ff';
    c2.lineWidth = 7;
    c2.shadowColor = '#66ccff';
    c2.shadowBlur = 10;
    c2.beginPath();
    c2.moveTo(0, H * 0.5);
    for (let x = 0; x <= W; x += W / 8) {
      c2.lineTo(x, H * (0.2 + ((x / (W / 8)) % 2) * 0.55 + Math.sin(x) * 0.05));
    }
    c2.stroke();
  },
  nyan(c2, W, H) {
    const cols = ['#ff2222', '#ff9900', '#ffee00', '#22cc44', '#2266ff', '#8833cc'];
    cols.forEach((col, i) => {
      c2.fillStyle = col;
      c2.fillRect(0, H * 0.15 + (i / 6) * H * 0.7, W * 0.72, (H * 0.7) / 6 + 1);
    });
    // pop-tart body + gray cat head, chunky pixels
    c2.fillStyle = '#ffcc99';
    c2.fillRect(W * 0.68, H * 0.22, W * 0.2, H * 0.56);
    c2.fillStyle = '#ff99cc';
    c2.fillRect(W * 0.7, H * 0.28, W * 0.16, H * 0.44);
    c2.fillStyle = '#999999';
    c2.fillRect(W * 0.84, H * 0.3, W * 0.13, H * 0.4);
    c2.fillRect(W * 0.85, H * 0.22, W * 0.03, H * 0.1);
    c2.fillRect(W * 0.93, H * 0.22, W * 0.03, H * 0.1);
    c2.fillStyle = '#000';
    c2.fillRect(W * 0.87, H * 0.42, W * 0.02, H * 0.08);
    c2.fillRect(W * 0.93, H * 0.42, W * 0.02, H * 0.08);
  },
};

// flags without a hand-drawn painter render their emoji tiled along the wall
function emojiFlagPainter(pattern) {
  const emoji = flagEmoji(pattern.slice(5));
  return (c2, W, H) => {
    c2.fillStyle = '#101024';
    c2.fillRect(0, 0, W, H);
    c2.font = `${Math.round(H * 0.82)}px sans-serif`;
    c2.textAlign = 'center';
    c2.textBaseline = 'middle';
    c2.fillText(emoji, W / 2, H * 0.54);
  };
}

const trailPatternTextures = new Map();
function getTrailPatternTexture(pattern) {
  if (pattern === 'solid') return null;
  if (trailPatternTextures.has(pattern)) return trailPatternTextures.get(pattern);
  const flag = FLAG_PAINTERS[pattern] || PREMIUM_PAINTERS[pattern]
    || (isFlagPattern(pattern) ? emojiFlagPainter(pattern) : null);
  const cv = document.createElement('canvas');
  cv.width = flag ? 128 : 64;
  cv.height = 64;
  const c2 = cv.getContext('2d');
  c2.clearRect(0, 0, cv.width, cv.height);
  c2.fillStyle = '#fff';
  if (flag) {
    flag(c2, cv.width, cv.height);
  } else if (pattern === 'dashed') {
    c2.fillRect(0, 0, 38, 64);
  } else if (pattern === 'pulse') {
    const g = c2.createLinearGradient(0, 0, 64, 0);
    g.addColorStop(0, 'rgba(255,255,255,.12)');
    g.addColorStop(.5, 'rgba(255,255,255,1)');
    g.addColorStop(1, 'rgba(255,255,255,.12)');
    c2.fillStyle = g;
    c2.fillRect(0, 0, 64, 64);
  } else {
    c2.lineWidth = 10;
    c2.strokeStyle = '#fff';
    for (const x of [-32, 0, 32, 64]) {
      c2.beginPath(); c2.moveTo(x, 0); c2.lineTo(x + 28, 32); c2.lineTo(x, 64); c2.stroke();
    }
  }
  const tex = new THREE.CanvasTexture(cv);
  tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
  trailPatternTextures.set(pattern, tex);
  return tex;
}

const isFlag = (pattern) => pattern.startsWith('flag-');

const OWN_COLOR_PATTERNS = new Set(['gold', 'rainbow', 'nyan']);

function makeTrailMaterial(colorHex, pattern) {
  // Flags and premium trails keep their real colors (no player tint); the
  // colored top rail still marks whose wall it is.
  if (isFlag(pattern) || OWN_COLOR_PATTERNS.has(pattern)) {
    return new THREE.MeshBasicMaterial({
      color: 0xffffff, map: getTrailPatternTexture(pattern),
      transparent: true, opacity: 0.88,
      side: THREE.DoubleSide, depthWrite: false,
    });
  }
  if (pattern === 'lightning') {
    return new THREE.MeshBasicMaterial({
      color: 0xffffff, map: getTrailPatternTexture(pattern),
      transparent: true, opacity: 0.9,
      side: THREE.DoubleSide, blending: THREE.AdditiveBlending, depthWrite: false,
    });
  }
  return new THREE.MeshBasicMaterial({
    color: new THREE.Color(colorHex), map: getTrailPatternTexture(pattern),
    transparent: true, opacity: 0.45,
    side: THREE.DoubleSide, blending: THREE.AdditiveBlending, depthWrite: false,
  });
}

function makeBikeModel(colorHex, shape = 'blade') {
  const color = new THREE.Color(colorHex);
  const yaw = new THREE.Group();
  const roll = new THREE.Group();
  roll.scale.setScalar(BIKE_SCALE);
  yaw.add(roll);
  const mat = new THREE.MeshBasicMaterial({ color });
  const darkMat = new THREE.MeshBasicMaterial({ color: 0x0a0a16 });
  const suitMat = new THREE.MeshBasicMaterial({ color: 0x11111f });
  const whiteMat = new THREE.MeshBasicMaterial({ color: 0xffffff });

  const overclock = shape === 'overclock';
  const heavy = shape === 'heavy' || overclock;
  const wheelR = heavy ? 0.84 : 0.72;
  const wheelX = heavy ? 1.58 : 1.5;
  // Dark wheel faces keep the bright rims legible at arena scale. Inner glow
  // disc reads as the concept art's "glowing ring wheels".
  const rimGeo = new THREE.TorusGeometry(wheelR, heavy ? 0.16 : 0.13, 8, 24);
  const diskGeo = new THREE.CylinderGeometry(wheelR - 0.1, wheelR - 0.1, heavy ? 0.24 : 0.16, 20);
  const hubGeo = new THREE.CylinderGeometry(wheelR * 0.42, wheelR * 0.42, heavy ? 0.26 : 0.18, 14);
  for (const wx of [wheelX, -wheelX]) {
    const rim = new THREE.Mesh(rimGeo, mat);
    rim.position.set(wx, wheelR, 0);
    roll.add(rim);
    const disk = new THREE.Mesh(diskGeo, darkMat);
    disk.rotation.x = Math.PI / 2;
    disk.position.set(wx, wheelR, 0);
    roll.add(disk);
    const hub = new THREE.Mesh(hubGeo, mat);
    hub.rotation.x = Math.PI / 2;
    hub.position.set(wx, wheelR, 0);
    roll.add(hub);
  }

  // Segmented armored chassis: low dark hull, neon rail through its center,
  // angled nose cowl reaching over the front wheel.
  const hull = new THREE.Mesh(
    new THREE.BoxGeometry(heavy ? 3.05 : 2.7, heavy ? 0.72 : 0.48, heavy ? 0.86 : 0.56), darkMat,
  );
  hull.position.y = heavy ? 0.82 : 0.66;
  roll.add(hull);
  const rail = new THREE.Mesh(new THREE.BoxGeometry(heavy ? 2.85 : 2.55, 0.13, heavy ? 0.92 : 0.62), mat);
  rail.position.y = heavy ? 0.94 : 0.72;
  roll.add(rail);
  const cowl = new THREE.Mesh(new THREE.BoxGeometry(1.15, heavy ? 0.5 : 0.4, heavy ? 0.6 : 0.44), darkMat);
  cowl.position.set(heavy ? 1.05 : 0.98, heavy ? 1.06 : 0.9, 0);
  cowl.rotation.z = -0.28;
  roll.add(cowl);
  // low-profile power core glowing at the belly
  const core = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.16, heavy ? 0.5 : 0.36), mat);
  core.position.set(0.15, heavy ? 0.5 : 0.42, 0);
  roll.add(core);

  // canopy (white nose light)
  const canopy = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.22, 0.26), whiteMat);
  canopy.position.set(heavy ? 1.35 : 1.25, heavy ? 1.12 : 0.98, 0);
  roll.add(canopy);

  // tail fin, the classic light-cycle silhouette
  const fin = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.7, 0.09), mat);
  fin.position.set(-1.2, heavy ? 1.2 : 1.05, 0);
  fin.rotation.z = 0.35;
  roll.add(fin);

  if (shape === 'wing') {
    for (const z of [-0.48, 0.48]) {
      const wing = new THREE.Mesh(new THREE.BoxGeometry(2.15, 0.1, 0.72), mat);
      wing.position.set(-0.15, 0.68, z);
      wing.rotation.y = z < 0 ? -0.2 : 0.2;
      roll.add(wing);
    }
  }

  if (overclock) {
    // league-exclusive ride: platinum trim, twin tail fins, violet aura
    const platMat = new THREE.MeshBasicMaterial({ color: 0xd8dcff });
    for (const z of [-0.3, 0.3]) {
      const twin = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.62, 0.07), platMat);
      twin.position.set(-1.28, 1.32, z);
      twin.rotation.z = 0.42;
      roll.add(twin);
    }
    const trim = new THREE.Mesh(new THREE.BoxGeometry(3.1, 0.06, 0.98), platMat);
    trim.position.y = 1.0;
    roll.add(trim);
    const aura = new THREE.Mesh(
      new THREE.PlaneGeometry(9, 5.4),
      new THREE.MeshBasicMaterial({
        map: getGlowTexture(), color: 0xb000ff, transparent: true, opacity: 0.4,
        blending: THREE.AdditiveBlending, depthWrite: false,
      }),
    );
    aura.rotation.x = -Math.PI / 2;
    aura.position.y = 0.05;
    roll.add(aura);
  }

  // Neon rider crouched over the tank: suit-dark limbs, glowing helmet visor
  // and accent stripes (spec Task 2).
  {
    const rider = new THREE.Group();
    // hips on the seat, torso leaning hard toward the bars
    const hips = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.3, 0.4), suitMat);
    hips.position.set(-0.62, 1.28, 0);
    rider.add(hips);
    const torso = new THREE.Mesh(new THREE.BoxGeometry(0.95, 0.32, 0.44), suitMat);
    torso.position.set(-0.18, 1.5, 0);
    torso.rotation.z = -0.38;
    rider.add(torso);
    // spine accent stripe down the back
    const stripe = new THREE.Mesh(new THREE.BoxGeometry(0.95, 0.05, 0.1), mat);
    stripe.position.set(-0.18, 1.68, 0);
    stripe.rotation.z = -0.38;
    rider.add(stripe);
    // helmet with glowing visor
    const helmet = new THREE.Mesh(new THREE.SphereGeometry(0.26, 12, 9), suitMat);
    helmet.position.set(0.34, 1.72, 0);
    rider.add(helmet);
    const visor = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.1, 0.34), mat);
    visor.position.set(0.52, 1.7, 0);
    rider.add(visor);
    // arms reaching to the bars near the cowl
    for (const z of [-0.26, 0.26]) {
      const arm = new THREE.Mesh(new THREE.BoxGeometry(0.85, 0.14, 0.14), suitMat);
      arm.position.set(0.42, 1.42, z);
      arm.rotation.z = -0.5;
      rider.add(arm);
      const shoulder = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.2, 0.16), mat);
      shoulder.position.set(0.05, 1.62, z);
      rider.add(shoulder);
      // thigh forward-down to the pegs, shin down
      const thigh = new THREE.Mesh(new THREE.BoxGeometry(0.68, 0.17, 0.17), suitMat);
      thigh.position.set(-0.34, 1.1, z);
      thigh.rotation.z = 0.45;
      rider.add(thigh);
      const shin = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.5, 0.16), suitMat);
      shin.position.set(-0.06, 0.82, z);
      rider.add(shin);
    }
    if (heavy) { rider.position.y = 0.14; rider.position.x = -0.05; }
    roll.add(rider);
  }

  // The wall begins at the rear wheel, so its source remains visible in turns.
  // Local coords compensate BIKE_SCALE so the emitter lines up with the
  // world-space trail head and height.
  const emitter = new THREE.Mesh(
    new THREE.PlaneGeometry(0.42, TRAIL_H / BIKE_SCALE),
    new THREE.MeshBasicMaterial({
      map: getGlowTexture(), color, transparent: true, opacity: 0.9,
      side: THREE.DoubleSide, blending: THREE.AdditiveBlending, depthWrite: false,
    }),
  );
  emitter.position.set(-TRAIL_REAR / BIKE_SCALE, TRAIL_H / BIKE_SCALE / 2, 0);
  roll.add(emitter);

  // soft light pool under the bike
  const glow = new THREE.Mesh(
    new THREE.PlaneGeometry(7, 4.2),
    new THREE.MeshBasicMaterial({
      map: getGlowTexture(), color, transparent: true, opacity: 0.55,
      blending: THREE.AdditiveBlending, depthWrite: false,
    }),
  );
  glow.rotation.x = -Math.PI / 2;
  glow.position.y = 0.07;
  roll.add(glow);

  return { yaw, roll, glow, mats: [mat, darkMat, suitMat, whiteMat] };
}

class Trail {
  constructor(colorHex, pattern = 'solid') {
    this.count = 0; // committed points
    this.pts = [];  // for the minimap
    this.arc = [];
    this.drawStart = 0; // segments before this have waned away
    this.trimIdx = 0;   // first path index still ahead of the drawn head
    const verts = new Float32Array((MAX_TRAIL_PTS + 1) * 2 * 3);
    const idx = new Uint32Array(MAX_TRAIL_PTS * 6);
    for (let q = 0; q < MAX_TRAIL_PTS; q++) {
      const v = q * 2;
      idx.set([v, v + 1, v + 2, v + 1, v + 3, v + 2], q * 6);
    }
    this.geo = new THREE.BufferGeometry();
    this.geo.setAttribute('position', new THREE.BufferAttribute(verts, 3).setUsage(THREE.DynamicDrawUsage));
    this.geo.setAttribute('uv', new THREE.BufferAttribute(
      new Float32Array((MAX_TRAIL_PTS + 1) * 2 * 2), 2,
    ).setUsage(THREE.DynamicDrawUsage));
    this.geo.setIndex(new THREE.BufferAttribute(idx, 1));
    this.geo.setDrawRange(0, 0);
    const color = new THREE.Color(colorHex);
    this.mat = makeTrailMaterial(colorHex, pattern);
    this.baseOpacity = this.mat.opacity;
    this.uvScale = isFlag(pattern) ? 1 / (TRAIL_H * 2) : 1 / 6; // flags keep 2:1
    this.mesh = new THREE.Mesh(this.geo, this.mat);
    this.mesh.frustumCulled = false;

    // Top rail as segment pairs (not a strip) so EMP cuts can open real gaps.
    const topVerts = new Float32Array((MAX_TRAIL_PTS + 1) * 2 * 3);
    this.topGeo = new THREE.BufferGeometry();
    this.topGeo.setAttribute('position', new THREE.BufferAttribute(topVerts, 3).setUsage(THREE.DynamicDrawUsage));
    this.topGeo.setDrawRange(0, 0);
    this.topMat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.95 });
    this.top = new THREE.LineSegments(this.topGeo, this.topMat);
    this.top.frustumCulled = false;
    this.cutSet = new Set();

    scene.add(this.mesh);
    scene.add(this.top);
  }

  writeSlot(slot, x, y, arc) {
    // walls stand on the terrain; sink the base a little so slopes never gap
    const h = groundH(x, y);
    const p = this.geo.attributes.position.array;
    p.set([x, h - 0.25, y, x, h + TRAIL_H, y], slot * 6);
    const u = arc * this.uvScale;
    this.geo.attributes.uv.array.set([u, 0, u, 1], slot * 4);
    // point at `slot` ends segment slot-1 and starts segment slot
    const tp = this.topGeo.attributes.position.array;
    if (slot > 0) tp.set([x, h + TRAIL_H, y], (slot * 2 - 1) * 3);
    tp.set([x, h + TRAIL_H, y], slot * 2 * 3);
  }

  markSlot(slot) {
    // r170 uploads only these component ranges. Coalesce adjacent committed +
    // head slots so reconnecting to a long trail does not build a range list.
    this.markRange(this.geo.attributes.position, slot * 6, 6);
    this.markRange(this.geo.attributes.uv, slot * 4, 4);
    this.markRange(this.topGeo.attributes.position, slot > 0 ? (slot * 2 - 1) * 3 : 0, slot > 0 ? 9 : 3);
  }

  markRange(attribute, start, count) {
    const last = attribute.updateRanges[attribute.updateRanges.length - 1];
    if (last && start <= last.start + last.count) {
      // merge in either direction (cuts write behind the head)
      const end = Math.max(last.start + last.count, start + count);
      last.start = Math.min(last.start, start);
      last.count = end - last.start;
    } else {
      attribute.addUpdateRange(start, count);
    }
    attribute.needsUpdate = true;
  }

  addPoint(x, y) {
    if (this.count >= MAX_TRAIL_PTS - 1) return;
    const prev = this.pts[this.pts.length - 1];
    const arc = (this.arc[this.arc.length - 1] || 0) + (prev ? Math.hypot(x - prev[0], y - prev[1]) : 0);
    this.writeSlot(this.count, x, y, arc);
    this.markSlot(this.count);
    this.count++;
    this.pts.push([x, y]);
    this.arc.push(arc);
  }

  // The head argument is the BIKE CENTER; the drawn wall stops TRAIL_REAR of
  // arc length earlier, so the laser always ends at the rear wheel — even on
  // tight curves where corner points commit right under the bike. (Collision
  // stays on the server's center path; the hidden lead sits under the bike.)
  setHead(cx, cy) {
    const n = this.pts.length;
    if (n === 0) return;
    const last = this.pts[n - 1];
    const arcHead = this.arc[n - 1] + Math.hypot(cx - last[0], cy - last[1]);
    const T = arcHead - TRAIL_REAR;
    // wane: hide committed segments further than trailMaxArc behind the head
    while (this.drawStart + 1 < n && T - this.arc[this.drawStart + 1] > room.trailMaxArc) this.drawStart++;
    if (T <= this.arc[this.drawStart]) {
      this.geo.setDrawRange(0, 0);
      this.topGeo.setDrawRange(0, 0);
      return;
    }
    // restore committed slots the trim passed since last frame (they held the
    // interpolated head); T only moves forward, so this touches each slot once
    while (this.trimIdx < n && this.arc[this.trimIdx] <= T) {
      const i = this.trimIdx;
      this.writeSlot(i, this.pts[i][0], this.pts[i][1], this.arc[i]);
      this.markSlot(i);
      this.trimIdx++;
    }
    const j = this.trimIdx; // first path index with arc > T (n → live segment)
    const pa = this.arc[j - 1];
    const px = this.pts[j - 1][0], py = this.pts[j - 1][1];
    const nx = j < n ? this.pts[j][0] : cx;
    const ny = j < n ? this.pts[j][1] : cy;
    const na = j < n ? this.arc[j] : arcHead;
    const f = na - pa > 1e-9 ? (T - pa) / (na - pa) : 0;
    this.writeSlot(j, px + (nx - px) * f, py + (ny - py) * f, T);
    this.markSlot(j);
    this.geo.setDrawRange(this.drawStart * 6, (j - this.drawStart) * 6);
    this.topGeo.setDrawRange(this.drawStart * 2, (j - this.drawStart) * 2);
  }

  // EMP gap: degenerate quad i (zeroed indices) + collapse its top segment
  cutSegment(i) {
    if (i < 0 || i + 1 >= this.pts.length || this.cutSet.has(i)) return;
    this.cutSet.add(i);
    const idx = this.geo.index;
    idx.array.fill(0, i * 6, i * 6 + 6);
    this.markRange(idx, i * 6, 6);
    const [x, y] = this.pts[i];
    const th = groundH(x, y) + TRAIL_H;
    const tp = this.topGeo.attributes.position.array;
    tp.set([x, th, y, x, th, y], i * 2 * 3);
    this.markRange(this.topGeo.attributes.position, i * 2 * 3, 6);
  }

  dispose() {
    scene.remove(this.mesh); scene.remove(this.top);
    this.geo.dispose(); this.topGeo.dispose();
    this.mat.dispose(); this.topMat.dispose();
  }
}

class Bike {
  constructor(info) {
    Object.assign(this, info); // id, name, color, bot, alive, kills
    this.shape = SHAPES.includes(this.shape) ? this.shape : 'blade';
    this.pattern = validPattern(this.pattern) ? this.pattern : 'solid';
    const model = makeBikeModel(this.color, this.shape);
    this.yaw = model.yaw;
    this.roll = model.roll;
    this.glow = model.glow;
    this.mats = model.mats;
    this.phased = false;
    if (this.id !== room.you) this.yaw.add(makeNameSprite(this.name, this.color));
    scene.add(this.yaw);
    this.trail = new Trail(this.color, this.pattern);
    this.snaps = [];
    this.fade = 0;
    this.pos = { x: info.x, y: info.y, h: info.heading };
    this.yaw.position.set(info.x, 0, info.y);
    this.yaw.rotation.y = -info.heading;
  }

  pushSnap(t, x, y, h, flags) {
    this.snaps.push({
      t, x, y, h,
      boost: !!(flags & 1), phase: !!(flags & 2), surge: !!(flags & 4), pad: !!(flags & 8),
    });
    if (this.snaps.length > 12) this.snaps.splice(0, this.snaps.length - 12);
  }

  latest() { return this.snaps.length ? this.snaps[this.snaps.length - 1] : null; }

  // interpolated pose at match-time t
  poseAt(t) {
    const s = this.snaps;
    if (!s.length) return this.pos;
    if (t <= s[0].t) {
      this.pos.x = s[0].x; this.pos.y = s[0].y; this.pos.h = s[0].h;
      return this.pos;
    }
    for (let i = s.length - 1; i >= 1; i--) {
      if (t >= s[i - 1].t && t <= s[i].t) {
        const a = s[i - 1], b = s[i];
        const f = (t - a.t) / Math.max(1e-6, b.t - a.t);
        this.pos.x = a.x + (b.x - a.x) * f;
        this.pos.y = a.y + (b.y - a.y) * f;
        this.pos.h = lerpAngle(a.h, b.h, f);
        return this.pos;
      }
    }
    const b = s[s.length - 1], a = s.length > 1 ? s[s.length - 2] : b;
    const dt = Math.min(0.15, t - b.t);
    const span = Math.max(1e-6, b.t - a.t);
    this.pos.x = b.x + ((b.x - a.x) / span) * dt;
    this.pos.y = b.y + ((b.y - a.y) / span) * dt;
    this.pos.h = b.h;
    return this.pos;
  }

  turnRate() {
    const s = this.snaps;
    if (s.length < 2) return 0;
    const a = s[s.length - 2], b = s[s.length - 1];
    return angleDiff(b.h, a.h) / Math.max(1e-6, b.t - a.t);
  }

  destroy() {
    scene.remove(this.yaw);
    this.trail.dispose();
  }
}

function lerpAngle(a, b, f) { return a + angleDiff(b, a) * f; }
function angleDiff(b, a) {
  let d = b - a;
  while (d > Math.PI) d -= Math.PI * 2;
  while (d < -Math.PI) d += Math.PI * 2;
  return d;
}

function clearMatchScene() {
  for (const b of match.bikes.values()) b.destroy();
  match.bikes.clear();
  match.timeOffset = null;
  match.lastTime = 0;
  match.alarmDone = false;
  match.bounds = [0, 0, room.arena, room.arena];
  layoutWalls(match.bounds);
  clearPickups();
  clearPads();
  clearMines();
  clearShots();
  match.race = null;
  $('racelap').classList.add('hidden');
  updateItemSlot(null);
  setBoostSound(false);
}

function buildMatchScene(state) {
  clearMatchScene();
  match.race = state.race ? { laps: state.race.laps, order: [], myLap: 1, myCp: 0 } : null;
  for (const bi of state.bikes) {
    const bike = new Bike(bi);
    for (const p of bi.trail) bike.trail.addPoint(p.x, p.y);
    for (const i of bi.cuts || []) bike.trail.cutSegment(i);
    bike.alive = bi.alive;
    bike.ghost = !!bi.ghost;
    if (!bi.alive || bike.ghost) bike.yaw.visible = false;
    match.bikes.set(bi.id, bike);
    if (bi.id === room.you) updateItemSlot(bi.item, bi.item === 'cannon' ? 3 : 1);
  }
  for (const p of state.pickups || []) addPickup(p);
  for (const p of state.pads || []) addPad(p);
  for (const m of state.mines || []) addMine(m);
  match.bounds = state.bounds;
  layoutWalls(match.bounds);
}

// --- pickups & abilities ------------------------------------------------------

const ITEM_COLORS = {
  phase: '#00ffff', emp: '#ff0055', surge: '#ffb300', snip: '#b000ff',
  cannon: '#ff6600', trap: '#22ff88',
};
const itemName = (type) => t(`item_${type}`);
const pickupMeshes = new Map();
let heldItem = null;
let heldCharges = 0;

function addPickup(p) {
  if (pickupMeshes.has(p.id)) return;
  const color = new THREE.Color(ITEM_COLORS[p.type] || '#ffffff');
  const g = new THREE.Group();
  const boxGeo = new THREE.BoxGeometry(2.4, 2.4, 2.4);
  const cube = new THREE.LineSegments(
    new THREE.EdgesGeometry(boxGeo),
    new THREE.LineBasicMaterial({ color }),
  );
  boxGeo.dispose();
  g.add(cube);
  const inner = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.9, 0.9), new THREE.MeshBasicMaterial({ color }));
  g.add(inner);
  const halo = new THREE.Sprite(new THREE.SpriteMaterial({
    map: getGlowTexture(), color, transparent: true, opacity: 0.75,
    depthWrite: false, blending: THREE.AdditiveBlending,
  }));
  halo.scale.set(8, 8, 1);
  g.add(halo);
  const baseY = groundH(p.x, p.y) + 2.4;
  g.position.set(p.x, baseY, p.y);
  g.userData.type = p.type;
  g.userData.baseY = baseY;
  scene.add(g);
  pickupMeshes.set(p.id, g);
}

function removePickup(id) {
  const g = pickupMeshes.get(id);
  if (!g) return;
  scene.remove(g);
  g.traverse((o) => { o.geometry?.dispose(); if (o.material && !o.material.map) o.material.dispose(); });
  pickupMeshes.delete(id);
}

function clearPickups() {
  for (const id of [...pickupMeshes.keys()]) removePickup(id);
}

function stepPickupMeshes(dt, now) {
  for (const g of pickupMeshes.values()) {
    g.rotation.y += dt * 1.3;
    g.children[1].rotation.x += dt * 2.1;
    g.position.y = g.userData.baseY + Math.sin(now * 2.2 + g.position.x * 0.3) * 0.5;
  }
}

function updateItemSlot(type, charges = 1) {
  heldItem = type || null;
  heldCharges = heldItem ? charges || 1 : 0;
  $('itemrow').classList.toggle('hidden', !heldItem);
  $('usebtn').classList.toggle('hidden', !heldItem);
  if (heldItem) {
    const label = itemName(heldItem) + (heldCharges > 1 ? ` ×${heldCharges}` : '');
    $('itemname').textContent = label;
    $('itemname').style.color = ITEM_COLORS[heldItem] || '#fff';
    // on a phone the top-left stat box is far from the thumb: the button itself
    // says what it will fire
    $('usebtn').textContent = label;
    $('usebtn').style.color = ITEM_COLORS[heldItem] || '#b000ff';
    $('usebtn').style.borderColor = ITEM_COLORS[heldItem] || '#b000ff';
  }
}

// --- boost pads, trap mines, cannon bolts ------------------------------------

const padMeshes = new Map();
function addPad(p) {
  if (padMeshes.has(p.id)) return;
  const g = new THREE.Group();
  const ring = new THREE.Mesh(
    new THREE.RingGeometry(3.4, 4.6, 28).rotateX(-Math.PI / 2),
    new THREE.MeshBasicMaterial({
      color: 0xffb300, transparent: true, opacity: 0.8,
      side: THREE.DoubleSide, blending: THREE.AdditiveBlending, depthWrite: false,
    }),
  );
  g.add(ring);
  // double chevron pointing up-track (spins slowly, reads from any side)
  const chevShape = new THREE.Shape();
  chevShape.moveTo(-1.6, -0.6); chevShape.lineTo(0, 0.8); chevShape.lineTo(1.6, -0.6);
  chevShape.lineTo(1.6, 0.2); chevShape.lineTo(0, 1.6); chevShape.lineTo(-1.6, 0.2);
  chevShape.closePath();
  for (const off of [0, 1.4]) {
    const chev = new THREE.Mesh(
      new THREE.ShapeGeometry(chevShape),
      new THREE.MeshBasicMaterial({
        color: 0xffe600, transparent: true, opacity: 0.85,
        side: THREE.DoubleSide, blending: THREE.AdditiveBlending, depthWrite: false,
      }),
    );
    chev.rotation.x = -Math.PI / 2;
    chev.position.set(0, 0.1, 0.7 - off);
    g.add(chev);
  }
  const base = groundH(p.x, p.y);
  g.position.set(p.x, base + 0.15, p.y);
  g.userData.flash = 0;
  scene.add(g);
  padMeshes.set(p.id, g);
}

function clearPads() {
  for (const g of padMeshes.values()) {
    scene.remove(g);
    g.traverse((o) => { o.geometry?.dispose(); o.material?.dispose?.(); });
  }
  padMeshes.clear();
}

function stepPadMeshes(dt, now) {
  for (const g of padMeshes.values()) {
    g.rotation.y += dt * 0.9;
    const pulse = 1 + Math.sin(now * 3 + g.position.x) * 0.07 + g.userData.flash * 0.5;
    g.scale.setScalar(pulse);
    g.userData.flash = Math.max(0, g.userData.flash - dt * 3);
  }
}

const mineMeshes = new Map();
function addMine(m) {
  if (mineMeshes.has(m.id)) return;
  const color = new THREE.Color(ITEM_COLORS.trap);
  const g = new THREE.Group();
  const spike = new THREE.Mesh(
    new THREE.OctahedronGeometry(1.2),
    new THREE.MeshBasicMaterial({ color: 0x0a1410 }),
  );
  g.add(spike);
  const edges = new THREE.LineSegments(
    new THREE.EdgesGeometry(spike.geometry),
    new THREE.LineBasicMaterial({ color }),
  );
  g.add(edges);
  const halo = new THREE.Sprite(new THREE.SpriteMaterial({
    map: getGlowTexture(), color, transparent: true, opacity: 0.6,
    depthWrite: false, blending: THREE.AdditiveBlending,
  }));
  halo.scale.set(5, 5, 1);
  g.add(halo);
  g.position.set(m.x, groundH(m.x, m.y) + 1.1, m.y);
  scene.add(g);
  mineMeshes.set(m.id, g);
}

function removeMine(id, boom) {
  const g = mineMeshes.get(id);
  if (!g) return;
  if (boom) {
    explode(g.position.x, g.position.z, ITEM_COLORS.trap);
    spawnRing(g.position.x, g.position.z, 3, ITEM_COLORS.trap);
  }
  scene.remove(g);
  g.traverse((o) => { o.geometry?.dispose(); if (o.material && !o.material.map) o.material.dispose(); });
  mineMeshes.delete(id);
}

function clearMines() {
  for (const id of [...mineMeshes.keys()]) removeMine(id, false);
}

function stepMineMeshes(dt, now) {
  for (const g of mineMeshes.values()) {
    g.rotation.y += dt * 2.4;
    const blink = 0.55 + Math.sin(now * 6) * 0.3;
    g.children[2].material.opacity = blink;
  }
}

// client integrates bolt flight itself; the server only sends spawn + removal
const shotMeshes = new Map();
function addShot(ev) {
  if (shotMeshes.has(ev.id)) return;
  const color = new THREE.Color(ITEM_COLORS.cannon);
  const g = new THREE.Group();
  const bolt = new THREE.Mesh(
    new THREE.BoxGeometry(3.2, 0.5, 0.5),
    new THREE.MeshBasicMaterial({
      color: 0xffffff, transparent: true, opacity: 0.95,
      blending: THREE.AdditiveBlending, depthWrite: false,
    }),
  );
  g.add(bolt);
  const halo = new THREE.Sprite(new THREE.SpriteMaterial({
    map: getGlowTexture(), color, transparent: true, opacity: 0.85,
    depthWrite: false, blending: THREE.AdditiveBlending,
  }));
  halo.scale.set(7, 7, 1);
  g.add(halo);
  g.position.set(ev.x, groundH(ev.x, ev.y) + 1.1, ev.y);
  g.rotation.y = -ev.h;
  g.userData.vx = Math.cos(ev.h) * 95;
  g.userData.vy = Math.sin(ev.h) * 95;
  scene.add(g);
  shotMeshes.set(ev.id, g);
}

function removeShot(id, x, y, boom) {
  const g = shotMeshes.get(id);
  if (!g) return;
  if (boom) explode(x ?? g.position.x, y ?? g.position.z, ITEM_COLORS.cannon);
  scene.remove(g);
  g.traverse((o) => { o.geometry?.dispose(); if (o.material && !o.material.map) o.material.dispose(); });
  shotMeshes.delete(id);
}

function clearShots() {
  for (const id of [...shotMeshes.keys()]) removeShot(id, null, null, false);
}

function stepShotMeshes(dt) {
  for (const g of shotMeshes.values()) {
    const nx = g.position.x + g.userData.vx * dt;
    const nz = g.position.z + g.userData.vy * dt;
    g.position.set(nx, groundH(nx, nz) + 1.1, nz);
  }
}

// expanding EMP shockwave ring
const rings = [];
function spawnRing(x, y, r, colorHex) {
  const geo = new THREE.RingGeometry(0.9, 1, 40);
  const mat = new THREE.MeshBasicMaterial({
    color: new THREE.Color(colorHex), transparent: true, opacity: 0.9,
    side: THREE.DoubleSide, blending: THREE.AdditiveBlending, depthWrite: false,
  });
  const mesh = new THREE.Mesh(geo, mat);
  mesh.rotation.x = -Math.PI / 2;
  mesh.position.set(x, groundH(x, y) + 0.4, y);
  scene.add(mesh);
  rings.push({ mesh, age: 0, life: 0.7, r });
}

function stepRings(dt) {
  for (let i = rings.length - 1; i >= 0; i--) {
    const e = rings[i];
    e.age += dt;
    if (e.age >= e.life) {
      scene.remove(e.mesh);
      e.mesh.geometry.dispose(); e.mesh.material.dispose();
      rings.splice(i, 1);
      continue;
    }
    const f = e.age / e.life;
    e.mesh.scale.setScalar(0.5 + f * e.r * 1.6);
    e.mesh.material.opacity = 0.9 * (1 - f);
  }
}

// --- explosion particles ------------------------------------------------------

const explosions = [];
function explode(x, y, colorHex) {
  const n = 70;
  const h = groundH(x, y);
  const pos = new Float32Array(n * 3);
  const vel = [];
  for (let i = 0; i < n; i++) {
    pos.set([x, h + 1.2, y], i * 3);
    const a = Math.random() * Math.PI * 2;
    const up = Math.random() * 18;
    const sp = 6 + Math.random() * 26;
    vel.push([Math.cos(a) * sp, up, Math.sin(a) * sp]);
  }
  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  const mat = new THREE.PointsMaterial({
    color: new THREE.Color(colorHex), size: 0.9,
    transparent: true, opacity: 1, blending: THREE.AdditiveBlending, depthWrite: false,
  });
  const points = new THREE.Points(geo, mat);
  points.frustumCulled = false;
  scene.add(points);
  explosions.push({ points, vel, age: 0, life: 1.1, floorY: h + 0.1 });
}

function stepExplosions(dt) {
  for (let i = explosions.length - 1; i >= 0; i--) {
    const e = explosions[i];
    e.age += dt;
    if (e.age >= e.life) {
      scene.remove(e.points);
      e.points.geometry.dispose(); e.points.material.dispose();
      explosions.splice(i, 1);
      continue;
    }
    const p = e.points.geometry.attributes.position.array;
    for (let j = 0; j < e.vel.length; j++) {
      const v = e.vel[j];
      v[1] -= 30 * dt;
      p[j * 3] += v[0] * dt;
      p[j * 3 + 1] = Math.max(e.floorY, p[j * 3 + 1] + v[1] * dt);
      p[j * 3 + 2] += v[2] * dt;
    }
    e.points.geometry.attributes.position.needsUpdate = true;
    e.points.material.opacity = 1 - e.age / e.life;
  }
}

// --- networking ---------------------------------------------------------------

let ws = null;
let reconnectDelay = 0.5;
let localHost = null;   // portal builds host the match in this tab

// Portal builds have no server to talk to: stand up an in-tab host and hand the
// client a socket onto it. Same protocol, so nothing below this line changes.
async function openTransport() {
  if (!portal.OFFLINE) {
    const proto = location.protocol === 'https:' ? 'wss' : 'ws';
    return new WebSocket(`${proto}://${location.host}/ws`);
  }
  if (!localHost) {
    const { LocalHost } = await import('./local/host.js');
    localHost = new LocalHost();
    localHost.start();
  }
  return localHost.connect();
}

// ads must not run over a live simulation, and the local host would otherwise
// tick the next countdown away behind a full-screen ad
function pauseHost() { localHost?.stop(); }
function resumeHost() { localHost?.start(); }

let adBreak = false;

// Portal ad break: freeze the match, silence the game, hand the screen over.
// Resolves { shown } — only a true `shown` may be turned into a reward.
async function withAdBreak(type) {
  if (adBreak) return { shown: false, error: 'busy' };
  adBreak = true;
  pauseHost();
  duckAudio(true);
  try {
    return await portal.requestAd(type);
  } finally {
    duckAudio(false);
    resumeHost();
    adBreak = false;
  }
}

// setRoomState re-runs on every room message, but the SDK throttles repeated
// lifecycle calls and complains — so only edges are reported.
let lastPortalState;
let gameplayOn = false;
function reportPortalState(state) {
  if (state === lastPortalState) return;
  lastPortalState = state;
  const playing = state === 'running';
  // stop is only ever a counterpart to a start we actually made
  if (playing !== gameplayOn) {
    gameplayOn = playing;
    if (playing) portal.gameplayStart(); else portal.gameplayStop();
  }
  // Between rounds is the only break this game has; the SDK enforces its own
  // 3-minute floor on top, so this settles at one ad every few rounds.
  if (state === 'ended' && portal.PORTAL && !adBreak) withAdBreak('midgame');
}

async function connect() {
  ws = await openTransport();
  ws.onopen = () => {
    connected = true;
    reconnectDelay = 0.5;
    $('connmsg').textContent = '';
    $('play').disabled = false;
    if (telegram.inTelegram) sendMsg({ t: 'tg_auth', initData: telegram.initData });
    else sendMsg({ t: 'auth', token: deviceToken, name: $('name').value.trim().toUpperCase() });
  };
  ws.onmessage = (e) => {
    let m; try { m = JSON.parse(e.data); } catch { return; }
    handle(m);
  };
  ws.onclose = () => {
    connected = false;
    toMenu();
    $('connmsg').textContent = t('reconnecting');
    $('play').disabled = true;
    setTimeout(connect, reconnectDelay * 1000);
    reconnectDelay = Math.min(8, reconnectDelay * 1.7);
  };
  ws.onerror = () => ws.close();
}

function sendMsg(o) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); }

function handle(m) {
  switch (m.t) {
    case 'hello':
      room.arena = m.cfg.arena;
      tgConfig = m.tg || null;
      break;
    case 'room': {
      document.activeElement?.blur();
      room.code = m.code;
      room.private = m.private;
      room.you = m.you;
      room.arena = m.cfg.arena;
      room.map = m.cfg.map || 'empty';
      room.mode = m.cfg.mode || 'classic';
      room.size = m.cfg.size || 12;
      room.trailMaxArc = m.cfg.trailMaxArc || 420;
      room.fillBots = m.cfg.fillBots !== false;
      buildEnvironment(room.map, room.arena);
      room.members.clear();
      for (const mem of m.members) room.members.set(mem.id, mem);
      lobbyDeadline = m.lobbySecs === null ? null : perfNow() + m.lobbySecs;
      countdownDeadline = m.countdownSecs === null ? null : perfNow() + m.countdownSecs;
      if (m.match) buildMatchScene(m.match); else clearMatchScene();
      setRoomState(m.state);
      if (!portal.OFFLINE) history.replaceState(null, '', room.private ? `/room/${room.code}` : '/');
      break;
    }
    case 'ev': for (const ev of m.list) handleEvent(ev); break;
    case 's': {
      const cand = m.time - perfNow();
      match.timeOffset = match.timeOffset === null ? cand : match.timeOffset * 0.9 + cand * 0.1;
      match.lastTime = m.time;
      match.bounds = m.bounds;
      layoutWalls(match.bounds);
      for (const [id, x, y, h, flags] of m.ps) {
        const b = match.bikes.get(id);
        if (b && b.alive) b.pushSnap(m.time, x, y, h, flags);
        if (id === room.you) setBoostSound(!!(flags & 1));
      }
      break;
    }
    case 'me':
      $('energybar').style.width = `${m.energy}%`;
      $('mykills').textContent = t('kills_n', { n: m.kills });
      if ((m.item || null) !== heldItem || (m.item && (m.ch || 1) !== heldCharges)) {
        updateItemSlot(m.item, m.ch);
      }
      if (match.race && m.lap) {
        match.race.myLap = m.lap;
        match.race.myCp = m.cp || 0;
      }
      break;
    case 'pong': pingMs = Math.round(performance.now() - m.now); break;
    case 'joinfail': $('connmsg').textContent = m.reason; toMenu(); break;
    case 'profile': {
      profile = m.p;
      if (m.bought) {
        toast(t('unlocked'), '#ffe600');
        applyPurchase(m.bought);
      }
      if (m.adReward) toast(t('plus_shards', { n: m.adReward }), '#b000ff');
      if (m.starShards) {
        toast(t('plus_shards', { n: m.starShards }), '#ffd700');
        telegram.haptic('medium');
        renderStarShop();
      }
      if (m.linked) accMsg(t('msg_linked'), true);
      if (m.loggedIn) accMsg(t('msg_signedin'), true);
      if (!$('name').value && profile.name) $('name').value = profile.name;
      renderProfileUI();
      break;
    }
    case 'authfail':
      // a rejected Mini App session still gets to play as a guest device
      if (m.op === 'tg') {
        sendMsg({ t: 'auth', token: deviceToken, name: $('name').value.trim().toUpperCase() });
        break;
      }
      accMsg(tReason(m.reason), false);
      break;
    case 'star_invoice':
      telegram.openInvoice(m.link).then((status) => {
        setStarBusy(null);
        // 'paid' arrives before the webhook does; the profile push does the rest
        if (status === 'failed') toast(t('star_failed'), '#ff5252');
      });
      break;
    case 'buyfail':
      if (starBusy && m.item === starBusy) { setStarBusy(null); toast(t('star_failed'), '#ff5252'); }
      else if (m.item === 'ad') { closeModal('admodal'); toast(tReason(m.reason).toUpperCase(), '#ff5252'); }
      else showBuyHint(tReason(m.reason).toUpperCase(), null);
      break;
    case 'reward': {
      profile = m.p;
      renderProfileUI();
      const parts = [];
      if (m.cpDelta) parts.push(`${m.cpDelta > 0 ? '+' : ''}${m.cpDelta} CP`);
      if (m.neobitsDelta) parts.push(`+${m.neobitsDelta} ◉`);
      if (parts.length) toast(parts.join(' · '), '#9fd8ff');
      if (m.leagueUp) setTimeout(() => toast(t('league_up', { league: m.leagueUp.toUpperCase() }), '#ffd700'), 900);
      (m.challengesDone || []).forEach((c, i) => setTimeout(() => {
        const reward = c.reward.neobits ? `+${c.reward.neobits} ◉` : `+${c.reward.shards} ◆`;
        toast(`${t('ch_complete')} ${reward}`, '#00ff66');
        sfx.win?.();
      }, 1600 + i * 900));
      break;
    }
  }
}

function handleEvent(ev) {
  switch (ev.t) {
    case 'p+': room.members.set(ev.p.id, ev.p); renderLobbyMembers(); break;
    case 'p-': room.members.delete(ev.id); renderLobbyMembers(); break;
    case 'lobby':
      lobbyDeadline = ev.secs === null ? null : perfNow() + ev.secs;
      setRoomState('lobby');
      break;
    case 'countdown': {
      room.members.clear();
      for (const mem of ev.roster) room.members.set(mem.id, mem);
      countdownDeadline = perfNow() + ev.secs;
      lastCountShown = null;
      setRoomState('countdown');
      break;
    }
    case 'round_start':
      buildMatchScene(ev);
      setRoomState('running');
      sfx.go();
      break;
    case 'tp': {
      const b = match.bikes.get(ev.id);
      if (b) b.trail.addPoint(ev.x, ev.y);
      break;
    }
    case 'pu+': addPickup(ev); break;
    case 'pu-': {
      const g = pickupMeshes.get(ev.id);
      if (g && ev.by) {
        if (ev.by === room.you) {
          toast(itemName(ev.type), ITEM_COLORS[ev.type] || '#fff');
          updateItemSlot(ev.type, ev.type === 'cannon' ? 3 : 1);
          telegram.haptic('light');
        }
        sfx.orb();
      }
      removePickup(ev.id);
      break;
    }
    case 'sh+': {
      addShot(ev);
      if (ev.by === room.you) updateItemSlot(ev.left > 0 ? 'cannon' : null, ev.left);
      sfx.shot();
      break;
    }
    case 'sh-': removeShot(ev.id, ev.x, ev.y, !!ev.boom); break;
    case 'cut': {
      for (const [bid, list] of Object.entries(ev.cuts || {})) {
        const victim = match.bikes.get(Number(bid));
        if (victim) for (const i of list) victim.trail.cutSegment(i);
      }
      break;
    }
    case 'mine+': {
      addMine(ev);
      if (ev.by === room.you) { updateItemSlot(null); toast(itemName('trap'), ITEM_COLORS.trap); }
      sfx.mine();
      break;
    }
    case 'mine-': removeMine(ev.id, !!ev.boom); break;
    case 'pad': {
      const g = padMeshes.get(ev.id);
      if (g) g.userData.flash = 1;
      if (ev.by === room.you) { sfx.pad(); telegram.haptic('rigid'); }
      break;
    }
    case 'lap': {
      if (match.race && ev.id === room.you) {
        match.race.myLap = ev.lap;
        toast(t('lap_n', { n: ev.lap, total: match.race.laps }), '#00ffff');
        sfx.lap();
        telegram.haptic('medium');
      }
      break;
    }
    case 'fin': {
      const b = match.bikes.get(ev.id);
      if (ev.id === room.you) {
        toast(t('race_fin_you', { n: ev.place }), ev.place === 1 ? '#ffe600' : '#9fd8ff');
        sfx.win();
      } else if (b) {
        addFeed(`🏁 P${ev.place} ${b.name}`);
      }
      break;
    }
    case 'racepos': {
      if (match.race) {
        match.race.order = ev.o;
        renderRaceBoard();
      }
      break;
    }
    case 'rs': {
      const b = match.bikes.get(ev.id);
      if (!b) break;
      b.ghost = false;
      b.yaw.visible = true;
      b.snaps = [];
      b.pos = { x: ev.x, y: ev.y, h: ev.h };
      b.yaw.position.set(ev.x, groundH(ev.x, ev.y), ev.y);
      b.yaw.rotation.y = -ev.h;
      b.trail.addPoint(ev.x, ev.y);
      if (ev.id === room.you) sfx.spawn();
      break;
    }
    case 'use': {
      const b = match.bikes.get(ev.id);
      if (ev.id === room.you) updateItemSlot(null);
      if (ev.type === 'emp') {
        spawnRing(ev.x, ev.y, ev.r || 5, ITEM_COLORS.emp);
        for (const [bid, list] of Object.entries(ev.cuts || {})) {
          const victim = match.bikes.get(Number(bid));
          if (victim) for (const i of list) victim.trail.cutSegment(i);
        }
        sfx.emp();
      } else if (ev.type === 'snip' && b) {
        spawnRing(ev.x, ev.y, 2.5, b.color);
        b.trail.dispose();
        b.trail = new Trail(b.color, b.pattern);
        sfx.snip();
      } else if (ev.type === 'phase') {
        sfx.phase();
      } else if (ev.type === 'surge') {
        sfx.surge();
      }
      break;
    }
    case 'die': {
      const b = match.bikes.get(ev.id);
      if (b) {
        explode(ev.x, ev.y, b.color);
        sfx.death();
        if (ev.respawn) {
          // race crash: hide the wreck, fresh trail; 'rs' brings it back
          b.ghost = true;
          b.yaw.visible = false;
          b.trail.dispose();
          b.trail = new Trail(b.color, b.pattern);
        } else {
          b.alive = false;
          b.fade = 0.7;
          b.yaw.visible = false;
        }
      }
      const killer = ev.by === 0
        ? t(ev.k === 'building' ? (room.mode === 'race' ? 'the_track' : 'the_city') : 'the_wall')
        : ev.by === ev.id ? t('own_trail') : ev.byName;
      addFeed(`${killer} ⚡ ${b ? b.name : '?'}`);
      if (ev.by === room.you && ev.id !== room.you) {
        toast(t('derezzed_x', { name: b ? b.name : '' }), '#00ff66');
        sfx.kill();
        telegram.haptic('medium');
      }
      if (ev.id === room.you) {
        setBoostSound(false);
        releaseInputs();
        telegram.notifyHaptic('error');
        if (ev.respawn) {
          toast(t('respawning'), '#ff6ec7');
        } else {
          $('spectate').classList.remove('hidden');
          $('pads').classList.add('hidden');
          spectateId = null;
        }
      }
      renderRosterHud();
      break;
    }
    case 'round_end': {
      const winner = room.members.get(ev.winner);
      if (winner) winner.wins++;
      showWinner(ev, winner);
      setRoomState('ended');
      break;
    }
  }
}

// --- room state / UI ----------------------------------------------------------

function setRoomState(state) {
  room.state = state;
  const inMenu = state === null;
  setMusicMode(inMenu ? 'menu' : 'game');
  $('overlay').classList.toggle('gone', !inMenu);
  $('lobbyui').classList.toggle('hidden', inMenu || state === 'running');
  $('hud').classList.toggle('hidden', state !== 'running' && state !== 'ended');
  $('bigcount').classList.toggle('hidden', state !== 'countdown');
  if (state !== 'ended') $('winbanner').classList.add('hidden');
  $('lob-standings').classList.toggle('hidden', state !== 'ended');
  $('lob-members').classList.toggle('hidden', state === 'ended');
  renderSaveNudges();

  const iPlay = state === 'running' && match.bikes.has(room.you) && match.bikes.get(room.you).alive;
  $('pads').classList.toggle('hidden', !iPlay);
  $('spectate').classList.toggle('hidden', state !== 'running' || iPlay);
  if (!iPlay) releaseInputs();
  // "drag to steer" only until the player has actually done it once
  $('steerhint').classList.toggle('hidden', !(iPlay && touchDevice && controlScheme === 'wheel' && !hasSteered));
  // a live round is the only time a stray swipe should be worth a confirmation
  telegram.setClosingConfirmation(state === 'countdown' || state === 'running');
  syncBackButton();

  reportPortalState(state);

  $('ctrlhint').classList.toggle('hidden', state !== 'lobby' && state !== 'countdown');
  if (state === 'lobby' || state === 'countdown' || state === 'ended') {
    $('lob-code').textContent = t(room.private ? 'lobby_private_room' : 'lobby_room', { code: room.code });
    $('copylink').classList.toggle('hidden', !room.private);
    if (telegram.inTelegram) $('copylink').textContent = t('tg_share_link');
    renderLobbyMembers();
  }
  if (state === 'running') {
    $('mykills').textContent = t('kills_n', { n: 0 });
    $('energybar').style.width = '100%';
    $('shrinkwarn').classList.add('hidden');
    renderRosterHud();
  }
  disarmLeave();
}

function toMenu() {
  sendMsg({ t: 'leave' });
  clearMatchScene();
  room.code = null; room.state = null; room.members.clear();
  setRoomState(null);
  if (!portal.OFFLINE) history.replaceState(null, '', '/'); // no room links in a portal iframe
  setBoostSound(false);
}

function renderLobbyMembers() {
  if (room.state === 'running') { renderRosterHud(); return; }
  $('lob-members').innerHTML = [...room.members.values()].map((m) =>
    `<span class="chip ${m.bot ? 'bot' : ''}">
      <span class="dot" style="background:${m.color};color:${m.color}"></span>
      ${esc(m.name)}${m.wins ? ` · ${m.wins}W` : ''}${m.id === room.you ? ' (YOU)' : ''}
    </span>`).join('');
}

// race: live position board, ordered by the server's progress ranking
function renderRaceBoard() {
  if (!match.race) return;
  $('roster').classList.add('race');
  // rows carry their real position so a trimmed board still reads P1..P12
  let rows = match.race.order.map(([id, lap, done], i) => ({ id, lap, done, place: i + 1 }));
  // a phone cannot show 12 rows without eating the arena: keep the podium and
  // the riders you are actually racing
  if (document.body.classList.contains('phone') && rows.length > 6) {
    const mine = rows.findIndex((r) => r.id === room.you);
    const from = Math.min(Math.max(mine - 1, 3), rows.length - 3);
    rows = mine < 3 ? rows.slice(0, 6) : [...rows.slice(0, 3), ...rows.slice(from, from + 3)];
  }
  $('roster').innerHTML = rows.map(({ id, lap, done, place }) => {
    const m = room.members.get(id);
    if (!m) return '';
    return `<div class="r ${id === room.you ? 'me' : ''}">
      <span class="w">${place}.</span>
      <span class="dot" style="background:${m.color};color:${m.color}"></span>
      <span class="n">${esc(m.name)}${id === room.you ? ' ◄' : ''}</span>
      <span class="w">${done ? '🏁' : `L${lap}`}</span>
    </div>`;
  }).join('');
}

function renderRosterHud() {
  if (match.race) { renderRaceBoard(); return; }
  $('roster').classList.remove('race');
  $('roster').innerHTML = [...room.members.values()].map((m) => {
    const bike = match.bikes.get(m.id);
    const dead = bike ? !bike.alive : true;
    return `<div class="r ${dead ? 'dead' : ''}">
      <span class="dot" style="background:${m.color};color:${m.color}"></span>
      <span class="n">${esc(m.name)}${m.id === room.you ? ' ◄' : ''}</span>
      <span class="w">${m.wins}W</span>
    </div>`;
  }).join('');
}

function showWinner(ev, winner) {
  const el = $('winbanner');
  el.classList.remove('hidden');
  if (ev.winnerName) {
    el.innerHTML = `<small>${t('round_over')}</small>${t('x_wins', { name: esc(ev.winnerName) })}`;
    el.style.color = winner ? winner.color : '#ffe600';
  } else {
    el.innerHTML = `<small>${t('round_over')}</small>${t('draw')}`;
    el.style.color = '#9fb8d8';
  }
  if (ev.winner === room.you) {
    toast(t('victory'), '#ffe600'); sfx.win(); telegram.notifyHaptic('success'); portal.happytime();
  }
  $('lob-standings').innerHTML = ev.standings.map((s, i) =>
    `<div class="row ${s.id === ev.winner ? 'winner' : ''}">
      <span>${i + 1}. ${esc(s.name)}</span>
      <span>${ev.race ? (s.fin ? '🏁' : `LAP ${s.lap}`) : t('n_kills', { n: s.kills })}</span>
    </div>`).join('');
  $('lob-status').textContent = '';
}

function addFeed(text) {
  const el = document.createElement('div');
  el.className = 'feeditem';
  el.textContent = text;
  const feed = $('feed');
  feed.appendChild(el);
  while (feed.children.length > 5) feed.firstChild.remove();
  setTimeout(() => { el.style.opacity = '0'; }, 3400);
  setTimeout(() => el.remove(), 4100);
}

function toast(text, color) {
  const el = document.createElement('div');
  el.className = 'toast';
  el.style.color = color;
  el.textContent = text;
  $('toasts').appendChild(el);
  setTimeout(() => { el.style.opacity = '0'; }, 1400);
  setTimeout(() => el.remove(), 2000);
}

function esc(s) { return String(s).replace(/[<>&"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c])); }

// per-frame UI text (timers, statuses)
function updateUiTimers() {
  const now = perfNow();
  if (room.state === 'lobby') {
    let humans = 0;
    for (const m of room.members.values()) if (!m.bot) humans++;
    let msg = t('riders_n', { n: humans, max: room.size });
    if (room.fillBots === false && humans < 2) {
      msg += t('need_two');
    } else if (lobbyDeadline !== null) {
      const s = Math.max(0, Math.ceil(lobbyDeadline - now));
      msg += humans >= 2 ? '' : t('grid_opens_in', { s });
    }
    $('lob-status').textContent = msg;
  } else if (room.state === 'countdown' && countdownDeadline !== null) {
    $('lob-status').textContent = t('get_ready');
    const s = Math.max(1, Math.ceil(countdownDeadline - now));
    $('bigcount').textContent = s;
    if (s !== lastCountShown) { lastCountShown = s; sfx.count(); telegram.haptic('rigid'); }
  } else if (room.state === 'running') {
    let alive = 0;
    for (const b of match.bikes.values()) if (b.alive) alive++;
    if (match.race) {
      const myIdx = match.race.order.findIndex((o) => o[0] === room.you);
      $('racelap').classList.remove('hidden');
      $('racelap').textContent = t('lap_n', { n: match.race.myLap, total: match.race.laps })
        + (myIdx !== -1 ? ` · P${myIdx + 1}/${match.race.order.length}` : '');
      $('alivecount').textContent = t('mode_race');
    } else {
      $('alivecount').textContent = t('alive_n', { n: alive });
    }
    const secs = Math.floor(match.lastTime);
    $('roundtime').textContent = `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`;
    const shrinking = match.bounds[0] > 0.5;
    $('shrinkwarn').classList.toggle('hidden', !shrinking);
    if (shrinking && !match.alarmDone) { match.alarmDone = true; sfx.alarm(); telegram.notifyHaptic('warning'); }
    const myWins = room.members.get(room.you)?.wins || 0;
    $('mywins').textContent = myWins ? t('wins_n', { n: myWins }) : '';
  }
}

// --- render loop --------------------------------------------------------------

const camState = { pos: new THREE.Vector3(env.center, 120, env.center + 260), look: new THREE.Vector3(env.center, 0, env.center), fov: 62, roll: 0 };
const camTargetPos = new THREE.Vector3();
const camTargetLook = new THREE.Vector3();
let lastFrame = perfNow();

const FRAME_BUDGET_MS = 1000 / 58;
const MIN_RENDER_SCALE = 0.6;
let frameEmaMs = 1000 / 60;
let slowFrames = 0;
let fastFrames = 0;
let scaleCooldownUntil = 0;
let resolutionWarmupUntil = lastFrame + 2;

const perfHud = document.createElement('div');
perfHud.style.cssText = 'display:none;position:fixed;left:8px;top:8px;z-index:99;padding:4px 6px;background:#000a;color:#0ff;font:11px monospace;pointer-events:none';
document.body.appendChild(perfHud);
let perfVisible = new URLSearchParams(location.search).has('perf');
let perfHudNextUpdate = 0;
perfHud.style.display = perfVisible ? 'block' : 'none';

function setRenderScale(next, now) {
  renderScale = Math.max(MIN_RENDER_SCALE, Math.min(1, Math.round(next * 10) / 10));
  renderer.setPixelRatio(baseDpr * renderScale);
  scaleCooldownUntil = now + 2;
  slowFrames = 0;
  fastFrames = 0;
  // Let the new buffer size settle before making another decision.
  frameEmaMs = 1000 / 60;
}

function updateDynamicResolution(dt, now) {
  // rAF interval includes the previous render's GPU pressure and is available
  // without forcing a costly GPU sync/readback.
  frameEmaMs += (dt * 1000 - frameEmaMs) * 0.08;
  if (document.hidden || now < resolutionWarmupUntil || now < scaleCooldownUntil) return;

  if (frameEmaMs > FRAME_BUDGET_MS) {
    slowFrames++;
    fastFrames = 0;
  } else if (frameEmaMs < FRAME_BUDGET_MS * 0.94) {
    fastFrames++;
    slowFrames = 0;
  } else {
    slowFrames = Math.max(0, slowFrames - 1);
    fastFrames = Math.max(0, fastFrames - 1);
  }

  if (slowFrames >= 40 && renderScale > MIN_RENDER_SCALE) setRenderScale(renderScale - 0.1, now);
  else if (fastFrames >= 300 && renderScale < 1) setRenderScale(renderScale + 0.1, now);
}

document.addEventListener('visibilitychange', () => {
  lastFrame = perfNow();
  resolutionWarmupUntil = lastFrame + 1;
  frameEmaMs = 1000 / 60;
  slowFrames = 0;
  fastFrames = 0;
});

function frame() {
  requestAnimationFrame(frame);
  const now = perfNow();
  const dt = Math.min(0.1, now - lastFrame);
  lastFrame = now;
  updateDynamicResolution(dt, now);
  const rt = match.timeOffset === null ? 0 : now + match.timeOffset - INTERP_DELAY;

  // bikes
  for (const b of match.bikes.values()) {
    if (!b.alive) {
      if (b.fade > 0) {
        b.fade -= dt;
        const f = Math.max(0, b.fade / 0.7);
        b.trail.mat.opacity = b.trail.baseOpacity * f;
        b.trail.topMat.opacity = 0.95 * f;
      }
      continue;
    }
    if (b.ghost) continue; // waiting for the race respawn
    const pose = b.poseAt(rt);
    b.pos = pose;
    b.yaw.position.set(pose.x, groundH(pose.x, pose.y), pose.y);
    b.yaw.rotation.y = -pose.h;
    // smoothed bank + slope pitch keep the ride fluid on curves and hills
    const lean = Math.max(-1, Math.min(1, b.turnRate() / 2.6)) * 0.45;
    b.leanS = (b.leanS || 0) + (lean - (b.leanS || 0)) * Math.min(1, dt * 9);
    b.roll.rotation.x = b.leanS;
    if (env.map !== 'empty') {
      const fx = Math.cos(pose.h), fy = Math.sin(pose.h);
      const pitch = Math.atan2(
        groundH(pose.x + fx * 2, pose.y + fy * 2) - groundH(pose.x - fx * 2, pose.y - fy * 2), 4);
      b.pitchS = (b.pitchS || 0) + (pitch - (b.pitchS || 0)) * Math.min(1, dt * 9);
      b.roll.rotation.z = b.pitchS;
    }
    b.trail.setHead(pose.x, pose.y);
    const snap = b.latest();
    const boost = snap?.boost;
    b.glow.material.opacity = boost ? 0.85 : 0.55;
    b.glow.scale.setScalar(boost ? 1.5 : 1);
    const phased = !!snap?.phase;
    if (phased !== b.phased) {
      b.phased = phased;
      for (const m of b.mats) {
        m.transparent = phased;
        m.opacity = phased ? 0.3 : 1;
        m.needsUpdate = true;
      }
    }
  }

  stepExplosions(dt);
  stepRings(dt);
  stepPickupMeshes(dt, now);
  stepPadMeshes(dt, now);
  stepMineMeshes(dt, now);
  stepShotMeshes(dt);
  // race: the gate you need next glows, the rest stay dim
  if (match.race && env.gates.length) {
    env.gates.forEach((gg, i) => {
      const active = i === match.race.myCp;
      gg.traverse((o) => { if (o.material) o.material.opacity = active ? 0.95 : 0.3; });
      gg.scale.y = active ? 1 + Math.sin(now * 5) * 0.08 : 1;
    });
  }
  if (cruise.active) stepCruise(dt, now);

  // camera
  updateCamera(dt, now);
  updateUiTimers();
  flushSteer(false);
  renderer.render(scene, camera);
  if (!firstFramePainted) {
    firstFramePainted = true;
    // hold Telegram's splash until there is something to look at
    telegram.ready();
  }
  if (!$('overlay').classList.contains('gone')) showroomFrame(dt);
  drawMinimap(dt);
  if (perfVisible && now >= perfHudNextUpdate) {
    perfHudNextUpdate = now + 0.25;
    perfHud.textContent = `${Math.round(1000 / frameEmaMs)} fps  ${renderScale.toFixed(1)}x  ${renderer.getPixelRatio().toFixed(2)} dpr`;
  }
}

function updateCamera(dt, now) {
  let targetFov = rig.fov;

  const my = match.bikes.get(room.you);
  let followed = null;
  if (room.state === 'running' || room.state === 'ended') {
    if (my && my.alive) followed = my;
    else {
      let spec = spectateId !== null ? match.bikes.get(spectateId) : null;
      if (!spec || !spec.alive) {
        spec = null;
        for (const b of match.bikes.values()) {
          if (b.alive) { spec = b; break; }
        }
        spectateId = spec ? spec.id : null;
      }
      followed = spec;
    }
  }

  if (followed) {
    const { x, y, h } = followed.pos;
    const fx = Math.cos(h), fy = Math.sin(h);
    const cx = x - fx * rig.dist, cz = y - fy * rig.dist;
    camTargetPos.set(cx, rig.height + Math.max(groundH(cx, cz), groundH(x, y) - 2), cz);
    camTargetLook.set(x + fx * rig.ahead, rig.lookH + groundH(x, y), y + fy * rig.ahead);
    const snap = followed.latest();
    if (snap?.boost) targetFov = rig.fov + 10;
    if (snap?.pad) targetFov = rig.fov + 14;
    if (snap?.surge) targetFov = rig.fov + 22;
  } else if (cruise.active) {
    const { x, y, heading: h } = cruise;
    const fx = Math.cos(h), fy = Math.sin(h);
    camTargetPos.set(x - fx * rig.dist, rig.height, y - fy * rig.dist);
    camTargetLook.set(x + fx * rig.ahead, rig.lookH, y + fy * rig.ahead);
    if (cruise.boost) targetFov = rig.fov + 10;
  } else {
    // menu / lobby orbit
    const a = now * 0.08;
    const c = env.center;
    camTargetPos.set(c + Math.cos(a) * 250, 110, c + Math.sin(a) * 250);
    camTargetLook.set(c, 0, c);
  }

  const k = followed || cruise.active ? Math.min(1, dt * 7) : Math.min(1, dt * 1.5);
  camState.pos.lerp(camTargetPos, k);
  camState.look.lerp(camTargetLook, Math.min(1, k * 1.4));
  camState.fov += (targetFov - camState.fov) * Math.min(1, dt * 5);
  camera.position.copy(camState.pos);
  camera.lookAt(camState.look);
  // the bike answers a turn only after the server round-trip; a small camera
  // roll answers it on the same frame, which is what makes touch feel direct
  const mine = followed === my || cruise.active;
  const rollTarget = mine ? -steerInput * 0.055 : 0;
  camState.roll += (rollTarget - camState.roll) * Math.min(1, dt * 6);
  if (camState.roll) camera.rotateZ(camState.roll);
  camera.fov = camState.fov;
  camera.updateProjectionMatrix();
}

// --- minimap ------------------------------------------------------------------

const mctx = $('minimap').getContext('2d');
let minimapElapsed = 1;
function drawMinimap(dt) {
  minimapElapsed += dt;
  if (minimapElapsed < 1 / 15) return;
  minimapElapsed %= 1 / 15;
  const S = 132;
  mctx.clearRect(0, 0, S, S);
  if (room.state !== 'running' && room.state !== 'ended') return;
  const k = S / room.arena;
  // bounds
  const [x0, y0, x1, y1] = match.bounds;
  mctx.strokeStyle = x0 > 0.5 ? 'rgba(255,0,85,0.8)' : 'rgba(0,255,255,0.4)';
  mctx.lineWidth = 1;
  mctx.strokeRect(x0 * k, y0 * k, (x1 - x0) * k, (y1 - y0) * k);
  // obstacles: towers, gate pillars, track barriers — any map that has them
  const obs = MAPS[room.map]?.obstacles;
  if (obs) {
    mctx.fillStyle = room.map === 'circuit' ? 'rgba(255,110,199,0.45)' : 'rgba(255,0,255,0.35)';
    for (const o of obs) {
      mctx.fillRect(o.minX * k, o.minY * k, (o.maxX - o.minX) * k, (o.maxY - o.minY) * k);
    }
  }
  // race gates; the one you need next lights up
  if (match.race && MAPS.circuit.gates) {
    MAPS.circuit.gates.forEach((gt, i) => {
      mctx.strokeStyle = i === match.race.myCp ? '#00ffff' : 'rgba(0,255,255,0.25)';
      mctx.lineWidth = i === match.race.myCp ? 2 : 1;
      mctx.beginPath();
      mctx.moveTo(gt.x1 * k, gt.y1 * k);
      mctx.lineTo(gt.x2 * k, gt.y2 * k);
      mctx.stroke();
    });
  }
  // pads & mines
  mctx.fillStyle = '#ffb300';
  for (const g of padMeshes.values()) mctx.fillRect(g.position.x * k - 2, g.position.z * k - 2, 4, 4);
  mctx.fillStyle = ITEM_COLORS.trap;
  for (const g of mineMeshes.values()) mctx.fillRect(g.position.x * k - 1.5, g.position.z * k - 1.5, 3, 3);
  // trails
  for (const b of match.bikes.values()) {
    const pts = b.trail.pts;
    const start = b.trail.drawStart;
    if (!b.alive || pts.length - start < 1) continue;
    // Bound path work for very long rounds while always retaining the endpoint.
    const stride = Math.max(1, Math.ceil((pts.length - start) / 512));
    const cuts = b.trail.cutSet;
    mctx.beginPath();
    mctx.moveTo(pts[start][0] * k, pts[start][1] * k);
    for (let i = start + stride; i < pts.length; i += stride) {
      let gap = false;
      if (cuts.size) for (let j = i - stride; j < i; j++) if (cuts.has(j)) { gap = true; break; }
      if (gap) mctx.moveTo(pts[i][0] * k, pts[i][1] * k);
      else mctx.lineTo(pts[i][0] * k, pts[i][1] * k);
    }
    const last = pts[pts.length - 1];
    mctx.lineTo(last[0] * k, last[1] * k);
    mctx.strokeStyle = b.color + '55';
    mctx.lineWidth = 1;
    mctx.stroke();
  }
  // pickups
  for (const g of pickupMeshes.values()) {
    mctx.fillStyle = ITEM_COLORS[g.userData.type] || '#fff';
    mctx.fillRect(g.position.x * k - 2, g.position.z * k - 2, 4, 4);
  }
  // bikes
  for (const b of match.bikes.values()) {
    if (!b.alive || b.ghost) continue;
    mctx.fillStyle = b.id === room.you ? '#ffffff' : b.color;
    const r = b.id === room.you ? 3.5 : 2.5;
    mctx.fillRect(b.pos.x * k - r / 2, b.pos.y * k - r / 2, r, r);
  }
}

// --- input --------------------------------------------------------------------

let steerKeys = { left: false, right: false };
let boostHeld = false;
// steering is analog end to end: the sim clamps to [-1,1] and eases towards it,
// so a thumb drag can ask for a gentle arc where a key can only ask for a lock.
let steerInput = 0;     // what the player is asking for right now
let steerSent = 0;      // what the server last heard
let steerNextSend = 0;

function setSteerInput(v) {
  const next = Math.max(-1, Math.min(1, v));
  if (next === steerInput) return;
  steerInput = next;
  // neutral and full lock are the moments that decide a round: never delay them
  flushSteer(next === 0 || Math.abs(next) === 1);
}

// throttled so a drag can't spam the socket at frame rate
function flushSteer(force) {
  if (steerInput === steerSent) return;
  const now = performance.now();
  if (!force && (now < steerNextSend || Math.abs(steerInput - steerSent) < 0.03)) return;
  steerNextSend = now + 33;
  steerSent = steerInput;
  sendMsg({ t: 'steer', v: Math.round(steerInput * 100) / 100 });
}

function sendSteer() {
  setSteerInput((steerKeys.right ? 1 : 0) - (steerKeys.left ? 1 : 0));
}

// a lost pointer, a backgrounded tab or a Telegram minimise must not leave the
// bike locked into a turn (or burning energy) while nobody is holding anything
function releaseInputs() {
  steerKeys.left = steerKeys.right = false;
  endWheel();
  setSteerInput(0);
  setBoost(false);
  for (const el of [$('padL'), $('padR')]) el.classList.remove('on');
}

function setBoost(on) {
  if (boostHeld === on) return;
  boostHeld = on;
  $('boostbtn').classList.toggle('on', on);
  if (on) telegram.haptic('medium');
  sendMsg({ t: 'boost', on });
}

addEventListener('keydown', (e) => {
  if (e.repeat) {
    if (['ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) e.preventDefault();
    return;
  }
  if (e.code === 'Backquote') {
    perfVisible = !perfVisible;
    perfHud.style.display = perfVisible ? 'block' : 'none';
    e.preventDefault();
    return;
  }
  const active = document.activeElement;
  const typing = !$('overlay').classList.contains('gone') &&
    active?.tagName === 'INPUT' && ['text', 'search', 'email', 'password'].includes(active.type);
  if (typing) {
    if (e.key === 'Enter') {
      if (active.id === 'flagsearch') return;
      if (active.id.startsWith('acc-')) $('linkacc').click();
      else if (active === $('roomcode')) joinPrivate();
      else if (urlRoom) joinPrivate(urlRoom);
      else joinPublic();
    }
    return;
  }
  switch (e.code) {
    case 'ArrowLeft': case 'KeyA': steerKeys.left = true; sendSteer(); e.preventDefault(); break;
    case 'ArrowRight': case 'KeyD': steerKeys.right = true; sendSteer(); e.preventDefault(); break;
    case 'Space': case 'ShiftLeft': setBoost(true); e.preventDefault(); break;
    case 'KeyE': if (heldItem) sendMsg({ t: 'use' }); e.preventDefault(); break;
    case 'KeyM': unlock(); toggleAllAudio(); break;
  }
});
addEventListener('keyup', (e) => {
  switch (e.code) {
    case 'ArrowLeft': case 'KeyA': steerKeys.left = false; sendSteer(); break;
    case 'ArrowRight': case 'KeyD': steerKeys.right = false; sendSteer(); break;
    case 'Space': case 'ShiftLeft': setBoost(false); break;
  }
});

function bindPad(el, key) {
  const down = (e) => {
    steerKeys[key] = true; el.classList.add('on'); sendSteer();
    telegram.haptic('light');
    try { el.setPointerCapture(e.pointerId); } catch { /* not captured: pointerup still fires */ }
    e.preventDefault();
  };
  const up = (e) => { steerKeys[key] = false; el.classList.remove('on'); sendSteer(); e.preventDefault(); };
  el.addEventListener('pointerdown', down);
  // pointercapture keeps the press alive when the thumb drifts off the pad,
  // so we deliberately do NOT release on pointerleave
  for (const ev of ['pointerup', 'pointercancel']) el.addEventListener(ev, up);
}
bindPad($('padL'), 'left');
bindPad($('padR'), 'right');

// --- steering wheel: drag anywhere in the play area, analog ------------------
// Anchored to wherever the thumb lands (not to a fixed pad centre) so putting a
// finger down never snaps you into a turn — in this game one wrong turn is the
// whole round.
const wheel = { id: null, x: 0, y: 0 };
const knob = $('steerknob');

function moveKnob(dx) {
  const clamped = Math.max(-STEER_RADIUS, Math.min(STEER_RADIUS, dx));
  knob.style.transform = `translate(${wheel.x}px, ${wheel.y}px)`;
  knob.style.setProperty('--dx', `${clamped}px`);
}

function endWheel() {
  if (wheel.id === null) return;
  wheel.id = null;
  knob.classList.add('hidden');
}

const zone = $('steerzone');
zone.addEventListener('pointerdown', (e) => {
  if (wheel.id !== null) return;              // one steering finger; the rest are buttons
  wheel.id = e.pointerId;
  wheel.x = e.clientX; wheel.y = e.clientY;
  knob.classList.remove('hidden');
  moveKnob(0);
  try { zone.setPointerCapture(e.pointerId); } catch { /* fall back to window events */ }
  e.preventDefault();
});
zone.addEventListener('pointermove', (e) => {
  if (e.pointerId !== wheel.id) return;
  const dx = e.clientX - wheel.x;
  const v = steerFromDrag(dx);
  if (!hasSteered && v !== 0) {
    hasSteered = true;
    localStorage.setItem('tron.steered', '1');
    $('steerhint').classList.add('hidden');
  }
  // detent feedback only when crossing left / straight / right, never per pixel
  const bucket = Math.sign(v);
  if (bucket !== Math.sign(steerInput)) telegram.selectionHaptic();
  setSteerInput(v);
  moveKnob(dx);
  e.preventDefault();
});
for (const ev of ['pointerup', 'pointercancel']) {
  zone.addEventListener(ev, (e) => {
    if (e.pointerId !== wheel.id) return;
    endWheel();
    setSteerInput(0);
    e.preventDefault();
  });
}

const bb = $('boostbtn');
bb.addEventListener('pointerdown', (e) => {
  setBoost(true);
  try { bb.setPointerCapture(e.pointerId); } catch { /* pointerup still fires */ }
  e.preventDefault();
});
for (const ev of ['pointerup', 'pointercancel']) bb.addEventListener(ev, (e) => { setBoost(false); e.preventDefault(); });
$('usebtn').addEventListener('pointerdown', (e) => {
  if (heldItem) { sendMsg({ t: 'use' }); telegram.haptic('light'); }
  e.preventDefault();
});

function applyControlScheme() {
  document.body.classList.toggle('pads-mode', controlScheme === 'pads');
  document.body.classList.toggle('wheel-mode', controlScheme === 'wheel');
  releaseInputs();
  for (const b of document.querySelectorAll('#ctrlpick button')) {
    b.classList.toggle('selected', b.dataset.controls === controlScheme);
  }
  const key = controlScheme === 'pads' ? 'ctrl_steer_touch' : 'ctrl_steer_wheel';
  for (const el of document.querySelectorAll('.help-touch-steer')) el.textContent = t(key);
}
$('ctrlpick').addEventListener('click', (e) => {
  const b = e.target.closest('button[data-controls]');
  if (!b) return;
  controlScheme = validScheme(b.dataset.controls);
  localStorage.setItem('tron.controls', controlScheme);
  applyControlScheme();
});

addEventListener('blur', releaseInputs);
addEventListener('visibilitychange', () => { if (document.hidden) releaseInputs(); });

// tap to switch spectated rider
$('game').addEventListener('pointerdown', () => {
  const my = match.bikes.get(room.you);
  if (room.state === 'running' && (!my || !my.alive)) {
    const alive = [...match.bikes.values()].filter((b) => b.alive);
    if (!alive.length) return;
    const idx = alive.findIndex((b) => b.id === spectateId);
    spectateId = alive[(idx + 1) % alive.length].id;
  }
});

// --- menu wiring --------------------------------------------------------------

function joinPublic() {
  unlock();
  const name = $('name').value.trim().toUpperCase();
  localStorage.setItem('tron.name', name);
  sendMsg({ t: 'play', name, mode: selectedMode, map: selectedMap, ...selectedCosmetics() });
}

function joinPrivate(code = $('roomcode').value.trim().toUpperCase()) {
  if (!code) return;
  unlock();
  const name = $('name').value.trim().toUpperCase();
  localStorage.setItem('tron.name', name);
  sendMsg({
    t: 'play', name, room: code, bots: fillBots, size: selectedSize,
    mode: selectedMode, map: selectedMap, ...selectedCosmetics(),
  });
}

// --- main menu sections -------------------------------------------------------

const MENU_SECTIONS = ['menu-home', 'menu-setup', 'menu-room', 'menu-garage', 'menu-challenges', 'menu-settings'];
const MODAL_IDS = ['flagmodal', 'profilemodal', 'leaguesmodal', 'admodal'];
function showSection(id) {
  for (const s of MENU_SECTIONS) $(s).classList.toggle('hidden', s !== id);
  if (id === 'menu-challenges') renderChallenges();
  syncBackButton();
}
$('mainmenu').addEventListener('click', (e) => {
  const row = e.target.closest('.mrow');
  if (row && row.dataset.section) { showSection(row.dataset.section); telegram.selectionHaptic(); }
});
for (const b of document.querySelectorAll('.backbtn')) {
  b.addEventListener('click', () => showSection('menu-home'));
}

// Telegram's native back arrow, driven from one handler: modal → sub-menu →
// out of the room → gone. Web players still use the on-screen buttons.
function openModalId() {
  return MODAL_IDS.find((id) => !$(id).classList.contains('hidden')) || null;
}
function syncBackButton() {
  if (!telegram.inTelegram) return;
  const modal = openModalId();
  const sub = MENU_SECTIONS.find((s) => s !== 'menu-home' && !$(s).classList.contains('hidden'));
  const inMenu = !$('overlay').classList.contains('gone');
  if (modal) telegram.setBackAction(() => { closeModal(modal); syncBackButton(); });
  else if (inMenu && sub) telegram.setBackAction(() => showSection('menu-home'));
  else if (!inMenu && !cruise.active) telegram.setBackAction(() => toMenu());
  else if (cruise.active) telegram.setBackAction(() => exitCruise());
  else telegram.setBackAction(null);
}

function patternLabel(p) {
  if (isFlagPattern(p)) return `${flagEmoji(p.slice(5))} ${p.slice(5).toUpperCase()}`;
  return p.toUpperCase();
}

// one-line state summaries under each menu row
function renderMenuSummaries() {
  $('sum-setup').textContent = selectedMode === 'race'
    ? `${t('mode_race')} · ${t('map_circuit')}`
    : `${t('mode_' + selectedMode)} · ${t('map_' + selectedMap)}`;
  $('sum-room').textContent = t('sum_room', { n: selectedSize })
    + ' · ' + t(fillBots ? 'sum_bots_on' : 'sum_bots_off');
  $('sum-garage').textContent = `${displayShape().toUpperCase()} · ${patternLabel(displayPattern())}`;
  $('sum-settings').textContent = currentLang().toUpperCase();
  const list = profile?.challenges;
  $('sum-challenges').textContent = list
    ? t('ch_sum', { done: list.filter((c) => c.done).length, total: list.length })
    : t('ch_sum_new');
}

// --- mode & map ---------------------------------------------------------------

const GAME_MODES = ['classic', 'royale', 'race'];
const GAME_MAPS = ['empty', 'ridge', 'city'];
let selectedMode = GAME_MODES.includes(localStorage.getItem('tron.mode'))
  ? localStorage.getItem('tron.mode') : 'classic';
let selectedMap = GAME_MAPS.includes(localStorage.getItem('tron.map'))
  ? localStorage.getItem('tron.map') : 'empty';

function renderModeMap() {
  for (const el of $('modepick').children) el.classList.toggle('selected', el.dataset.mode === selectedMode);
  // race always runs on the circuit: the map row goes dormant
  const race = selectedMode === 'race';
  for (const el of $('mappick').children) {
    el.classList.toggle('selected', !race && el.dataset.map === selectedMap);
    el.classList.toggle('disabled', race);
  }
  $('modehint').textContent = race
    ? t('mh_race')
    : `${t('mh_' + selectedMode)} ${t('mh_' + selectedMap)}`;
  renderMenuSummaries();
}
// NOTE: renderModeMap/renderBotsToggle/renderSizePick render summaries that read
// `profile` and the cosmetic selections declared further down — their initial
// calls live at the bottom of the file, after every `let` they touch exists.
$('modepick').addEventListener('click', (e) => {
  if (!GAME_MODES.includes(e.target.dataset.mode)) return;
  selectedMode = e.target.dataset.mode;
  localStorage.setItem('tron.mode', selectedMode);
  renderModeMap();
});
$('mappick').addEventListener('click', (e) => {
  if (selectedMode === 'race') return; // circuit is pinned
  if (!GAME_MAPS.includes(e.target.dataset.map)) return;
  selectedMap = e.target.dataset.map;
  localStorage.setItem('tron.map', selectedMap);
  renderModeMap();
});

let fillBots = localStorage.getItem('tron.fillbots') !== '0';
function renderBotsToggle() {
  $('botstoggle').textContent = t(fillBots ? 'fill_bots_on' : 'fill_bots_off');
  $('botstoggle').classList.toggle('warn', !fillBots);
  renderMenuSummaries();
}
$('botstoggle').addEventListener('click', () => {
  fillBots = !fillBots;
  localStorage.setItem('tron.fillbots', fillBots ? '1' : '0');
  renderBotsToggle();
});

// private-room size (2..12); public rooms always run the full 12
const ROOM_SIZES = [2, 4, 6, 8, 10, 12];
let selectedSize = Number(localStorage.getItem('tron.size'));
if (!ROOM_SIZES.includes(selectedSize)) selectedSize = 12;
function renderSizePick() {
  for (const el of $('sizepick').children) el.classList.toggle('selected', Number(el.dataset.size) === selectedSize);
  renderMenuSummaries();
}
$('sizepick').addEventListener('click', (e) => {
  const size = Number(e.target.dataset.size);
  if (!ROOM_SIZES.includes(size)) return;
  selectedSize = size;
  localStorage.setItem('tron.size', size);
  renderSizePick();
});

$('soundbtn').addEventListener('click', () => {
  unlock();
  $('audiopanel').classList.remove('hidden');
});

function makeCode() {
  const chars = 'ABCDEFGHJKMNPQRSTVWXYZ23456789';
  return Array.from({ length: 4 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}

$('play').addEventListener('click', () => urlRoom ? joinPrivate(urlRoom) : joinPublic());
$('joinroom').addEventListener('click', () => joinPrivate());
$('mkroom').addEventListener('click', () => joinPrivate(makeCode()));
$('leave').addEventListener('click', toMenu);
$('copylink').addEventListener('click', async () => {
  // inside Telegram, sharing into a chat beats a clipboard copy nobody pastes
  if (telegram.inTelegram) {
    telegram.shareRoom(room.code, tgConfig, t('tg_share_text', { code: room.code }));
    return;
  }
  const link = `${location.origin}/room/${room.code}`;
  try { await navigator.clipboard.writeText(link); $('copylink').textContent = t('link_copied'); }
  catch { prompt('Invite link:', link); }
  setTimeout(() => { $('copylink').textContent = t('copy_link'); }, 1600);
});

$('name').value = localStorage.getItem('tron.name') || '';
if (urlRoom) {
  $('roomcode').value = urlRoom;
  $('play').textContent = t('join_room', { code: urlRoom });
}

let selectedColor = PLAYER_COLORS.includes(localStorage.getItem('tron.color'))
  ? localStorage.getItem('tron.color') : PLAYER_COLORS[0];
let selectedShape = SHAPES.includes(localStorage.getItem('tron.shape'))
  ? localStorage.getItem('tron.shape') : 'blade';
let selectedPattern = validPattern(localStorage.getItem('tron.pattern'))
  ? localStorage.getItem('tron.pattern') : 'solid';
// Locked gear can be tried on in the showroom before buying; only what the
// player actually owns rides out (spec: preview-then-buy).
let previewShape = null;
let previewPattern = null;

function selectedCosmetics() {
  return { color: selectedColor, shape: selectedShape, pattern: selectedPattern };
}

function ownedItem(item) {
  if (FREE_PATTERNS.includes(item) || FREE_SHAPES.includes(item)) return true;
  return !!profile?.inventory?.includes(item);
}

function displayShape() { return previewShape || selectedShape; }
function displayPattern() { return previewPattern || selectedPattern; }

function renderCosmeticPicks() {
  for (const el of $('colorpick').children) el.classList.toggle('selected', el.dataset.color === selectedColor);
  for (const el of $('shapepick').children) {
    el.classList.toggle('selected', el.dataset.shape === selectedShape);
    el.classList.toggle('preview', el.dataset.shape === previewShape);
    el.classList.toggle('locked', !ownedItem(el.dataset.shape));
  }
  for (const pickId of ['patternpick', 'prempick']) {
    for (const el of $(pickId).children) {
      el.classList.toggle('selected', el.dataset.pattern === selectedPattern);
      el.classList.toggle('preview', el.dataset.pattern === previewPattern);
      el.classList.toggle('locked', !ownedItem(el.dataset.pattern));
    }
  }
  const flagPat = [previewPattern, selectedPattern].find((p) => p && isFlagPattern(p));
  $('flagopen').textContent = flagPat
    ? `${flagEmoji(flagPat.slice(5))} ${flagPat.slice(5).toUpperCase()}`
    : t('choose_flag');
  $('flagopen').classList.toggle('selected', isFlagPattern(selectedPattern));
  $('flagopen').classList.toggle('preview', !!previewPattern && isFlagPattern(previewPattern));
  renderMenuSummaries();
  showroomRefresh();
}

// --- menu showroom: live 3D preview of the selected ride (spec Task 3) --------

const shr = {
  canvas: $('showroom'), renderer: null, scene: null, camera: null,
  rig: null, built: '', angle: -0.6, fling: 0, dragging: false, lastX: 0,
};

function showroomInit() {
  try {
    shr.renderer = new THREE.WebGLRenderer({ canvas: shr.canvas, antialias: true, alpha: true });
  } catch { return; }
  shr.renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
  shr.renderer.setSize(880, 440, false);
  shr.scene = new THREE.Scene();
  shr.camera = new THREE.PerspectiveCamera(36, 2, 0.1, 80);
  shr.camera.position.set(0, 3.4, 11);
  shr.camera.lookAt(0, 1.15, 0);
  const grid = new THREE.GridHelper(26, 26, 0x2a5aa8, 0x16345f);
  grid.material.transparent = true;
  grid.material.opacity = 0.5;
  shr.scene.add(grid);

  const c = shr.canvas;
  c.addEventListener('pointerdown', (e) => {
    shr.dragging = true; shr.lastX = e.clientX;
    try { c.setPointerCapture(e.pointerId); } catch {}
    e.preventDefault();
  });
  c.addEventListener('pointermove', (e) => {
    if (!shr.dragging) return;
    const dx = e.clientX - shr.lastX;
    shr.lastX = e.clientX;
    shr.angle += dx * 0.012;
    shr.fling = dx * 0.8;
  });
  for (const ev of ['pointerup', 'pointercancel']) c.addEventListener(ev, () => { shr.dragging = false; });
}

function showroomRefresh() {
  if (!shr.renderer) return;
  const key = `${selectedColor}|${displayShape()}|${displayPattern()}`;
  if (shr.built === key) return;
  shr.built = key;
  if (shr.rig) {
    shr.scene.remove(shr.rig);
    shr.rig.traverse((o) => {
      if (o.geometry) o.geometry.dispose();
      if (o.material) o.material.dispose(); // shared textures survive material dispose
    });
  }
  shr.rig = new THREE.Group();
  shr.rig.add(makeBikeModel(selectedColor, displayShape()).yaw);

  // sample trail ribbon curving away behind the bike
  const SLICES = 26;
  const verts = new Float32Array((SLICES + 1) * 2 * 3);
  const uvs = new Float32Array((SLICES + 1) * 2 * 2);
  const idx = [];
  const uvScale = isFlag(displayPattern()) ? 1 / (TRAIL_H * 2) : 1 / 6;
  for (let i = 0; i <= SLICES; i++) {
    const f = i / SLICES;
    const x = -TRAIL_REAR - f * 10;
    const z = Math.sin(f * 1.9) * 2.4 * f;
    verts.set([x, 0, z, x, TRAIL_H, z], i * 6);
    const u = f * 10 * uvScale;
    uvs.set([u, 0, u, 1], i * 4);
    if (i < SLICES) {
      const v = i * 2;
      idx.push(v, v + 1, v + 2, v + 1, v + 3, v + 2);
    }
  }
  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.BufferAttribute(verts, 3));
  geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
  geo.setIndex(idx);
  shr.rig.add(new THREE.Mesh(geo, makeTrailMaterial(selectedColor, displayPattern())));
  shr.scene.add(shr.rig);
}

function showroomFrame(dt) {
  if (!shr.renderer || !shr.rig) return;
  if (!shr.dragging) {
    shr.fling *= Math.pow(0.03, dt);
    shr.angle += (0.35 + shr.fling) * dt;
  }
  shr.rig.rotation.y = shr.angle;
  shr.renderer.render(shr.scene, shr.camera);
}

$('colorpick').addEventListener('click', (e) => {
  const color = e.target.dataset.color;
  if (!PLAYER_COLORS.includes(color)) return;
  selectedColor = color;
  localStorage.setItem('tron.color', color);
  renderCosmeticPicks();
});
$('shapepick').addEventListener('click', (e) => {
  const shape = e.target.dataset.shape;
  if (!SHAPES.includes(shape)) return;
  if (!ownedItem(shape)) { previewShape = shape; offerItem(shape); renderCosmeticPicks(); return; }
  hideBuyHint();
  previewShape = null;
  selectedShape = shape;
  localStorage.setItem('tron.shape', shape);
  renderCosmeticPicks();
});
function pickPattern(pattern) {
  if (!validPattern(pattern)) return;
  if (!ownedItem(pattern)) { previewPattern = pattern; offerItem(pattern); renderCosmeticPicks(); return; }
  hideBuyHint();
  previewPattern = null;
  selectedPattern = pattern;
  localStorage.setItem('tron.pattern', pattern);
  renderCosmeticPicks();
}
for (const pick of [$('patternpick'), $('prempick')]) {
  pick.addEventListener('click', (e) => {
    if (e.target.dataset.pattern) pickPattern(e.target.dataset.pattern);
  });
}

// --- flag picker modal --------------------------------------------------------

let regionNames = null;
function flagName(code) {
  try {
    regionNames ||= new Intl.DisplayNames([currentLang()], { type: 'region' });
    return regionNames.of(code.toUpperCase()) || code.toUpperCase();
  } catch { return code.toUpperCase(); }
}

function renderFlagGrid() {
  const q = $('flagsearch').value.trim().toLowerCase();
  const current = displayPattern();
  $('flaggrid').innerHTML = FLAG_CODES
    .filter((code) => !q || code.includes(q) || flagName(code).toLowerCase().includes(q))
    .map((code) => {
      const pat = `flag-${code}`;
      const cls = [
        'flagbtn',
        pat === selectedPattern ? 'selected' : '',
        pat === current && pat !== selectedPattern ? 'preview' : '',
        ownedItem(pat) ? '' : 'locked',
      ].join(' ');
      return `<button class="${cls}" data-pattern="${pat}" title="${esc(flagName(code))}">` +
        `<span>${flagEmoji(code)}</span><small>${code.toUpperCase()}</small></button>`;
    }).join('');
}

$('flagopen').addEventListener('click', () => {
  renderFlagGrid();
  $('flagsearch').value = '';
  $('flagmodal').classList.remove('hidden');
  syncBackButton();
});
$('flagsearch').addEventListener('input', renderFlagGrid);
$('flaggrid').addEventListener('click', (e) => {
  const pattern = e.target.closest('[data-pattern]')?.dataset.pattern;
  if (!pattern) return;
  pickPattern(pattern);
  closeModal('flagmodal');
});

// --- economy / player card (spec Раздел II + accounts) ------------------------

const deviceToken = localStorage.getItem('tron.token')
  || (crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2) + Date.now());
localStorage.setItem('tron.token', deviceToken);
let profile = null;
let pendingBuy = null;

const LEAGUE_COLORS = {
  'Bronze Grid': '#c8874a', 'Neon Cobalt': '#4488ff', 'Cyber Gold': '#ffd700',
  'Overclock Platinum': '#d8dcff', 'NEON DEMON': '#ff0055',
};
// mirrors server/store.js LEAGUES (thresholds + one-time rewards)
const LEAGUE_TABLE = [
  { name: 'Bronze Grid', min: 0, rewardKey: null },
  { name: 'Neon Cobalt', min: 1500, rewardKey: 'lg_r_cobalt' },
  { name: 'Cyber Gold', min: 4500, rewardKey: 'lg_r_gold' },
  { name: 'Overclock Platinum', min: 9000, rewardKey: 'lg_r_plat' },
  { name: 'NEON DEMON', min: 15000, rewardKey: 'lg_r_demon' },
];

function fmtCur(p) { return `◉ ${p.neobits}  ◆ ${p.shards}`; }

// server messages arrive in english; map the known ones onto locale keys
const REASON_KEYS = {
  'wrong email or password': 'err_wrong_login',
  'not enough neobits': 'err_no_neobits',
  'not enough data shards': 'err_no_shards',
  'daily ad limit reached': 'err_ad_limit',
  'email already linked': 'err_email_linked',
  'password too short (6+)': 'err_pass_short',
  'already owned': 'err_owned',
};
function tReason(reason) { return REASON_KEYS[reason] ? t(REASON_KEYS[reason]) : reason; }

function offerItem(item) {
  const deal = shopDeal(item);
  if (!deal) return;
  const label = isFlagPattern(item)
    ? `${flagEmoji(item.slice(5))} ${flagName(item.slice(5))}`.toUpperCase()
    : item.toUpperCase();
  if (deal.league) {
    showBuyHint(t('league_reward', { item: label, league: deal.league }), null);
  } else {
    const afford = profile && profile[deal.cur] >= deal.cost;
    showBuyHint(
      `${label} · ${deal.cost} ${CUR_ICON[deal.cur]} ${t(deal.cur)}${afford ? '' : t('not_enough')}`,
      afford ? item : null,
    );
  }
}

function showBuyHint(text, buyItem) {
  pendingBuy = buyItem;
  $('buyhint').classList.remove('hidden');
  $('buyhint-text').textContent = text;
  $('buybtn').classList.toggle('hidden', !buyItem);
}

function hideBuyHint() {
  pendingBuy = null;
  $('buyhint').classList.add('hidden');
}

$('buybtn').addEventListener('click', () => {
  if (pendingBuy) sendMsg({ t: 'buy', item: pendingBuy });
});

function applyPurchase(item) {
  hideBuyHint();
  if (SHAPES.includes(item)) {
    previewShape = null;
    selectedShape = item;
    localStorage.setItem('tron.shape', item);
  } else if (validPattern(item)) {
    previewPattern = null;
    selectedPattern = item;
    localStorage.setItem('tron.pattern', item);
  }
  renderCosmeticPicks();
}

// daily challenges (menu section; state is server-authoritative via profile)
function renderChallenges() {
  const list = profile?.challenges;
  if (!list) {
    $('chlist').innerHTML = `<div class="chempty">${t('ch_empty')}</div>`;
    return;
  }
  $('chlist').innerHTML = list.map((c) => {
    const reward = c.reward.neobits ? `+${c.reward.neobits} ◉` : `+${c.reward.shards} ◆`;
    const pct = Math.round((c.progress / c.target) * 100);
    return `<div class="chrow ${c.done ? 'done' : ''}">
      <div class="chtop"><span>${t('chm_' + c.metric, { n: c.target })}</span><span class="chreward">${c.done ? '✓ ' : ''}${reward}</span></div>
      <div class="chbar"><div style="width:${c.done ? 100 : pct}%"></div></div>
      <div class="chprog">${c.done ? t('ch_done') : `${c.progress} / ${c.target}`}</div>
    </div>`;
  }).join('');
}

// guest profiles live under a device token in localStorage: clear the browser
// and the progress is gone. In the Mini App the Telegram account already is the
// login, so nothing to nag about there.
function progressSaved() {
  return telegram.inTelegram || !!(profile && profile.email);
}

// three surfaces nag the guest: the profile chip, a card under PLAY, and a row
// in the between-rounds lobby (right where the rewards just landed)
function renderSaveNudges() {
  const nag = !!profile && !progressSaved();
  $('pb-alert').classList.toggle('hidden', !nag);
  $('savecard').classList.toggle('hidden', !nag);
  $('lobsave').classList.toggle('hidden', !nag || room.state !== 'ended');
  if (nag) {
    $('sc-sub').textContent = t('save_sub', { cp: profile.cp, nb: profile.neobits });
  }
}

function openAccountBox() {
  fillPlayerCard();
  accMsg('', true);
  $('profilemodal').classList.remove('hidden');
  syncBackButton();
  $('linkbox').scrollIntoView({ block: 'center' });
  if (!profile?.email) $('acc-email').focus({ preventScroll: true });
}

function renderProfileUI() {
  if (!profile) return;
  $('profilebtn').classList.remove('hidden');
  $('pb-name').textContent = profile.name || 'RIDER';
  $('pb-league').style.background = LEAGUE_COLORS[profile.league] || '#888';
  $('pb-league').style.color = LEAGUE_COLORS[profile.league] || '#888';
  $('pb-cur').textContent = fmtCur(profile);
  renderSaveNudges();
  renderCosmeticPicks();
  if (!$('menu-challenges').classList.contains('hidden')) renderChallenges();
  if (!$('profilemodal').classList.contains('hidden')) fillPlayerCard();
}

function fillPlayerCard() {
  const p = profile;
  if (!p) return;
  $('pc-name').textContent = p.name || 'RIDER';
  $('pc-league').textContent = p.league.toUpperCase();
  $('pc-league').style.color = LEAGUE_COLORS[p.league] || '#fff';
  const base = LEAGUE_TABLE.find((lg) => lg.name === p.league)?.min || 0;
  const pct = p.nextLeagueAt ? Math.min(100, ((p.cp - base) / (p.nextLeagueAt - base)) * 100) : 100;
  $('pc-leaguebar').style.width = `${pct}%`;
  $('pc-cp').textContent = p.nextLeagueAt
    ? t('cp_next', { cp: p.cp, left: p.nextLeagueAt - p.cp })
    : t('cp_top', { cp: p.cp });
  $('pc-currencies').innerHTML =
    `<span style="color:#4dd8ff">◉ ${p.neobits} ${t('neobits')}</span><span style="color:#d9b0ff">◆ ${p.shards} ${t('shards')}</span>`;
  const wr = p.stats.matches ? Math.round((p.stats.wins / p.stats.matches) * 100) : 0;
  $('pc-stats').innerHTML =
    `<span>${t('matches')} <b>${p.stats.matches}</b></span><span>${t('wins')} <b>${p.stats.wins}</b></span>` +
    `<span>${t('kills')} <b>${p.stats.kills}</b></span><span>${t('winrate')} <b>${wr}%</b></span>`;
  $('pc-adsleft').textContent = t('ads_left', { n: p.adsLeft });
  $('watchad').disabled = p.adsLeft <= 0;
  renderStarShop();
  // in the Mini App the Telegram account *is* the login, so no email box
  $('linkbox').classList.toggle('hidden', telegram.inTelegram);
  $('linkbox').classList.toggle('linked', !!p.email);
  $('linkform').classList.toggle('hidden', !!p.email);
  $('linkstate').textContent = p.email
    ? t('linked_header', { email: p.email })
    : t('link_header');
  $('linknote').textContent = p.email
    ? t('linked_note')
    : t('link_note', { cp: p.cp, nb: p.neobits });
}

// --- telegram stars shop ------------------------------------------------------

let starBusy = null;

function setStarBusy(pack) {
  starBusy = pack;
  renderStarShop();
}

function renderStarShop() {
  const box = $('starshop');
  if (!tgConfig || !telegram.inTelegram) { box.classList.add('hidden'); return; }
  box.classList.remove('hidden');
  $('starshop-title').textContent = t('star_title');
  $('starpacks').innerHTML = tgConfig.packs.map((p) => {
    const owned = (p.items || []).every((i) => profile && profile.inventory.includes(i));
    const sold = p.items && p.items.length && owned;
    const gain = p.items
      ? t('star_bundle', { n: p.shards, k: p.items.length })
      : `+${p.shards} ◆`;
    return `<button class="starpack ${p.items ? 'bundle' : ''}" data-pack="${p.id}"
      ${starBusy || sold ? 'disabled' : ''}>
      <span class="sp-title">${p.title}${p.bonus ? ` <em>${p.bonus}</em>` : ''}</span>
      <span class="sp-gain">${sold ? t('star_owned') : gain}</span>
      <span class="sp-price">${starBusy === p.id ? '…' : `${p.stars} ⭐`}</span>
    </button>`;
  }).join('');
}

document.addEventListener('click', (e) => {
  const pack = e.target.closest('[data-pack]')?.dataset.pack;
  if (!pack || starBusy) return;
  telegram.haptic();
  setStarBusy(pack);
  sendMsg({ t: 'star_buy', pack });
});

function accMsg(text, ok) {
  const el = $('accmsg');
  el.textContent = text;
  el.classList.toggle('ok', !!ok);
}

function closeModal(id) { $(id).classList.add('hidden'); syncBackButton(); }

function renderLeagueLadder() {
  const myIdx = profile ? profile.leagueIdx : -1;
  const myCp = profile ? profile.cp : 0;
  $('leagueladder').innerHTML = LEAGUE_TABLE.map((lg, i) => {
    const color = LEAGUE_COLORS[lg.name] || '#888';
    const cur = i === myIdx;
    return `<div class="lgrow ${cur ? 'current' : ''} ${i < myIdx ? 'passed' : ''}">
      <span class="dot" style="background:${color};color:${color}"></span>
      <span class="lgname" style="color:${color}">${lg.name.toUpperCase()}</span>
      <span class="lgcp">${lg.min}+ CP</span>
      <span class="lgreward">${lg.rewardKey ? t(lg.rewardKey) : '—'}</span>
      ${cur ? `<span class="lgyou">◄ ${t('lg_you', { cp: myCp })}</span>` : ''}
    </div>`;
  }).join('');
}

$('leaguesbtn').addEventListener('click', () => {
  renderLeagueLadder();
  $('leaguesmodal').classList.remove('hidden');
  syncBackButton();
});

$('profilebtn').addEventListener('click', () => {
  fillPlayerCard();
  accMsg('', true);
  $('profilemodal').classList.remove('hidden');
  syncBackButton();
});
for (const btn of document.querySelectorAll('.modalclose')) {
  btn.addEventListener('click', () => {
    if (btn.dataset.close === 'admodal') clearInterval(adTimer); // abort = no reward
    closeModal(btn.dataset.close);
  });
}

// language switcher (spec Task 4)
function renderLangPick() {
  for (const el of $('langpick').children) el.classList.toggle('selected', el.dataset.lang === currentLang());
}
$('langpick').addEventListener('click', async (e) => {
  const lang = e.target.dataset.lang;
  if (!languages.includes(lang) || lang === currentLang()) return;
  await loadLang(lang);
  regionNames = null;
  renderLangPick();
  renderBotsToggle();
  renderModeMap();
  renderCosmeticPicks();
  applyControlScheme();   // its labels are built by hand, not by data-i18n
  if (urlRoom) $('play').textContent = t('join_room', { code: urlRoom });
  if (heldItem) updateItemSlot(heldItem);
  renderProfileUI();
  if (!$('menu-challenges').classList.contains('hidden')) renderChallenges();
  renderMenuSummaries();
  disarmLeave();
});
renderLangPick();

$('savecard').addEventListener('click', openAccountBox);
$('lobsave').addEventListener('click', openAccountBox);

$('linkacc').addEventListener('click', () => {
  sendMsg({ t: 'link', email: $('acc-email').value, pass: $('acc-pass').value });
});
$('loginacc').addEventListener('click', () => {
  sendMsg({ t: 'login', token: deviceToken, email: $('acc-email').value, pass: $('acc-pass').value });
});

// simulated rewarded ad: full watch required before the claim appears
let adTimer = null;
$('watchad').addEventListener('click', async () => {
  // portal builds run a real rewarded ad instead, and only pay out if it ran
  if (portal.PORTAL) {
    closeModal('profilemodal');
    const res = await withAdBreak('rewarded');
    $('profilemodal').classList.remove('hidden');
    syncBackButton();
    if (res.shown) sendMsg({ t: 'ad_done' });
    else toast(t('ad_unavailable'), '#ff0055');
    return;
  }
  closeModal('profilemodal');
  $('admodal').classList.remove('hidden');
  syncBackButton();
  $('adclaim').classList.add('hidden');
  let left = 5;
  $('adcount').textContent = left;
  clearInterval(adTimer);
  adTimer = setInterval(() => {
    left--;
    if (left <= 0) {
      clearInterval(adTimer);
      $('adcount').textContent = '✓';
      $('adclaim').classList.remove('hidden');
    } else {
      $('adcount').textContent = left;
    }
  }, 1000);
});
$('adclaim').addEventListener('click', () => {
  sendMsg({ t: 'ad_done' });
  closeModal('admodal');
  $('profilemodal').classList.remove('hidden');
  syncBackButton();
});
showroomInit();
renderCosmeticPicks();
renderModeMap();
renderBotsToggle();
renderSizePick();

// --- NIGHT RIDE: endless solo cruise through a procedural city (client-only) --

const cruise = {
  active: false, x: 0, y: 0, heading: -Math.PI / 2, steerS: 0, boost: false,
  group: null, bike: null, trail: null, grid: null, floor: null, skyG: null,
  chunks: new Map(), lastSample: null, savedTrailArc: 420,
};
const CHUNK = 220;
const CHUNK_R = 2;

// deterministic per-cell rng so chunks rebuild identically when you ride back
function cruiseRng(cx, cy) {
  let s = ((cx * 73856093) ^ (cy * 19349663)) >>> 0;
  return () => {
    s = (s * 1664525 + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

function buildChunk(cx, cy) {
  const g = new THREE.Group();
  const rng = cruiseRng(cx, cy);
  const cols = [0x00ffff, 0xff00ff, 0xffe600, 0xff0055, 0x4488ff, 0xb000ff];
  const n = 4 + Math.floor(rng() * 5);
  for (let i = 0; i < n; i++) {
    // towers snap to a street grid so open lanes always thread between them
    const bx = cx * CHUNK + 36 + Math.floor(rng() * 4) * 52 + rng() * 8;
    const by = cy * CHUNK + 36 + Math.floor(rng() * 4) * 52 + rng() * 8;
    const w = 14 + rng() * 18;
    const h = 20 + rng() * 85;
    const col = cols[Math.floor(rng() * cols.length)];
    const boxGeo = new THREE.BoxGeometry(w, h, w);
    const solid = new THREE.Mesh(boxGeo, new THREE.MeshBasicMaterial({ color: 0x070712 }));
    solid.position.set(bx, h / 2, by);
    g.add(solid);
    const edges = new THREE.LineSegments(
      new THREE.EdgesGeometry(boxGeo),
      new THREE.LineBasicMaterial({ color: col, transparent: true, opacity: 0.45 + rng() * 0.4 }),
    );
    edges.position.copy(solid.position);
    g.add(edges);
    const bands = Math.floor(h / 12);
    for (let bnd = 1; bnd <= bands; bnd++) {
      const ring = new THREE.LineSegments(
        new THREE.EdgesGeometry(new THREE.PlaneGeometry(w, w).rotateX(-Math.PI / 2)),
        new THREE.LineBasicMaterial({ color: col, transparent: true, opacity: 0.2 }),
      );
      ring.position.set(bx, (h / (bands + 1)) * bnd, by);
      g.add(ring);
    }
  }
  return g;
}

function startCruise() {
  if (cruise.active) return;
  unlock();
  if (room.code) toMenu();
  buildEnvironment('empty', room.arena); // resets groundH to flat
  env.group.visible = false;
  for (const w of walls) w.visible = false;
  cruise.active = true;
  cruise.x = CHUNK / 2;
  cruise.y = CHUNK / 2;
  cruise.heading = -Math.PI / 2;
  cruise.steerS = 0;
  const g = cruise.group = new THREE.Group();
  cruise.floor = new THREE.Mesh(
    new THREE.PlaneGeometry(2600, 2600),
    new THREE.MeshBasicMaterial({ color: 0x04040d }),
  );
  cruise.floor.rotation.x = -Math.PI / 2;
  cruise.floor.position.y = -0.08;
  g.add(cruise.floor);
  cruise.grid = new THREE.GridHelper(1320, 120, 0x8a2a9a, 0x2a1044);
  cruise.grid.material.transparent = true;
  cruise.grid.material.opacity = 0.7;
  g.add(cruise.grid);
  cruise.skyG = new THREE.Group();
  addSky(cruise.skyG, 0);
  g.add(cruise.skyG);
  scene.add(g);
  const shape = ownedItem(selectedShape) ? selectedShape : 'blade';
  const pattern = ownedItem(selectedPattern) ? selectedPattern : 'solid';
  cruise.bike = makeBikeModel(selectedColor, shape);
  scene.add(cruise.bike.yaw);
  cruise.savedTrailArc = room.trailMaxArc;
  room.trailMaxArc = 160; // short waning ribbon, just for the looks
  cruise.trail = new Trail(selectedColor, pattern);
  cruise.trail.addPoint(cruise.x, cruise.y);
  cruise.lastSample = { x: cruise.x, y: cruise.y };
  $('overlay').classList.add('gone');
  $('cruisehud').classList.remove('hidden');
  $('minimap').classList.add('hidden');
  if (touchDevice) {
    $('pads').classList.remove('hidden');
    $('usebtn').classList.add('hidden');
    $('steerhint').classList.add('hidden'); // the cruise hint line already says it
  }
  setMusicMode('game');
}

function exitCruise() {
  if (!cruise.active) return;
  cruise.active = false;
  scene.remove(cruise.group);
  cruise.group.traverse((o) => { o.geometry?.dispose(); o.material?.dispose?.(); });
  for (const [, chunk] of cruise.chunks) {
    scene.remove(chunk);
    chunk.traverse((o) => { o.geometry?.dispose(); o.material?.dispose?.(); });
  }
  cruise.chunks.clear();
  scene.remove(cruise.bike.yaw);
  cruise.trail.dispose();
  room.trailMaxArc = cruise.savedTrailArc;
  env.group.visible = true;
  for (const w of walls) w.visible = true;
  $('cruisehud').classList.add('hidden');
  $('minimap').classList.remove('hidden');
  $('pads').classList.add('hidden');
  setBoostSound(false);
  toMenu();
}

function stepCruise(dt) {
  const steer = (steerKeys.right ? 1 : 0) - (steerKeys.left ? 1 : 0);
  const steerStep = 3.2 * dt;
  cruise.steerS += Math.max(-steerStep, Math.min(steerStep, steer - cruise.steerS));
  cruise.heading += cruise.steerS * 2.6 * dt;
  cruise.boost = boostHeld;
  setBoostSound(cruise.boost);
  const sp = cruise.boost ? 48 : 30;
  cruise.x += Math.cos(cruise.heading) * sp * dt;
  cruise.y += Math.sin(cruise.heading) * sp * dt;

  // bike pose + light ribbon
  const bk = cruise.bike;
  bk.yaw.position.set(cruise.x, 0, cruise.y);
  bk.yaw.rotation.y = -cruise.heading;
  const lean = Math.max(-1, Math.min(1, cruise.steerS)) * 0.45;
  bk.roll.rotation.x = lean;
  bk.glow.material.opacity = cruise.boost ? 0.85 : 0.55;
  bk.glow.scale.setScalar(cruise.boost ? 1.5 : 1);
  if (Math.hypot(cruise.x - cruise.lastSample.x, cruise.y - cruise.lastSample.y) > 3) {
    cruise.trail.addPoint(cruise.x, cruise.y);
    cruise.lastSample = { x: cruise.x, y: cruise.y };
  }
  cruise.trail.setHead(cruise.x, cruise.y);

  // stream city chunks around the rider, drop the ones left behind
  const ccx = Math.floor(cruise.x / CHUNK);
  const ccy = Math.floor(cruise.y / CHUNK);
  for (let dx = -CHUNK_R; dx <= CHUNK_R; dx++) {
    for (let dy = -CHUNK_R; dy <= CHUNK_R; dy++) {
      const key = `${ccx + dx},${ccy + dy}`;
      if (!cruise.chunks.has(key)) {
        const chunk = buildChunk(ccx + dx, ccy + dy);
        cruise.chunks.set(key, chunk);
        scene.add(chunk);
      }
    }
  }
  for (const [key, chunk] of cruise.chunks) {
    const [kx, ky] = key.split(',').map(Number);
    if (Math.abs(kx - ccx) > CHUNK_R + 1 || Math.abs(ky - ccy) > CHUNK_R + 1) {
      scene.remove(chunk);
      chunk.traverse((o) => { o.geometry?.dispose(); o.material?.dispose?.(); });
      cruise.chunks.delete(key);
    }
  }

  // floor, grid and sky ride along (grid snaps so its lines stay put)
  cruise.floor.position.set(cruise.x, -0.08, cruise.y);
  cruise.grid.position.set(Math.round(cruise.x / 11) * 11, 0, Math.round(cruise.y / 11) * 11);
  cruise.skyG.position.set(cruise.x, 0, cruise.y);
}

$('cruisebtn').addEventListener('click', startCruise);
$('cruiseexit').addEventListener('click', exitCruise);

// --- audio panel: independent music / sfx sliders + one-tap mutes ------------

const lastVol = { music: 1, sfx: 1 }; // restore targets for the mute toggles

function syncAudioUi() {
  const v = getVolumes();
  if (v.music > 0) lastVol.music = v.music;
  if (v.sfx > 0) lastVol.sfx = v.sfx;
  $('musicvol').value = Math.round(v.music * 100);
  $('sfxvol').value = Math.round(v.sfx * 100);
  $('musicmute').classList.toggle('off', v.music === 0);
  $('sfxmute').classList.toggle('off', v.sfx === 0);
  $('audiobtn').classList.toggle('off', v.music === 0 && v.sfx === 0);
}
$('audiobtn').addEventListener('click', () => {
  unlock();
  $('audiopanel').classList.toggle('hidden');
});
$('musicvol').addEventListener('input', (e) => { unlock(); setMusicVolume(e.target.value / 100); syncAudioUi(); });
$('sfxvol').addEventListener('input', (e) => { unlock(); setSfxVolume(e.target.value / 100); syncAudioUi(); });
$('musicmute').addEventListener('click', () => {
  unlock();
  setMusicVolume(getVolumes().music > 0 ? 0 : lastVol.music);
  syncAudioUi();
});
$('sfxmute').addEventListener('click', () => {
  unlock();
  setSfxVolume(getVolumes().sfx > 0 ? 0 : lastVol.sfx);
  syncAudioUi();
});
function toggleAllAudio() {
  const v = getVolumes();
  if (v.music > 0 || v.sfx > 0) { setMusicVolume(0); setSfxVolume(0); }
  else { setMusicVolume(lastVol.music); setSfxVolume(lastVol.sfx); }
  syncAudioUi();
}
syncAudioUi();

// --- in-game leave: two taps so a stray thumb doesn't quit a round ------------

let leaveArmedUntil = 0;
function disarmLeave() {
  leaveArmedUntil = 0;
  const el = $('leavegame');
  el.classList.remove('armed');
  el.textContent = t('leave');
}
$('leavegame').addEventListener('click', () => {
  if (performance.now() < leaveArmedUntil) { toMenu(); return; }
  leaveArmedUntil = performance.now() + 2200;
  $('leavegame').classList.add('armed');
  $('leavegame').textContent = t('leave_sure');
  setTimeout(() => { if (performance.now() >= leaveArmedUntil) disarmLeave(); }, 2300);
});

addEventListener('pointerdown', unlock, { once: true });

// --- mobile / Telegram shell --------------------------------------------------

function syncDeviceClasses() {
  document.body.classList.toggle('touch', touchDevice);
  // a phone is a phone in either orientation, so key off the short side
  document.body.classList.toggle('phone', touchDevice && Math.min(innerWidth, innerHeight) <= 520);
}
syncDeviceClasses();
addEventListener('resize', syncDeviceClasses);
applyControlScheme();

if (telegram.inTelegram) {
  telegram.initBackButton();
  // Telegram animates the viewport during expand/fullscreen; re-measure after
  telegram.onEvent('viewportChanged', () => setTimeout(resize, 60));
  telegram.onEvent('fullscreenChanged', () => setTimeout(resize, 120));
  // fullscreen must be asked for from a gesture, and PLAY is the first one
  const goFullscreen = () => telegram.requestFullscreen();
  $('play').addEventListener('click', goFullscreen);
  $('mkroom').addEventListener('click', goFullscreen);
  $('joinroom').addEventListener('click', goFullscreen);
  $('cruisebtn').addEventListener('click', goFullscreen);
  syncBackButton();
}

setInterval(() => {
  sendMsg({ t: 'ping', now: performance.now() });
  // on a phone the corner is precious: only speak up when the link is bad
  $('ping').textContent = connected ? `${pingMs}ms` : 'offline';
  $('ping').classList.toggle('warn', connected && pingMs >= 150);
  $('ping').classList.toggle('quiet', connected && pingMs < 150);
}, 2000);

connect().then(() => portal.loadingStop());
requestAnimationFrame(frame);
