{"v":1,"kind":"homie-game-source","id":"glow","credit":{"studio":"Glow Worms","game":"Glow Worms","page":"https://grok.homie.rocks/glow/"},"license":{"kind":"remix-with-credit","spdx":null},"files":{"game.json":"{\n  \"id\": \"glow\",\n  \"name\": \"Glow Worms\",\n  \"blurb\": \"Eat drifting spores and outglow the cave. Seventy-five-second rounds that start again on their own, and everyone who presses Play is in the same room.\",\n  \"players\": {\n    \"min\": 1,\n    \"max\": 8\n  },\n  \"roundSeconds\": 75,\n  \"entry\": \"src/main.ts\",\n  \"netplay\": {\n    \"v\": 1,\n    \"public\": true,\n    \"movement\": \"owner\"\n  },\n  \"chat\": {\n    \"lines\": {\n      \"glow\": \"Nice glow!\",\n      \"spore\": \"Spore!\",\n      \"bright\": \"So bright!\",\n      \"cave\": \"Dark in here!\"\n    }\n  },\n  \"landing\": {\n    \"pitch\": \"Eat the light. Outglow the cave.\",\n    \"about\": \"A dark cave, top-down. You are a glow worm. Common spores are 1 light, gold is 3 and drifts faster, and a short-lived cluster means you commit. Eating makes you a little faster and wider. Bumping the brighter worm spills chaseable light. Nobody is out. The last 10 seconds are double. When the clock hits zero, the brightest worm wins, and the next round starts on its own.\",\n    \"howToPlay\": [\n      \"Steer your worm. The head leads and the body follows.\",\n      \"Cyan is 1. Gold is 3 and moves faster. A bright cluster dies if you do not go.\",\n      \"Eating makes you faster and your glow wider. The leader is easier to chase and to bump.\",\n      \"The brighter worm spills more light, as spores you can eat. Nobody is eliminated.\",\n      \"The last 10 seconds, spores are worth double. Then a new round starts.\"\n    ],\n    \"controls\": {\n      \"phone\": \"Drag from the lower left to move.\",\n      \"computer\": \"WASD or arrows to move.\"\n    },\n    \"players\": {\n      \"one\": \"worm\",\n      \"many\": \"worms\"\n    },\n    \"hero\": {\n      \"alt\": \"Glow worms gathering spores in a dark cave\",\n      \"focus\": \"50% 45%\",\n      \"tint\": 30\n    },\n    \"credits\": [\n      {\n        \"role\": \"Game\",\n        \"name\": \"Glow Worms\"\n      },\n      {\n        \"role\": \"Music\",\n        \"name\": \"Sporelight\",\n        \"url\": \"https://grok.homie.rocks/music/sporelight/\"\n      }\n    ]\n  },\n  \"from\": {\n    \"starter\": \"gem-rush\",\n    \"studio\": \"0.30.1\"\n  },\n  \"genre\": [\n    \"Party\",\n    \"Arcade\"\n  ]\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>Glow Worms</title>\n<link rel=\"icon\" href=\"data:,\">\n<style>\n  html, body { margin: 0; height: 100%; background: #06110c; 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\": \"gather\",\n  \"takes\": {\n    \"gather\": {\n      \"note\": \"Worms gather spores in the cave\",\n      \"seconds\": 4,\n      \"seed\": 7,\n      \"device\": \"desk\",\n      \"fps\": 60,\n      \"view\": \"arena\",\n      \"track\": \"speed\"\n    }\n  }\n}\n","src/main.ts":"/*\n * GLOW WORMS — a 2D cave on the netplay v1 contract (NETPLAY.md).\n *\n * You are a short segmented worm. The head steers, the body follows, and\n * brightness is the score. Three kinds of spore drift in the dark and leave a short trail:\n * common (1), rare gold (3, faster), and a short-lived cluster you have to commit to.\n * Eating makes a worm a little faster and its glow wider. A touch spills light from the\n * brighter worm back into the cave as chaseable spores. Nobody is eliminated.\n * The last 10 seconds are double. Rounds are 75 seconds (game.json roundSeconds), then\n * results, then the next round starts on its own.\n *\n * Every browser renders. The host runs pickups, spills, scoring, the clock,\n * bots and round end. Owner movement is the default (a replica moves its own\n * head; the host bounds it). `?movement=host` lets the host move every body\n * from stick intents. If the host leaves, a survivor continues the SAME round\n * from the checkpoint. Bots read the room's skill dial. A watcher draws the\n * followed player's camera from net.viewSeat.\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';\nimport { BUBBLE_FONT, createBubbles, createLabels, exposePort, fitView, paintBubbles, type BubbleIn, type BubbleOut, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';\nimport { lab } from '@homie-rocks/studio/lab';\nimport tuning from '../tunables.json';\n\n/* ------------------------------------------------------------------ rules */\nconst W = 1600;\nconst H = 1000;\nconst R_HEAD = 16;\nconst SEGMENTS = 6;\nconst SEG_GAP = 13;\nconst ROUND_MS = 75_000; // game.json roundSeconds\nconst BREAK_MS = 6_500;\nconst MIN_SLOTS = 8; // bots fill every empty seat\nconst MAX_SLOTS = 8;\nconst DIM = 1; // a worm starts dim: a little light, not none\nconst SPILL_COOL_MS = 900;\nconst LOOSE_MAX = 12;\nconst LAST_MS = 10_000;\nconst EAT = R_HEAD + 12;\n/** Feel numbers (tunables.json). Outside the lab these are the file; inside, the sliders. */\nconst T = lab.tunables(tuning);\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\". A plain bot never wears a person's name. */\nconst label = (name: string, bot: boolean): string => (name.endsWith(AI_MARK) ? name : bot ? `${name} · bot` : name);\n\n/* ------------------------------------------------------------ wire shapes */\ntype P = [slot: number, seat: number, x: number, y: number, score: number, vx: number, vy: number];\ntype Kind = 'common' | 'gold' | 'cluster' | 'spill';\ntype G = [id: number, x: number, y: number, kind: number, worth: number, expiresAt: number];\ninterface Snap { r: [n: number, phase: number, startedAt: number, endsAt: number]; p: P[]; g: G[] }\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 }\ninterface Spore { id: number; x: number; y: number; vx: number; vy: number; backAt: number; loose: boolean; kind: Kind; worth: number; expiresAt: number }\ninterface Rock { x: number; y: number; r: number }\ninterface Ckpt { round: RoundInfo; bodies: Body[]; spores: Spore[]; roster: Slot[]; tick: number; sporeSeq: number; cool: [string, number][]; nextClusterAt: number }\n\n/* --------------------------------------------------------------- the net */\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: 'glow', maxPlayers: MAX_SLOTS, movement: MOVEMENT, snapshotHz: 20, inputHz: 20, checkpointMs: 1000, caps: ['skill', 'agents'], checkpoint: () => checkpoint() });\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 spores: Spore[] = [];\nlet sporeSeq = 0;\nlet round: RoundInfo | null = null;\nlet tick = 0;\nlet hosting = false;\nlet cheatResets = 0;\nlet controlResets = 0;\nlet predictionError = 0;\nlet pickups = new Map<number, number>();\nconst cool = new Map<string, number>();\nlet spillsSeen = 0;\nlet eatsSeen = 0;\nlet nextClusterAt = 0;\nlet heardLast = -1;\nconst KIND_CODE: Record<Kind, number> = { common: 0, gold: 1, cluster: 2, spill: 3 };\nfunction kindFrom(n: number | undefined, loose = false): Kind {\n  if (n === 1) return 'gold';\n  if (n === 2) return 'cluster';\n  if (n === 3) return 'spill';\n  return loose ? 'spill' : 'common';\n}\nfunction worthOf(kind: Kind): number {\n  return kind === 'gold' ? 3 : kind === 'cluster' ? 2 : 1;\n}\n/** Eating light makes you a little faster. Capped, so a bright leader is catchable. */\nfunction wormSpeed(score: number): number {\n  const base = Math.max(40, T.speed);\n  const lights = Math.max(0, score - DIM);\n  return base * (1 + Math.min(0.48, lights * 0.04));\n}\n/** A brighter worm is easier to bump. The head grows a little; the reach grows more. */\nfunction reach(score: number): number {\n  return R_HEAD + Math.min(18, Math.max(0, score - DIM) * 1.15);\n}\nfunction inLastCall(now = net.now()): boolean {\n  if (!round || round.phase !== 'live') return false;\n  const left = round.endsAt - now;\n  return left <= LAST_MS && left > 0;\n}\nfunction myScore(): number {\n  const seat = mySeat();\n  if (seat === null) return DIM;\n  if (hosting) return [...bodies.values()].find((x) => x.seat === seat)?.score ?? DIM;\n  return [...drawn.values()].find((x) => x.seat === seat)?.score ?? DIM;\n}\n\n/* ------------------------------------------------ this browser's avatar */\nconst me = { x: W / 2, y: H / 2, vx: 0, vy: 0, has: false };\nconst mySeat = (): number | null => (net.offline ? 0 : net.seat);\nconst viewSeat = (): number | null => (net.offline ? 0 : net.viewSeat);\n\n/* ------------------------------------------------------- replica view */\nconst drawn = new Map<number, { x: number; y: number; vx: number; vy: number; seat: number; score: number; slot: number }>();\nlet firstSnapAt = 0;\nlet frames = 0;\n\nconst clamp = (v: number, a: number, b: number): number => Math.max(a, Math.min(b, v));\n/** A stable cave for this round: the same rocks on every browser, from the round number alone. */\nfunction caveRnd(salt: number): () => number {\n  const n = round?.n ?? 1;\n  let seed = (n * 1103515245 + 12345 + salt) >>> 0;\n  return () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };\n}\nfunction caveRocks(): Rock[] {\n  const rnd = caveRnd(0);\n  const rocks: Rock[] = [];\n  for (let i = 0; i < 5; i += 1) {\n    const a = (i / 5) * Math.PI * 2 + rnd() * 0.5;\n    const rad = 300 + rnd() * 160;\n    rocks.push({ x: W / 2 + Math.cos(a) * rad, y: H / 2 + Math.sin(a) * rad * 0.7, r: 46 + rnd() * 36 });\n  }\n  return rocks;\n}\n/** Floor grit. A different salt, so it never moves the rocks worms slide around. */\nfunction caveGrit(): { x: number; y: number; s: number }[] {\n  const rnd = caveRnd(9001);\n  const grit: { x: number; y: number; s: number }[] = [];\n  for (let i = 0; i < 36; i += 1) {\n    grit.push({ x: 40 + rnd() * (W - 80), y: 40 + rnd() * (H - 80), s: rnd() > 0.72 ? 3 : 2 });\n  }\n  return grit;\n}\nfunction pushRocks(o: { x: number; y: number }): void {\n  for (const r of caveRocks()) {\n    let dx = o.x - r.x; let dy = o.y - r.y;\n    let dist = Math.hypot(dx, dy);\n    const min = r.r + R_HEAD;\n    if (dist >= min) continue;\n    if (dist < 0.001) { dx = 1; dy = 0; dist = 1; }\n    o.x = clamp(r.x + (dx / dist) * min, R_HEAD, W - R_HEAD);\n    o.y = clamp(r.y + (dy / dist) * min, R_HEAD, H - R_HEAD);\n  }\n}\nconst spawnPoint = (i: number): { x: number; y: number } => {\n  const a = (i / MAX_SLOTS) * Math.PI * 2 - Math.PI / 2;\n  const p = { x: W / 2 + Math.cos(a) * 210, y: H / 2 + Math.sin(a) * 160 };\n  pushRocks(p);\n  return p;\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: DIM, tx: sp.x, ty: sp.y };\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}\nfunction publishRoster(): void { net.roster(roster.toJSON()); }\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 driftOf(id: number, kind: Kind = 'common'): { vx: number; vy: number } {\n  const a = ((id * 47) % 360) * Math.PI / 180;\n  const mul = kind === 'gold' ? 2.2 : kind === 'cluster' ? 0.45 : kind === 'spill' ? 1.15 : 1;\n  const s = Math.max(0, T.drift) * mul;\n  return { vx: Math.cos(a) * s, vy: Math.sin(a) * s };\n}\nfunction placeSpore(s: Spore): void {\n  for (let n = 0; n < 12; n += 1) {\n    s.x = 70 + Math.random() * (W - 140);\n    s.y = 70 + Math.random() * (H - 140);\n    const hit = caveRocks().some((r) => Math.hypot(s.x - r.x, s.y - r.y) < r.r + 20);\n    const near = [...bodies.values()].some((b) => Math.hypot(s.x - b.x, s.y - b.y) < 90);\n    if (!hit && !near) break;\n  }\n  const d = driftOf(s.id + Math.floor(Math.random() * 8), s.kind);\n  s.vx = d.vx; s.vy = d.vy; s.backAt = 0; s.expiresAt = s.kind === 'cluster' ? s.expiresAt : 0;\n}\nfunction makeSpore(loose: boolean, kind?: Kind): Spore {\n  const k: Kind = kind ?? (loose ? 'spill' : 'common');\n  const s: Spore = { id: ++sporeSeq, x: 0, y: 0, vx: 0, vy: 0, backAt: 0, loose, kind: k, worth: worthOf(k), expiresAt: 0 };\n  placeSpore(s);\n  return s;\n}\n/** A bright patch in one place. It dies if nobody commits. */\nfunction placeCluster(now: number): void {\n  const leader = [...bodies.values()].sort((a, b) => b.score - a.score)[0];\n  let x = W / 2;\n  let y = H / 2;\n  for (let n = 0; n < 18; n += 1) {\n    x = 180 + Math.random() * (W - 360);\n    y = 160 + Math.random() * (H - 320);\n    const rock = caveRocks().some((r) => Math.hypot(x - r.x, y - r.y) < r.r + 70);\n    const fromLead = leader ? Math.hypot(x - leader.x, y - leader.y) : 400;\n    if (!rock && fromLead > 220) break;\n  }\n  const life = 6000;\n  const count = 6;\n  for (let i = 0; i < count; i += 1) {\n    const a = (i / count) * Math.PI * 2 + Math.random() * 0.35;\n    const rad = 16 + Math.random() * 54;\n    const s: Spore = {\n      id: ++sporeSeq,\n      x: clamp(x + Math.cos(a) * rad, 40, W - 40),\n      y: clamp(y + Math.sin(a) * rad, 40, H - 40),\n      vx: 0, vy: 0, backAt: 0, loose: false, kind: 'cluster', worth: 2, expiresAt: now + life,\n    };\n    const d = driftOf(s.id, 'cluster');\n    s.vx = d.vx; s.vy = d.vy;\n    spores.push(s);\n  }\n  const payload: BloomEv = { x: q(x, 0), y: q(y, 0), n: round?.n ?? 0 };\n  net.send('bloom', payload);\n  hearBloom(payload);\n}\nfunction fieldCount(): number { return Math.max(4, Math.min(14, Math.round(T.spores))); }\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 = DIM; b.tx = sp.x; b.ty = sp.y;\n    hostMoved(b);\n  }\n  sporeSeq = n * 100;\n  const count = fieldCount();\n  const golds = count >= 7 ? 2 : 1;\n  spores = Array.from({ length: count }, (_, i) => makeSpore(false, i < golds ? 'gold' : 'common'));\n  nextClusterAt = now + 12_000;\n  pickups = new Map();\n  sight.clear();\n  cool.clear();\n  round = { n, phase: 'live', startedAt: now, endsAt: now + ROUND_MS };\n  net.round(round);\n  publishRoster();\n  net.snapshot(buildSnap(), tick, true);\n}\n\nfunction endRound(): void {\n  if (!round || round.phase === 'over') 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  const star = ranked.find((b) => b.seat !== null && !b.bot);\n  if (star?.seat != null) net.spotlight(star.seat);\n  const over = { n: round.n, seat: star?.seat ?? -1 };\n  net.send('over', over);\n  hearOver(over);\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  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) {\n    const b = bodies.get(s.slot);\n    // Keep the score a checkpoint restored. A brand-new arrival is dimmed in `join`.\n    if (b && b.seat !== mySeat()) hostMoved(b);\n  }\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  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    spores = ck.spores.map((s) => ({ ...s }));\n    sporeSeq = ck.sporeSeq;\n    round = ck.round;\n    tick = ck.tick;\n    nextClusterAt = ck.nextClusterAt || net.now() + 8_000;\n    cool.clear();\n    for (const [k, until] of ck.cool ?? []) cool.set(k, until);\n    for (const sp of spores) {\n      if (!sp.kind) sp.kind = sp.loose ? 'spill' : 'common';\n      if (!sp.worth) sp.worth = worthOf(sp.kind);\n      if (sp.expiresAt == null) sp.expiresAt = 0;\n    }\n  } else {\n    roster = Roster.from(e.roster ?? [], { min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });\n    bodies = new Map();\n    syncBodiesFromRoster();\n  }\n  const s = e.snap;\n  if (s && (!e.ckpt || s.st >= e.ckpt.st)) {\n    for (const [slot, seat, x, y, score, vx, vy] 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.vx = vx ?? 0; b.vy = vy ?? 0; b.tx = x; b.ty = y;\n    }\n    const prev = new Map((ck?.spores ?? []).map((sp) => [sp.id, sp]));\n    const active = s.d.g.map((row) => {\n      const [id, x, y, code, worth, expiresAt] = row;\n      const old = prev.get(id);\n      const kind = kindFrom(code, old?.loose ?? false);\n      const d = driftOf(id, kind);\n      return { id, x, y, vx: old?.vx ?? d.vx, vy: old?.vy ?? d.vy, backAt: 0, loose: old?.loose ?? kind === 'spill', kind, worth: worth || old?.worth || worthOf(kind), expiresAt: expiresAt || old?.expiresAt || 0 } as Spore;\n    });\n    const pending = (ck?.spores ?? []).filter((sp) => sp.backAt > net.now() && !active.some((a) => a.id === sp.id));\n    spores = [...active, ...pending.map((sp) => ({ ...sp }))];\n    sporeSeq = Math.max(sporeSeq, ...spores.map((g) => g.id), 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  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    spores: spores.map((s) => ({ ...s })),\n    roster: roster.toJSON(),\n    tick,\n    sporeSeq,\n    cool: [...cool.entries()],\n    nextClusterAt,\n  };\n}\n\nnet.on('role', (e) => { if (e.role === 'host') becomeHost(e); else hosting = false; });\nnet.on('join', (p) => {\n  if (!hosting || p.seat === null) return;\n  const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null);\n  if (!c) return;\n  syncBodiesFromRoster();\n  const b = bodies.get(c.slot.slot);\n  // yielded: they took a bot's body mid-round, and start dim. A reload of the same seat does not.\n  if (b && c.yielded && b.seat !== null && !b.bot) { b.score = DIM; b.vx = 0; b.vy = 0; hostMoved(b); }\n  else if (b && b.seat !== null && !b.bot && b.seat !== mySeat()) 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);\n  syncBodiesFromRoster();\n  publishRoster();\n});\nnet.on('policy', () => { if (!hosting) return; roster.fill(); syncBodiesFromRoster(); publishRoster(); });\nnet.on('event', (e) => {\n  if (e.k === 'eat') hearEat(e.d as EatEv);\n  if (e.k === 'spill') hearSpill(e.d as SpillEv);\n  if (e.k === 'bloom') hearBloom(e.d as BloomEv);\n  if (e.k === 'over') hearOver(e.d as OverEv);\n});\nnet.on('round', (r) => { round = hosting ? round : r; });\nnet.on('snapshot', () => { if (!firstSnapAt) firstSnapAt = performance.now(); });\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  unlock();\n  if (['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) e.preventDefault();\n  keys.add(e.code);\n});\naddEventListener('keyup', (e) => keys.delete(e.code));\naddEventListener('blur', () => keys.clear());\n\n/** Phone: a stick that starts in the lower part of the screen. No second finger, no bump key. */\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  unlock();\n  if (e.pointerType === 'mouse') return;\n  if (stick.active) return; // a second finger does nothing\n  if (e.clientY < innerHeight * 0.55) return; // keep the centre clear: drag from below\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  try { canvas.setPointerCapture(e.pointerId); } catch { /* already gone */ }\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 > 8) { const m = Math.min(1, (len - 8) / 52); 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\n/* ---------------------------------------------------------------- sound */\nlet audioCtx: AudioContext | null = null;\nfunction ac(): AudioContext | null {\n  try {\n    if (!audioCtx) audioCtx = new AudioContext();\n    if (audioCtx.state === 'suspended') void audioCtx.resume();\n    return audioCtx;\n  } catch { return null; }\n}\nfunction unlock(): void { const a = ac(); if (a && a.state === 'suspended') void a.resume(); }\n/** A tiny free synth. Every browser plays it when the event is visible, not only the host. */\nfunction blip(freq: number, dur: number, type: OscillatorType, gain: number, slide: number): void {\n  const a = ac(); if (!a) return;\n  const t0 = a.currentTime;\n  const o = a.createOscillator();\n  const g = a.createGain();\n  o.type = type;\n  o.frequency.setValueAtTime(Math.max(40, freq), t0);\n  if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(40, freq + slide), t0 + dur);\n  g.gain.setValueAtTime(gain, t0);\n  g.gain.exponentialRampToValueAtTime(0.0008, t0 + dur);\n  o.connect(g); g.connect(a.destination);\n  o.start(t0); o.stop(t0 + dur + 0.02);\n}\ninterface EatEv { slot: number; seat: number; x: number; y: number; score: number; big: boolean; kind: number; gain: number }\ninterface SpillEv { a: number; b: number; x: number; y: number; n: number; lost: number }\ninterface BloomEv { x: number; y: number; n: number }\ninterface OverEv { n: number; seat: number }\nconst heardEat = new Set<string>();\nconst heardSpill = new Set<string>();\nlet heardOver = -1;\nconst puffs: { x: number; y: number; at: number; kind: 'eat' | 'spill' | 'bloom' }[] = [];\nconst heardBloom = new Set<string>();\nfunction hearEat(d: EatEv): void {\n  const k = `${d.slot}:${d.score}:${d.gain ?? 1}`;\n  if (heardEat.has(k)) return;\n  heardEat.add(k);\n  if (heardEat.size > 80) heardEat.clear();\n  eatsSeen += 1;\n  if (d.kind === 1) blip(740, 0.14, 'triangle', 0.08, 360);\n  else if (d.kind === 2) blip(880, 0.09, 'sine', 0.065, 220);\n  else blip(560, 0.1, 'sine', 0.07, 280);\n  puffs.push({ x: d.x, y: d.y, at: performance.now(), kind: 'eat' });\n  if (d.big && typeof d.seat === 'number' && d.seat >= 0) net.spotlight(d.seat);\n}\nfunction hearSpill(d: SpillEv): void {\n  const k = `${d.a}:${d.b}:${d.n}`;\n  if (heardSpill.has(k)) return;\n  heardSpill.add(k);\n  if (heardSpill.size > 80) heardSpill.clear();\n  spillsSeen += 1;\n  const big = (d.lost ?? 1) >= 3;\n  blip(big ? 110 : 150, big ? 0.2 : 0.14, 'triangle', big ? 0.07 : 0.05, -70);\n  puffs.push({ x: d.x, y: d.y, at: performance.now(), kind: 'spill' });\n}\nfunction hearBloom(d: BloomEv): void {\n  const k = `${d.n}:${d.x}:${d.y}`;\n  if (heardBloom.has(k)) return;\n  heardBloom.add(k);\n  if (heardBloom.size > 24) heardBloom.clear();\n  blip(520, 0.16, 'sine', 0.05, 180);\n  setTimeout(() => blip(780, 0.12, 'triangle', 0.04, 60), 90);\n  puffs.push({ x: d.x, y: d.y, at: performance.now(), kind: 'bloom' });\n}\nfunction watchLastCall(): void {\n  if (!round || round.phase !== 'live') return;\n  const left = round.endsAt - net.now();\n  if (left <= LAST_MS && left > 0 && heardLast !== round.n) {\n    heardLast = round.n;\n    blip(660, 0.12, 'sine', 0.045, 90);\n    setTimeout(() => blip(880, 0.18, 'triangle', 0.04, 40), 130);\n  }\n}\nfunction hearOver(d: OverEv): void {\n  if (heardOver === d.n) return;\n  heardOver = d.n;\n  blip(392, 0.22, 'sine', 0.06, -40);\n  setTimeout(() => blip(294, 0.36, 'sine', 0.05, -30), 180);\n  if (typeof d.seat === 'number' && d.seat >= 0) net.spotlight(d.seat);\n}\n\n/* ---------------------------------------------------------------- simulate */\nfunction integrate(o: { x: number; y: number; vx: number; vy: number }, mx: number, my: number, dt: number, speed = Math.max(40, T.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; }\n  speed = Math.max(40, speed);\n  const k = Math.min(1, dt * 12);\n  o.vx += (mx * speed - o.vx) * k;\n  o.vy += (my * speed - o.vy) * k;\n  o.x = clamp(o.x + o.vx * dt, R_HEAD, W - R_HEAD);\n  o.y = clamp(o.y + o.vy * dt, R_HEAD, H - R_HEAD);\n  pushRocks(o);\n}\nfunction stepMe(dt: number): void {\n  const seat = mySeat();\n  if (seat === null) { me.has = false; return; }\n  // Until the host's snapshot says where this worm is, do not drift a phantom into a corner.\n  if (!net.owned || !me.has) return;\n  const v = moveVector();\n  integrate(me, v.x, v.y, dt, wormSpeed(myScore()));\n}\nlet predictedK = -1;\nfunction predictMe(dt: number): void {\n  const v = moveVector();\n  const spd = wormSpeed(myScore());\n  integrate(me, v.x, v.y, dt, spd);\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, spd); left -= h; }\n  }\n  const err = Math.hypot(pred.x - me.x, pred.y - me.y);\n  predictionError = err;\n  if (!me.has || err > 160) { 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\nconst sight = new Map<number, { at: number; tx: number; ty: number; arrived?: boolean }>();\nfunction nearestSpore(b: Body, taken: Set<number>, s: Skill): Spore | null {\n  let best: Spore | null = null; let bd = Infinity;\n  const now = net.now();\n  const hot = inLastCall(now) ? 2 : 1;\n  for (const g of spores) {\n    if (g.backAt || taken.has(g.id)) continue;\n    let contest = 0;\n    for (const o of bodies.values()) {\n      if (o !== b && Math.hypot(o.x - g.x, o.y - g.y) < 150) contest += 1;\n    }\n    let urgency = 1;\n    if (g.kind === 'cluster' && g.expiresAt && g.expiresAt - now < 2800) urgency = 1.4;\n    const worth = Math.max(1, g.worth) * hot * urgency;\n    const d = Math.hypot(g.x - b.x, g.y - b.y) * (1 + contest * (1 - s.positioning)) / worth;\n    if (d < bd) { bd = d; best = g; }\n  }\n  return best;\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    const s: Skill = net.skillOf(b.slot);\n    let eye = sight.get(b.slot);\n    if (!eye || now - eye.at >= s.reactionMs) {\n      let tx = b.tx; let ty = b.ty;\n      let leader: Body | null = null;\n      for (const o of bodies.values()) {\n        if (o !== b && (!leader || o.score > leader.score)) leader = o;\n      }\n      const behind = leader !== null && leader.score >= b.score + 2;\n      const dLead = leader ? Math.hypot(leader.x - b.x, leader.y - b.y) : 9999;\n      const chase = behind && dLead < 420 + s.aggression * 280 && Math.random() < 0.25 + s.aggression * 0.55;\n      if (chase && leader) { tx = leader.x; ty = leader.y; }\n      else {\n        const g = nearestSpore(b, taken, s);\n        const miss = 180 * s.aimNoise;\n        if (g) { tx = g.x + (Math.random() - 0.5) * miss; ty = g.y + (Math.random() - 0.5) * miss; taken.add(g.id); }\n      }\n      eye = { at: now, tx, ty };\n      sight.set(b.slot, eye);\n    }\n    b.tx = eye.tx; b.ty = eye.ty;\n    const dx = b.tx - b.x; const dy = b.ty - b.y; const dist = Math.hypot(dx, dy) || 1;\n    if (dist < 10 && !eye.arrived) { eye.arrived = true; eye.at = now; }\n    const mx = dist < 10 ? 0 : dx / dist;\n    const my = dist < 10 ? 0 : dy / dist;\n    integrate(b, mx, my, dt, wormSpeed(b.score));\n  }\n}\n\nfunction dropLoose(x: number, y: number): void {\n  const loose = spores.filter((s) => s.loose && !s.backAt);\n  if (loose.length >= LOOSE_MAX) {\n    const old = loose[0] as Spore;\n    old.x = clamp(x + (Math.random() - 0.5) * 24, 40, W - 40);\n    old.y = clamp(y + (Math.random() - 0.5) * 24, 40, H - 40);\n    const d = driftOf(old.id + tick, 'spill');\n    old.vx = d.vx; old.vy = d.vy;\n    return;\n  }\n  const s = makeSpore(true, 'spill');\n  s.x = clamp(x + (Math.random() - 0.5) * 20, 40, W - 40);\n  s.y = clamp(y + (Math.random() - 0.5) * 20, 40, H - 40);\n}\nfunction spillLost(donor: Body, other: Body): number {\n  const ahead = Math.max(0, donor.score - other.score);\n  const extra = ahead >= 9 ? 3 : ahead >= 5 ? 2 : ahead >= 2 ? 1 : 0;\n  return Math.min(donor.score, Math.max(1, Math.round(T.spill)) + extra);\n}\nfunction spillPair(a: Body, b: Body): void {\n  const donor = a.score >= b.score ? a : b;\n  const other = donor === a ? b : a;\n  const lost = spillLost(donor, other);\n  if (lost <= 0) return;\n  donor.score -= lost;\n  const x = (a.x + b.x) / 2; const y = (a.y + b.y) / 2;\n  for (let i = 0; i < lost; i += 1) dropLoose(x, y);\n  const payload: SpillEv = { a: a.slot, b: b.slot, x: q(x, 0), y: q(y, 0), n: tick, lost };\n  net.send('spill', payload);\n  hearSpill(payload);\n}\nfunction stepSpores(dt: number): void {\n  const now = net.now();\n  const keep: Spore[] = [];\n  for (const s of spores) {\n    if (!s.backAt && s.expiresAt && now >= s.expiresAt) continue;\n    if (s.backAt) {\n      if (now >= s.backAt) placeSpore(s);\n      keep.push(s);\n      continue;\n    }\n    s.x += s.vx * dt; s.y += s.vy * dt;\n    if (s.x < 36 || s.x > W - 36) s.vx *= -1;\n    if (s.y < 36 || s.y > H - 36) s.vy *= -1;\n    s.x = clamp(s.x, 30, W - 30); s.y = clamp(s.y, 30, H - 30);\n    if ((tick + s.id) % 100 === 0) {\n      const d = driftOf(s.id + tick, s.kind);\n      s.vx = d.vx; s.vy = d.vy;\n    }\n    keep.push(s);\n  }\n  spores = keep;\n}\nfunction stepHost(dt: number): void {\n  tick += 1;\n  const seat = mySeat();\n  for (const b of bodies.values()) {\n    if (b.bot) continue;\n    if (b.seat === seat) { me.has = true; b.x = me.x; b.y = me.y; b.vx = me.vx; b.vy = me.vy; continue; }\n    if (b.seat === null) continue;\n    if (MOVEMENT === 'host') {\n      const f = net.inputOf(b.seat);\n      integrate(b, f ? Number(f.a?.[4]) : 0, f ? Number(f.a?.[5]) : 0, dt, wormSpeed(b.score));\n      continue;\n    }\n    const a = net.avatar(b.seat);\n    if (a && Array.isArray(a)) {\n      const claim = { x: clamp(Number(a[0]), R_HEAD, W - R_HEAD), y: clamp(Number(a[1]), R_HEAD, H - R_HEAD) };\n      const spd = wormSpeed(b.score);\n      const m = capMove(b, claim, spd * 1.35 * dt + 8);\n      if (m.over > spd * 0.55) { 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; pushRocks(b); }\n    }\n  }\n  stepBots(dt);\n  const now = net.now();\n  if (round && round.phase === 'live') {\n    stepSpores(dt);\n    if (now >= nextClusterAt && round.endsAt - now > 12_000) {\n      placeCluster(now);\n      nextClusterAt = now + 14_000;\n    }\n    const hot = inLastCall(now) ? 2 : 1;\n    const gone: number[] = [];\n    for (const b of bodies.values()) {\n      for (const s of spores) {\n        if (s.backAt || gone.includes(s.id)) continue;\n        if (Math.hypot(s.x - b.x, s.y - b.y) >= EAT) continue;\n        const gain = Math.max(1, s.worth) * hot;\n        b.score += gain;\n        pickups.set(b.slot, (pickups.get(b.slot) ?? 0) + gain);\n        const ahead = [...bodies.values()].every((o) => o === b || o.score < b.score);\n        const big = b.seat !== null && !b.bot && (s.kind === 'gold' || s.kind === 'cluster' || (ahead && b.score >= 6));\n        const payload: EatEv = { slot: b.slot, seat: b.seat ?? -1, x: q(s.x, 0), y: q(s.y, 0), score: b.score, big, kind: KIND_CODE[s.kind], gain };\n        net.send('eat', payload);\n        hearEat(payload);\n        if (s.loose || s.kind === 'cluster') gone.push(s.id);\n        else { s.backAt = now + Math.max(400, T.respawnMs) * (s.kind === 'gold' ? 2.2 : 1); s.x = -300; s.y = -300; }\n      }\n    }\n    if (gone.length) spores = spores.filter((s) => !gone.includes(s.id));\n    const list = [...bodies.values()];\n    for (let i = 0; i < list.length; i += 1) {\n      for (let j = i + 1; j < list.length; j += 1) {\n        const a = list[i] as Body; const c = list[j] as Body;\n        if (Math.hypot(a.x - c.x, a.y - c.y) > reach(a.score) + reach(c.score)) continue;\n        const key = a.slot < c.slot ? `${a.slot}:${c.slot}` : `${c.slot}:${a.slot}`;\n        if ((cool.get(key) ?? 0) > now) continue;\n        cool.set(key, now + SPILL_COOL_MS);\n        spillPair(a, c);\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}\nfunction buildSnap(): Snap {\n  const r = round ?? { n: 0, phase: 'live' as const, startedAt: 0, endsAt: 0 };\n  const now = net.now();\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: spores.filter((s) => !s.backAt || s.backAt <= now).map((s) => [s.id, q(s.x, 0), q(s.y, 0), KIND_CODE[s.kind] ?? 0, s.worth, s.expiresAt || 0] as G),\n  };\n}\nfunction finite(v: unknown, fallback = 0): number {\n  const n = Number(v);\n  return Number.isFinite(n) ? n : fallback;\n}\n/** Bodies from the newest snapshots. One snapshot is enough: a phone must draw its worm on the first frame. */\nfunction ingestSnap(prev: Snap | null, next: Snap, alpha: number): void {\n  drawn.clear();\n  const bySlot = new Map<number, P>((prev ?? next).p.map((row) => [row[0], row]));\n  const blend = finite(alpha, 1);\n  for (const pb of next.p) {\n    const pa = bySlot.get(pb[0]) ?? pb;\n    drawn.set(pb[0], {\n      slot: pb[0], seat: pb[1],\n      x: lerp(finite(pa[2]), finite(pb[2]), blend),\n      y: lerp(finite(pa[3]), finite(pb[3]), blend),\n      vx: finite(pb[5]), vy: finite(pb[6]), score: finite(pb[4]),\n    });\n  }\n  const [n, ph, startedAt, endsAt] = next.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}\nfunction stepReplica(dt: number): void {\n  const smp = net.sample();\n  const latest = net.latest();\n  if (smp) ingestSnap(smp.a.d, smp.b.d, smp.alpha);\n  else if (latest) ingestSnap(null, latest.d, 1);\n  else drawn.clear();\n  const seat = net.seat;\n  // Adopt the host's worm as soon as it is in a snapshot, not only on a reset. The camera follows it the same frame.\n  if (seat !== null && !me.has) {\n    const mine = [...drawn.values()].find((d) => d.seat === seat);\n    if (mine) { me.x = mine.x; me.y = mine.y; me.vx = mine.vx; me.vy = mine.vy; me.has = true; }\n  }\n  if (seat !== null) {\n    if (!net.owned) predictMe(dt);\n    if (me.has) {\n      const v = moveVector();\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}\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 }) });\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;\nconst trails = new Map<number, { x: number; y: number }[]>();\ninterface Dust { x: number; y: number; at: number }\nconst sporeDust = new Map<number, Dust[]>();\nconst wormDust = new Map<number, Dust[]>();\n/** A short fading trail. A big jump (respawn, new round) drops the old points. */\nfunction pushDust(map: Map<number, Dust[]>, key: number, x: number, y: number, at: number, life: number, step: number): Dust[] {\n  let pts = map.get(key);\n  if (!pts) { pts = []; map.set(key, pts); }\n  const last = pts[0];\n  if (last && Math.hypot(last.x - x, last.y - y) > 100) pts.length = 0;\n  else if (!last || Math.hypot(last.x - x, last.y - y) >= step) pts.unshift({ x, y, at });\n  let n = 0;\n  for (let i = 0; i < pts.length; i += 1) if (at - (pts[i] as Dust).at < life) { pts[n] = pts[i] as Dust; n += 1; }\n  pts.length = n;\n  return pts;\n}\nfunction brighterThan(a: { score: number; bot: boolean; slot: number }, b: { score: number; bot: boolean; slot: number }): boolean {\n  if (a.score !== b.score) return a.score > b.score;\n  if (a.bot !== b.bot) return !a.bot;\n  return a.slot < b.slot;\n}\nfunction trailOf(slot: number, x: number, y: number): { x: number; y: number }[] {\n  let pts = trails.get(slot);\n  if (!pts) {\n    pts = Array.from({ length: SEGMENTS }, () => ({ x, y }));\n    trails.set(slot, pts);\n    return pts;\n  }\n  if (Math.hypot(pts[0].x - x, pts[0].y - y) > 90) {\n    for (const p of pts) { p.x = x; p.y = y; }\n    return pts;\n  }\n  pts[0].x = x; pts[0].y = y;\n  for (let i = 1; i < pts.length; i += 1) {\n    const prev = pts[i - 1] as { x: number; y: number };\n    const cur = pts[i] as { x: number; y: number };\n    const d = Math.hypot(prev.x - cur.x, prev.y - cur.y);\n    if (d > SEG_GAP) { const k = (d - SEG_GAP) / d; cur.x += (prev.x - cur.x) * k; cur.y += (prev.y - cur.y) * k; }\n  }\n  return pts;\n}\n/** Where the worm for `seat` is, on every browser: local head, the host's body, or the latest snapshot. */\nfunction seatPos(seat: number): { x: number; y: number } | null {\n  if (seat === mySeat() && me.has && Number.isFinite(me.x) && Number.isFinite(me.y) && !net.watching) return { x: me.x, y: me.y };\n  if (hosting) {\n    const b = [...bodies.values()].find((x) => x.seat === seat);\n    return b && Number.isFinite(b.x) ? { x: b.x, y: b.y } : null;\n  }\n  const d = [...drawn.values()].find((x) => x.seat === seat);\n  if (d && Number.isFinite(d.x)) return { x: d.x, y: d.y };\n  const row = net.latest()?.d.p.find((p) => p[1] === seat);\n  return row && Number.isFinite(row[2]) ? { x: row[2], y: row[3] } : null;\n}\nfunction rockOutline(r: Rock): { x: number; y: number }[] {\n  const n = 7;\n  const pts: { x: number; y: number }[] = [];\n  for (let i = 0; i < n; i += 1) {\n    const a = (i / n) * Math.PI * 2 - 0.35;\n    const j = 0.72 + 0.28 * Math.abs(Math.sin(r.x * 0.017 + i * 1.9));\n    pts.push({ x: r.x + Math.cos(a) * r.r * j, y: r.y + Math.sin(a) * r.r * j * 0.88 });\n  }\n  return pts;\n}\nfunction tracePoly(pts: { x: number; y: number }[]): void {\n  ctx.beginPath();\n  pts.forEach((p, i) => { if (i) ctx.lineTo(p.x, p.y); else ctx.moveTo(p.x, p.y); });\n  ctx.closePath();\n}\nfunction drawRock(r: Rock): void {\n  const pts = rockOutline(r);\n  ctx.fillStyle = 'rgba(2,8,6,0.8)';\n  ctx.beginPath();\n  ctx.ellipse(r.x + 7, r.y + r.r * 0.46, r.r * 0.92, r.r * 0.3, 0.15, 0, Math.PI * 2);\n  ctx.fill();\n  tracePoly(pts);\n  ctx.fillStyle = '#123026';\n  ctx.fill();\n  tracePoly(pts.map((p) => ({ x: r.x + (p.x - r.x) * 0.58, y: r.y + (p.y - r.y) * 0.46 - r.r * 0.14 })));\n  ctx.fillStyle = '#1e4a36';\n  ctx.fill();\n  tracePoly(pts);\n  ctx.strokeStyle = '#4d9a6c';\n  ctx.lineWidth = 2;\n  ctx.stroke();\n  ctx.fillStyle = '#2f6b44';\n  for (let i = 0; i < 3; i += 1) {\n    const a = (i / 3) * Math.PI * 2 + r.x * 0.01;\n    ctx.fillRect(Math.round(r.x + Math.cos(a) * r.r * 0.28) - 2, Math.round(r.y + Math.sin(a) * r.r * 0.16) - 1, 4, 3);\n  }\n}\nfunction draw(t: number): void {\n  const cw = innerWidth; const ch = innerHeight;\n  const phone = Math.min(cw, ch) <= 540;\n  // Follow the seat this browser is showing (its own seat while playing, the watched seat otherwise).\n  // Never a fixed corner, and not only when this browser is the host.\n  const view = viewSeat();\n  const focus = view !== null ? seatPos(view) : null;\n  const overview = focus === null;\n  // Phone: close enough to read the worm and the spores beside it, not the whole cave as a thin strip.\n  const zoom = phone ? Math.max(1.05, Math.min(cw, ch) / 380) : Math.min(cw, ch) / 820;\n  let want = overview\n    ? { scale: Math.min(cw / (W + 40), ch / (H + 40)), x: W / 2, y: H / 2 }\n    : fitView({\n      world: { w: W, h: H }, screen: { w: cw, h: ch }, readable: 0, zoom, focus,\n      inset: { top: phone ? 96 : 56, bottom: phone ? 188 : 40, left: 28, right: 28 },\n    });\n  // A worm against the cave edge would sit under the clock or the stick. Nudge past the world\n  // bounds so that worm stays in the open middle of the phone, from the first frame and at round over.\n  if (focus && phone) {\n    const scale = want.scale;\n    const sx = cw / 2 + (focus.x - want.x) * scale;\n    const sy = ch / 2 + (focus.y - want.y) * scale;\n    const safeTop = 150;\n    const safeBottom = ch - 210;\n    const safeLeft = 72;\n    const safeRight = cw - 72;\n    let x = want.x; let y = want.y;\n    if (sy < safeTop) y -= (safeTop - sy) / scale;\n    else if (sy > safeBottom) y += (sy - safeBottom) / scale;\n    if (sx < safeLeft) x -= (safeLeft - sx) / scale;\n    else if (sx > safeRight) x += (sx - safeRight) / scale;\n    want = { scale, x, y };\n  }\n  const jump = Math.hypot((want.x - cam.x), (want.y - cam.y));\n  // First sight of the worm, or a round restart: cut to it so a phone is not left on an empty corner.\n  if (!cam.scale || !Number.isFinite(cam.x) || jump > 320) {\n    cam.x = want.x; cam.y = want.y; cam.scale = want.scale;\n  } else {\n    cam.scale += (want.scale - cam.scale) * 0.35;\n    cam.x += (want.x - cam.x) * 0.45;\n    cam.y += (want.y - cam.y) * 0.45;\n  }\n  const scale = cam.scale;\n  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n  ctx.imageSmoothingEnabled = false;\n  ctx.fillStyle = '#06110c';\n  ctx.fillRect(0, 0, cw, ch);\n  ctx.save();\n  ctx.translate(cw / 2, ch / 2); ctx.scale(scale, scale); ctx.translate(-cam.x, -cam.y);\n  ctx.fillStyle = '#07140f';\n  ctx.fillRect(0, 0, W, H);\n  // a few soft floor patches, still the same cave black-green\n  ctx.fillStyle = '#081910';\n  ctx.beginPath(); ctx.ellipse(420, 280, 220, 120, 0.4, 0, Math.PI * 2); ctx.fill();\n  ctx.beginPath(); ctx.ellipse(1180, 740, 260, 140, -0.3, 0, Math.PI * 2); ctx.fill();\n  ctx.beginPath(); ctx.ellipse(860, 180, 140, 70, 0.2, 0, Math.PI * 2); ctx.fill();\n  ctx.fillStyle = '#0c2218';\n  for (const g of caveGrit()) ctx.fillRect(Math.round(g.x), Math.round(g.y), g.s, g.s);\n  for (const r of caveRocks()) drawRock(r);\n  ctx.strokeStyle = '#143024'; ctx.lineWidth = 6; ctx.strokeRect(8, 8, W - 16, H - 16);\n\n  const sporeList: { x: number; y: number; id: number; kind: number }[] = hosting\n    ? spores.filter((s) => !s.backAt).map((s) => ({ id: s.id, x: s.x, y: s.y, kind: KIND_CODE[s.kind] ?? 0 }))\n    : (net.latest()?.d.g ?? []).map(([id, x, y, kind]) => ({ id, x, y, kind: kind ?? 0 }));\n  const blooms = sporeList.filter((g) => g.kind === 2);\n  if (blooms.length) {\n    let bx = 0; let by = 0;\n    for (const g of blooms) { bx += g.x; by += g.y; }\n    bx /= blooms.length; by /= blooms.length;\n    const pulse = 0.55 + 0.45 * Math.sin(t / 140);\n    ctx.save();\n    ctx.globalCompositeOperation = 'lighter';\n    ctx.fillStyle = `rgba(244,255,232,${(0.07 + 0.05 * pulse).toFixed(3)})`;\n    ctx.beginPath(); ctx.arc(bx, by, 78 + pulse * 10, 0, Math.PI * 2); ctx.fill();\n    ctx.strokeStyle = `rgba(214,255,74,${(0.35 + 0.25 * pulse).toFixed(3)})`;\n    ctx.lineWidth = 2;\n    ctx.beginPath(); ctx.arc(bx, by, 70, 0, Math.PI * 2); ctx.stroke();\n    ctx.restore();\n  }\n  const seenSpores = new Set<number>();\n  const hot = inLastCall();\n  for (const g of sporeList) {\n    seenSpores.add(g.id);\n    const dust = pushDust(sporeDust, g.id, g.x, g.y, t, 520, 3.5);\n    for (let i = dust.length - 1; i >= 1; i -= 1) {\n      const p = dust[i] as Dust;\n      const fade = 1 - (t - p.at) / 520;\n      if (fade <= 0) continue;\n      const sz = Math.max(2, 4 - Math.floor(i / 2));\n      ctx.globalAlpha = 0.22 * fade;\n      ctx.fillStyle = g.kind === 1 ? '#ffd24a' : g.kind === 2 ? '#f4ffe8' : g.kind === 3 ? '#b6ff6a' : '#3dffe0';\n      ctx.fillRect(Math.round(p.x - sz / 2), Math.round(p.y - sz / 2), sz, sz);\n    }\n    ctx.globalAlpha = 1;\n    const gold = g.kind === 1;\n    const cluster = g.kind === 2;\n    const spill = g.kind === 3;\n    const color = gold ? '#ffd24a' : cluster ? '#f4ffe8' : spill ? '#b6ff6a' : '#3dffe0';\n    const glow = gold ? 'rgba(255,210,74,0.28)' : cluster ? 'rgba(244,255,232,0.34)' : spill ? 'rgba(182,255,106,0.24)' : 'rgba(61,255,224,0.22)';\n    const pulse = 1 + (hot ? 0.55 : 0.35) * Math.sin(t / (cluster ? 90 : 160) + g.id);\n    const rad = (gold ? 7 : cluster ? 6.5 : 5) * pulse;\n    ctx.save();\n    ctx.globalCompositeOperation = 'lighter';\n    ctx.fillStyle = glow;\n    ctx.beginPath(); ctx.arc(g.x, g.y, rad * (cluster ? 3.6 : 3.2), 0, Math.PI * 2); ctx.fill();\n    ctx.restore();\n    const sz = Math.max(4, Math.round(rad));\n    ctx.fillStyle = color;\n    ctx.fillRect(Math.round(g.x - sz / 2), Math.round(g.y - sz / 2), sz, sz);\n  }\n  for (const id of [...sporeDust.keys()]) if (!seenSpores.has(id)) sporeDust.delete(id);\n  const nowP = t;\n  for (let i = puffs.length - 1; i >= 0; i -= 1) {\n    const p = puffs[i] as (typeof puffs)[number];\n    const age = (nowP - p.at) / (p.kind === 'bloom' ? 700 : 420);\n    if (age > 1) { puffs.splice(i, 1); continue; }\n    ctx.strokeStyle = p.kind === 'eat' ? '#3dffe0' : p.kind === 'bloom' ? '#d6ff4a' : '#7dff6a';\n    ctx.globalAlpha = 1 - age;\n    ctx.lineWidth = 2;\n    ctx.beginPath(); ctx.arc(p.x, p.y, (p.kind === 'bloom' ? 18 : 8) + age * (p.kind === 'bloom' ? 70 : 28), 0, Math.PI * 2); ctx.stroke();\n    ctx.globalAlpha = 1;\n  }\n\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; vx: number; vy: number; score: 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, vx: b.vx, vy: b.vy, score: b.score, 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({\n        slot: d.slot, seat: d.seat >= 0 ? d.seat : null,\n        x: own && me.has ? me.x : d.x, y: own && me.has ? me.y : d.y,\n        vx: own && me.has ? me.vx : d.vx, vy: own && me.has ? me.vy : d.vy,\n        score: d.score, bot: d.seat < 0,\n        name: s?.name ?? (d.seat >= 0 ? `Player ${d.seat + 1}` : botName(d.slot)),\n        mine: isView(d.seat >= 0 ? d.seat : null, d.seat < 0),\n      });\n    }\n  }\n  const overNow = round?.phase === 'over';\n  const ranked = [...list].sort((a, b) => (brighterThan(a, b) ? -1 : brighterThan(b, a) ? 1 : 0));\n  const winnerSlot = overNow ? (round?.results?.[0]?.slot ?? ranked[0]?.slot ?? -1) : -1;\n  const seenWorms = new Set<number>();\n  // glows first, so brightness reads before the body\n  ctx.globalCompositeOperation = 'lighter';\n  for (const a of list) {\n    seenWorms.add(a.slot);\n    const dust = pushDust(wormDust, a.slot, a.x, a.y, t, 700, 7);\n    for (let i = dust.length - 1; i >= 1; i -= 1) {\n      const p = dust[i] as Dust;\n      const fade = 1 - (t - p.at) / 700;\n      if (fade <= 0) continue;\n      ctx.fillStyle = `rgba(125,255,106,${(0.05 * fade).toFixed(3)})`;\n      ctx.fillRect(Math.round(p.x - 3), Math.round(p.y - 3), 6, 6);\n    }\n    const win = a.slot === winnerSlot;\n    const rad = 30 + Math.min(210, Math.max(0, a.score) * 18) + (win ? 36 : 0);\n    const alpha = (overNow && !win ? 0.45 : 1) * (0.08 + Math.min(0.5, Math.max(0, a.score) * 0.045));\n    if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !(rad > 4)) continue;\n    const g = ctx.createRadialGradient(a.x, a.y, 4, a.x, a.y, rad);\n    g.addColorStop(0, `rgba(125,255,106,${alpha})`);\n    g.addColorStop(1, 'rgba(125,255,106,0)');\n    ctx.fillStyle = g;\n    ctx.beginPath(); ctx.arc(a.x, a.y, rad, 0, Math.PI * 2); ctx.fill();\n  }\n  ctx.globalCompositeOperation = 'source-over';\n  for (const slot of [...wormDust.keys()]) if (!seenWorms.has(slot)) wormDust.delete(slot);\n\n  const fs = Math.round(Math.max(13, Math.min(18, (14 * Math.min(cw, ch)) / 720)));\n  ctx.font = `600 ${fs}px ui-monospace, monospace`;\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  for (const a of list) {\n    const pts = trailOf(a.slot, a.x, a.y);\n    const moving = Math.hypot(a.vx, a.vy) > 12;\n    let face = moving ? Math.atan2(a.vy, a.vx) : Math.atan2(pts[0].y - (pts[1]?.y ?? pts[0].y), pts[0].x - (pts[1]?.x ?? pts[0].x));\n    if (!Number.isFinite(face)) face = 0;\n    const bulk = Math.min(6, Math.max(0, a.score - DIM) * 0.4);\n    for (let i = pts.length - 1; i >= 0; i -= 1) {\n      const p = pts[i] as { x: number; y: number };\n      const size = i === 0 ? 16 + bulk : Math.max(8, 14 + bulk * 0.45 - i * 1.4);\n      ctx.fillStyle = '#7dff6a';\n      ctx.globalAlpha = (overNow && a.slot !== winnerSlot ? 0.62 : 1) * (a.bot ? 0.78 : 1);\n      ctx.fillRect(Math.round(p.x - size / 2), Math.round(p.y - size / 2), Math.round(size), Math.round(size));\n    }\n    ctx.globalAlpha = 1;\n    const head = pts[0] as { x: number; y: number };\n    ctx.save();\n    ctx.translate(head.x, head.y); ctx.rotate(face);\n    ctx.fillStyle = '#e7ffe4';\n    ctx.fillRect(6, -3, 7, 6); // the snout: the worm faces its movement\n    ctx.fillStyle = colourOf(a.slot, a.bot ? null : a.seat);\n    ctx.fillRect(2, -2, 4, 4); // seat colour, so a watcher’s strip matches\n    ctx.restore();\n    if (a.mine) {\n      ctx.strokeStyle = '#e7ffe4'; ctx.lineWidth = 2;\n      ctx.strokeRect(Math.round(head.x - 12), Math.round(head.y - 12), 24, 24);\n    }\n    if (a.slot === winnerSlot) {\n      const bob = Math.sin(t / 140) * 2;\n      const y0 = head.y - 28 + bob;\n      ctx.globalAlpha = 0.9;\n      ctx.fillStyle = '#d6ff4a';\n      ctx.fillRect(Math.round(head.x - 7), Math.round(y0), 4, 6);\n      ctx.fillRect(Math.round(head.x - 2), Math.round(y0 - 4), 4, 10);\n      ctx.fillRect(Math.round(head.x + 3), Math.round(y0), 4, 6);\n      ctx.fillStyle = '#e7ffe4';\n      ctx.fillRect(Math.round(head.x - 8), Math.round(y0 + 6), 16, 3);\n      ctx.strokeStyle = '#d6ff4a';\n      ctx.lineWidth = 2;\n      ctx.globalAlpha = 0.55 + 0.35 * (0.5 + 0.5 * Math.sin(t / 160));\n      const ring = 26 + 3 * Math.sin(t / 200);\n      ctx.strokeRect(Math.round(head.x - ring), Math.round(head.y - ring), Math.round(ring * 2), Math.round(ring * 2));\n      ctx.globalAlpha = 1;\n    }\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 = 16 * scale;\n    if (sx + r < 0 || sx - r > cw || sy + r < 0 || sy - r > ch) continue;\n    const lift = a.slot === winnerSlot ? 16 : 0;\n    if (a.seat !== null && !a.bot) heads.set(a.slot, { x: sx, y: sy - r - 4 - lift, seat: a.seat, mine: a.mine });\n    tags.push({ key: a.slot, text, x: sx, y: sy - r - 4 - lift, w: ctx.measureText(text).width, h: fs * 1.15, below: sy + r + 4 + fs, 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  if (lab.on && me.has) lab.track('speed', Math.hypot(me.vx, me.vy), 'px/s');\n  ctx.restore();\n\n  shownLabels = labels.place(tags, Math.min(0.1, (t - (lastDraw || t)) / 1000));\n  lastDraw = t;\n  ctx.font = `600 ${fs}px ui-monospace, monospace`;\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    ctx.strokeStyle = 'rgba(6,17,12,0.9)'; ctx.lineWidth = 3; ctx.strokeText(l.text, l.cx, l.cy);\n    ctx.fillStyle = l.self ? '#ffffff' : '#e7ffe4';\n    ctx.fillText(l.text, l.cx, l.cy);\n  }\n  ctx.globalAlpha = 1; ctx.textBaseline = 'alphabetic';\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  // vignette, so the cave falls off at the edges and the middle stays readable\n  const vig = ctx.createRadialGradient(cw / 2, ch / 2, Math.min(cw, ch) * 0.2, cw / 2, ch / 2, Math.max(cw, ch) * 0.72);\n  vig.addColorStop(0, 'rgba(6,17,12,0)');\n  vig.addColorStop(1, 'rgba(2,6,4,0.55)');\n  ctx.fillStyle = vig; ctx.fillRect(0, 0, cw, ch);\n\n  hud(cw, ch, phone, t, list);\n  if (phone && mySeat() !== null && !net.watching) {\n    const ox = 86; const oy = ch - 108;\n    ctx.strokeStyle = 'rgba(231,255,228,0.28)'; ctx.lineWidth = 2;\n    ctx.beginPath(); ctx.arc(ox, oy, 46, 0, Math.PI * 2); ctx.stroke();\n    if (!stick.active) {\n      ctx.fillStyle = 'rgba(125,255,106,0.35)';\n      ctx.beginPath(); ctx.arc(ox, oy, 14, 0, Math.PI * 2); ctx.fill();\n    }\n  }\n  if (stick.active) {\n    ctx.strokeStyle = 'rgba(231,255,228,0.4)'; ctx.lineWidth = 3;\n    ctx.beginPath(); ctx.arc(stick.ox, stick.oy, 48, 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 > 48 ? 48 / len : 1;\n    ctx.fillStyle = 'rgba(125,255,106,0.7)';\n    ctx.beginPath(); ctx.arc(stick.ox + dx * m, stick.oy + dy * m, 16, 0, Math.PI * 2); ctx.fill();\n  }\n}\n\nfunction hud(cw: number, ch: number, phone: boolean, t: number, list: { slot: number; name: string; bot: boolean; mine: boolean; score: number }[]): void {\n  const pad = phone ? 12 : 18;\n  const top = pad;\n  const now = net.now();\n  const r = round;\n  ctx.textAlign = 'left'; ctx.fillStyle = '#e7ffe4';\n  ctx.font = `700 ${phone ? 16 : 20}px ui-monospace, monospace`;\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 last = Boolean(r && r.phase === 'live' && inLastCall(now));\n  if (last) ctx.fillStyle = '#ffd24a';\n  ctx.fillText(r ? (r.phase === 'live' ? `Glow ${clock}` : `Next ${left}`) : 'Joining…', pad, top + 16);\n  if (last) {\n    ctx.font = `700 ${phone ? 12 : 13}px ui-monospace, monospace`;\n    ctx.fillStyle = '#ffd24a';\n    ctx.fillText('Last call  ×2', pad, top + (phone ? 34 : 38));\n    ctx.fillStyle = '#e7ffe4';\n  }\n  const rows = [...list].sort((a, b) => b.score - a.score).slice(0, phone ? 4 : 6);\n  ctx.font = `600 ${phone ? 12 : 14}px ui-monospace, monospace`;\n  ctx.textAlign = 'right';\n  const board = top + 16 + 36;\n  rows.forEach((a, i) => {\n    ctx.fillStyle = a.mine ? '#ffffff' : a.bot ? 'rgba(231,255,228,0.55)' : '#e7ffe4';\n    ctx.fillText(`${a.mine && !net.watching ? 'You' : label(a.name, a.bot)}  ${a.score}`, cw - pad, board + i * (phone ? 16 : 18));\n  });\n  ctx.textAlign = 'left'; ctx.font = '600 11px ui-monospace, monospace'; ctx.fillStyle = 'rgba(125,255,106,0.75)';\n  ctx.fillText(net.offline ? 'OFFLINE' : net.watching ? 'WATCHING' : net.role.toUpperCase(), pad, ch - pad);\n  if (r && r.phase === 'over' && r.results) {\n    // A strip under the clock, not a card over the worm. The centre stays the cave.\n    const view = viewSeat();\n    const rows = r.results.slice(0, phone ? 4 : 6);\n    const topRow = r.results[0];\n    const topMine = Boolean(topRow && !topRow.bot && topRow.seat !== null && topRow.seat === view);\n    const topName = topRow ? (topMine && !net.watching ? 'You' : label(topRow.name, topRow.bot)) : '';\n    ctx.textAlign = 'left'; ctx.font = '700 14px ui-monospace, monospace'; ctx.fillStyle = '#d6ff4a';\n    ctx.fillText(topName ? `Brightest  ${topName}` : `Round ${r.n}`, pad, top + (phone ? 40 : 46));\n    ctx.font = '600 13px ui-monospace, monospace';\n    rows.forEach((row, i) => {\n      const mine = !row.bot && row.seat !== null && row.seat === view;\n      ctx.fillStyle = mine ? '#ffffff' : row.bot ? 'rgba(231,255,228,0.6)' : '#e7ffe4';\n      ctx.fillText(`${row.place}. ${mine && !net.watching ? 'You' : label(row.name, row.bot)}  ${row.score}`, pad, top + (phone ? 58 : 66) + i * 16);\n    });\n  }\n  if (!net.offline && !net.connected && net.role !== 'host') {\n    ctx.textAlign = 'center'; ctx.fillStyle = '#3dffe0'; ctx.font = '600 13px ui-monospace, monospace';\n    ctx.fillText('Reconnecting…', cw / 2, ch - pad);\n  }\n  if (t < 4500 && mySeat() !== null && !(r && r.phase === 'over')) {\n    ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(231,255,228,0.7)'; ctx.font = '500 13px ui-monospace, monospace';\n    ctx.fillText(phone ? 'Drag from the lower left' : 'WASD or arrows', cw / 2, ch - pad - (phone ? 150 : 8));\n  }\n}\n\n/* ------------------------------------------------------------------ loop */\nfunction frame(t: number): void {\n  try {\n    const dt = lab.time.dt(t, 0.05);\n    stepMe(dt);\n    if (hosting) stepHost(dt);\n    else stepReplica(dt);\n    watchLastCall();\n    draw(t);\n  } catch {\n    // A bad frame must not stop the loop: a phone left on the first cave corner never sees its worm.\n  }\n  frames += 1;\n  requestAnimationFrame(frame);\n}\n\nfunction scoreRows(): { seat: number | null; score: number }[] {\n  if (hosting) return [...bodies.values()].map((b) => ({ seat: b.seat, score: b.score }));\n  return [...drawn.values()].map((d) => ({ seat: d.seat >= 0 ? d.seat : null, score: d.score }));\n}\n\nnet.expose({\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: () => scoreRows().filter((r) => typeof r.seat === 'number').map((r) => ({ seat: r.seat as number, score: r.score })),\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  sight: () => (hosting ? [...sight.entries()].map(([slot, e]) => ({ slot, at: e.at })) : null),\n  waves: () => eatsSeen,\n  knocks: () => spillsSeen,\n  zone: () => null,\n  owned: () => net.owned,\n  cheatResets: () => cheatResets,\n  movement: () => MOVEMENT,\n  controlResets: () => controlResets,\n  predictionError: () => predictionError,\n  camera: () => ({ x: cam.x, y: cam.y, scale: cam.scale, view: viewSeat() }),\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  arrivals: () => ({ firstStateAt: 0, firstSnapAt }),\n  /** Harness hook: spill a little light off this seat. No knock-out. */\n  debugKnock: (seat: number) => {\n    if (!hosting || !round || round.phase !== 'live') return false;\n    const b = [...bodies.values()].find((x) => x.seat === seat);\n    if (!b || b.score <= 0) return false;\n    const other = [...bodies.values()].find((x) => x !== b);\n    if (!other) return false;\n    spillPair(b, other);\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_HEAD,\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","style.json":"{\n  \"v\": 1,\n  \"palette\": {\n    \"bg\": \"#0e1430\",\n    \"ink\": \"#e8ecff\",\n    \"accent\": \"#c9a8ff\",\n    \"accent2\": \"#b8f26a\",\n    \"danger\": \"#ff5e7a\",\n    \"good\": \"#6fe0a0\",\n    \"gold\": \"#ffe08a\",\n    \"ramp\": [\n      \"#070a1c\",\n      \"#0e1430\",\n      \"#1b2350\",\n      \"#2c3770\",\n      \"#47509a\",\n      \"#7c84c9\",\n      \"#c9a8ff\",\n      \"#e8ecff\",\n      \"#b8f26a\",\n      \"#ffe08a\",\n      \"#3a5a4a\",\n      \"#5e3a5a\"\n    ]\n  },\n  \"fonts\": {\n    \"display\": \"Silkscreen\",\n    \"body\": \"Outfit\"\n  },\n  \"light\": {\n    \"time\": \"night\",\n    \"key\": [\n      0.4,\n      -1,\n      0.3\n    ],\n    \"intensity\": 1.1,\n    \"hardness\": 0.4,\n    \"sky\": \"#0e1430\",\n    \"ground\": \"#526d47\",\n    \"fog\": 0.6,\n    \"bloom\": 0.5\n  },\n  \"camera\": {\n    \"angle\": \"top-down\",\n    \"projection\": \"perspective\",\n    \"pitch\": 80,\n    \"distance\": 24,\n    \"fov\": 35\n  },\n  \"render\": \"lowpoly-flat\",\n  \"materials\": {\n    \"model\": \"flat\",\n    \"outline\": false,\n    \"texelDensity\": null,\n    \"atlas\": true\n  },\n  \"rev\": {\n    \"style.palette\": 1,\n    \"style.ui\": 1,\n    \"style.light\": 1,\n    \"style.camera\": 1\n  }\n}\n","tunables.json":"{\n  \"speed\": { \"value\": 280, \"min\": 140, \"max\": 460, \"step\": 10, \"unit\": \"px/s\", \"group\": \"Worm\", \"note\": \"How fast every worm moves. The same for people and bots.\" },\n  \"spill\": { \"value\": 1, \"min\": 1, \"max\": 3, \"step\": 1, \"group\": \"Bump\", \"note\": \"How much light a gentle bump spills back into the cave\" },\n  \"spores\": { \"value\": 8, \"min\": 4, \"max\": 14, \"step\": 1, \"group\": \"Spores\", \"note\": \"How many spores drift in the cave at once\" },\n  \"drift\": { \"value\": 34, \"min\": 0, \"max\": 90, \"step\": 2, \"unit\": \"px/s\", \"group\": \"Spores\", \"note\": \"How fast a spore wanders\" },\n  \"respawnMs\": { \"value\": 1400, \"min\": 400, \"max\": 4000, \"step\": 100, \"unit\": \"ms\", \"group\": \"Spores\", \"note\": \"How soon an eaten spore comes back\" }\n}\n"}}
