{"v":1,"kind":"homie-game-source","id":"gem-rush","credit":{"studio":"My Studio","game":"Gem Rush","page":"https://super-game.ryan-d2f.workers.dev/gem-rush/"},"license":{"kind":"remix-with-credit","spdx":null},"files":{"game.json":"{\n  \"id\": \"gem-rush\",\n  \"name\": \"Gem Rush\",\n  \"blurb\": \"Grab gems, knock rivals away. Sixty-second rounds that never stop, and everyone who presses Play is in the same room.\",\n  \"players\": { \"min\": 1, \"max\": 8 },\n  \"roundSeconds\": 60,\n  \"entry\": \"src/main.ts\",\n  \"netplay\": { \"v\": 1, \"public\": true, \"movement\": \"owner\" }\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>Gem Rush</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","src/main.ts":"/*\n * GEM RUSH — the netplay v1 reference game (@homie-rocks/studio/netplay/NETPLAY.md).\n *\n * Move, collect gems, score; 60 s rounds that cycle forever; the round starts\n * the moment the first browser arrives, with two bots, and every human who\n * arrives takes a bot's body. Every browser renders the game itself with its\n * own camera. One browser is host: it runs pickups, scoring, bots and the\n * clock, and broadcasts snapshots. Everyone else moves their own avatar\n * locally (instant) and sends it to the host; everything else is drawn from\n * interpolated snapshots. If the host leaves, a survivor continues the SAME\n * round from the relay's checkpoint.\n *\n * It also exercises the rest of the contract: a wave near another body knocks\n * it back (the host TAKES a client-owned body, drives the knockback itself, and\n * GIVES it back), replica movement is speed-capped on the host, and the hot\n * zone (gems inside it score double) is slow state on the keyed state channel,\n * so a joiner has it in its welcome and a promoted host inherits it.\n *\n * Canvas 2D on purpose: the point is the contract, in ~700 readable lines.\n */\nimport { createNetplay, Roster, q, lerp, capMove, type RoleChange, type RoundInfo, type RoundResult, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';\n// The port toolkit's probe: what `homie-studio port check` reads for the owner tests (and sandbox + audio shims).\nimport { exposePort } from '@homie-rocks/studio/port';\n\n/* ------------------------------------------------------------------ rules */\nconst W = 1600;\nconst H = 1000;\nconst R_AV = 22;\nconst R_GEM = 13;\nconst SPEED = 340;\nconst BOT_SPEED = 250;\nconst ROUND_MS = 60_000;\nconst BREAK_MS = 7_000;\nconst GEM_COUNT = 14;\nconst MIN_SLOTS = 3; // 1 human + 2 bots from the first frame\nconst MAX_SLOTS = 8;\nconst KNOCK_RANGE = 110;\nconst KNOCK_SPEED = 950;\nconst KNOCK_MS = 420;\nconst ZONE_MS = 12_000;\nconst PALETTE = ['#ff3bd4', '#b6ff3b', '#ffe23b', '#9d5cff', '#ff7a3b', '#3bffb0', '#ff3b6b', '#3b8bff', '#f6ff8a', '#ff9bf0']; // no cyan: that is the gems' colour\nconst BOT_NAMES = ['Rook', 'Vex', 'Moth', 'Kilo', 'Juno', 'Pike', 'Nyx', 'Ash'];\nconst botName = (slot: number): string => BOT_NAMES[slot % BOT_NAMES.length] as string;\nconst label = (name: string, bot: boolean): string => (bot ? `${name} · bot` : name);\n\n/* ------------------------------------------------------------ wire shapes */\n/** Snapshot: compact arrays, quantized. ~40 B per body, ~16 B per gem. (Reset epochs ride in the helper's `c` table.) */\ntype P = [slot: number, seat: number, x: number, y: number, score: number, vx: number, vy: number];\ntype G = [id: number, x: number, y: number];\ninterface Snap { r: [n: number, phase: number, startedAt: number, endsAt: number]; p: P[]; g: G[] }\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];\ninterface Body { slot: number; seat: number | null; name: string; bot: boolean; x: number; y: number; vx: number; vy: number; score: number; tx: number; ty: number; kvx: number; kvy: number; knockUntil: number }\ninterface Gem { id: number; x: number; y: number }\n/** Slow state, on the keyed state channel (net.state('zone', ...)), not in the 20 Hz snapshot. */\ninterface Zone { n: number; x: number; y: number; r: number; until: number }\ninterface Ckpt { round: RoundInfo; bodies: Body[]; gems: Gem[]; roster: Slot[]; tick: number; gemSeq: 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'; } })();\nconst net = createNetplay<Snap, Avatar, Ckpt>({ game: 'gem-rush', maxPlayers: MAX_SLOTS, movement: MOVEMENT, snapshotHz: 20, inputHz: 20, checkpointMs: 1000, checkpoint: () => checkpoint() });\n\n/* ------------------------------------------------------------ host state */\nlet roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName });\nlet bodies = new Map<number, Body>();\nlet gems: Gem[] = [];\nlet gemSeq = 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\n/* ------------------------------------------------ this browser's avatar */\nconst me = { x: W / 2, y: H / 2, vx: 0, vy: 0, kvx: 0, kvy: 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\n/* ------------------------------------------------------- replica view */\nconst drawn = new Map<number, { x: number; y: number; seat: number; score: number; slot: number }>();\nconst waves: { x: number; y: number; at: number; colour: string; knock?: boolean }[] = [];\nlet wavesSeen = 0;\n/** Neon look, drawn only: sparkles where a gem vanished, and a short light trail behind every body. */\nconst sparks: { x: number; y: number; vx: number; vy: number; at: number; colour: string }[] = [];\nlet lastGems = new Map<number, { x: number; y: number }>();\nconst trails = new Map<number, { x: number; y: number }[]>();\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};\nconst newGem = (): Gem => ({ id: ++gemSeq, x: rnd(60, W - 60), y: rnd(60, H - 60) });\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, 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(220, W - 220), y: rnd(200, H - 200), r: 150, until: net.now() + ZONE_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  gems = Array.from({ length: GEM_COUNT }, newGem);\n  round = { n, phase: 'live', startedAt: now, endsAt: now + ROUND_MS };\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 results: RoundResult[] = ranked.map((b, i) => ({ slot: b.slot, seat: b.seat, name: b.name, score: b.score, bot: b.bot, place: i + 1 }));\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 });\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);\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 hot zone 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 });\n    bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));\n    gems = ck.gems.map((g) => ({ ...g }));\n    gemSeq = ck.gemSeq;\n    round = ck.round;\n    tick = ck.tick;\n  } else {\n    roster = Roster.from(e.roster ?? [], { min: MIN_SLOTS, max: MAX_SLOTS, botName });\n    bodies = new Map();\n    syncBodiesFromRoster();\n  }\n  // The snapshot is newer than the checkpoint (20 Hz vs 1 Hz): positions, scores and gems 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    gems = s.d.g.map(([id, x, y]) => ({ id, x, y }));\n    gemSeq = Math.max(gemSeq, ...gems.map((g) => g.id));\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    gems: gems.map((g) => ({ ...g })),\n    roster: roster.toJSON(),\n    tick,\n    gemSeq,\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  const c = roster.claim(p.seat, p.name);\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});\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) > KNOCK_RANGE) continue;\n    knock(b, from.x, from.y);\n  }\n}\n/**\n * Host: knock a body back. A replica's body is TAKEN for KNOCK_MS (its owner's avatar frames are ignored; the host\n * drives the knockback; the owner draws its body from snapshots), then the helper GIVES it back with a reset, so\n * the owner stands where the knockback ended. The owner cannot ignore it: the host holds the pen.\n */\nfunction knock(b: Body, fx: number, fy: number): void {\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 until = net.now() + KNOCK_MS;\n  if (!b.bot && b.seat !== null && b.seat === mySeat()) { me.kvx = dx * KNOCK_SPEED; me.kvy = dy * KNOCK_SPEED; me.knockUntil = until; }\n  else { b.kvx = dx * KNOCK_SPEED; b.kvy = dy * KNOCK_SPEED; b.knockUntil = until; if (!b.bot && b.seat !== null) net.take(b.seat, KNOCK_MS); }\n  addWave({ slot: b.slot }, true); net.send('knock', { slot: b.slot });\n}\nfunction addWave(d: { slot: number }, isKnock = false): void {\n  const pos = hosting ? bodies.get(d.slot) : [...drawn.values()].find((x) => x.slot === d.slot);\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}\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): 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\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()) {\n    me.x = Math.max(R_AV, Math.min(W - R_AV, me.x + me.kvx * dt)); me.y = Math.max(R_AV, Math.min(H - R_AV, me.y + me.kvy * dt));\n    me.kvx *= Math.max(0, 1 - dt * 5); me.kvy *= Math.max(0, 1 - dt * 5);\n    return;\n  }\n  const v = moveVector();\n  integrate(me, v.x, v.y, dt);\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);\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 {\n  b.x = Math.max(R_AV, Math.min(W - R_AV, b.x + b.kvx * dt)); b.y = Math.max(R_AV, Math.min(H - R_AV, b.y + b.kvy * dt));\n  b.kvx *= Math.max(0, 1 - dt * 5); b.kvy *= Math.max(0, 1 - dt * 5);\n  b.vx = b.kvx; b.vy = b.kvy;\n}\n\nfunction stepBots(dt: number): void {\n  const taken = new Set<number>();\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    let best: Gem | null = null; let bd = Infinity;\n    for (const g of gems) {\n      if (taken.has(g.id)) continue;\n      const d = Math.hypot(g.x - b.x, g.y - b.y);\n      if (d < bd) { bd = d; best = g; }\n    }\n    if (best) { taken.add(best.id); b.tx = best.x; b.ty = best.y; }\n    const dx = b.tx - b.x; const dy = b.ty - b.y; const len = Math.hypot(dx, dy) || 1;\n    const k = Math.min(1, dt * 5);\n    b.vx += ((dx / len) * BOT_SPEED - b.vx) * k;\n    b.vy += ((dy / len) * BOT_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 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);\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 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, SPEED * 1.3 * dt + 6);\n      if (m.over > SPEED * 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    if (!zone || now >= zone.until) setZone(newZone((zone?.n ?? 0) + 1));\n    for (const b of bodies.values()) {\n      for (let i = 0; i < gems.length; i += 1) {\n        const g = gems[i] as Gem;\n        if (Math.hypot(g.x - b.x, g.y - b.y) < R_AV + R_GEM) {\n          b.score += zone && Math.hypot(g.x - zone.x, g.y - zone.y) < zone.r ? 2 : 1;\n          gems[i] = newGem();\n        }\n      }\n    }\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\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    g: gems.map((g) => [g.id, q(g.x, 0), q(g.y, 0)] as G),\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 (!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/* ------------------------------------------------------------------ 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 };\nconst halos = new Map<string, CanvasGradient>();\n/** A soft additive halo in one colour, radius 1 (scale it); cached per colour. */\nfunction halo(colour: string): CanvasGradient {\n  let g = halos.get(colour);\n  if (!g) {\n    g = ctx.createRadialGradient(0, 0, 0, 0, 0, 1);\n    g.addColorStop(0, `${colour}aa`); g.addColorStop(0.35, `${colour}33`); g.addColorStop(1, `${colour}00`);\n    halos.set(colour, g);\n  }\n  return g;\n}\nfunction glowAt(x: number, y: number, r: number, colour: string, alpha = 1): void {\n  ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = alpha;\n  ctx.translate(x, y); ctx.scale(r, r); ctx.fillStyle = halo(colour);\n  ctx.beginPath(); ctx.arc(0, 0, 1, 0, Math.PI * 2); ctx.fill();\n  ctx.restore();\n}\n/** A line drawn twice: a wide faint pass and a thin bright one, which reads as neon tubing. */\nfunction neonStroke(colour: string, width: number): void {\n  ctx.strokeStyle = colour; ctx.globalAlpha = 0.25; ctx.lineWidth = width * 3; ctx.stroke();\n  ctx.globalAlpha = 1; ctx.lineWidth = width; ctx.stroke();\n}\nfunction draw(t: number): void {\n  const cw = innerWidth; const ch = innerHeight;\n  const phone = Math.min(cw, ch) <= 540;\n  const spectating = mySeat() === null;\n  const scale = spectating ? Math.min(cw / (W + 80), ch / (H + 80)) : Math.min(cw, ch) / (phone ? 560 : 820);\n  const target = spectating || !me.has ? { x: W / 2, y: H / 2 } : me;\n  cam.x += (target.x - cam.x) * 0.2; cam.y += (target.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, '#120a24'); bg.addColorStop(0.6, '#070512'); bg.addColorStop(1, '#020208');\n  ctx.fillStyle = bg; ctx.fillRect(0, 0, cw, ch);\n\n  ctx.save();\n  ctx.translate(cw / 2, ch / 2); ctx.scale(scale, scale); ctx.translate(-cam.x, -cam.y);\n  // arena\n  ctx.fillStyle = 'rgba(10,4,24,0.6)'; ctx.fillRect(0, 0, W, H);\n  ctx.strokeStyle = 'rgba(157,92,255,0.10)'; ctx.lineWidth = 1 / scale;\n  ctx.beginPath();\n  for (let x = 50; x < W; x += 100) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }\n  for (let y = 50; y < H; y += 100) { ctx.moveTo(0, y); ctx.lineTo(W, y); }\n  ctx.stroke();\n  ctx.strokeStyle = 'rgba(0,234,255,0.22)'; ctx.lineWidth = 1.5;\n  ctx.beginPath();\n  for (let x = 100; x < W; x += 100) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }\n  for (let y = 100; y < H; y += 100) { ctx.moveTo(0, y); ctx.lineTo(W, y); }\n  ctx.stroke();\n  // the arena wall: a magenta tube with a cyan inner line, breathing slowly\n  const breathe = 0.75 + 0.25 * Math.sin(t / 900);\n  ctx.beginPath(); ctx.rect(0, 0, W, H); ctx.globalAlpha = breathe; neonStroke('#ff3bd4', 5); ctx.globalAlpha = 1;\n  ctx.beginPath(); ctx.rect(10, 10, W - 20, H - 20); neonStroke('#00eaff', 1.5);\n  // hot zone (keyed state): gems inside score double\n  if (zone) {\n    const pulse = 0.5 + 0.5 * Math.sin(t / 300);\n    glowAt(zone.x, zone.y, zone.r * 1.1, '#ff3bd4', 0.25 + 0.15 * pulse);\n    ctx.beginPath(); ctx.arc(zone.x, zone.y, zone.r, 0, Math.PI * 2);\n    ctx.lineDashOffset = -t / 40; ctx.setLineDash([14, 10]); neonStroke('#ff3bd4', 3); ctx.setLineDash([]); ctx.lineDashOffset = 0;\n    ctx.fillStyle = '#ffd0f4'; ctx.font = '800 20px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'center';\n    ctx.shadowColor = '#ff3bd4'; ctx.shadowBlur = 12; ctx.fillText('×2', zone.x, zone.y + 7); ctx.shadowBlur = 0;\n  }\n\n  // gems\n  const gemList: { x: number; y: number; id: number }[] = hosting ? gems : (net.latest()?.d.g ?? []).map(([id, x, y]) => ({ id, x, y }));\n  const nowGems = new Map<number, { x: number; y: number }>();\n  for (const g of gemList) {\n    nowGems.set(g.id, { x: g.x, y: g.y });\n    const spin = t / 500 + g.id;\n    const r = R_GEM * (1 + 0.12 * Math.sin(t / 200 + g.id));\n    glowAt(g.x, g.y, r * 3.2, '#00eaff', 0.9);\n    ctx.save(); ctx.translate(g.x, g.y); ctx.rotate(spin);\n    ctx.beginPath(); ctx.moveTo(0, -r); ctx.lineTo(r * 0.8, 0); ctx.lineTo(0, r); ctx.lineTo(-r * 0.8, 0); ctx.closePath();\n    ctx.fillStyle = 'rgba(0,234,255,0.35)'; ctx.fill();\n    ctx.lineJoin = 'round'; neonStroke('#7affff', 2.5);\n    ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(-r * 0.2, -r * 0.3, r * 0.18, 0, Math.PI * 2); ctx.fill();\n    ctx.restore();\n  }\n  // a gem that was here last frame and is gone now was grabbed: sparkle where it was\n  if (lastGems.size - nowGems.size <= 3) { // not a round reset clearing the board\n    for (const [id, g] of lastGems) {\n      if (nowGems.has(id)) continue;\n      for (let k = 0; k < 14; k += 1) {\n        const a = (k / 14) * Math.PI * 2 + Math.random() * 0.4; const v = 120 + Math.random() * 220;\n        sparks.push({ x: g.x, y: g.y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, at: t, colour: k % 3 === 0 ? '#ff3bd4' : k % 3 === 1 ? '#00eaff' : '#ffffff' });\n      }\n    }\n  }\n  lastGems = nowGems;\n  ctx.save(); ctx.globalCompositeOperation = 'lighter';\n  for (let i = sparks.length - 1; i >= 0; i -= 1) {\n    const p = sparks[i] as (typeof sparks)[number];\n    const age = (t - p.at) / 650;\n    if (age > 1 || sparks.length > 400) { sparks.splice(i, 1); continue; }\n    const d = (1 - (1 - age) * (1 - age)) * 0.65; // eases out\n    const x = p.x + p.vx * d; const y = p.y + p.vy * d;\n    ctx.globalAlpha = 1 - age; ctx.fillStyle = p.colour;\n    ctx.beginPath(); ctx.arc(x, y, 3.2 * (1 - age) + 0.8, 0, Math.PI * 2); ctx.fill();\n  }\n  ctx.restore();\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) / 900;\n    if (age > 1) { waves.splice(i, 1); continue; }\n    ctx.beginPath(); ctx.arc(w.x, w.y, w.knock ? R_AV + 6 + age * 40 : R_AV + age * (KNOCK_RANGE + 10), 0, Math.PI * 2);\n    ctx.globalAlpha = 1 - age; neonStroke(w.colour, w.knock ? 2.5 : 4); ctx.globalAlpha = 1;\n  }\n\n  // avatars\n  const names = new Map<number, Slot>((net.slots ?? roster.slots).map((s) => [s.slot, s]));\n  const list: { slot: number; seat: number | null; x: number; y: number; bot: boolean; name: string; mine: boolean }[] = [];\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: b.seat !== null && b.seat === mySeat() });\n  } else {\n    for (const d of drawn.values()) {\n      const mine = 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: mine && me.has ? me.x : d.x, y: mine && me.has ? me.y : d.y, bot: d.seat < 0, name: s?.name ?? (d.seat >= 0 ? `Player ${d.seat + 1}` : botName(d.slot)), mine });\n    }\n  }\n  const seen = new Set<number>();\n  for (const a of list) {\n    const colour = PALETTE[a.slot % PALETTE.length] as string;\n    seen.add(a.slot);\n    // light trail: the last few positions, fading\n    let trail = trails.get(a.slot);\n    if (!trail) { trail = []; trails.set(a.slot, trail); }\n    const last = trail[trail.length - 1];\n    if (last && Math.hypot(a.x - last.x, a.y - last.y) > 200) trail.length = 0; // a respawn, not a dash\n    trail.push({ x: a.x, y: a.y }); if (trail.length > 10) trail.shift();\n    if (trail.length > 1) {\n      ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.lineCap = 'round'; ctx.strokeStyle = colour;\n      for (let k = 1; k < trail.length; k += 1) {\n        const p0 = trail[k - 1] as { x: number; y: number }; const p1 = trail[k] as { x: number; y: number };\n        ctx.globalAlpha = (k / trail.length) * (a.bot ? 0.25 : 0.4); ctx.lineWidth = R_AV * 1.4 * (k / trail.length);\n        ctx.beginPath(); ctx.moveTo(p0.x, p0.y); ctx.lineTo(p1.x, p1.y); ctx.stroke();\n      }\n      ctx.restore();\n    }\n    glowAt(a.x, a.y, R_AV * (a.mine ? 3.4 : 2.8), colour, a.bot ? 0.55 : 1);\n    ctx.globalAlpha = a.bot ? 0.7 : 1;\n    ctx.fillStyle = '#0a0614'; ctx.beginPath(); ctx.arc(a.x, a.y, R_AV, 0, Math.PI * 2); ctx.fill();\n    ctx.beginPath(); ctx.arc(a.x, a.y, R_AV - 2, 0, Math.PI * 2); neonStroke(colour, 4);\n    ctx.fillStyle = colour; ctx.beginPath(); ctx.arc(a.x, a.y, R_AV * 0.32, 0, Math.PI * 2); ctx.fill();\n    if (a.mine) {\n      ctx.save(); ctx.translate(a.x, a.y); ctx.rotate(t / 700);\n      ctx.beginPath(); ctx.arc(0, 0, R_AV + 8, 0, Math.PI * 2); ctx.setLineDash([10, 7]); neonStroke('#ffffff', 2.5); ctx.setLineDash([]);\n      ctx.restore();\n    }\n    ctx.globalAlpha = 1;\n    ctx.font = `700 ${Math.round(15 / Math.max(0.6, scale))}px ui-sans-serif, system-ui, sans-serif`;\n    ctx.textAlign = 'center'; ctx.fillStyle = a.mine ? '#ffffff' : 'rgba(232,236,245,0.85)';\n    ctx.fillText(a.mine ? 'You' : label(a.name, a.bot), a.x, a.y - R_AV - 12);\n  }\n  for (const slot of trails.keys()) if (!seen.has(slot)) trails.delete(slot);\n  ctx.restore();\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 = '#dffcff'; ctx.shadowColor = '#00eaff'; ctx.shadowBlur = 10;\n  ctx.font = `800 ${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  ctx.fillText(r ? (r.phase === 'live' ? `Round ${r.n} · ${clock}` : `Next round in ${left}`) : 'Joining…', pad, top + 18);\n  ctx.shadowBlur = 0;\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  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.shadowColor = PALETTE[a.slot % PALETTE.length] as string; ctx.shadowBlur = a.mine ? 10 : 6;\n    ctx.fillText(`${a.mine ? 'You' : label(a.name, a.bot)}  ${scores.get(a.slot) ?? 0}`, cw - pad, top + 18 + i * (phone ? 20 : 22));\n  });\n  ctx.shadowBlur = 0;\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.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(10,4,24,0.9)'; ctx.fillRect(x, y, w, h);\n    ctx.beginPath(); ctx.rect(x, y, w, h); neonStroke('#ff3bd4', 2);\n    ctx.textAlign = 'center'; ctx.fillStyle = '#7affff'; ctx.font = '800 18px ui-sans-serif, system-ui, sans-serif';\n    ctx.shadowColor = '#00eaff'; ctx.shadowBlur = 12; ctx.fillText(`Round ${r.n} results`, cw / 2, y + 28); ctx.shadowBlur = 0;\n    ctx.font = '600 16px ui-sans-serif, system-ui, sans-serif';\n    r.results.slice(0, 6).forEach((row, i) => {\n      const mine = row.seat !== null && row.seat === mySeat();\n      ctx.fillStyle = mine ? '#ffffff' : row.bot ? 'rgba(232,236,245,0.6)' : '#e8ecf5';\n      ctx.fillText(`${row.place}. ${mine ? 'You' : label(row.name, row.bot)} — ${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 · second finger bumps' : 'WASD / arrows to move · Space bumps', cw / 2, ch - pad - 18);\n  }\n}\n\n/* ------------------------------------------------------------------ loop */\nlet last = performance.now();\nfunction frame(t: number): void {\n  const dt = Math.min(0.05, (t - last) / 1000);\n  last = t;\n  stepMe(dt);\n  if (hosting) stepHost(dt);\n  else stepReplica(dt);\n  draw(t);\n  frames += 1;\n  requestAnimationFrame(frame);\n}\n\nnet.expose({\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  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  /** 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"}}
