Back to Napplet Machines V8
SOURCE / PINNED RELEASE

Made of little things.

Napplet Machines V8

Release
b3f614e739f0…
Author-recorded commit
534f19acb4ba…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-b5572362d4a

Archive hash verified: 0f7feb97e2c3d336…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/main.ts
import './napplet-settings.js';
import { storage } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import {
  Road,
  presets,
  copyTrack,
  MIN_POINT_WIDTH,
  MAX_POINT_WIDTH,
  validateTrack,
  decodeTrack,
  clamp,
  lerp,
  wrapAngle,
} from './track.js';
import { carPortrait, courseStyle } from './art.js';
import { Renderer } from './render.js';
import { TrackEditor } from './editor.js';
import { ControllerDriving, TouchDriving, mixDriving } from './controls.js';
import {
  DT,
  cars,
  createWorld,
  advanceBody,
  stepWorld,
  ranking,
  idleInput,
  slipAngle,
  type Body,
  type World,
  type Input,
  type Driver,
} from './physics.js';
import { Multiplayer, errorText, type Start, type Command } from './network.js';
import './styles.css';
const $ = <T extends HTMLElement = HTMLElement>(id: string) =>
  document.getElementById(id) as T;
const button = (id: string, fn: () => void | Promise<void>) =>
  $(id).addEventListener('click', () => {
    void action(fn);
  });
const widthInput = $<HTMLInputElement>('widthInput');
widthInput.min = String(MIN_POINT_WIDTH);
widthInput.max = String(MAX_POINT_WIDTH);
const status = (message: string, error = false) => {
  $('status').textContent = message;
  $('status').classList.toggle('error', error);
};
async function action(fn: () => void | Promise<void>) {
  try {
    await fn();
  } catch (e) {
    status(errorText(e), true);
  }
}
let track = copyTrack(presets[0]),
  road = new Road(track),
  car = 0,
  view = 'garage',
  world: World | null = null,
  localId = 'you',
  paused = false,
  online = false,
  follow = true;
let frameId = 0,
  last = performance.now(),
  accumulator = 0,
  hudAt = 0,
  sendAt = 0,
  sequence = 0,
  lastSnapshot = -1,
  lastPacket = 0,
  anchorTick = 0,
  anchorTime = 0;
let pending: Command[] = [],
  snapshots: { world: World; at: number }[] = [],
  remoteQueues = new Map<string, Command[]>(),
  prediction: Body | null = null;
let rendered: Body[] = [];
const pressed = new Set<string>();
const controllers = new ControllerDriving();
let controllerDrive = idleInput(),
  resetRequested = false,
  controllerLost = false;
const multiplayer = new Multiplayer();
let renderer: Renderer;
try {
  renderer = new Renderer($<HTMLCanvasElement>('scene'));
} catch (e) {
  status(errorText(e), true);
  $('raceButton').setAttribute('disabled', '');
  throw e;
}
const editor = new TrackEditor($<HTMLCanvasElement>('plan'), track);
const formatTime = (t: number) =>
  `${Math.floor(t / 60)
    .toString()
    .padStart(2, '0')}:${(t % 60).toFixed(1).padStart(4, '0')}`;
function selectCar(index: number) {
  car = index;
  document.querySelectorAll<HTMLElement>('[data-car]').forEach((el) => {
    el.classList.toggle('selected', Number(el.dataset.car) === car);
    el.setAttribute('aria-pressed', String(Number(el.dataset.car) === car));
  });
  const c = cars[car];
  $('carDetail').style.setProperty('--car', c.color);
  $('carDetail').innerHTML =
    `<div class="ride-hero"><span class="ride-number">0${car + 1}</span>${carPortrait(car)}<div class="car-name">${c.name}</div><div class="car-tag">${c.tag}</div></div>${['Acceleration', 'Top speed', 'Grip', 'Weight'].map((s, i) => `<div class="stat"><span>${s}</span><span class="bars" aria-label="${c.stats[i]} out of 5">${Array.from({ length: 5 }, (_, j) => `<i class="${j < c.stats[i] ? 'filled' : ''}"></i>`).join('')}</span></div>`).join('')}`;
}
cars.forEach((c, i) => {
  const b = document.createElement('button');
  b.dataset.car = String(i);
  b.style.setProperty('--car', c.color);
  b.innerHTML = '<i class="car-icon"></i>';
  b.setAttribute('aria-label', `Choose ${c.name}`);
  b.title = c.name;
  b.onclick = () => selectCar(i);
  $('carPicker').append(b);
});
selectCar(0);
presets.forEach((p, i) =>
  $<HTMLSelectElement>('trackSelect').add(new Option(p.name, String(i))),
);
function updateRoad() {
  road = new Road(track);
  renderer.setRoad(road);
  editor.track = track;
  editor.road = road;
  $('sceneTitle').textContent = track.name;
  $('sceneSubtitle').textContent = courseStyle(track.name).subtitle;
  $('curveNotice').hidden = road.closedSections.size === 0;
  $('curveNotice').textContent =
    'Tight inside corners are filled and rounded; walls follow the closed edge.';
  $('scene').dataset.closedSections = String(road.closedSections.size);
  $('scene').dataset.roadSamples = String(road.samples.length);
  $('trackLength').textContent = `${Math.round(road.length)} M`;
  $('wallFact').textContent = track.points.every((p) => p.wall)
    ? 'WALLED'
    : 'OPEN EDGES';
  $<HTMLInputElement>('trackName').value = track.name;
  editor.draw();
  updatePoint();
}
function updatePoint() {
  const p = track.points[editor.selected];
  $('pointLabel').textContent = p
    ? `POINT ${String(editor.selected + 1).padStart(2, '0')}`
    : 'SELECT A POINT';
  $('pointCount').textContent = `${track.points.length} / 32`;
  $<HTMLInputElement>('heightInput').disabled = !p;
  $<HTMLInputElement>('widthInput').disabled = !p;
  $<HTMLInputElement>('wallThicknessInput').disabled = !p || !p.wall;
  $('sectionLabel').textContent = p
    ? `SECTION ${editor.selected + 1} → ${((editor.selected + 1) % track.points.length) + 1}`
    : 'SELECT A SECTION';
  $<HTMLInputElement>('segmentWall').disabled = !p;
  $<HTMLButtonElement>('removePoint').disabled = !p || track.points.length <= 4;
  $<HTMLButtonElement>('addPoint').disabled = track.points.length >= 32;
  if (p) {
    $<HTMLInputElement>('heightInput').value = String(p.z);
    $('heightValue').textContent = `Z ${p.z.toFixed(1)} m`;
    $<HTMLInputElement>('segmentWall').checked = p.wall;
    $<HTMLInputElement>('widthInput').value = String(p.width);
    $('widthValue').textContent = `${Number(p.width.toFixed(2))} m`;
    $<HTMLInputElement>('wallThicknessInput').value = String(p.wallThickness);
    $('wallThicknessValue').textContent = `${p.wallThickness.toFixed(1)} m`;
  }
}
let roadDirty = false;
editor.onChange = () => {
  roadDirty = true;
  $('saveState').textContent = 'UNSAVED';
};
editor.onSelect = updatePoint;
function setView(next: string) {
  if (world) return;
  view = next;
  $('app').classList.toggle('editor', next === 'editor');
  document
    .querySelectorAll<HTMLElement>('[data-view]')
    .forEach((b) => b.classList.toggle('active', b.dataset.view === next));
  for (const v of ['garage', 'rooms', 'editor'])
    $(v + 'Panel').hidden = v !== next;
  $('planWrap').hidden = next !== 'editor';
  if (next === 'editor') {
    editor.draw();
    updatePoint();
  }
  if (next === 'rooms' && !multiplayer.room) void refreshRooms();
}
document
  .querySelectorAll<HTMLElement>('[data-view]')
  .forEach((b) => b.addEventListener('click', () => setView(b.dataset.view!)));
$<HTMLSelectElement>('trackSelect').addEventListener('change', (e) => {
  track = copyTrack(presets[Number((e.target as HTMLSelectElement).value)]);
  editor.selected = 0;
  editor.track = track;
  editor.fit();
  updateRoad();
});
$<HTMLInputElement>('trackName').addEventListener('input', (e) => {
  track.name =
    (e.target as HTMLInputElement).value.slice(0, 48) || 'Untitled circuit';
  $('sceneTitle').textContent = track.name;
  $('sceneSubtitle').textContent = courseStyle(track.name).subtitle;
  $('saveState').textContent = 'UNSAVED';
});
for (const id of [
  'heightInput',
  'widthInput',
  'segmentWall',
  'wallThicknessInput',
])
  $(id).addEventListener('input', () => {
    const p = track.points[editor.selected];
    if (id === 'heightInput' && p) p.z = Number($<HTMLInputElement>(id).value);
    if (id === 'widthInput' && p)
      p.width = Number($<HTMLInputElement>(id).value);
    if (id === 'wallThicknessInput' && p)
      p.wallThickness = Number($<HTMLInputElement>(id).value);
    if (id === 'segmentWall' && p) p.wall = $<HTMLInputElement>(id).checked;
    editor.onChange();
    updatePoint();
  });
button('addPoint', () => editor.setTool('add'));
button('removePoint', () => editor.remove());
button('allWalls', () => {
  track.points.forEach((p) => (p.wall = true));
  editor.onChange();
});
button('noWalls', () => {
  track.points.forEach((p) => (p.wall = false));
  editor.onChange();
});
editor.onNotice = (message) => status(message);
editor.onTool = () => {
  for (const [id, tool] of [
    ['drawTool', 'draw'],
    ['moveTool', 'move'],
    ['addPoint', 'add'],
    ['wallTool', 'walls'],
  ]) {
    $(id).classList.toggle('active', editor.tool === tool);
    $(id).setAttribute('aria-pressed', String(editor.tool === tool));
  }
  $('planMode').textContent = {
    move: 'PLAN / DRAG POINTS',
    draw: 'PLAN / DRAW LOOP',
    add: 'PLAN / PLACE POINTS',
    walls: 'PLAN / SELECT SECTION',
  }[editor.tool];
  $('editorHint').textContent = {
    move: 'Drag a point. Width and elevation apply to that point; the curve closes automatically.',
    draw: 'Draw one loop on the plan, then release. At least four points.',
    add: 'Click anywhere on the plan to place a point in the nearest section. Esc finishes.',
    walls:
      'Click a section in the plan, then set its walls and thickness below.',
  }[editor.tool];
};
button('drawTool', () => editor.setTool('draw'));
button('moveTool', () => editor.setTool('move'));
button('wallTool', () => editor.setTool('walls'));
window.addEventListener('keydown', (e) => {
  if (e.code === 'Escape' && view === 'editor' && !world)
    editor.setTool('move');
});
editor.onTool();
button('saveTrack', async () => {
  if (!runtimeHasDomain('storage'))
    throw new Error(
      'Saving is unavailable in this host; your current track is still playable.',
    );
  if (!validateTrack(track))
    throw new Error('Track needs 4–32 separated points and a name.');
  await storage.setItem('pocket-circuit.track.v1', JSON.stringify(track));
  $('saveState').textContent = 'SAVED';
  status('Track saved. Load saved brings it back after reopening.');
});
button('loadTrack', async () => {
  if (!runtimeHasDomain('storage'))
    throw new Error('Saving is unavailable in this host.');
  const saved = await storage.getItem('pocket-circuit.track.v1');
  if (!saved)
    throw new Error('No saved track yet. Draw a circuit and save it first.');
  const parsed = decodeTrack(JSON.parse(saved));
  if (!parsed)
    throw new Error('Saved track has an unsupported or damaged format.');
  track = parsed;
  editor.setTool('move');
  editor.selected = 0;
  editor.track = track;
  editor.fit();
  updateRoad();
  $('saveState').textContent = 'SAVED';
  status('Saved track loaded.');
});
function roster(count: number): Driver[] {
  const ds: Driver[] = [{ id: 'you', name: 'You', car, ai: false }];
  const names = ['Pip', 'Mochi', 'Rivet', 'Nori', 'Bolt'];
  for (let i = 1; i < count; i++)
    ds.push({
      id: `ai-${i}`,
      name: names[i - 1],
      car: (i + car) % 4,
      ai: true,
    });
  return ds;
}
function begin(w: World, isOnline: boolean) {
  world = w;
  scene.dataset.countdown = String(w.countdown);
  scene.dataset.racing = 'true';
  online = isOnline;
  localId = isOnline ? multiplayer.actor : 'you';
  paused = false;
  pending = [];
  snapshots = [];
  remoteQueues.clear();
  sequence = w.bodies.find((body) => body.id === localId)?.ack || 0;
  if (isOnline && w.bodies.find((body) => body.id === localId)?.ai)
    localId = `spectator:${multiplayer.actor}`;
  lastSnapshot = -1;
  lastPacket = performance.now();
  prediction = null;
  accumulator = 0;
  sendAt = 0;
  $('app').classList.remove('editor');
  $('app').classList.add('racing');
  $('raceHud').hidden = false;
  $('raceActions').hidden = false;
  $('sceneInfo').hidden = true;
  $('sceneBottom').hidden = true;
  $('planWrap').hidden = true;
  $('results').hidden = true;
  $('touchControls').hidden = false;
  $('pauseButton').hidden = isOnline;
  $('reconnectRace').hidden = !isOnline;
  $('pauseButton').textContent = 'Pause';
  $('cameraButton').textContent = follow ? 'Overview' : 'Follow';
  $('modeTag').textContent = isOnline
    ? 'ONLINE / ' + w.bodies.filter((b) => !b.ai).length + ' PLAYERS'
    : 'LOCAL / ' + w.bodies.length + ' CARS';
  clearInput();
  $<HTMLCanvasElement>('scene').focus();
  status(
    isOnline
      ? 'Race connected. The host judges collisions and the finish.'
      : 'WASD / arrows to drive · Space to drift · R to recover',
  );
}
function startLocal() {
  if (multiplayer.room)
    throw new Error('Leave your online room before starting a local race.');
  updateRoad();
  begin(
    createWorld(roster(Number($<HTMLSelectElement>('gridSize').value)), road),
    false,
  );
}
button('raceButton', startLocal);
button('testTrack', startLocal);
function exit() {
  scene.dataset.racing = 'false';
  world = null;
  online = false;
  prediction = null;
  clearInput();
  $('app').classList.remove('racing');
  $('raceHud').hidden = true;
  $('raceActions').hidden = true;
  $('results').hidden = true;
  $('countdown').hidden = true;
  $('touchControls').hidden = true;
  $('sceneInfo').hidden = false;
  $('sceneBottom').hidden = false;
  renderer.zoom = 1;
  $('modeTag').textContent = 'LOCAL / 6 CARS';
  setView(multiplayer.room ? 'rooms' : view);
}
button('exitRace', async () => {
  if (online && multiplayer.room) {
    await multiplayer.leave();
    status('Left the online race.');
  }
  exit();
});
button('againButton', async () => {
  if (online && multiplayer.isHost) {
    await multiplayer.sendControl({ type: 'return' });
    multiplayer.start = null;
  } else if (online) {
    await multiplayer.leave();
  }
  exit();
});
button('pauseButton', () => {
  paused = !paused;
  controllerLost = false;
  clearInput();
  $('pauseButton').textContent = paused ? 'Resume' : 'Pause';
});
button('reconnectRace', () => multiplayer.retry());
button('recoverButton', () => {
  if (world && !paused) resetRequested = true;
});
button('cameraButton', () => {
  follow = !follow;
  $('cameraButton').textContent = follow ? 'Overview' : 'Follow';
});
button('orbitLeft', () => {
  renderer.azimuth -= 0.25;
});
button('orbitRight', () => {
  renderer.azimuth += 0.25;
});
button('fitPlan', () => {
  editor.fit();
  editor.draw();
});
button('fullPlan', () => {
  editor.zoom = 1;
  editor.draw();
});
button('zoomIn', () => {
  renderer.zoom = clamp(renderer.zoom + 0.15, 0.6, 2);
});
button('zoomOut', () => {
  renderer.zoom = clamp(renderer.zoom - 0.15, 0.6, 2);
});
let orbit: { x: number; y: number; id: number } | null = null;
const scene = $<HTMLCanvasElement>('scene');
scene.addEventListener('pointerdown', (e) => {
  if (world) return;
  scene.setPointerCapture(e.pointerId);
  orbit = { x: e.clientX, y: e.clientY, id: e.pointerId };
});
scene.addEventListener('pointermove', (e) => {
  if (!orbit || orbit.id !== e.pointerId) return;
  renderer.azimuth += (e.clientX - orbit.x) * 0.008;
  renderer.elevation = clamp(
    renderer.elevation + (e.clientY - orbit.y) * 0.006,
    0.35,
    1.3,
  );
  orbit = { x: e.clientX, y: e.clientY, id: e.pointerId };
});
scene.addEventListener('pointerup', () => (orbit = null));
scene.addEventListener('pointercancel', () => (orbit = null));
const touchDriving = new TouchDriving($('steeringPad'), $('pedalPad'));
function clearInput() {
  controllerLost = false;
  pressed.clear();
  touchDriving.clear();
  controllerDrive = idleInput();
  resetRequested = false;
  scene.dataset.inputSteer = '0';
  scene.dataset.inputThrottle = '0';
  scene.dataset.inputBrake = 'false';
  controllers.reset();
}
window.addEventListener('resize', clearInput);
window.addEventListener('keydown', (e) => {
  if (
    !world ||
    e.target instanceof HTMLInputElement ||
    e.target instanceof HTMLSelectElement
  )
    return;
  if (
    [
      'ArrowUp',
      'ArrowDown',
      'ArrowLeft',
      'ArrowRight',
      'KeyW',
      'KeyA',
      'KeyS',
      'KeyD',
      'Space',
      'KeyR',
    ].includes(e.code)
  ) {
    e.preventDefault();
    if (e.code === 'KeyR') {
      if (!e.repeat) resetRequested = true;
    } else pressed.add(e.code);
  }
  if (e.code === 'Escape' && !online) {
    paused = !paused;
    clearInput();
    $('pauseButton').textContent = paused ? 'Resume' : 'Pause';
  }
});
window.addEventListener('keyup', (e) => pressed.delete(e.code));
window.addEventListener('blur', () => {
  clearInput();
  if (world && !online) {
    paused = true;
    $('pauseButton').textContent = 'Resume';
  }
});
document.addEventListener('visibilitychange', () => {
  clearInput();
  accumulator = 0;
  last = performance.now();
  if (document.hidden && world && !online) {
    paused = true;
    $('pauseButton').textContent = 'Resume';
  }
});
function input(): Input {
  const keyboard: Input = {
    throttle:
      Number(pressed.has('KeyW') || pressed.has('ArrowUp')) -
      Number(pressed.has('KeyS') || pressed.has('ArrowDown')),
    steer:
      Number(pressed.has('KeyA') || pressed.has('ArrowLeft')) -
      Number(pressed.has('KeyD') || pressed.has('ArrowRight')),
    brake: pressed.has('Space'),
    reset: resetRequested,
  };
  resetRequested = false;
  return mixDriving(keyboard, touchDriving.value, controllerDrive);
}
function pollController() {
  const pad = controllers.poll();
  controllerDrive = pad.drive;
  const label =
    pad.selected !== null
      ? `PAD ${pad.selected + 1}`
      : pad.unmapped
        ? 'UNMAPPED PAD'
        : pad.status === 'blocked'
          ? 'PAD BLOCKED'
          : pad.status === 'unavailable'
            ? 'NO GAMEPAD API'
            : 'PRESS A / ✕';
  if ($('controllerState').textContent !== label)
    $('controllerState').textContent = label;
  $('controllerState').title = pad.unmapped
    ? 'This controller has no standard mapping. Keyboard and touch remain available.'
    : 'Left stick / D-pad: steer · RT/R2: gas · LT/L2: brake · B/○: drift · Y/△: recover · Menu: pause';
  $('controllerState').dataset.state =
    pad.selected === null ? 'waiting' : 'connected';
  if (pad.disconnected) {
    clearInput();
    controllerLost = true;
    status(
      'Controller disconnected. Press A / ✕ to reconnect, or use touch / keyboard.',
    );
    if (world && !online) {
      paused = true;
      $('pauseButton').textContent = 'Resume';
    }
    return;
  }
  if (pad.joined) controllerLost = false;
  if (pad.status !== 'ready' || pad.joined) return;
  if (world) {
    if (world.ended && pad.confirm) $('againButton').click();
    else if (pad.pause && !online) $('pauseButton').click();
    else if (paused && pad.back) $('exitRace').click();
    else if (!paused && pad.recover) resetRequested = true;
  } else if (view === 'garage') {
    if (pad.carStep) selectCar((car + pad.carStep + cars.length) % cars.length);
    if (pad.trackStep) {
      const select = $<HTMLSelectElement>('trackSelect');
      select.selectedIndex =
        (select.selectedIndex + pad.trackStep + presets.length) %
        presets.length;
      select.dispatchEvent(new Event('change'));
    }
    if (pad.confirm) $('raceButton').click();
  } else if (view === 'rooms' && pad.confirm) {
    const target = multiplayer.room
      ? multiplayer.isHost
        ? 'startRoom'
        : 'readyButton'
      : 'hostButton';
    const control = $<HTMLButtonElement>(target);
    if (control && !control.disabled) control.click();
  }
}
let audio: AudioContext | null = null,
  osc: OscillatorNode | null = null,
  gain: GainNode | null = null,
  sound = false;
button('soundButton', async () => {
  sound = !sound;
  if (sound) {
    audio ??= new AudioContext();
    await audio.resume();
    if (!osc) {
      osc = audio.createOscillator();
      gain = audio.createGain();
      osc.type = 'sawtooth';
      osc.connect(gain);
      gain.connect(audio.destination);
      gain.gain.value = 0;
      osc.start();
    }
  }
  $('soundButton').textContent = sound ? '♪ On' : '♪ Off';
  $('soundButton').setAttribute(
    'aria-label',
    sound ? 'Disable sound' : 'Enable sound',
  );
});
function showResults() {
  if (!world) return;
  $('results').hidden = false;
  const order = ranking(world),
    position = order.findIndex((b) => b.id === localId) + 1;
  $('resultTitle').textContent =
    position === 1
      ? 'Small car. Big win.'
      : `Finished ${position || '—'}${position === 2 ? 'nd' : position === 3 ? 'rd' : 'th'}.`;
  $('resultOrder').replaceChildren(
    ...order.map((b, i) => {
      const li = document.createElement('li'),
        n = document.createElement('span'),
        t = document.createElement('span');
      n.textContent = `${i + 1}. ${b.name}${b.id === localId ? ' · YOU' : ''}`;
      t.textContent = b.finished ? formatTime(b.finished) : 'DNF';
      li.append(n, t);
      return li;
    }),
  );
}
function updateHud() {
  if (!world) return;
  const own = world.bodies.find((b) => b.id === localId),
    pos = ranking(world).findIndex((b) => b.id === localId) + 1;
  $('position').innerHTML = `${pos || '—'}<span>/${world.bodies.length}</span>`;
  $('lap').textContent = `LAP ${Math.min(3, (own?.lap || 0) + 1)} / 3`;
  $('raceTime').textContent = formatTime(world.time);
  const body = rendered.find((b) => b.id === localId) || own;
  $('speed').textContent = String(
    Math.round(Math.hypot(body?.vx || 0, body?.vy || 0) * 3.6),
  );
  $('driftState').hidden = !(
    body?.grounded &&
    Math.hypot(body.vx, body.vy) > 5 &&
    Math.abs(slipAngle(body)) > 0.22
  );
  $('raceOrder').replaceChildren(
    ...ranking(world).map((b, i) => {
      const li = document.createElement('li'),
        dot = document.createElement('i'),
        text = document.createElement('span');
      dot.style.background = cars[b.car].color;
      text.textContent = `${i + 1}  ${b.name}${b.id === localId ? ' · YOU' : ''}`;
      li.classList.toggle('you', b.id === localId);
      li.append(dot, text);
      return li;
    }),
  );
  if (world.ended) showResults();
}
async function refreshRooms() {
  if (multiplayer.room) return;
  $('roomList').textContent = 'Finding rooms…';
  try {
    const rooms = await multiplayer.list();
    $('roomList').replaceChildren();
    if (!rooms.length)
      $('roomList').textContent = 'No rooms yet. Start the first grid.';
    for (const r of rooms) {
      const b = document.createElement('button'),
        name = document.createElement('span'),
        count = document.createElement('small');
      b.className = 'room-item';
      name.textContent = r.name;
      count.textContent = `${r.peers.length}/${r.capacity} · Join`;
      b.append(name, count);
      b.disabled = r.peers.length >= r.capacity;
      b.onclick = () => void action(() => joinRoom(r.room));
      $('roomList').append(b);
    }
    status('Room list updated.');
  } catch (e) {
    $('roomList').textContent = 'Rooms unavailable. Refresh to retry.';
    status(errorText(e), true);
  }
}
function configureProfile() {
  multiplayer.profile = {
    name:
      $<HTMLInputElement>('playerName').value.trim().slice(0, 18) || 'Racer',
    car,
    ready: false,
  };
}
let roomBusy = false;
async function joinRoom(code: string) {
  if (roomBusy || multiplayer.room) return;
  roomBusy = true;
  configureProfile();
  try {
    status('Joining room…');
    await multiplayer.join(code);
  } finally {
    roomBusy = false;
  }
}
button('refreshRooms', refreshRooms);
button('joinCode', () => joinRoom($<HTMLInputElement>('roomCode').value));
button('hostButton', async () => {
  if (roomBusy || multiplayer.room) return;
  roomBusy = true;
  configureProfile();
  $<HTMLButtonElement>('hostButton').disabled = true;
  try {
    status('Creating room…');
    await multiplayer.host(
      $<HTMLInputElement>('roomName').value,
      Number($<HTMLSelectElement>('roomCapacity').value),
    );
  } finally {
    roomBusy = false;
    $<HTMLButtonElement>('hostButton').disabled = false;
  }
});
button('leaveRoom', async () => {
  await multiplayer.leave();
  status('Left the room.');
  void refreshRooms();
});
button('readyButton', async () => {
  multiplayer.profile.ready = !multiplayer.profile.ready;
  await multiplayer.hello();
  updateRoom();
});
const retry = document.createElement('button');
retry.id = 'reconnect';
retry.textContent = 'Reconnect';
retry.onclick = () => void action(() => multiplayer.retry());
$('activeRoom').append(retry);
button('startRoom', async () => {
  if (!multiplayer.room || !multiplayer.isHost) return;
  const drivers = multiplayer.roster(),
    count = multiplayer.room.capacity;
  for (let i = drivers.length; i < count; i++)
    drivers.push({
      id: `ai-${i}`,
      name: ['Pip', 'Mochi', 'Rivet', 'Nori', 'Bolt', 'Pip'][i],
      car: i % 4,
      ai: true,
    });
  await multiplayer.begin(track, createWorld(drivers, road));
});
function updateRoom() {
  const r = multiplayer.room;
  $('browseRooms').hidden = !!r;
  $('activeRoom').hidden = !r;
  if (!r) return;
  $('roomTitle').textContent = r.name;
  $<HTMLInputElement>('activeCode').value = r.room;
  $('startRoom').hidden = !multiplayer.isHost;
  $('readyButton').hidden = multiplayer.isHost;
  $('readyButton').textContent = multiplayer.profile.ready
    ? 'Ready ✓'
    : 'Ready up';
  const expected = multiplayer.isHost ? r.peers.length - 1 : 1,
    connected = multiplayer.connected.size;
  const all = connected >= expected;
  $('connectionState').textContent = all
    ? expected
      ? 'Connected'
      : 'Waiting for players'
    : `Connecting · ${connected}/${expected} peers`;
  const canStart = r.peers.every(
    (id) =>
      id === multiplayer.actor ||
      (multiplayer.members.get(id)?.ready && multiplayer.connected.has(id)),
  );
  $<HTMLButtonElement>('startRoom').disabled = !canStart;
  $('members').replaceChildren(
    ...r.peers.map((id) => {
      const p = multiplayer.members.get(id),
        row = document.createElement('div'),
        name = document.createElement('span'),
        state = document.createElement('small'),
        dot = document.createElement('b');
      row.className = 'member';
      dot.style.background = cars[p?.car || 0].color;
      name.append(
        dot,
        document.createTextNode(
          (p?.name || 'Connecting…') +
            (id === multiplayer.actor ? ' · YOU' : ''),
        ),
      );
      state.textContent =
        id === multiplayer.authority
          ? 'HOST'
          : p?.ready
            ? 'READY'
            : 'NOT READY';
      row.append(name, state);
      return row;
    }),
  );
}
multiplayer.onUpdate = () => {
  updateRoom();
  if (world && online && multiplayer.isHost && multiplayer.room)
    for (const b of world.bodies)
      if (!b.ai && !multiplayer.room.peers.includes(b.id)) {
        b.ai = true;
        b.name = b.name.slice(0, 12) + ' · AI';
      }
};
multiplayer.onError = (m) => status(m, true);
multiplayer.onEnded = (m) => {
  exit();
  status(m);
};
multiplayer.onStart = (s: Start) => {
  track = copyTrack(s.track);
  updateRoad();
  begin(structuredClone(s.world), true);
};
multiplayer.onInput = (from, commands) => {
  const b = world?.bodies.find((b) => b.id === from);
  if (!b || b.ai || !world) return;
  const q = remoteQueues.get(from) || [],
    seen = new Set(q.map((c) => c.seq));
  for (const c of commands)
    if (c.seq > b.ack && c.seq <= b.ack + 120 && !seen.has(c.seq)) {
      q.push(c);
      seen.add(c.seq);
    }
  q.sort((a, b) => a.seq - b.seq);
  remoteQueues.set(from, q.slice(0, 120));
};
multiplayer.onSnapshot = (w) => {
  if (!world || multiplayer.isHost || w.tick <= lastSnapshot) return;
  if (
    w.bodies.length !== world.bodies.length ||
    w.bodies.some((b, i) => b.id !== world!.bodies[i].id)
  )
    return;
  const own = w.bodies.find((b) => b.id === localId);
  if (own && own.ack > sequence) return;
  const now = performance.now();
  lastPacket = now;
  lastSnapshot = w.tick;
  anchorTick = w.tick;
  anchorTime = now;
  world = structuredClone(w);
  if (own) {
    pending = pending.filter((c) => c.seq > own.ack);
    prediction = structuredClone(own);
    if (!w.ended && w.countdown === 0)
      for (const c of pending) advanceBody(prediction, c.input, road);
  }
  snapshots.push({ world: structuredClone(w), at: now });
  if (snapshots.length > 24) snapshots.shift();
};
function guestStep(inp: Input) {
  if (!world) return;
  const b = prediction || world.bodies.find((b) => b.id === localId);
  if (!b) return;
  if (!prediction) prediction = structuredClone(b);
  if (world.countdown > 0 || world.ended) return;
  if (pending.length >= 120) {
    status('Waiting for host · input history full', true);
    return;
  }
  const command = { seq: ++sequence, input: { ...inp } };
  pending.push(command);
  advanceBody(prediction!, inp, road);
}
function remoteStep(b: Body) {
  if (b.id === localId) return false;
  const q = remoteQueues.get(b.id);
  if (q?.length) {
    const command = q.shift()!;
    if (command.seq > b.ack) {
      advanceBody(b, command.input, road);
      b.ack = command.seq;
    }
  } else advanceBody(b, idleInput(), road);
  return true;
}
function interpolated(now: number): Body[] {
  if (!world) return [];
  if (!online || multiplayer.isHost) return world.bodies;
  const tick = anchorTick + (now - anchorTime) / 1000 / DT - 6;
  let a = snapshots[0]?.world,
    b = a;
  for (let i = 1; i < snapshots.length; i++) {
    b = snapshots[i].world;
    if (b.tick >= tick) break;
    a = b;
  }
  if (!a || !b)
    return prediction
      ? world.bodies.map((x) => (x.id === localId ? prediction! : x))
      : world.bodies;
  const t = clamp((tick - a.tick) / (b.tick - a.tick || 1), 0, 1);
  return b.bodies.map((body, i) => {
    if (body.id === localId && prediction) return prediction;
    const prev = a!.bodies[i];
    if (
      !prev ||
      prev.falls !== body.falls ||
      Math.hypot(prev.x - body.x, prev.y - body.y) > 8
    )
      return body;
    return {
      ...body,
      x: lerp(prev.x, body.x, t),
      y: lerp(prev.y, body.y, t),
      z: lerp(prev.z, body.z, t),
      angle: prev.angle + wrapAngle(body.angle - prev.angle) * t,
      pitch: lerp(prev.pitch, body.pitch, t),
      roll: lerp(prev.roll, body.roll, t),
      steering: lerp(prev.steering, body.steering, t),
      handbrake: lerp(prev.handbrake, body.handbrake, t),
    };
  });
}
function tick(now: number) {
  frameId = requestAnimationFrame(tick);
  const elapsed = Math.min(0.1, (now - last) / 1000);
  last = now;
  pollController();
  if (roadDirty) {
    roadDirty = false;
    updateRoad();
  }
  if (view === 'editor' && !world) editor.draw();
  if (world) {
    accumulator += elapsed;
    if (!paused) {
      let steps = 0;
      while (accumulator >= DT && steps++ < 6) {
        const inp = input();
        scene.dataset.inputSteer = inp.steer.toFixed(3);
        scene.dataset.inputThrottle = inp.throttle.toFixed(3);
        scene.dataset.inputBrake = String(inp.brake);
        if (online && !multiplayer.isHost) guestStep(inp);
        else
          stepWorld(
            world,
            road,
            new Map([[localId, inp]]),
            online ? remoteStep : undefined,
          );
        accumulator -= DT;
      }
    } else accumulator = 0;
    if (online && now >= sendAt) {
      sendAt = Math.max(sendAt + (multiplayer.isHost ? 50 : 1000 / 30), now);
      if (multiplayer.isHost) {
        if (multiplayer.start) multiplayer.start.world = structuredClone(world);
        void multiplayer.sendLatest({
          type: 'world',
          race: multiplayer.start?.race,
          world,
        });
      } else if (pending.length)
        void multiplayer.sendLatest({
          type: 'input',
          race: multiplayer.start?.race,
          commands: pending.slice(0, 120),
        });
    }
    rendered = interpolated(now);
    const own = rendered.find((b) => b.id === localId);
    renderer.render(rendered, follow ? own : undefined, localId);
    scene.dataset.localX = (own?.x || 0).toFixed(4);
    scene.dataset.localY = (own?.y || 0).toFixed(4);
    scene.dataset.localSpeed = Math.hypot(own?.vx || 0, own?.vy || 0).toFixed(
      4,
    );
    scene.dataset.localSlip = (own ? slipAngle(own) : 0).toFixed(4);
    scene.dataset.localPitch = (own?.pitch || 0).toFixed(4);
    scene.dataset.localRoll = (own?.roll || 0).toFixed(4);
    scene.dataset.localSteering = (own?.steering || 0).toFixed(4);
    scene.dataset.localHandbrake = (own?.handbrake || 0).toFixed(4);
    const remote = rendered.find((body) => body.id === multiplayer.authority);
    scene.dataset.remoteSteering = (remote?.steering || 0).toFixed(4);
    scene.dataset.remotePitch = (remote?.pitch || 0).toFixed(4);
    scene.dataset.remoteHandbrake = (remote?.handbrake || 0).toFixed(4);
    scene.dataset.remoteX = (remote?.x || 0).toFixed(4);
    scene.dataset.remoteY = (remote?.y || 0).toFixed(4);
    scene.dataset.tick = String(world.tick);
    scene.dataset.ack = String(
      world.bodies.find((b) => b.id === localId)?.ack || 0,
    );
    scene.dataset.pending = String(pending.length);
    scene.dataset.countdown = String(world.countdown);
    scene.dataset.racing = 'true';
    const countdown =
      world.countdown > 0
        ? String(Math.ceil(world.countdown))
        : paused
          ? controllerLost
            ? 'PAD LOST'
            : 'PAUSED'
          : online && !multiplayer.isHost && now - lastPacket > 2500
            ? 'WAITING'
            : '';
    $('countdown').hidden = !countdown;
    $('countdown').textContent = countdown;
    if (now > hudAt) {
      hudAt = now + 120;
      updateHud();
    }
    if (gain && audio && osc) {
      gain.gain.setTargetAtTime(
        sound && !paused ? 0.018 : 0,
        audio.currentTime,
        0.15,
      );
      osc.frequency.setTargetAtTime(
        45 + Math.hypot(own?.vx || 0, own?.vy || 0) * 5,
        audio.currentTime,
        0.08,
      );
    }
  } else {
    const demos = createWorld(roster(4), road).bodies;
    renderer.render(demos);
    scene.dataset.racing = 'false';
    if (gain && audio) gain.gain.setTargetAtTime(0, audio.currentTime, 0.1);
  }
}
updateRoad();
frameId = requestAnimationFrame(tick);
if (!multiplayer.available) {
  $('networkBadge').textContent = 'UNAVAILABLE';
  $<HTMLButtonElement>('hostButton').disabled = true;
  $<HTMLButtonElement>('joinCode').disabled = true;
}
window.addEventListener('pagehide', () => {
  cancelAnimationFrame(frameId);
  controllers.dispose();
  touchDriving.clear();
  void multiplayer.leave();
  renderer.dispose();
  void audio?.close();
});