{"v":1,"kind":"homie-game-source","id":"volt-run","credit":{"studio":"My Studio","game":"Volt Run","page":"https://super-game.ryan-d2f.workers.dev/volt-run/"},"license":{"kind":"remix-with-credit","spdx":null},"files":{"game.json":"{\n  \"id\": \"volt-run\",\n  \"name\": \"Volt Run\",\n  \"blurb\": \"Grab the case, bank it, and survive the surge. Sixty-second rounds that never stop.\",\n  \"genre\": \"arena\",\n  \"players\": { \"min\": 1, \"max\": 8 },\n  \"roundSeconds\": 60,\n  \"entry\": \"src/main.ts\",\n  \"netplay\": { \"v\": 1, \"public\": true, \"movement\": \"owner\" },\n  \"landing\": {\n    \"pitch\": \"One case. Then the last twelve seconds come for you.\",\n    \"about\": \"A night alley, eight couriers, one case. Stand on the drop to score. Bump the carrier and it pops loose. The searchlight cooks whoever is holding it. With twelve seconds left the pad pays triple, the light widens, and a drone leaves the rim to take the case.\",\n    \"howToPlay\": [\n      \"Grab the case. You are the only one who can score.\",\n      \"Stand on the DROP pad to bank points. The pad moves.\",\n      \"Bump the carrier and the case pops free.\",\n      \"The searchlight slows you and, if you stay, throws the case.\",\n      \"The last twelve seconds are the surge: triple bank, a wider light, and a drone that hunts the carrier.\"\n    ],\n    \"controls\": {\n      \"phone\": \"Drag to move. A second finger bumps.\",\n      \"computer\": \"WASD or arrows to move. Space bumps.\"\n    }\n  },\n  \"from\": { \"starter\": \"gem-rush\", \"studio\": \"0.30.0\" }\n}\n","index.html":"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no\">\n<title>Volt Run</title>\n<link rel=\"icon\" href=\"data:,\">\n<style>\n  html, body { margin: 0; height: 100%; background: #04060c; overflow: hidden; overscroll-behavior: none; -webkit-user-select: none; user-select: none; }\n  canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }\n</style>\n</head>\n<body>\n<canvas id=\"c\"></canvas>\n<script type=\"module\" crossorigin src=\"./assets/main.js\"></script>\n</body>\n</html>\n","lab.json":"{\n  \"v\": 1,\n  \"default\": \"knock\",\n  \"takes\": {\n    \"knock\": {\n      \"note\": \"Bump the dummy beside you\",\n      \"seconds\": 2.2,\n      \"seed\": 7,\n      \"stage\": \"dummy\",\n      \"device\": \"desk\",\n      \"fps\": 60,\n      \"view\": \"close\",\n      \"track\": \"speed\",\n      \"inputs\": [\n        { \"at\": 0.5, \"key\": \"Space\", \"hold\": 0.1 }\n      ]\n    }\n  }\n}\n","src/main.ts":"/*\n * VOLT RUN — one case, a drop pad, a searchlight. Netplay v1 (@homie-rocks/studio/netplay/NETPLAY.md).\n *\n * Grab the case and stand on the pad to bank it. Bump the carrier and the case pops loose.\n * The searchlight cooks whoever is carrying: heat slows them, and a full cook drops the case\n * and throws them clear. 60 s rounds cycle forever. The round starts the moment the first\n * browser arrives, with two bots, and every human who arrives takes a bot's body. Every\n * browser renders the game itself. One browser is host.\n *\n * The last twelve seconds are the surge: the pad pays triple, the light widens, and a drone\n * leaves the rim to take the case off whoever is holding it.\n *\n * WATCH ANY PLAYER (contract revision 5): a watcher (/<game>/watch) never\n * takes a seat. `net.viewSeat` says whose view to draw; the camera follows that\n * player's body as their own browser does, the board marks their row, and with\n * nobody followed the whole arena shows. Waves call `net.spotlight(seat)`, so a\n * watcher on Auto cuts to whoever just bumped.\n *\n * SERVERS AND THE SKILL DIAL (contract revision 6): the bots play at the room's\n * dial (`net.skillOf(slot)`: how late they see, how well they aim, whether they\n * fight for the hot zone, how often they bump), which the party votes in the\n * play page; a hybrid server's AI seats are kept as AI bodies (the Roster's\n * reserve, marked \" · AI\"), an AI with a pass takes one, and a humans-only\n * server's bots are off. The game says so: `caps: ['skill', 'agents']`.\n *\n * THE GAME LAB (`homie-studio lab volt-run`): the knock is tuned in the lab. Its numbers are tunables.json (read with\n * lab.tunables, so the lab's sliders move them and write kept values back), and the game tells the lab what the knock\n * is doing: its phases, the bumped body's speed and distance, poses for the onion skin and the spacing arc, and preset\n * views. Outside the lab every lab call is a no-op. lab.json holds the take the lab plays.\n *\n * Canvas 2D on purpose: the point is the contract, in ~700 readable lines.\n */\nimport { createNetplay, Roster, q, lerp, capMove, PALETTE, AI_MARK, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';\n// The port toolkit: its probe (what `homie-studio port check` reads for the owner tests, and sandbox + audio shims),\n// and a flat world on every screen (port/view.ts: the camera, and name labels that never pile up).\nimport { BUBBLE_FONT, createBubbles, createLabels, exposePort, fitView, paintBubbles, type BubbleIn, type BubbleOut, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';\n// The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).\nimport { lab } from '@homie-rocks/studio/lab';\nimport tuning from '../tunables.json';\n\n/* ------------------------------------------------------------------ rules */\nconst W = 1600;\nconst H = 1000;\nconst R_AV = 22;\nconst R_CASE = 14;\nconst SPEED = 340;\nconst BOT_SPEED = 268;\nconst ROUND_MS = 60_000;\nconst BREAK_MS = 7_000;\nconst MIN_SLOTS = 3; // 1 human + 2 bots from the first frame\nconst MAX_SLOTS = 8;\n/** The knock's numbers (tunables.json): the file's values, or the Game Lab's sliders while it plays a take. */\nconst T = lab.tunables(tuning);\nconst PAD_MS = 14_000;\nconst BANK_EVERY = 0.42; // seconds on the pad, case in hand, per point\nconst LIGHT_R = 188;\nconst SURGE_MS = 12_000;\n/** The contract's 12 colours (PALETTE, NETPLAY.md section 3): a person wears their seat's, so the watch page's strip matches. */\nconst colourOf = (slot: number, seat: number | null): string => PALETTE[(seat ?? slot) % PALETTE.length] as string;\nconst BOT_NAMES = ['Rook', 'Vex', 'Moth', 'Kilo', 'Juno', 'Pike', 'Nyx', 'Ash'];\nconst botName = (slot: number): string => BOT_NAMES[slot % BOT_NAMES.length] as string;\n/** An AI's name already ends in \" · AI\" (the relay sees to it); a plain bot says bot. */\nconst label = (name: string, bot: boolean): string => (name.endsWith(AI_MARK) ? name : bot ? `${name} · bot` : name);\n\n/* ------------------------------------------------------------ wire shapes */\n/** Snapshot: bodies, plus the case (x, y, holder slot or -1, heat 0–100). */\ntype P = [slot: number, seat: number, x: number, y: number, score: number, vx: number, vy: number];\ntype C = [x: number, y: number, holder: number, heat: number];\ninterface Snap { r: [n: number, phase: number, startedAt: number, endsAt: number]; p: P[]; c: C; h: [x: number, y: number] }\n/**\n * Replica input: the avatar (owner movement) AND the stick intent (host movement), so either mode reads the same\n * frame. The helper stamps the reset epoch the replica has adopted.\n */\ntype Avatar = [x: number, y: number, vx: number, vy: number, mx: number, my: number];\n/** A body's bump, while it lasts: its direction (kvx, kvy), where its slide starts (kx, ky) and when (kat, after the hit-stop). */\ninterface Knock { kvx: number; kvy: number; kx: number; ky: number; kat: number; knockUntil: number }\ninterface Body extends Knock { slot: number; seat: number | null; name: string; bot: boolean; x: number; y: number; vx: number; vy: number; score: number; tx: number; ty: number }\n/** The case: loose at x,y, or held by `holder` (a slot). Heat cooks only the carrier. */\ninterface Cargo { x: number; y: number; holder: number; heat: number }\n/** Slow state: the drop pad (net.state('zone', ...)), so a joiner and a promoted host both have it. */\ninterface Zone { n: number; x: number; y: number; r: number; until: number }\ninterface Ckpt { round: RoundInfo; bodies: Body[]; cargo: Cargo; roster: Slot[]; tick: number; bank: number; hunter: { x: number; y: number } }\n\n/* --------------------------------------------------------------- the net */\n/**\n * `?movement=host` plays host movement: the host moves every body from the seats' stick intents (its rules own\n * movement), and each replica PREDICTS its own body by replaying its unacknowledged intents on the host's position.\n * Default `owner`: each browser moves its own body and the host bounds it.\n */\nconst MOVEMENT: 'owner' | 'host' = (() => { try { return new URLSearchParams(location.search).get('movement') === 'host' ? 'host' : 'owner'; } catch { return 'owner'; } })();\n// caps: its bots read the dial ('skill'), and its Roster takes an AI's slot for it ('agents': the join passes p.agent).\nconst net = createNetplay<Snap, Avatar, Ckpt>({ game: 'volt-run', maxPlayers: MAX_SLOTS, movement: MOVEMENT, snapshotHz: 20, inputHz: 20, checkpointMs: 1000, caps: ['skill', 'agents'], checkpoint: () => checkpoint() });\n/** The Roster keeps the server's AI seats (revision 6): the policy is read whenever it fills. */\nconst policy = () => net.policy;\n\n/* ------------------------------------------------------------ host state */\nlet roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });\nlet bodies = new Map<number, Body>();\nlet cargo: Cargo = { x: W / 2, y: H / 2, holder: -1, heat: 0 };\nlet bank = 0;\nlet round: RoundInfo | null = null;\nlet zone: Zone | null = null;\nlet tick = 0;\nlet hosting = false;\nlet cheatResets = 0;\nlet controlResets = 0;\nlet predictionError = 0;\n/** Case grabs this round (the dial's `pickups` probe). */\nlet pickups = new Map<number, number>();\n/** The rim drone. It idles on the edge, then dives the carrier for the surge. */\nlet hunter = { x: W / 2, y: 70, vx: 0, vy: 0 };\nlet hunterCd = 0;\n\n/* ------------------------------------------------ this browser's avatar */\nconst me = { x: W / 2, y: H / 2, vx: 0, vy: 0, kvx: 0, kvy: 0, kx: 0, ky: 0, kat: 0, knockUntil: 0, has: false };\n/** Offline (no shell) plays as a local seat 0, with keys or touch. */\nconst mySeat = (): number | null => (net.offline ? 0 : net.seat);\n/** Whose view to draw: my own seat, or for a watcher the player it follows (null: the whole arena). */\nconst viewSeat = (): number | null => (net.offline ? 0 : net.viewSeat);\n\n/* ------------------------------------------------------- replica view */\nconst drawn = new Map<number, { x: number; y: number; seat: number; score: number; slot: number }>();\nlet viewHunter = { x: W / 2, y: 70 };\nconst waves: { x: number; y: number; at: number; colour: string; knock?: boolean }[] = [];\nlet wavesSeen = 0;\nlet knocksSeen = 0;\nlet firstStateAt = 0;\nlet firstSnapAt = 0;\nlet frames = 0;\n\nconst rnd = (a: number, b: number): number => a + Math.random() * (b - a);\nconst spawnPoint = (i: number): { x: number; y: number } => {\n  const a = (i / MAX_SLOTS) * Math.PI * 2;\n  return { x: W / 2 + Math.cos(a) * 330, y: H / 2 + Math.sin(a) * 250 };\n};\n/** Milliseconds left in the surge (the last twelve seconds), or 0. Same clock on every screen. */\nfunction surgeLeft(now: number): number {\n  if (!round || round.phase !== 'live') return 0;\n  const left = round.endsAt - now;\n  return left > 0 && left <= SURGE_MS ? left : 0;\n}\n/** The searchlight is a function of the shared clock, so every browser draws the same one. Wider and faster in the surge. */\nfunction lightAt(now: number): { x: number; y: number; r: number } {\n  const surge = surgeLeft(now) > 0;\n  const t = now / 1000;\n  const a = t * (surge ? 1.45 : 0.62);\n  return {\n    x: W / 2 + Math.cos(a) * (surge ? 280 : 390) + Math.sin(t * 1.15) * 70,\n    y: H / 2 + Math.sin(a * 0.86) * (surge ? 180 : 250),\n    r: surge ? LIGHT_R + 78 : LIGHT_R,\n  };\n}\n\nfunction bodyFor(slot: Slot): Body {\n  const sp = spawnPoint(slot.slot);\n  return { slot: slot.slot, seat: slot.seat, name: slot.name, bot: slot.bot, x: sp.x, y: sp.y, vx: 0, vy: 0, score: 0, tx: sp.x, ty: sp.y, kvx: 0, kvy: 0, kx: sp.x, ky: sp.y, kat: 0, knockUntil: 0 };\n}\n\nfunction syncBodiesFromRoster(): void {\n  const seen = new Set<number>();\n  for (const s of roster.slots) {\n    seen.add(s.slot);\n    const b = bodies.get(s.slot);\n    if (!b) { bodies.set(s.slot, bodyFor(s)); continue; }\n    b.seat = s.seat; b.name = s.name; b.bot = s.bot;\n  }\n  for (const k of [...bodies.keys()]) if (!seen.has(k)) bodies.delete(k);\n}\n\nfunction publishRoster(): void { net.roster(roster.toJSON()); }\n\n/** The host moved body `b` itself: tell its owner (a replica adopts it; the host's own body is `me`). */\nfunction hostMoved(b: Body): void {\n  if (b.bot || b.seat === null) return;\n  if (b.seat === mySeat()) { me.x = b.x; me.y = b.y; me.vx = 0; me.vy = 0; me.has = true; return; }\n  net.reset(b.seat);\n}\n\nfunction newZone(n: number): Zone {\n  return { n, x: rnd(240, W - 240), y: rnd(200, H - 200), r: 118, until: net.now() + PAD_MS };\n}\nfunction setZone(z: Zone): void { zone = z; net.state('zone', z); }\n\nfunction startRound(n: number): void {\n  const now = net.now();\n  roster.trim();\n  syncBodiesFromRoster();\n  let i = 0;\n  for (const b of [...bodies.values()].sort((a, c) => a.slot - c.slot)) {\n    const sp = spawnPoint(i++);\n    b.x = sp.x; b.y = sp.y; b.vx = 0; b.vy = 0; b.score = 0; b.tx = sp.x; b.ty = sp.y; b.kvx = 0; b.kvy = 0; b.knockUntil = 0;\n    hostMoved(b);\n  }\n  cargo = { x: W / 2, y: H / 2, holder: -1, heat: 0 };\n  bank = 0;\n  hunter = { x: W / 2, y: 70, vx: 0, vy: 0 };\n  hunterCd = 0;\n  pickups = new Map();\n  sight.clear();\n  round = { n, phase: 'live', startedAt: now, endsAt: now + ROUND_MS };\n  if (lab.stage === 'dummy') stageDummy();\n  setZone(newZone((zone?.n ?? 0) + 1));\n  net.round(round);\n  publishRoster();\n  net.snapshot(buildSnap(), tick, true);\n}\n\nfunction endRound(): void {\n  if (!round) return;\n  const now = net.now();\n  const ranked = [...bodies.values()].sort((a, b) => b.score - a.score || Number(a.bot) - Number(b.bot) || a.slot - b.slot);\n  const agentSlot = (slot: number): boolean => Boolean(roster.slots.find((x) => x.slot === slot)?.agent);\n  const results: RoundResult[] = ranked.map((b, i) => ({ slot: b.slot, seat: b.seat, name: b.name, score: b.score, bot: b.bot, place: i + 1, ...(agentSlot(b.slot) ? { agent: true as const } : {}) }));\n  round = { n: round.n, phase: 'over', startedAt: now, endsAt: now + BREAK_MS, results };\n  net.round(round);\n}\n\n/* ---------------------------------------------------- becoming the host */\nfunction becomeHost(e: RoleChange<Snap, Ckpt>): void {\n  hosting = true;\n  if (e.promoted) restore(e);\n  else {\n    roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });\n    bodies = new Map();\n    const seat = mySeat();\n    if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name);\n    syncBodiesFromRoster();\n    startRound((e.round?.n ?? 0) + 1);\n  }\n  // Whoever is connected now is who plays: seats that left during the gap become bots.\n  const peers = net.offline ? [{ seat: 0, name: 'You' }] : [...net.peers.values()].filter((p) => p.seat !== null).map((p) => ({ seat: p.seat, name: p.name, agent: p.agent ?? null }));\n  const { claimed } = roster.reconcile(peers);\n  syncBodiesFromRoster();\n  for (const s of claimed) { const b = bodies.get(s.slot); if (b && b.seat !== mySeat()) hostMoved(b); }\n  // My own body is wherever my local avatar is: it was client-owned a moment ago and still is.\n  const mine = mySeat() !== null ? [...bodies.values()].find((b) => b.seat === mySeat()) : undefined;\n  if (mine && me.has) { mine.x = me.x; mine.y = me.y; }\n  else if (mine) { me.x = mine.x; me.y = mine.y; me.has = true; }\n  // The drop pad is keyed state: the relay handed it over with the role.\n  zone = net.stateOf<Zone>('zone') ?? zone;\n  if (!zone && round) setZone(newZone(1));\n  publishRoster();\n}\n\nfunction restore(e: RoleChange<Snap, Ckpt>): void {\n  const ck = e.ckpt?.d ?? null;\n  if (ck) {\n    roster = Roster.from(ck.roster, { min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });\n    bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));\n    cargo = { ...ck.cargo };\n    bank = ck.bank;\n    if (ck.hunter) { hunter.x = ck.hunter.x; hunter.y = ck.hunter.y; hunter.vx = 0; hunter.vy = 0; }\n    round = ck.round;\n    tick = ck.tick;\n  } else {\n    roster = Roster.from(e.roster ?? [], { min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });\n    bodies = new Map();\n    syncBodiesFromRoster();\n  }\n  // The snapshot is newer than the checkpoint (20 Hz vs 1 Hz): positions, scores and the case from it.\n  const s = e.snap;\n  if (s && (!e.ckpt || s.st >= e.ckpt.st)) {\n    for (const [slot, seat, x, y, score] of s.d.p) {\n      let b = bodies.get(slot);\n      if (!b) {\n        const rs0 = roster.slots.find((r) => r.slot === slot) ?? { slot, seat: seat >= 0 ? seat : null, name: seat >= 0 ? `Player ${seat + 1}` : botName(slot), bot: seat < 0 };\n        b = bodyFor(rs0);\n        bodies.set(slot, b);\n      }\n      b.x = x; b.y = y; b.score = score; b.tx = x; b.ty = y;\n    }\n    const [cx, cy, holder, heat] = s.d.c;\n    cargo = { x: cx, y: cy, holder, heat };\n    if (s.d.h) { hunter.x = s.d.h[0]; hunter.y = s.d.h[1]; hunter.vx = 0; hunter.vy = 0; }\n    const [n, ph, startedAt, endsAt] = s.d.r;\n    round = { n, phase: ph ? 'over' : 'live', startedAt, endsAt, ...(round?.n === n && round.results ? { results: round.results } : {}) };\n    tick = Math.max(tick, s.k);\n  }\n  // A round message the relay kept is at least as authoritative as the checkpoint's copy.\n  if (e.round && (!round || e.round.n > round.n || (e.round.n === round.n && e.round.phase === 'over' && round.phase === 'live'))) round = e.round;\n  if (!round) { startRound(1); return; }\n  net.round(round);\n}\n\nfunction checkpoint(): Ckpt {\n  return {\n    round: round ?? { n: 0, phase: 'live', startedAt: 0, endsAt: 0 },\n    bodies: [...bodies.values()].map((b) => ({ ...b })),\n    cargo: { ...cargo },\n    roster: roster.toJSON(),\n    tick,\n    bank,\n    hunter: { x: hunter.x, y: hunter.y },\n  };\n}\n\nnet.on('role', (e) => {\n  if (e.role === 'host') becomeHost(e);\n  else { hosting = false; }\n});\nnet.on('join', (p) => {\n  if (!hosting || p.seat === null) return;\n  // An AI takes a seat kept for AI, a person never does (revision 6: the Roster needs p.agent for that).\n  const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null);\n  if (!c) return; // full: they watch\n  syncBodiesFromRoster();\n  const b = bodies.get(c.slot.slot);\n  // The arriving human takes over the bot's body where it stands, score and all (reset = \"adopt this position\").\n  if (b) { b.vx = 0; b.vy = 0; b.kvx = 0; b.kvy = 0; b.knockUntil = 0; hostMoved(b); }\n  publishRoster();\n  net.snapshot(buildSnap(), tick, true);\n});\nnet.on('leave', (p) => {\n  if (!hosting || p.seat === null) return;\n  roster.release(p.seat); // their body stays, driven by a bot\n  syncBodiesFromRoster();\n  publishRoster();\n});\n// The server's policy changed: its AI seats come (or go between rounds) at once.\nnet.on('policy', () => { if (!hosting) return; roster.fill(); syncBodiesFromRoster(); publishRoster(); });\nnet.on('event', (e) => {\n  if (e.k === 'wave') addWave(e.d as { slot: number });\n  if (e.k === 'knock') addWave(e.d as { slot: number }, true);\n});\nnet.on('round', (r) => { round = hosting ? round : r; });\nnet.on('state', (e) => { if (e.k === 'zone' && !hosting) zone = (e.d as Zone | null) ?? null; if (!firstStateAt) firstStateAt = performance.now(); });\nnet.on('snapshot', () => { if (!firstSnapAt) firstSnapAt = performance.now(); });\n/** My body's control changed: on a reset, stand where the host put me (a new round, a takeover, a knockback's end). */\nnet.on('control', (e) => {\n  if (!e.reset) return;\n  controlResets += 1;\n  const p = (e.snap as Snapshot<Snap>).d.p.find((x) => x[1] === net.seat);\n  if (p) { me.x = p[2]; me.y = p[3]; me.vx = 0; me.vy = 0; me.has = true; }\n});\n\n/* ----------------------------------------------------------------- input */\nconst keys = new Set<string>();\naddEventListener('keydown', (e) => {\n  if (['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) e.preventDefault();\n  if (e.code === 'Space' && !keys.has('Space')) wave();\n  keys.add(e.code);\n});\naddEventListener('keyup', (e) => keys.delete(e.code));\naddEventListener('blur', () => keys.clear());\n\nconst stick = { id: -1, ox: 0, oy: 0, x: 0, y: 0, active: false };\nconst canvas = document.getElementById('c') as HTMLCanvasElement;\nconst ctx = canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D;\ncanvas.addEventListener('pointerdown', (e) => {\n  if (e.pointerType === 'mouse') return;\n  if (stick.active && e.pointerId !== stick.id) { wave(); return; } // a second finger waves\n  stick.id = e.pointerId; stick.ox = e.clientX; stick.oy = e.clientY; stick.x = e.clientX; stick.y = e.clientY; stick.active = true;\n  canvas.setPointerCapture(e.pointerId);\n});\ncanvas.addEventListener('pointermove', (e) => { if (e.pointerId === stick.id) { stick.x = e.clientX; stick.y = e.clientY; } });\nconst endStick = (e: PointerEvent): void => { if (e.pointerId === stick.id) { stick.active = false; stick.id = -1; } };\ncanvas.addEventListener('pointerup', endStick);\ncanvas.addEventListener('pointercancel', endStick);\n\nfunction moveVector(): { x: number; y: number } {\n  let x = 0; let y = 0;\n  if (keys.has('KeyA') || keys.has('ArrowLeft')) x -= 1;\n  if (keys.has('KeyD') || keys.has('ArrowRight')) x += 1;\n  if (keys.has('KeyW') || keys.has('ArrowUp')) y -= 1;\n  if (keys.has('KeyS') || keys.has('ArrowDown')) y += 1;\n  if (stick.active) {\n    const dx = stick.x - stick.ox; const dy = stick.y - stick.oy;\n    const len = Math.hypot(dx, dy);\n    if (len > 6) { const m = Math.min(1, len / 56); x += (dx / len) * m; y += (dy / len) * m; }\n  }\n  const len = Math.hypot(x, y);\n  return len > 1 ? { x: x / len, y: y / len } : { x, y };\n}\n\nfunction wave(): void {\n  const seat = mySeat();\n  if (seat === null) return;\n  if (hosting) {\n    const b = [...bodies.values()].find((x) => x.seat === seat);\n    if (b) hostWave(b);\n  } else net.press('wave');\n}\n/** Host: a wave rings out, and knocks back every body within reach (bots, the host itself, and replicas). */\nfunction hostWave(from: Body): void {\n  addWave({ slot: from.slot }); net.send('wave', { slot: from.slot });\n  for (const b of bodies.values()) {\n    if (b === from || Math.hypot(b.x - from.x, b.y - from.y) > T.knockRange) continue;\n    knock(b, from.x, from.y, from.slot);\n  }\n}\n/** The case pops off this body and stays where they were hit. */\nfunction releaseCase(b: Body): void {\n  if (cargo.holder !== b.slot) return;\n  cargo.x = Math.max(48, Math.min(W - 48, b.x));\n  cargo.y = Math.max(48, Math.min(H - 48, b.y));\n  cargo.holder = -1;\n  cargo.heat = 0;\n  bank = 0;\n}\n/**\n * Host: knock a body back. A carrier drops the case where they stood. The hit LANDS first: for T.hitStopMs the body holds where it was hit (it flashes, squashes\n * and shakes on every screen), then it SLIDES T.knockDistance along the hit, fast at first and easing into the stop\n * (T.knockEase), so it arrives at rest, the same distance at any frame rate, and its owner steers again with no pop.\n * A replica's body is TAKEN for the whole bump (its owner's avatar frames are ignored; the host drives it; the owner\n * draws its body from snapshots), then the helper GIVES it back with a reset, so the owner stands where it ended.\n */\nfunction knock(b: Body, fx: number, fy: number, by: number): void {\n  if (cargo.holder === b.slot) releaseCase(b);\n  let dx = b.x - fx; let dy = b.y - fy;\n  const len = Math.hypot(dx, dy);\n  if (len < 1) { const a = Math.random() * Math.PI * 2; dx = Math.cos(a); dy = Math.sin(a); } else { dx /= len; dy /= len; }\n  const at = net.now() + T.hitStopMs;\n  const o: Knock & { x: number; y: number } = !b.bot && b.seat !== null && b.seat === mySeat() ? me : b;\n  o.kvx = dx; o.kvy = dy; o.kx = o.x; o.ky = o.y; o.kat = at; o.knockUntil = at + T.knockMs;\n  if (o === b && !b.bot && b.seat !== null) net.take(b.seat, T.hitStopMs + T.knockMs);\n  labKnock(b);\n  const d = { slot: b.slot, dx: q(dx, 2), dy: q(dy, 2), by };\n  addWave(d, true); net.send('knock', d);\n}\n\n/** Where a bumped body is now: held through the hit-stop, then eased out along the hit. Sets its velocity too. */\nfunction slide(o: Knock & { x: number; y: number; vx: number; vy: number }, now: number, dt: number): void {\n  const u = Number.isFinite(o.kat) ? Math.max(0, Math.min(1, (now - o.kat) / Math.max(1, T.knockMs))) : 1;\n  const e = 1 - (1 - u) ** Math.max(1, T.knockEase);\n  const x = Math.max(R_AV, Math.min(W - R_AV, o.kx + o.kvx * T.knockDistance * e));\n  const y = Math.max(R_AV, Math.min(H - R_AV, o.ky + o.kvy * T.knockDistance * e));\n  if (u >= 1) { o.vx = 0; o.vy = 0; } else if (dt > 0) { o.vx = (x - o.x) / dt; o.vy = (y - o.y) / dt; }\n  o.x = x; o.y = y;\n}\n/** The frame a bump is over: the body lands exactly where its slide ends, at rest (at 12 fps the last step is long). */\nfunction landed(o: Knock & { x: number; y: number; vx: number; vy: number }): void {\n  if (!o.kat) return;\n  slide(o, o.kat + T.knockMs, 0);\n  o.kat = 0;\n}\nfunction addWave(d: { slot: number; dx?: number; dy?: number; by?: number }, isKnock = false): void {\n  const pos = hosting ? bodies.get(d.slot) : [...drawn.values()].find((x) => x.slot === d.slot);\n  // The action, for a watcher on Auto: whoever waved (a person, never a bot).\n  const waver = pos && 'bot' in pos ? (pos.bot ? null : pos.seat) : pos && pos.seat >= 0 ? pos.seat : null;\n  if (!isKnock) net.spotlight(waver);\n  const mineSlot = hosting ? [...bodies.values()].find((x) => x.seat !== null && x.seat === mySeat())?.slot : [...drawn.values()].find((x) => x.seat === net.seat)?.slot;\n  const at = mineSlot === d.slot && me.has ? me : pos;\n  if (!at) return;\n  waves.push({ x: at.x, y: at.y, at: performance.now(), colour: isKnock ? '#ffffff' : PALETTE[d.slot % PALETTE.length] as string, knock: isKnock });\n  if (isKnock) knocksSeen += 1; else wavesSeen += 1;\n  if (isKnock) bumped(d, at); else pushes.set(d.slot, performance.now());\n}\n\n/* ---------------------------------------------------------------- simulate */\n/** The one movement rule, shared by the owner, the host (host movement) and a replica's prediction. */\nfunction integrate(o: { x: number; y: number; vx: number; vy: number }, mx: number, my: number, dt: number, speed = SPEED): void {\n  mx = Number(mx) || 0; my = Number(my) || 0;\n  const len = Math.hypot(mx, my);\n  if (len > 1) { mx /= len; my /= len; } // an intent is at most full stick: no speed hack through intents\n  const k = Math.min(1, dt * 14);\n  o.vx += (mx * speed - o.vx) * k;\n  o.vy += (my * speed - o.vy) * k;\n  o.x = Math.max(R_AV, Math.min(W - R_AV, o.x + o.vx * dt));\n  o.y = Math.max(R_AV, Math.min(H - R_AV, o.y + o.vy * dt));\n}\n/** Heat on the case slows whoever is carrying it. Host reads the live case; a replica reads the last snapshot. */\nfunction carrySpeed(slot: number | null): number {\n  if (slot === null) return SPEED;\n  const heat = hosting ? (cargo.holder === slot ? cargo.heat : 0) : (() => {\n    const c = net.latest()?.d.c;\n    return c && c[2] === slot ? c[3] : 0;\n  })();\n  return SPEED * (1 - 0.55 * Math.max(0, Math.min(100, heat)) / 100);\n}\nfunction myBodySlot(): number | null {\n  const seat = mySeat();\n  if (seat === null) return null;\n  if (hosting) return [...bodies.values()].find((b) => b.seat === seat)?.slot ?? null;\n  return [...drawn.values()].find((d) => d.seat === seat)?.slot ?? null;\n}\n\nfunction stepMe(dt: number): void {\n  const seat = mySeat();\n  if (seat === null) { me.has = false; return; }\n  // Host-driven (host movement, or knocked back on a replica): stepReplica predicts me instead.\n  if (!net.owned) return;\n  if (me.knockUntil > net.now()) { slide(me, net.now(), dt); return; }\n  landed(me);\n  const v = moveVector();\n  integrate(me, v.x, v.y, dt, carrySpeed(myBodySlot()));\n}\n\n/**\n * Replica, host-driven body. My stick moves me locally every frame (instant response), with the same rule the host\n * runs. When a new snapshot lands: start from where the host has me, replay every intent it had not acknowledged,\n * and pull toward that by 30% (or jump, if it is far). The host stays the truth; I never wait for it.\n */\nlet predictedK = -1;\nfunction predictMe(dt: number): void {\n  const v = moveVector();\n  integrate(me, v.x, v.y, dt, carrySpeed(myBodySlot()));\n  const snap = net.latest();\n  if (!snap || snap.k === predictedK) return;\n  predictedK = snap.k;\n  const p = snap.d.p.find((x) => x[1] === net.seat);\n  if (!p) return;\n  const pred = { x: p[2], y: p[3], vx: p[5] ?? 0, vy: p[6] ?? 0 };\n  for (const f of net.pending()) {\n    let left = Math.min(0.25, f.dt / 1000);\n    while (left > 0) { const h = Math.min(left, 1 / 60); integrate(pred, f.a[4], f.a[5], h); left -= h; }\n  }\n  const err = Math.hypot(pred.x - me.x, pred.y - me.y);\n  predictionError = err;\n  if (!me.has || err > 150) { me.x = pred.x; me.y = pred.y; me.vx = pred.vx; me.vy = pred.vy; }\n  else { me.x += (pred.x - me.x) * 0.3; me.y += (pred.y - me.y) * 0.3; }\n  me.has = true;\n}\n\nfunction stepKnocked(b: Body, dt: number): void { slide(b, net.now(), dt); }\n\n/**\n * THE BOTS, AT THE ROOM'S DIAL (NETPLAY.md section 17). A bot re-reads the world only every `reactionMs`,\n * aims up to 200 px off at aimNoise 1, and bumps about `aggression` times a second once it is in reach.\n * Carrying: it runs for the pad (positioning 1 sticks the pad, 0 wanders). Someone else has it: aggression\n * chases them to knock the case loose; a calm bot camps the pad. Loose and sitting in the light: low\n * positioning waits at the edge. A carrier in the light slows, same as a person.\n */\nconst sight = new Map<number, { at: number; tx: number; ty: number; arrived?: boolean }>();\n\nfunction stepBots(dt: number): void {\n  const now = net.now();\n  for (const b of bodies.values()) {\n    if (!b.bot) continue;\n    if (b.knockUntil > now) { stepKnocked(b, dt); continue; }\n    landed(b);\n    if (lab.stage === 'dummy') { standStill(b, dt); continue; }\n    const s: Skill = net.skillOf(b.slot); // Fair when nobody set a dial\n    let eye = sight.get(b.slot);\n    if (!eye || now - eye.at >= s.reactionMs) {\n      const g = pickTarget(b, s);\n      eye = { at: now, tx: g.x, ty: g.y };\n      sight.set(b.slot, eye);\n    }\n    b.tx = eye.tx; b.ty = eye.ty;\n    const rival = bumpChoice(b);\n    const urge = rival && cargo.holder === rival.slot ? 1.5 : 0.55;\n    if (rival && round?.phase === 'live' && Math.random() < s.aggression * urge * dt) hostWave(b);\n    const dx = b.tx - b.x; const dy = b.ty - b.y; const dist = Math.hypot(dx, dy);\n    const len = dist || 1;\n    if (dist < 6 && !eye.arrived) { eye.arrived = true; eye.at = now; }\n    const hot = cargo.holder === b.slot ? (1 - 0.55 * cargo.heat / 100) : 1;\n    const speed = dist < 6 ? 0 : BOT_SPEED * hot;\n    const k = Math.min(1, dt * 5);\n    b.vx += ((dx / len) * speed - b.vx) * k;\n    b.vy += ((dy / len) * speed - b.vy) * k;\n    b.x = Math.max(R_AV, Math.min(W - R_AV, b.x + b.vx * dt));\n    b.y = Math.max(R_AV, Math.min(H - R_AV, b.y + b.vy * dt));\n  }\n}\n\nfunction aimJitter(s: Skill): number { return (Math.random() - 0.5) * 200 * s.aimNoise; }\n\n/** Where this bot heads: the pad, the carrier, or the loose case. */\nfunction pickTarget(b: Body, s: Skill): { x: number; y: number } {\n  const jx = aimJitter(s); const jy = aimJitter(s);\n  if (surgeLeft(net.now()) > 0 && cargo.holder === b.slot && Math.hypot(hunter.x - b.x, hunter.y - b.y) < 300) {\n    return { x: b.x - (hunter.x - b.x) + jx * 0.25, y: b.y - (hunter.y - b.y) + jy * 0.25 };\n  }\n  if (cargo.holder === b.slot && zone) {\n    const wander = (1 - s.positioning) * 180;\n    return { x: zone.x + jx * 0.35 + (Math.random() - 0.5) * wander, y: zone.y + jy * 0.35 + (Math.random() - 0.5) * wander };\n  }\n  if (cargo.holder >= 0) {\n    const h = bodies.get(cargo.holder);\n    if (h && h !== b) {\n      if (s.aggression >= 0.34 || !zone) return { x: h.x + jx, y: h.y + jy };\n      return { x: zone.x + jx, y: zone.y + jy };\n    }\n  }\n  const L = lightAt(net.now());\n  const lit = Math.hypot(cargo.x - L.x, cargo.y - L.y) < L.r;\n  if (lit && s.positioning < 0.4) {\n    const dx = cargo.x - L.x; const dy = cargo.y - L.y; const len = Math.hypot(dx, dy) || 1;\n    return { x: L.x + (dx / len) * (L.r + 46) + jx, y: L.y + (dy / len) * (L.r + 46) + jy };\n  }\n  return { x: cargo.x + jx, y: cargo.y + jy };\n}\n\n/** Who this bot shoves: the carrier if it can reach them, or anyone crowding it while it banks. */\nfunction bumpChoice(b: Body): Body | null {\n  if (cargo.holder >= 0 && cargo.holder !== b.slot) {\n    const h = bodies.get(cargo.holder);\n    if (h && Math.hypot(h.x - b.x, h.y - b.y) <= T.knockRange) return h;\n    return null;\n  }\n  if (cargo.holder === b.slot) return nearestBody(b, T.knockRange);\n  return null;\n}\n\n/** The nearest other body within `range` (a bot's rival), or null. */\nfunction nearestBody(b: Body, range: number): Body | null {\n  let best: Body | null = null; let bd = range;\n  for (const o of bodies.values()) {\n    if (o === b) continue;\n    const d = Math.hypot(o.x - b.x, o.y - b.y);\n    if (d < bd) { bd = d; best = o; }\n  }\n  return best;\n}\n\nfunction stepHost(dt: number): void {\n  tick += 1;\n  const seat = mySeat();\n  const now0 = net.now();\n  for (const b of bodies.values()) {\n    if (b.bot) continue;\n    if (b.seat === seat) {\n      me.has = true;\n      b.x = me.x; b.y = me.y; b.vx = me.vx; b.vy = me.vy;\n      continue;\n    }\n    if (b.seat === null) continue;\n    const ctl = net.control(b.seat);\n    if (ctl.taken || b.knockUntil > now0) { stepKnocked(b, dt); if (net.takePresses(b.seat)['wave']) hostWave(b); continue; } // the host drives the knockback\n    if (MOVEMENT === 'host') {\n      // Host movement: the seat sends intents; the rules move the body.\n      const f = net.inputOf(b.seat);\n      integrate(b, f ? Number(f.a?.[4]) : 0, f ? Number(f.a?.[5]) : 0, dt, carrySpeed(b.slot));\n      if (net.takePresses(b.seat)['wave']) hostWave(b);\n      continue;\n    }\n    const a = net.avatar(b.seat); // null while taken, or until the owner has adopted the last reset\n    if (a && Array.isArray(a)) {\n      // Client-owned movement, bounded: to the arena, and to 1.3x top speed per frame. A legitimate avatar catches\n      // up within a frame; a teleport crawls, and a claim more than half a second of running away is reset.\n      const top = carrySpeed(b.slot);\n      const claim = { x: Math.max(R_AV, Math.min(W - R_AV, Number(a[0]))), y: Math.max(R_AV, Math.min(H - R_AV, Number(a[1]))) };\n      const m = capMove(b, claim, top * 1.3 * dt + 6);\n      if (m.over > top * 0.5) { net.reset(b.seat); cheatResets += 1; }\n      else { b.x = m.x; b.y = m.y; b.vx = Number(a[2]) || 0; b.vy = Number(a[3]) || 0; }\n    }\n    if (net.takePresses(b.seat)['wave']) hostWave(b);\n  }\n  stepBots(dt);\n  const now = net.now();\n  if (round && round.phase === 'live') {\n    stepCase(now, dt);\n    stepHunter(now, dt);\n    if (now >= round.endsAt) endRound();\n  } else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1);\n  if (net.snapshotDue()) net.snapshot(buildSnap(), tick);\n}\n\n/** Host rules: pickup, the searchlight, and banking on the pad. */\nfunction stepCase(now: number, dt: number): void {\n  if (!zone || now >= zone.until) setZone(newZone((zone?.n ?? 0) + 1));\n  if (cargo.holder < 0) {\n    let best: Body | null = null; let bd = R_AV + R_CASE;\n    for (const b of bodies.values()) {\n      if (b.knockUntil > now) continue;\n      const d = Math.hypot(cargo.x - b.x, cargo.y - b.y);\n      if (d < bd) { bd = d; best = b; }\n    }\n    if (best) {\n      cargo.holder = best.slot;\n      cargo.heat = 0;\n      bank = 0;\n      pickups.set(best.slot, (pickups.get(best.slot) ?? 0) + 1);\n    }\n    return;\n  }\n  const b = bodies.get(cargo.holder);\n  if (!b) { cargo.holder = -1; cargo.heat = 0; return; }\n  cargo.x = b.x;\n  cargo.y = b.y;\n  if (b.knockUntil > now) return;\n  const L = lightAt(now);\n  const lit = Math.hypot(b.x - L.x, b.y - L.y) < L.r;\n  const surge = surgeLeft(now) > 0;\n  cargo.heat = Math.max(0, Math.min(100, cargo.heat + (lit ? (surge ? 78 : 52) : -40) * dt));\n  if (cargo.heat >= 100) { knock(b, L.x, L.y, -1); return; }\n  if (zone && Math.hypot(b.x - zone.x, b.y - zone.y) < zone.r) {\n    const every = surge ? BANK_EVERY / 3 : BANK_EVERY;\n    bank += dt;\n    while (bank >= every) { bank -= every; b.score += 1; }\n  }\n}\n\n/** Host: the drone walks the rim, then hunts the carrier for the surge and knocks the case loose. */\nfunction stepHunter(now: number, dt: number): void {\n  if (lab.stage === 'dummy') return;\n  const surge = surgeLeft(now) > 0;\n  let tx = W / 2; let ty = 70;\n  if (surge) {\n    if (cargo.holder >= 0) { const b = bodies.get(cargo.holder); if (b) { tx = b.x; ty = b.y; } }\n    else { tx = cargo.x; ty = cargo.y; }\n  } else {\n    const a = now / 1000 * 0.45;\n    tx = W / 2 + Math.cos(a) * (W / 2 - 70);\n    ty = H / 2 + Math.sin(a) * (H / 2 - 70);\n  }\n  const dx = tx - hunter.x; const dy = ty - hunter.y; const dist = Math.hypot(dx, dy) || 1;\n  const spd = surge ? 560 : 120;\n  const k = Math.min(1, dt * (surge ? 6 : 2));\n  hunter.vx += ((dx / dist) * spd - hunter.vx) * k;\n  hunter.vy += ((dy / dist) * spd - hunter.vy) * k;\n  hunter.x += hunter.vx * dt; hunter.y += hunter.vy * dt;\n  if (!surge || now < hunterCd) return;\n  for (const b of bodies.values()) {\n    if (b.knockUntil > now) continue;\n    if (Math.hypot(b.x - hunter.x, b.y - hunter.y) < R_AV + 18) {\n      knock(b, hunter.x, hunter.y, -1);\n      hunterCd = now + 780;\n      break;\n    }\n  }\n}\n\nfunction buildSnap(): Snap {\n  const r = round ?? { n: 0, phase: 'live', startedAt: 0, endsAt: 0 };\n  return {\n    r: [r.n, r.phase === 'over' ? 1 : 0, r.startedAt, r.endsAt],\n    p: [...bodies.values()].map((b) => [b.slot, b.seat ?? -1, q(b.x, 1), q(b.y, 1), b.score, q(b.vx, 0), q(b.vy, 0)] as P),\n    c: [q(cargo.x, 0), q(cargo.y, 0), cargo.holder, q(cargo.heat, 0)],\n    h: [q(hunter.x, 0), q(hunter.y, 0)],\n  };\n}\n\nfunction stepReplica(dt: number): void {\n  const seat = net.seat;\n  if (seat !== null) {\n    // Host-driven (host movement, or taken for a knockback): predict from the host's position and my intents.\n    if (!net.owned) predictMe(dt);\n    // Only once I have a body (the first control event adopts it): before that my position is nobody's.\n    if (me.has) {\n      const v = moveVector();\n      // Stick direction as held \"buttons\": a new direction is a press edge, so it is sent within 16 ms.\n      const held = [v.x > 0.2 ? 'R' : v.x < -0.2 ? 'L' : '', v.y > 0.2 ? 'D' : v.y < -0.2 ? 'U' : ''].filter(Boolean);\n      net.input([q(me.x, 1), q(me.y, 1), q(me.vx, 0), q(me.vy, 0), q(v.x, 2), q(v.y, 2)], held);\n    }\n  }\n  const smp = net.sample();\n  drawn.clear();\n  if (!smp) return;\n  const bySlot = new Map<number, P>(smp.a.d.p.map((p) => [p[0], p]));\n  for (const pb of smp.b.d.p) {\n    const pa = bySlot.get(pb[0]) ?? pb;\n    drawn.set(pb[0], { slot: pb[0], seat: pb[1], x: lerp(pa[2], pb[2], smp.alpha), y: lerp(pa[3], pb[3], smp.alpha), score: pb[4] });\n  }\n  const [n, ph, startedAt, endsAt] = smp.b.d.r;\n  if (smp.b.d.h) {\n    const ha = smp.a.d.h ?? smp.b.d.h;\n    viewHunter.x = lerp(ha[0], smp.b.d.h[0], smp.alpha);\n    viewHunter.y = lerp(ha[1], smp.b.d.h[1], smp.alpha);\n  }\n  if (!round || round.n !== n || (round.phase === 'over') !== (ph === 1)) {\n    const kept = net.roundInfo && net.roundInfo.n === n ? net.roundInfo : null;\n    round = kept ?? { n, phase: ph ? 'over' : 'live', startedAt, endsAt };\n  }\n}\n\n/* -------------------------------------------------------------- how a bump looks */\n/*\n * Every screen draws a bump from the knock event (the host's own, or the one it sends): the hit lands (the body flashes\n * white and holds, squashed against the hit, shaking), flies (stretched along the hit, less as it slows), stops\n * (squashed, then a wobble that dies away: overlap), throws sparks, and kicks the camera of whoever was in it. The\n * waver's own body pushes out a little when it waves. Sparks and the camera's kick roll lab.random(): their own dice, so\n * the world's (Math.random) stay the same as a build without them, which the Game Lab needs to compare the two.\n */\nconst bumps = new Map<number, { at: number; dx: number; dy: number }>();\nconst pushes = new Map<number, number>();\nconst sparks: { x: number; y: number; vx: number; vy: number; at: number; life: number }[] = [];\nlet kickAt = -1e9;\nfunction bumped(d: { slot: number; dx?: number; dy?: number; by?: number }, at: { x: number; y: number }): void {\n  const now = performance.now();\n  const dx = Number(d.dx) || 0; const dy = Number(d.dy) || 0;\n  bumps.set(d.slot, { at: now, dx, dy });\n  const base = Math.atan2(dy, dx);\n  for (let i = 0; i < T.sparks; i += 1) {\n    const a = base + (lab.random() - 0.5) * 1.6;\n    const v = 420 + lab.random() * 520;\n    sparks.push({ x: at.x - dx * R_AV * 0.6, y: at.y - dy * R_AV * 0.6, vx: Math.cos(a) * v, vy: Math.sin(a) * v, at: now, life: 170 + lab.random() * 170 });\n  }\n  if (sparks.length > 160) sparks.splice(0, sparks.length - 160);\n  // The camera kicks for whoever was in it: the body bumped, or the one who waved.\n  const mine = hosting ? [...bodies.values()].find((x) => x.seat !== null && x.seat === mySeat())?.slot : [...drawn.values()].find((x) => x.seat === net.seat)?.slot;\n  if (mine !== undefined && (d.slot === mine || d.by === mine)) kickAt = now;\n}\n/** A body's look this frame: offset, stretch along an angle, and how white it flashes. */\nfunction bodyFx(slot: number, t: number): { ox: number; oy: number; sx: number; sy: number; ang: number; flash: number } {\n  const out = { ox: 0, oy: 0, sx: 1, sy: 1, ang: 0, flash: 0 };\n  const p = pushes.get(slot);\n  if (p !== undefined) { const u = (t - p) / 170; if (u >= 0 && u < 1) { const k = 0.14 * Math.sin(Math.PI * u); out.sx = 1 + k; out.sy = 1 + k; } }\n  const b = bumps.get(slot);\n  if (!b) return out;\n  const age = t - b.at;\n  const hs = T.hitStopMs; const end = hs + T.knockMs;\n  if (age > end + T.settleMs + 200) { bumps.delete(slot); return out; }\n  out.ang = Math.atan2(b.dy, b.dx);\n  out.flash = T.flashMs > 0 ? Math.max(0, 1 - age / T.flashMs) : 0;\n  let k = 0;\n  if (age < hs) { k = -T.squash * 0.45; out.ox = Math.sin(age * 0.9) * 3; out.oy = Math.cos(age * 1.3) * 3; }\n  else if (age < end) k = T.squash * (1 - (age - hs) / T.knockMs) ** Math.max(0, T.knockEase - 1);\n  else if (T.settleMs > 0 && age < end + T.settleMs) { const w = (age - end) / T.settleMs; k = -T.squash * 0.8 * Math.exp(-3 * w) * Math.sin(2.5 * Math.PI * w); }\n  out.sx *= 1 + k; out.sy *= 1 / (1 + k);\n  return out;\n}\nfunction drawSparks(t: number, scale: number): void {\n  ctx.lineCap = 'round';\n  for (let i = sparks.length - 1; i >= 0; i -= 1) {\n    const sp = sparks[i] as (typeof sparks)[number];\n    const age = t - sp.at;\n    if (age > sp.life) { sparks.splice(i, 1); continue; }\n    if (age < T.hitStopMs * 0.5) continue;\n    const u = age / sp.life; const s = age / 1000 * (1 - u * 0.5);\n    const x = sp.x + sp.vx * s; const y = sp.y + sp.vy * s;\n    ctx.strokeStyle = u < 0.4 ? '#ffffff' : '#ffd166'; ctx.globalAlpha = 1 - u; ctx.lineWidth = (2.5 + 4 * (1 - u)) / Math.max(0.5, scale);\n    ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x - sp.vx * 0.022 * (1 - u), y - sp.vy * 0.022 * (1 - u)); ctx.stroke();\n  }\n  ctx.globalAlpha = 1; ctx.lineCap = 'butt';\n}\n\n/* -------------------------------------------------------------- the Game Lab */\n/*\n * What the lab shows of a knock (lab.json's take \"knock\"): the body bumped last is the subject. Every frame its phase,\n * its speed and its distance from where it was hit go to the lab, and its pose feeds the onion skin and the spacing\n * arc (dots far apart: fast; close together: slow). Only in the lab: outside it, labKnock and labReport never run.\n */\ntype Pose = { x: number; y: number; sx?: number; sy?: number; a?: number };\n/**\n * The take's stage \"dummy\" (lab.stage, only ever set by the lab): you stand left of the middle with a bot a short step\n * to your right, and every bot stands still unless it is bumped, like a training dummy. The knock alone, every time.\n */\nfunction stageDummy(): void {\n  const seat = mySeat();\n  const list = [...bodies.values()].sort((a, c) => a.slot - c.slot);\n  const mine = list.find((b) => !b.bot && b.seat === seat);\n  const bots = list.filter((b) => b.bot);\n  if (mine) { mine.x = W / 2 - 160; mine.y = H / 2; hostMoved(mine); }\n  bots.forEach((b, i) => { b.x = i === 0 ? W / 2 - 160 + 92 : W - 180; b.y = i === 0 ? H / 2 : 150 + (i - 1) * 700; b.tx = b.x; b.ty = b.y; });\n}\n/** A dummy at rest: it eases to a stop wherever the last bump left it. */\nfunction standStill(b: Body, dt: number): void {\n  const k = Math.min(1, dt * 5);\n  b.vx -= b.vx * k; b.vy -= b.vy * k;\n  b.x = Math.max(R_AV, Math.min(W - R_AV, b.x + b.vx * dt)); b.y = Math.max(R_AV, Math.min(H - R_AV, b.y + b.vy * dt));\n}\nconst subject = { slot: -1, at: 0, x0: 0, y0: 0, px: 0, py: 0, has: false };\nfunction labKnock(b: Body): void {\n  if (!lab.on) return;\n  subject.slot = b.slot; subject.at = net.now(); subject.x0 = b.x; subject.y0 = b.y; subject.px = b.x; subject.py = b.y; subject.has = true;\n}\n/** Where the subject is now (the host's own body is `me`), or null. */\nfunction subjectAt(): Pose | null {\n  if (!subject.has) return null;\n  const b = bodies.get(subject.slot);\n  if (!b) return null;\n  return !b.bot && b.seat !== null && b.seat === mySeat() ? me : b;\n}\nfunction labReport(dt: number): void {\n  const at = subjectAt();\n  if (!at || dt <= 0) { lab.phase(null); return; }\n  const age = net.now() - subject.at;\n  lab.track('speed', Math.hypot(at.x - subject.px, at.y - subject.py) / dt, 'px/s');\n  lab.track('distance', Math.hypot(at.x - subject.x0, at.y - subject.y0), 'px');\n  subject.px = at.x; subject.py = at.y;\n  const fx = bodyFx(subject.slot, performance.now());\n  lab.track('stretch', (fx.sx - 1) * 100, '%');\n  if (age < T.hitStopMs) lab.phase('HIT-STOP', 'The hit lands: hold, flash, squash');\n  else if (age < T.hitStopMs + T.knockMs * 0.3) lab.phase('LAUNCH', 'Leaves fast, stretched along the hit');\n  else if (age < T.hitStopMs + T.knockMs) lab.phase('SLIDE', 'Eases into the stop: no creep, no pop');\n  else if (age < T.hitStopMs + T.knockMs + T.settleMs) lab.phase('SETTLE', 'Squash on the stop, overlap on the way out');\n  else lab.phase(null);\n  lab.pose('subject', { x: at.x, y: at.y, sx: fx.sx, sy: fx.sy, a: fx.ang });\n}\n/** The lab's views: the game's own camera, close on the knock, the whole arena. */\nconst labView = lab.camera<{ zoom?: number; whole?: boolean } | null>({ game: null, close: { zoom: 2.4 }, arena: { whole: true } });\nlab.overlay('onion', (c, k) => {\n  const g = c as CanvasRenderingContext2D; const s = Number(k) || 1;\n  const ghosts = lab.past<Pose>('subject', 8, 3);\n  ghosts.forEach((p, i) => { g.globalAlpha = 0.5 * (1 - i / ghosts.length); g.strokeStyle = '#ffad3b'; g.lineWidth = 2 / s; g.beginPath(); g.ellipse(p.x, p.y, R_AV * (p.sx ?? 1), R_AV * (p.sy ?? 1), p.a ?? 0, 0, Math.PI * 2); g.stroke(); });\n  g.globalAlpha = 1;\n});\nlab.overlay('arcs', (c, k) => {\n  const g = c as CanvasRenderingContext2D; const s = Number(k) || 1;\n  const pts = lab.past<Pose>('subject', 90, 1);\n  if (pts.length < 2) return;\n  g.strokeStyle = 'rgba(124,196,255,.55)'; g.lineWidth = 1.5 / s;\n  g.beginPath(); pts.forEach((p, i) => (i ? g.lineTo(p.x, p.y) : g.moveTo(p.x, p.y))); g.stroke();\n  g.fillStyle = '#7cc4ff';\n  for (const p of pts) { g.beginPath(); g.arc(p.x, p.y, 2.5 / s, 0, Math.PI * 2); g.fill(); }\n});\nlab.overlay('reach', (c, k) => {\n  if (!me.has) return;\n  const g = c as CanvasRenderingContext2D; const s = Number(k) || 1;\n  g.strokeStyle = 'rgba(255,255,255,.35)'; g.lineWidth = 1.5 / s; g.setLineDash([8 / s, 6 / s]);\n  g.beginPath(); g.arc(me.x, me.y, T.knockRange, 0, Math.PI * 2); g.stroke(); g.setLineDash([]);\n});\n\n/* ------------------------------------------------------------------ draw */\nlet dpr = 1;\nfunction resize(): void {\n  dpr = Math.min(2, devicePixelRatio || 1);\n  canvas.width = Math.round(innerWidth * dpr);\n  canvas.height = Math.round(innerHeight * dpr);\n}\naddEventListener('resize', resize);\nresize();\n\nconst cam = { x: W / 2, y: H / 2, scale: 0 };\nconst labels = createLabels({ screen: () => ({ w: innerWidth, h: innerHeight }) });\n// Room chat (NETPLAY.md section 19): what a player says in the room's chat (the play page's Chat, or a quick line) shows\n// over their body for a few seconds, when they want it there. The studio taking a message down takes its bubble too.\nconst bubbles = createBubbles({ measure: (t) => { ctx.font = BUBBLE_FONT; return ctx.measureText(t).width; }, screen: () => ({ w: innerWidth, h: innerHeight }) });\nnet.on('say', (s) => bubbles.say(s.seat, s.text, { id: s.id, kind: s.kind }));\nnet.on('unchat', (e) => { for (const id of e.ids) bubbles.remove(id); });\nlet shownBubbles: BubbleOut[] = [];\nlet shownLabels: LabelOut[] = [];\nlet lastDraw = 0;\n/** Where a seat's body is drawn now (host: the real body; replica: interpolated), or null. */\nfunction seatPos(seat: number): { x: number; y: number } | null {\n  if (seat === mySeat() && me.has && !net.watching) return me;\n  if (hosting) { const b = [...bodies.values()].find((x) => !x.bot && x.seat === seat); return b ? { x: b.x, y: b.y } : null; }\n  const d = [...drawn.values()].find((x) => x.seat === seat);\n  return d ? { x: d.x, y: d.y } : null;\n}\nfunction caseNow(): Cargo {\n  if (hosting) return cargo;\n  const c = net.latest()?.d.c;\n  return c ? { x: c[0], y: c[1], holder: c[2], heat: c[3] } : { x: W / 2, y: H / 2, holder: -1, heat: 0 };\n}\n/** Where to draw the case: on its carrier's body, or on the ground. */\nfunction caseDrawAt(list: { slot: number; x: number; y: number }[]): { x: number; y: number; held: boolean; heat: number } {\n  const c = caseNow();\n  if (c.holder < 0) return { x: c.x, y: c.y, held: false, heat: 0 };\n  const who = list.find((a) => a.slot === c.holder);\n  return { x: who?.x ?? c.x, y: who?.y ?? c.y, held: true, heat: c.heat };\n}\nfunction hunterNow(): { x: number; y: number } {\n  return hosting ? hunter : viewHunter;\n}\nconst rain = Array.from({ length: 64 }, () => ({ x: Math.random() * W, y: Math.random() * H, v: 420 + Math.random() * 260 }));\nconst audio: { ctx: AudioContext | null; master: GainNode | null; next: number; step: number } = { ctx: null, master: null, next: 0, step: 0 };\nfunction ensureAudio(): AudioContext | null {\n  const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;\n  if (!AC) return null;\n  if (!audio.ctx) {\n    audio.ctx = new AC();\n    audio.master = audio.ctx.createGain();\n    audio.master.gain.value = 0.16;\n    audio.master.connect(audio.ctx.destination);\n  }\n  if (audio.ctx.state === 'suspended') void audio.ctx.resume();\n  return audio.ctx;\n}\naddEventListener('pointerdown', () => { ensureAudio(); }, { passive: true });\naddEventListener('keydown', () => { ensureAudio(); });\nfunction tone(freq: number, dur: number, type: OscillatorType, gain: number, when: number, slide?: number): void {\n  const ctx = audio.ctx; const master = audio.master;\n  if (!ctx || !master) return;\n  const o = ctx.createOscillator(); const g = ctx.createGain();\n  o.type = type;\n  o.frequency.setValueAtTime(Math.max(40, freq), when);\n  if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(40, slide), when + dur);\n  g.gain.setValueAtTime(gain, when);\n  g.gain.exponentialRampToValueAtTime(0.0001, when + dur);\n  o.connect(g); g.connect(master);\n  o.start(when); o.stop(when + dur + 0.02);\n}\nfunction sting(kind: 'take' | 'drop' | 'bank' | 'surge'): void {\n  const ctx = ensureAudio();\n  if (!ctx) return;\n  const t = ctx.currentTime;\n  if (kind === 'take') { tone(523, 0.08, 'square', 0.1, t); tone(784, 0.12, 'square', 0.08, t + 0.06); }\n  else if (kind === 'drop') tone(180, 0.2, 'sawtooth', 0.09, t, 70);\n  else if (kind === 'bank') { tone(880, 0.06, 'square', 0.07, t); tone(1318, 0.09, 'square', 0.05, t + 0.05); }\n  else { tone(110, 0.45, 'sawtooth', 0.1, t, 440); tone(220, 0.45, 'square', 0.06, t, 880); }\n}\n/** A small pulse that climbs when someone is carrying, and doubles in the surge. Local only. */\nfunction music(): void {\n  const ctx = audio.ctx; const master = audio.master;\n  if (!ctx || !master || ctx.state !== 'running' || round?.phase !== 'live') return;\n  const surge = surgeLeft(net.now()) > 0;\n  const t = ctx.currentTime;\n  if (audio.next < t) audio.next = t + 0.02;\n  const beat = surge ? 0.25 : 0.4;\n  const carrying = caseNow().holder >= 0;\n  while (audio.next < t + 0.1) {\n    const step = audio.step;\n    audio.step += 1;\n    const root = surge ? 98 : 73;\n    if (step % 2 === 0) tone(root, 0.1, 'square', 0.07, audio.next);\n    if (step % 4 === 0) tone(root / 2, 0.18, 'triangle', 0.1, audio.next);\n    if (carrying && step % 2 === 1) {\n      const lead = [0, 3, 7, 10, 12, 7, 3, 10][step % 8] as number;\n      tone(root * 4 * 2 ** (lead / 12), 0.14, 'square', surge ? 0.055 : 0.035, audio.next);\n    }\n    audio.next += beat;\n  }\n}\nconst pops: { x: number; y: number; text: string; at: number; colour: string }[] = [];\nlet momentRound = -1;\nlet seenHolder = -2;\nconst seenScores = new Map<number, number>();\nlet surgeSung = false;\nfunction shout(x: number, y: number, text: string, colour: string): void {\n  pops.push({ x, y, text, at: performance.now(), colour });\n  if (pops.length > 24) pops.shift();\n}\n/** Local juice: a grab, a drop, a bank, and the surge horn. Scores come from the snapshot, so every screen agrees. */\nfunction noticeMoments(list: { slot: number; x: number; y: number; mine: boolean }[]): void {\n  const c = caseNow();\n  const n = round?.n ?? -1;\n  if (n !== momentRound) {\n    momentRound = n; seenHolder = c.holder; surgeSung = false;\n    seenScores.clear();\n    return;\n  }\n  if (seenHolder !== -2 && c.holder !== seenHolder) {\n    const who = list.find((a) => a.slot === (c.holder >= 0 ? c.holder : seenHolder));\n    const at = who ?? { x: c.x, y: c.y };\n    if (c.holder >= 0 && seenHolder < 0) { shout(at.x, at.y - 36, 'TAKEN', '#ffd166'); sting('take'); }\n    else if (c.holder < 0) { shout(at.x, at.y - 36, 'DROPPED', '#ff5b3a'); sting('drop'); }\n    else { shout(at.x, at.y - 36, 'STOLEN', '#ff3bd4'); sting('take'); }\n  }\n  seenHolder = c.holder;\n  const scores = new Map<number, number>();\n  if (hosting) for (const b of bodies.values()) scores.set(b.slot, b.score);\n  else for (const d of drawn.values()) scores.set(d.slot, d.score);\n  for (const [slot, score] of scores) {\n    const prev = seenScores.get(slot);\n    seenScores.set(slot, score);\n    if (prev === undefined || score <= prev) continue;\n    const who = list.find((a) => a.slot === slot);\n    if (!who) continue;\n    const surge = surgeLeft(net.now()) > 0;\n    shout(who.x, who.y - 28, surge ? `+${score - prev}` : `+${score - prev}`, surge ? '#ffe07a' : '#7df0ff');\n    if (who.mine) sting('bank');\n  }\n  if (!surgeSung && surgeLeft(net.now()) > 0 && round?.phase === 'live') { surgeSung = true; sting('surge'); shout(W / 2, H / 2 - 80, 'SURGE', '#ffe07a'); }\n}\nfunction draw(t: number): void {\n  const cw = innerWidth; const ch = innerHeight;\n  const phone = Math.min(cw, ch) <= 540;\n  // The view: my own body, or (a watcher) the followed player's, drawn exactly as their own browser frames it.\n  const view = viewSeat();\n  const followed = net.watching && view !== null ? seatPos(view) : null;\n  const overview = net.watching ? !followed : mySeat() === null;\n  // The camera: close on the body whose view this is, never past the arena's edge (except as far as it takes to keep\n  // that body clear of the HUD), and never so far out that an empty band shows beside the arena (held upright, a\n  // phone showed the arena as a band with a third of the screen dark below or above it; now the arena fills the\n  // screen). Following nobody: the whole arena.\n  const focus = net.watching ? followed : overview || !me.has ? null : me;\n  // The Game Lab's views (outside the lab: the game's own camera, always).\n  const lv = labView();\n  const want = overview || lv?.whole ? { scale: Math.min(cw / (W + 80), ch / (H + 80)), x: W / 2, y: H / 2 }\n    : fitView({ world: { w: W, h: H }, screen: { w: cw, h: ch }, readable: 0, zoom: (Math.min(cw, ch) / (phone ? 560 : 820)) * (lv?.zoom ?? 1), focus: lv?.zoom ? subjectAt() ?? focus : focus, inset: { top: 52, bottom: 36 } });\n  // A watcher's switch glides: the zoom and the pan ease over a few frames, never a cut.\n  cam.scale = cam.scale ? cam.scale + (want.scale - cam.scale) * 0.12 : want.scale;\n  const scale = cam.scale;\n  cam.x += (want.x - cam.x) * 0.2; cam.y += (want.y - cam.y) * 0.2;\n  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n  const bg = ctx.createRadialGradient(cw / 2, ch / 2, 0, cw / 2, ch / 2, Math.max(cw, ch) * 0.8);\n  bg.addColorStop(0, '#0d1426'); bg.addColorStop(1, '#04060c');\n  ctx.fillStyle = bg; ctx.fillRect(0, 0, cw, ch);\n\n  // The camera's kick (a bump you were in): a few px, gone in a fifth of a second.\n  const kick = t - kickAt < 200 ? T.shake * (1 - (t - kickAt) / 200) ** 2 : 0;\n  ctx.save();\n  ctx.translate(cw / 2 + (kick ? (lab.random() - 0.5) * 2 * kick : 0), ch / 2 + (kick ? (lab.random() - 0.5) * 2 * kick : 0)); ctx.scale(scale, scale); ctx.translate(-cam.x, -cam.y);\n  // arena\n  ctx.strokeStyle = 'rgba(125,240,255,0.07)'; ctx.lineWidth = 1 / scale;\n  ctx.beginPath();\n  for (let x = 0; x <= W; x += 100) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }\n  for (let y = 0; y <= H; y += 100) { ctx.moveTo(0, y); ctx.lineTo(W, y); }\n  ctx.stroke();\n  ctx.strokeStyle = surgeLeft(net.now()) > 0 ? 'rgba(255,210,90,0.9)' : 'rgba(125,240,255,0.55)'; ctx.lineWidth = 4; ctx.strokeRect(0, 0, W, H);\n  // rain, drawn only\n  ctx.strokeStyle = 'rgba(186,220,255,0.16)'; ctx.lineWidth = 1; ctx.beginPath();\n  for (const drop of rain) {\n    drop.y += drop.v * 0.016; if (drop.y > H) { drop.y = 0; drop.x = Math.random() * W; }\n    ctx.moveTo(drop.x, drop.y); ctx.lineTo(drop.x - 10, drop.y + 22);\n  }\n  ctx.stroke();\n  // searchlight: a soft cone into the circle you actually cook in\n  const beam = lightAt(net.now());\n  const surging = surgeLeft(net.now()) > 0;\n  ctx.save();\n  ctx.globalCompositeOperation = 'lighter';\n  const shaft = ctx.createLinearGradient(beam.x, beam.y - beam.r * 1.7, beam.x, beam.y);\n  shaft.addColorStop(0, 'rgba(255,250,230,0)');\n  shaft.addColorStop(1, surging ? 'rgba(255,150,80,0.45)' : 'rgba(255,244,210,0.34)');\n  ctx.fillStyle = shaft;\n  ctx.beginPath();\n  ctx.moveTo(beam.x, beam.y - beam.r * 1.7);\n  ctx.lineTo(beam.x - beam.r * 0.72, beam.y);\n  ctx.lineTo(beam.x + beam.r * 0.72, beam.y);\n  ctx.closePath();\n  ctx.fill();\n  ctx.restore();\n  const beamG = ctx.createRadialGradient(beam.x, beam.y, 8, beam.x, beam.y, beam.r);\n  beamG.addColorStop(0, surging ? 'rgba(255,120,80,0.28)' : 'rgba(255,246,214,0.22)');\n  beamG.addColorStop(0.55, 'rgba(255,220,140,0.08)'); beamG.addColorStop(1, 'rgba(255,220,140,0)');\n  ctx.fillStyle = beamG; ctx.beginPath(); ctx.arc(beam.x, beam.y, beam.r, 0, Math.PI * 2); ctx.fill();\n  ctx.strokeStyle = surging ? 'rgba(255,140,90,0.55)' : 'rgba(255,236,190,0.35)'; ctx.lineWidth = 2; ctx.setLineDash([6, 8]); ctx.stroke(); ctx.setLineDash([]);\n  // drop pad (keyed state): bank the case by standing here. Triple during the surge.\n  if (zone) {\n    const pulse = 0.5 + 0.5 * Math.sin(t / (surging ? 140 : 280));\n    ctx.fillStyle = `rgba(255,59,212,${0.08 + 0.08 * pulse})`; ctx.beginPath(); ctx.arc(zone.x, zone.y, zone.r, 0, Math.PI * 2); ctx.fill();\n    if (surging) { ctx.globalAlpha = 0.45; ctx.beginPath(); ctx.arc(zone.x, zone.y, zone.r + 18 + 8 * pulse, 0, Math.PI * 2); ctx.strokeStyle = '#ffe07a'; ctx.lineWidth = 2; ctx.stroke(); ctx.globalAlpha = 1; }\n    ctx.strokeStyle = 'rgba(255,59,212,0.85)'; ctx.lineWidth = 3; ctx.setLineDash([12, 8]); ctx.beginPath(); ctx.arc(zone.x, zone.y, zone.r, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);\n    ctx.fillStyle = surging ? '#ffe07a' : 'rgba(255,210,245,0.9)'; ctx.font = '800 15px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'center';\n    ctx.fillText(surging ? 'DROP ×3' : 'DROP', zone.x, zone.y + 5);\n  }\n\n  // waves\n  for (let i = waves.length - 1; i >= 0; i -= 1) {\n    const w = waves[i] as (typeof waves)[number];\n    const age = (t - w.at) / T.ringMs;\n    if (age > 1) { waves.splice(i, 1); continue; }\n    ctx.strokeStyle = w.colour; ctx.globalAlpha = 1 - age; ctx.lineWidth = w.knock ? 3 : 5;\n    ctx.beginPath(); ctx.arc(w.x, w.y, w.knock ? R_AV + 6 + age * 40 : R_AV + age * (T.knockRange + 10), 0, Math.PI * 2); ctx.stroke(); ctx.globalAlpha = 1;\n  }\n\n  // avatars\n  const names = new Map<number, Slot>((net.slots ?? roster.slots).map((s) => [s.slot, s]));\n  // `mine`: the body whose view this is: my own, or the player a watcher follows (named, never \"You\").\n  const list: { slot: number; seat: number | null; x: number; y: number; bot: boolean; name: string; mine: boolean }[] = [];\n  const isView = (seat: number | null, bot: boolean): boolean => !bot && seat !== null && seat === view;\n  if (hosting) {\n    for (const b of bodies.values()) list.push({ slot: b.slot, seat: b.seat, x: b.x, y: b.y, bot: b.bot, name: b.name, mine: isView(b.seat, b.bot) });\n  } else {\n    for (const d of drawn.values()) {\n      const own = !net.watching && d.seat >= 0 && d.seat === net.seat;\n      const s = names.get(d.slot);\n      list.push({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, x: own && me.has ? me.x : d.x, y: own && me.has ? me.y : d.y, bot: d.seat < 0, name: s?.name ?? (d.seat >= 0 ? `Player ${d.seat + 1}` : botName(d.slot)), mine: isView(d.seat >= 0 ? d.seat : null, d.seat < 0) });\n    }\n  }\n  // Names go on after the arena, on the screen, so they read at one size on any screen and never pile up.\n  const fs = Math.round(Math.max(15, Math.min(22, (15 * Math.min(cw, ch)) / 720)));\n  ctx.font = `600 ${fs}px ui-sans-serif, system-ui, sans-serif`;\n  const tags: LabelIn[] = [];\n  const heads = new Map<number, { x: number; y: number; seat: number; mine: boolean }>();\n  const viewAt = list.find((a) => a.mine) ?? null;\n  noticeMoments(list);\n  const carrier = caseNow().holder;\n  for (const a of list) {\n    const colour = colourOf(a.slot, a.bot ? null : a.seat);\n    const fx = bodyFx(a.slot, t);\n    ctx.save();\n    ctx.translate(a.x + fx.ox, a.y + fx.oy); ctx.rotate(fx.ang); ctx.scale(fx.sx, fx.sy); ctx.rotate(-fx.ang);\n    ctx.globalAlpha = a.bot ? 0.62 : 1;\n    ctx.fillStyle = colour; ctx.beginPath(); ctx.arc(0, 0, R_AV, 0, Math.PI * 2); ctx.fill();\n    ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.arc(0, 0, R_AV * 0.45, 0, Math.PI * 2); ctx.fill();\n    if (fx.flash > 0) { ctx.globalAlpha = fx.flash; ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(0, 0, R_AV, 0, Math.PI * 2); ctx.fill(); }\n    ctx.restore();\n    if (a.slot === carrier) {\n      ctx.strokeStyle = a.mine && caseNow().heat > 66 ? '#ff5b3a' : '#ffd166';\n      ctx.globalAlpha = 0.85; ctx.lineWidth = 2;\n      ctx.beginPath(); ctx.arc(a.x, a.y, R_AV + 12 + 3 * Math.sin(t / 90), 0, Math.PI * 2); ctx.stroke();\n      ctx.globalAlpha = 1;\n    }\n    if (a.mine) { ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(a.x, a.y, R_AV + 7, 0, Math.PI * 2); ctx.stroke(); }\n    ctx.globalAlpha = 1;\n    const text = a.mine && !net.watching ? 'You' : label(a.name, a.bot);\n    const sx = cw / 2 + (a.x - cam.x) * scale; const sy = ch / 2 + (a.y - cam.y) * scale; const r = (R_AV + (a.mine ? 7 : 0)) * scale;\n    if (sx + r < 0 || sx - r > cw || sy + r < 0 || sy - r > ch) continue; // off screen: no name at the edge\n    if (a.seat !== null && !a.bot) heads.set(a.slot, { x: sx, y: sy - r - 5, seat: a.seat, mine: a.mine });\n    // People before bots, nearer the view's body first; each keeps off the others' bodies when it can.\n    tags.push({ key: a.slot, text, x: sx, y: sy - r - 5, w: ctx.measureText(text).width, h: fs * 1.2, below: sy + r + 4 + fs * 1.2, body: { left: sx - r, top: sy - r, right: sx + r, bottom: sy + r }, self: a.mine, rank: (a.bot ? 10_000 : 0) + (viewAt ? Math.hypot(a.x - viewAt.x, a.y - viewAt.y) : 0) });\n  }\n  const box = caseDrawAt(list);\n  ctx.save();\n  ctx.translate(box.x, box.held ? box.y - R_AV - 8 : box.y);\n  const bob = box.held ? 0 : Math.sin(t / 180) * 3;\n  ctx.translate(0, bob);\n  ctx.fillStyle = 'rgba(255,209,102,0.35)'; ctx.beginPath(); ctx.arc(0, 0, 26, 0, Math.PI * 2); ctx.fill();\n  ctx.fillStyle = '#140e04'; ctx.fillRect(-12, -8, 24, 16);\n  ctx.strokeStyle = box.heat > 66 ? '#ff5b3a' : '#ffd166'; ctx.lineWidth = 2.5; ctx.strokeRect(-12, -8, 24, 16);\n  ctx.fillStyle = '#fff1c2'; ctx.fillRect(-4, -3, 8, 6);\n  ctx.restore();\n  const drone = hunterNow();\n  const droneSurge = surgeLeft(net.now()) > 0;\n  ctx.save();\n  ctx.translate(drone.x, drone.y); ctx.rotate(t / (droneSurge ? 90 : 220));\n  ctx.fillStyle = droneSurge ? 'rgba(255,70,50,0.35)' : 'rgba(255,59,100,0.2)';\n  ctx.beginPath(); ctx.arc(0, 0, droneSurge ? 34 : 22, 0, Math.PI * 2); ctx.fill();\n  ctx.fillStyle = droneSurge ? '#ff3b3b' : '#8a2048';\n  ctx.beginPath(); ctx.moveTo(0, -15); ctx.lineTo(15, 0); ctx.lineTo(0, 15); ctx.lineTo(-15, 0); ctx.closePath(); ctx.fill();\n  ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(0, 0, 3, 0, Math.PI * 2); ctx.fill();\n  ctx.restore();\n  ctx.font = '800 20px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'center';\n  for (let i = pops.length - 1; i >= 0; i -= 1) {\n    const p = pops[i] as (typeof pops)[number];\n    const age = (t - p.at) / 800;\n    if (age > 1) { pops.splice(i, 1); continue; }\n    ctx.globalAlpha = 1 - age; ctx.fillStyle = p.colour;\n    ctx.fillText(p.text, p.x, p.y - age * 52);\n  }\n  ctx.globalAlpha = 1;\n  drawSparks(t, scale);\n  lab.draw(ctx, scale);\n  ctx.restore();\n  shownLabels = labels.place(tags, Math.min(0.1, (t - (lastDraw || t)) / 1000));\n  lastDraw = t;\n  ctx.font = `600 ${fs}px ui-sans-serif, system-ui, sans-serif`;\n  ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.lineJoin = 'round';\n  for (const l of [...shownLabels].sort((x, y) => Number(Boolean(x.self)) - Number(Boolean(y.self)))) {\n    if (l.alpha <= 0) continue;\n    ctx.globalAlpha = l.alpha;\n    if (l.moved) {\n      // Off its own spot: a thin line to its body says whose it is.\n      const under = l.top > l.y;\n      ctx.strokeStyle = 'rgba(232,236,245,0.4)'; ctx.lineWidth = 1;\n      ctx.beginPath(); ctx.moveTo(l.cx, under ? l.top : l.bottom); ctx.lineTo(l.x, under ? (l.body?.bottom ?? l.top - 6) : l.y + 2); ctx.stroke();\n    }\n    if (l.self) { ctx.fillStyle = 'rgba(6,10,20,0.7)'; ctx.beginPath(); ctx.roundRect(l.left - 5, l.top - 1, l.right - l.left + 10, l.bottom - l.top + 2, 6); ctx.fill(); }\n    else { ctx.strokeStyle = 'rgba(4,6,12,0.85)'; ctx.lineWidth = 3; ctx.strokeText(l.text, l.cx, l.cy); }\n    ctx.fillStyle = l.self ? '#ffffff' : 'rgba(232,236,245,0.85)';\n    ctx.fillText(l.text, l.cx, l.cy);\n  }\n  ctx.globalAlpha = 1; ctx.textBaseline = 'alphabetic';\n  // Speech bubbles (room chat): over each speaker's name, the view's own player first.\n  if (bubbles.size) {\n    const anchors: BubbleIn[] = [];\n    for (const [slot, h] of heads) {\n      const l = shownLabels.find((x) => x.key === slot && x.alpha > 0 && !x.moved);\n      anchors.push({ key: h.seat, x: l ? l.cx : h.x, y: l ? l.top - 1 : h.y, self: h.mine });\n    }\n    shownBubbles = bubbles.place(anchors);\n    paintBubbles(ctx, shownBubbles);\n  } else shownBubbles = [];\n\n  hud(cw, ch, phone, list);\n  if (stick.active) {\n    ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 3;\n    ctx.beginPath(); ctx.arc(stick.ox, stick.oy, 56, 0, Math.PI * 2); ctx.stroke();\n    const dx = stick.x - stick.ox; const dy = stick.y - stick.oy; const len = Math.hypot(dx, dy); const m = len > 56 ? 56 / len : 1;\n    ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.beginPath(); ctx.arc(stick.ox + dx * m, stick.oy + dy * m, 24, 0, Math.PI * 2); ctx.fill();\n  }\n}\n\nfunction hud(cw: number, ch: number, phone: boolean, list: { slot: number; name: string; bot: boolean; mine: boolean }[]): void {\n  const pad = phone ? 14 : 20;\n  const debugStrip = new URLSearchParams(location.search).get('debug') === '1' || (window as unknown as { HOMIE_NET?: { debug?: boolean } }).HOMIE_NET?.debug;\n  const top = pad + (debugStrip ? (phone ? 50 : 26) : 0);\n  const now = net.now();\n  const r = round;\n  ctx.textAlign = 'left'; ctx.fillStyle = '#e8ecf5';\n  ctx.font = `700 ${phone ? 20 : 24}px ui-sans-serif, system-ui, sans-serif`;\n  const left = r ? Math.max(0, Math.ceil((r.endsAt - now) / 1000)) : 0;\n  const clock = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;\n  const surging = surgeLeft(now) > 0 && r?.phase === 'live';\n  ctx.fillStyle = surging ? '#ffe07a' : '#e8ecf5';\n  ctx.fillText(r ? (r.phase === 'live' ? `${surging ? 'SURGE' : `Round ${r.n}`} · ${clock}` : `Next round in ${left}`) : 'Joining…', pad, top + 18);\n  const box = caseNow();\n  const mineSlot = list.find((a) => a.mine)?.slot;\n  const status = box.holder < 0 ? 'Case is loose' : mineSlot !== undefined && box.holder === mineSlot ? 'You have the case' : 'Someone has the case';\n  ctx.font = `600 ${phone ? 13 : 15}px ui-sans-serif, system-ui, sans-serif`;\n  ctx.fillStyle = box.holder < 0 ? '#ffd166' : mineSlot !== undefined && box.holder === mineSlot ? '#ffffff' : 'rgba(232,236,245,0.8)';\n    ctx.fillText(status, pad, top + 40);\n    if (surging) {\n      ctx.fillStyle = '#ffe07a';\n      ctx.fillText(phone ? '×3 bank · drone is in' : 'Triple bank · the drone is hunting', pad, top + (mineSlot !== undefined && box.holder === mineSlot ? 74 : 58));\n    }\n    if (mineSlot !== undefined && box.holder === mineSlot) {\n      const bw = phone ? 120 : 160;\n      ctx.fillStyle = 'rgba(255,255,255,0.15)'; ctx.fillRect(pad, top + 50, bw, 6);\n      ctx.fillStyle = box.heat > 66 ? '#ff5b3a' : '#ffd166'; ctx.fillRect(pad, top + 50, bw * (box.heat / 100), 6);\n    }\n  // scores\n  const scores = new Map<number, number>();\n  if (hosting) for (const b of bodies.values()) scores.set(b.slot, b.score);\n  else for (const d of drawn.values()) scores.set(d.slot, d.score);\n  const rows = [...list].sort((a, b) => (scores.get(b.slot) ?? 0) - (scores.get(a.slot) ?? 0)).slice(0, phone ? 4 : 6);\n  ctx.font = `600 ${phone ? 14 : 16}px ui-sans-serif, system-ui, sans-serif`;\n  ctx.textAlign = 'right';\n  // The play page's room button (and a server's pill beside it) sit at the top right (game.json screen.share's\n  // default): the scores start under that band, so the buttons never cover a score.\n  const board = top + 18 + 44;\n  rows.forEach((a, i) => {\n    ctx.fillStyle = a.mine ? '#ffffff' : a.bot ? 'rgba(232,236,245,0.55)' : 'rgba(232,236,245,0.85)';\n    ctx.fillText(`${a.mine && !net.watching ? 'You' : label(a.name, a.bot)}  ${scores.get(a.slot) ?? 0}`, cw - pad, board + i * (phone ? 20 : 22));\n  });\n  // role badge\n  ctx.textAlign = 'left'; ctx.font = '600 11px ui-monospace, Menlo, monospace'; ctx.fillStyle = 'rgba(125,240,255,0.7)';\n  ctx.fillText(net.offline ? 'OFFLINE HOST' : net.watching ? 'WATCHING' : net.role.toUpperCase(), pad, ch - pad);\n  if (r && r.phase === 'over' && r.results) {\n    const w = Math.min(360, cw - 40); const h = 44 + Math.min(6, r.results.length) * 26;\n    const x = (cw - w) / 2; const y = (ch - h) / 2;\n    ctx.fillStyle = 'rgba(6,10,20,0.86)'; ctx.fillRect(x, y, w, h);\n    ctx.strokeStyle = 'rgba(125,240,255,0.5)'; ctx.strokeRect(x, y, w, h);\n    ctx.textAlign = 'center'; ctx.fillStyle = '#7df0ff'; ctx.font = '700 18px ui-sans-serif, system-ui, sans-serif';\n    ctx.fillText(`Round ${r.n} results`, cw / 2, y + 28);\n    ctx.font = '600 16px ui-sans-serif, system-ui, sans-serif';\n    const view = viewSeat();\n    r.results.slice(0, 6).forEach((row, i) => {\n      const mine = !row.bot && row.seat !== null && row.seat === view;\n      ctx.fillStyle = mine ? '#ffffff' : row.bot ? 'rgba(232,236,245,0.6)' : '#e8ecf5';\n      ctx.fillText(`${row.place}. ${mine && !net.watching ? 'You' : label(row.name, row.bot)}${mine && net.watching ? ' ◂' : ''} — ${row.score}`, cw / 2, y + 56 + i * 26);\n    });\n  }\n  if (!net.offline && !net.connected && net.role !== 'host') {\n    ctx.textAlign = 'center'; ctx.fillStyle = '#ffd166'; ctx.font = '600 14px ui-sans-serif, system-ui, sans-serif';\n    ctx.fillText('Reconnecting…', cw / 2, ch - pad);\n  }\n  if (frames < 240 && mySeat() !== null) {\n    ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(232,236,245,0.7)'; ctx.font = '500 14px ui-sans-serif, system-ui, sans-serif';\n    ctx.fillText(phone ? 'Drag to move · bank the case on DROP · a second finger bumps' : 'WASD to move · bank the case on DROP · Space bumps', cw / 2, ch - pad - 18);\n  }\n}\n\n/* ------------------------------------------------------------------ loop */\nfunction frame(t: number): void {\n  const dt = lab.time.dt(t, 0.05);\n  stepMe(dt);\n  if (hosting) stepHost(dt);\n  else stepReplica(dt);\n  if (lab.on) labReport(dt);\n  music();\n  draw(t);\n  frames += 1;\n  requestAnimationFrame(frame);\n}\n\nnet.expose({\n  // Room chat's bubbles on this screen now: whose, what, and where (an end-to-end test reads them).\n  bubbles: () => shownBubbles.map((b) => ({ seat: b.key, text: b.lines.join(' '), alpha: b.alpha, left: Math.round(b.left), top: Math.round(b.top), right: Math.round(b.right), bottom: Math.round(b.bottom) })),\n  self: () => (me.has ? { x: me.x, y: me.y } : null),\n  peer: (seat: number) => {\n    if (hosting) { const b = [...bodies.values()].find((x) => x.seat === seat); return b ? { x: b.x, y: b.y } : null; }\n    const d = [...drawn.values()].find((x) => x.seat === seat);\n    return d ? { x: d.x, y: d.y } : null;\n  },\n  frames: () => frames,\n  scores: () => (hosting ? [...bodies.values()].map((b) => ({ slot: b.slot, seat: b.seat, bot: b.bot, score: b.score })) : [...drawn.values()].map((d) => ({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, bot: d.seat < 0, score: d.score }))),\n  /** Host: case grabs this round, the round's age, and the dial each bot plays at. */\n  pickups: () => (hosting ? { round: round ? { n: round.n, phase: round.phase, ageMs: net.now() - round.startedAt } : null, slots: [...bodies.values()].map((b) => ({ slot: b.slot, bot: b.bot, gems: pickups.get(b.slot) ?? 0, level: b.bot ? net.skillOf(b.slot).level : null })) } : null),\n  /** Host: when each bot last looked (its reaction clock). */\n  sight: () => (hosting ? [...sight.entries()].map(([slot, e]) => ({ slot, at: e.at })) : null),\n  waves: () => wavesSeen,\n  knocks: () => knocksSeen,\n  zone: () => zone,\n  owned: () => net.owned,\n  cheatResets: () => cheatResets,\n  movement: () => MOVEMENT,\n  controlResets: () => controlResets,\n  predictionError: () => predictionError,\n  /** Where the camera looks and whose view it is (a watcher following a player: their body, under the camera). */\n  camera: () => ({ x: cam.x, y: cam.y, scale: cam.scale, view: viewSeat() }),\n  /** The names as drawn (boxes, never the text): the e2e probe counts overlaps and checks your own. */\n  labels: () => shownLabels.map((l) => ({ self: Boolean(l.self), alpha: l.alpha, moved: l.moved, left: Math.round(l.left), top: Math.round(l.top), right: Math.round(l.right), bottom: Math.round(l.bottom) })),\n  /** performance.now() of the first keyed-state value and the first live snapshot this browser received. */\n  arrivals: () => ({ firstStateAt, firstSnapAt }),\n  /** Harness hook (host only): knock the body of `seat` back, toward the middle of the arena. */\n  debugKnock: (seat: number) => {\n    if (!hosting) return false;\n    const b = [...bodies.values()].find((x) => x.seat === seat);\n    if (!b) return false;\n    knock(b, b.x < W / 2 ? b.x - 1 : b.x + 1, b.y); // push toward the middle, away from the nearer wall\n    return true;\n  },\n});\n\nexposePort(net, {\n  view: 'top',\n  self: () => (me.has && mySeat() !== null ? { x: me.x, y: me.y } : null),\n  size: R_AV,\n  score: () => { const seat = mySeat(); const b = hosting ? [...bodies.values()].find((x) => x.seat === seat) : [...drawn.values()].find((x) => x.seat === seat); return b ? b.score : null; },\n});\n\nvoid net.ready.then(() => {\n  const seat = mySeat();\n  if (seat !== null && hosting) {\n    const b = [...bodies.values()].find((x) => x.seat === seat);\n    if (b) { me.x = b.x; me.y = b.y; me.has = true; }\n  }\n});\nrequestAnimationFrame(frame);\n","tunables.json":"{\n  \"knockRange\": { \"value\": 110, \"min\": 60, \"max\": 200, \"step\": 1, \"unit\": \"px\", \"group\": \"Knock\", \"note\": \"How near a body must be for a wave to bump it\" },\n  \"hitStopMs\": { \"value\": 70, \"min\": 0, \"max\": 200, \"step\": 5, \"unit\": \"ms\", \"group\": \"Knock\", \"note\": \"The hit lands: the bumped body holds, squashed and shaking, before it flies\" },\n  \"knockDistance\": { \"value\": 185, \"min\": 40, \"max\": 400, \"step\": 5, \"unit\": \"px\", \"group\": \"Knock\", \"note\": \"How far a bump sends a body, the same at any frame rate\" },\n  \"knockMs\": { \"value\": 420, \"min\": 120, \"max\": 1000, \"step\": 10, \"unit\": \"ms\", \"group\": \"Knock\", \"note\": \"How long the slide takes\" },\n  \"knockEase\": { \"value\": 4, \"min\": 1, \"max\": 6, \"step\": 0.1, \"group\": \"Knock\", \"note\": \"1 slides at one speed; higher leaves faster and eases into the stop\" },\n  \"squash\": { \"value\": 0.35, \"min\": 0, \"max\": 0.8, \"step\": 0.01, \"group\": \"Body\", \"note\": \"How much a flying body stretches along the hit, and squashes when it stops\" },\n  \"settleMs\": { \"value\": 260, \"min\": 0, \"max\": 700, \"step\": 10, \"unit\": \"ms\", \"group\": \"Body\", \"note\": \"The wobble after the stop (overlap)\" },\n  \"flashMs\": { \"value\": 110, \"min\": 0, \"max\": 300, \"step\": 5, \"unit\": \"ms\", \"group\": \"Hit\", \"note\": \"How long the bumped body flashes white\" },\n  \"shake\": { \"value\": 7, \"min\": 0, \"max\": 24, \"step\": 0.5, \"unit\": \"px\", \"group\": \"Hit\", \"note\": \"The camera's kick when you bump or are bumped\" },\n  \"sparks\": { \"value\": 9, \"min\": 0, \"max\": 24, \"step\": 1, \"group\": \"Hit\", \"note\": \"Sparks thrown off the hit\" },\n  \"ringMs\": { \"value\": 900, \"min\": 200, \"max\": 2000, \"step\": 10, \"unit\": \"ms\", \"group\": \"Wave\", \"note\": \"How long a wave's ring lasts\" }\n}\n"}}
