{"v":1,"kind":"homie-game-source","id":"midnight-candy-raid","credit":{"studio":"Jeepers Games","game":"Midnight Candy Raid","page":"https://jeepers-games.ryan-d2f.workers.dev/midnight-candy-raid/"},"license":{"kind":"remix-with-credit","spdx":null},"files":{"game.json":"{\n  \"id\": \"midnight-candy-raid\",\n  \"name\": \"Midnight Candy Raid\",\n  \"blurb\": \"Raid a haunted neighborhood for candy. Bank your haul, dodge the Headless Horseman, and rescue your rivals before midnight.\",\n  \"players\": {\n    \"min\": 1,\n    \"max\": 6\n  },\n  \"roundSeconds\": 180,\n  \"entry\": \"src/main.ts\",\n  \"netplay\": {\n    \"v\": 1,\n    \"public\": true,\n    \"movement\": \"owner\"\n  },\n  \"from\": {\n    \"starter\": \"gem-rush\",\n    \"studio\": \"0.14.1\"\n  },\n  \"genre\": [\n    \"Arcade\",\n    \"Party\"\n  ],\n  \"landing\": {\n    \"pitch\": \"Grab the candy. Dodge the Horseman. Make it home.\",\n    \"about\": \"Six trick-or-treaters. One haunted neighborhood. Three minutes to bank the biggest candy haul. Play instantly with friends or bots.\",\n    \"howToPlay\": [\n      \"Collect glowing candy on Lantern Lane.\",\n      \"Bank your bag inside a green safe house.\",\n      \"Dash away from the Horseman\\u2019s warning line.\",\n      \"Rescue captured rivals for a bonus. Most banked candy wins.\"\n    ],\n    \"controls\": {\n      \"phone\": \"Drag to move. Tap DASH to escape, RESCUE beside a captured player.\",\n      \"computer\": \"WASD or arrows to move. Space to dash. E to rescue.\"\n    },\n    \"credits\": [\n      {\n        \"role\": \"Design and original art\",\n        \"name\": \"Jeepers Games\"\n      },\n      {\n        \"role\": \"Multiplayer toolkit\",\n        \"name\": \"Homie\"\n      }\n    ]\n  },\n  \"screen\": {\n    \"share\": {\n      \"desk\": {\n        \"at\": \"bottom-right\",\n        \"y\": 100\n      },\n      \"phone\": {\n        \"at\": \"top-left\",\n        \"y\": 92\n      },\n      \"sideways\": {\n        \"at\": \"bottom-left\",\n        \"y\": 90\n      }\n    },\n    \"chat\": {\n      \"lines\": \"sheet-only\"\n    }\n  },\n  \"chat\": {\n    \"mode\": \"lines\",\n    \"lines\": {\n      \"gg\": \"Good raid!\",\n      \"bank\": \"Bank your candy!\",\n      \"rescue\": \"Rescue me!\",\n      \"horse\": \"Horseman incoming!\"\n    },\n    \"emoji\": {\n      \"pumpkin\": \"\\ud83c\\udf83\",\n      \"candy\": \"\\ud83c\\udf6c\"\n    }\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>Midnight Candy Raid</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","public/sound/sound.json":"{\n  \"v\": 1,\n  \"sfx\": {\n    \"countdown\": [\n      [\n        \"countdown-1.ogg\",\n        \"countdown-1.wav\"\n      ]\n    ],\n    \"go\": [\n      [\n        \"go-1.ogg\",\n        \"go-1.wav\"\n      ]\n    ],\n    \"score\": [\n      [\n        \"score-1.ogg\",\n        \"score-1.wav\"\n      ],\n      [\n        \"score-2.ogg\",\n        \"score-2.wav\"\n      ],\n      [\n        \"score-3.ogg\",\n        \"score-3.wav\"\n      ]\n    ],\n    \"coin\": [\n      [\n        \"coin-1.ogg\",\n        \"coin-1.wav\"\n      ],\n      [\n        \"coin-2.ogg\",\n        \"coin-2.wav\"\n      ],\n      [\n        \"coin-3.ogg\",\n        \"coin-3.wav\"\n      ]\n    ],\n    \"hit\": [\n      [\n        \"hit-1.ogg\",\n        \"hit-1.wav\"\n      ],\n      [\n        \"hit-2.ogg\",\n        \"hit-2.wav\"\n      ],\n      [\n        \"hit-3.ogg\",\n        \"hit-3.wav\"\n      ]\n    ],\n    \"dash\": [\n      [\n        \"dash-1.ogg\",\n        \"dash-1.wav\"\n      ],\n      [\n        \"dash-2.ogg\",\n        \"dash-2.wav\"\n      ],\n      [\n        \"dash-3.ogg\",\n        \"dash-3.wav\"\n      ]\n    ],\n    \"bounce\": [\n      [\n        \"bounce-1.ogg\",\n        \"bounce-1.wav\"\n      ],\n      [\n        \"bounce-2.ogg\",\n        \"bounce-2.wav\"\n      ],\n      [\n        \"bounce-3.ogg\",\n        \"bounce-3.wav\"\n      ]\n    ],\n    \"tick\": [\n      [\n        \"tick-1.ogg\",\n        \"tick-1.wav\"\n      ],\n      [\n        \"tick-2.ogg\",\n        \"tick-2.wav\"\n      ],\n      [\n        \"tick-3.ogg\",\n        \"tick-3.wav\"\n      ]\n    ],\n    \"alarm\": [\n      [\n        \"alarm-1.ogg\",\n        \"alarm-1.wav\"\n      ]\n    ],\n    \"win\": [\n      [\n        \"win-1.ogg\",\n        \"win-1.wav\"\n      ]\n    ],\n    \"lose\": [\n      [\n        \"lose-1.ogg\",\n        \"lose-1.wav\"\n      ]\n    ],\n    \"confirm\": [\n      [\n        \"confirm-1.ogg\",\n        \"confirm-1.wav\"\n      ]\n    ],\n    \"back\": [\n      [\n        \"back-1.ogg\",\n        \"back-1.wav\"\n      ]\n    ]\n  },\n  \"music\": {\n    \"lantern-lane\": {\n      \"bpm\": 108,\n      \"beatsPerBar\": 4,\n      \"barSeconds\": 2.2222222222222223,\n      \"loops\": {\n        \"main\": [\n          \"lantern-lane-main-loop-8bars.ogg\",\n          \"lantern-lane-main-loop-8bars.wav\"\n        ],\n        \"chase\": [\n          \"lantern-lane-chase-loop-4bars.ogg\",\n          \"lantern-lane-chase-loop-4bars.wav\"\n        ]\n      }\n    }\n  }\n}\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';\nimport { createSound } from './sound.js';\nimport style from '../style.json';\nconst theme=style.palette;\nconst sound=createSound({base:'sound/'});\nsound.volume({music:.85,sfx:.8});\nsound.music('lantern-lane');\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 = 180_000;\nconst BREAK_MS = 7_000;\nconst GEM_COUNT = 38;\nconst MIN_SLOTS = 6; // 1 human + 2 bots from the first frame\nconst MAX_SLOTS = 6;\nconst KNOCK_RANGE = 110;\nconst KNOCK_SPEED = 950;\nconst KNOCK_MS = 420;\nconst ZONE_MS = 12_000;\nconst PALETTE = ['#8fe36a', '#ffd166', '#ef6f6c', '#6cb4ee', '#c792ea', '#f4a261', '#2ec4b6', '#ff8fab', '#a7c957', '#e9c46a', '#90e0ef', '#f28482'];\nconst BOT_NAMES = ['Boo', 'Salem', 'Patch', 'Cinder', 'Hex', 'Bones'];\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[]; horse: Horse }\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 Horse { x: number; y: number; mode: number; until: number; dx: number; dy: number; target: number }\ninterface Body { bag: number; caught: number; dash: number; dashUntil: number; immune: number; costume: number; 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; horse: Horse }\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: 'midnight-candy-raid', maxPlayers: MAX_SLOTS, movement: MOVEMENT, caps: ['skill','agents'], snapshotHz: 20, inputHz: 20, checkpointMs: 1000, checkpoint: () => checkpoint() });\n\n/* ------------------------------------------------------------ host state */\nlet roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName, policy: () => net.policy });\nlet bodies = new Map<number, Body>();\nlet gems: Gem[] = [];\nlet gemSeq = 0;\nlet round: RoundInfo | null = null;\nlet zone: Zone | null = null;\nlet horse: Horse = { x: 800, y: 500, mode: 0, until: 0, dx: 1, dy: 0, target: -1 };\nconst banks = [{x: 130,y: 500}, {x: 1470,y: 500}];\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;\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 => { const g=Math.random()<.72 ? {id:++gemSeq,x:rnd(240,W-240),y:rnd(345,655)} : {id:++gemSeq,x:rnd(700,900),y:rnd(45,H-45)}; return g; };\n\nfunction bodyFor(slot: Slot): Body {\n  const sp = spawnPoint(slot.slot);\n  return { bag: 0, caught: 0, dash: 0, dashUntil: 0, immune: net.now()+3000, costume: slot.slot % 3, 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.bag = 0; b.caught = 0; b.dash = 0; b.dashUntil = 0; b.immune = now+3000; b.tx = sp.x; b.ty = sp.y; b.kvx = 0; b.kvy = 0; b.knockUntil = 0;\n    hostMoved(b);\n  }\n  horse = {x: W/2, y: H/2, mode: 0, until: now+3000, dx: 1, dy: 0, target: -1};\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: ranked.findIndex(x=>x.score===b.score)+1 }));\n  round = { n: round.n, phase: 'over', startedAt: now, endsAt: now + BREAK_MS, results };\n  net.round(round); sound.play('win');\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: () => net.policy });\n    bodies = new Map();\n    const seat = mySeat();\n    if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name);\n    syncBodiesFromRoster();\n    startRound((e.round?.n ?? 0) + 1);\n  }\n  // Whoever is connected now is who plays: seats that left during the gap become bots.\n  const peers = net.offline ? [{ seat: 0, name: 'You' }] : [...net.peers.values()].filter((p) => p.seat !== null);\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, policy: () => net.policy });\n    bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));\n    gems = ck.gems.map((g) => ({ ...g }));\n    gemSeq = ck.gemSeq; horse = ck.horse;\n    round = ck.round;\n    tick = ck.tick;\n  } else {\n    roster = Roster.from(e.roster ?? [], { min: MIN_SLOTS, max: MAX_SLOTS, botName, policy: () => net.policy });\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    horse = s.d.horse;\n    for (const [slot, seat, x, y, score, vx, vy, bag, caught, dash, costume] 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.bag = bag; b.caught = caught; b.dash = dash; b.costume = costume; 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, horse: {...horse},\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, p.agent ? {role:p.agent.role,hands:p.agent.hands} : null);\n  if (!c) return; // full: they watch\n  syncBodiesFromRoster();\n  const b = bodies.get(c.slot.slot);\n  // The arriving human takes over the bot's body where it stands, score and all (reset = \"adopt this position\").\n  if (b) { b.vx = 0; b.vy = 0; b.kvx = 0; b.kvy = 0; b.knockUntil = 0; hostMoved(b); }\n  publishRoster();\n  net.snapshot(buildSnap(), tick, true);\n});\nnet.on('leave', (p) => {\n  if (!hosting || p.seat === null) return;\n  roster.release(p.seat); // their body stays, driven by a bot\n  syncBodiesFromRoster();\n  publishRoster();\n});\nnet.on('event', (e) => {\n  if (e.k === 'wave') {addWave(e.d as { slot: number }); sound.play('dash',{volume:.45});}\n  if (e.k === 'knock') addWave(e.d as { slot: number }, true);\n  if(['bank','rescue','caught','pickup'].includes(e.k)) { const slot=(e.d as {slot:number}).slot; const seat=net.slots.find(s=>s.slot===slot)?.seat; if(seat===mySeat()) sound.play(({bank:'score',rescue:'confirm',caught:'hit',pickup:'coin'} as Record<string,string>)[e.k]); }\n});\nnet.on('round', (r) => { if(r.phase==='over' && round?.phase!=='over') sound.play('win'); 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  if (e.code === 'KeyE' && !keys.has('KeyE')) rescue();\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  unlockAudio();\n  if (e.clientX > innerWidth-180 && e.clientY > innerHeight-110) { if(e.clientX < innerWidth-95) rescue(); else wave(); 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  const now = net.now();\n  if(from.caught > now || from.dash > now || round?.phase !== 'live') return;\n  if(from.seat===mySeat()) sound.play('dash');\n  from.dash = now+3000; from.dashUntil = now+250;\n  let dx = from.vx, dy = from.vy; const len = Math.hypot(dx,dy);\n  if(len < 1) {dx = 1; dy = 0;} else {dx/=len; dy/=len;}\n  knock(from, from.x-dx, from.y-dy);\n  addWave({slot: from.slot}); net.send('wave',{slot: from.slot});\n}\nfunction rescue(): void {\n  const seat = mySeat(); if(seat === null) return;\n  if(hosting) { const b=[...bodies.values()].find(b=>b.seat===seat); if(b) hostRescue(b); }\n  else net.press('rescue');\n}\nfunction hostRescue(from: Body): void {\n  const now=net.now(); if(from.caught>now || round?.phase !== 'live') return;\n  for(const b of bodies.values()) if(b !== from && b.caught>now && Math.hypot(b.x-from.x,b.y-from.y)<105) {\n    b.caught=0; b.immune=now+3000; if(!b.bot && b.seat!==null && b.seat!==mySeat()) net.give(b.seat); from.score+=3; net.send('rescue',{slot:b.slot}); if(from.seat===mySeat()) sound.play('confirm'); net.spotlight(from.seat??0); break;\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() + 250;\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, 250); }\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\nconst botSight=new Map<number,{at:number;tx:number;ty:number}>();\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.caught > now) { b.vx=0; b.vy=0; continue; }\n    if (b.knockUntil > now) { stepKnocked(b, dt); continue; }\n    const skill=net.skillOf(b.slot);\n    const react=botSight.get(b.slot)??{at:0,tx:b.tx,ty:b.ty};\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&&now-react.at>=skill.reactionMs){taken.add(best.id);react.at=now;react.tx=best.x+(Math.random()-.5)*60*skill.aimNoise;react.ty=best.y+(Math.random()-.5)*60*skill.aimNoise;botSight.set(b.slot,react);}\n    b.tx=react.tx;b.ty=react.ty;\n    if(b.bag >= 4+Math.round(skill.positioning*4)) { const bank=[...banks].sort((a,c)=>Math.hypot(a.x-b.x,a.y-b.y)-Math.hypot(c.x-b.x,c.y-b.y))[0]; b.tx=bank.x; b.ty=bank.y; }\n    if(Math.hypot(b.x-horse.x,b.y-horse.y)<190) { b.tx=b.x+(b.x-horse.x)*3; b.ty=b.y+(b.y-horse.y)*3; if(b.dash<now) hostWave(b); }\n    if(Math.random()<dt*(.25+skill.positioning*.5)) hostRescue(b);\n    const dx = b.tx - b.x; const dy = b.ty - b.y; const len = Math.hypot(dx, dy) || 1;\n    const botSpeed=BOT_SPEED*(.8+skill.positioning*.25);\n    const k = Math.min(1, dt * 5);\n    b.vx += ((dx / len) * botSpeed - b.vx) * k;\n    b.vy += ((dy / len) * botSpeed - 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.caught > now0) { if(b.seat===seat) {me.x=b.x;me.y=b.y;me.vx=0;me.vy=0;} else if(b.seat!==null) net.take(b.seat,200); 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); { const presses=net.takePresses(b.seat); if(presses['wave']) hostWave(b); if(presses['rescue']) hostRescue(b); if(presses['costume']) b.costume=(b.costume+1)%3; } 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      { const presses=net.takePresses(b.seat); if(presses['wave']) hostWave(b); if(presses['rescue']) hostRescue(b); if(presses['costume']) b.costume=(b.costume+1)%3; }\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    { const presses=net.takePresses(b.seat); if(presses['wave']) hostWave(b); if(presses['rescue']) hostRescue(b); if(presses['costume']) b.costume=(b.costume+1)%3; }\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    stepHorse(dt,now);\n    for (const b of bodies.values()) {\n      if(b.caught>now) continue;\n      for(const bank of banks) if(b.bag && Math.hypot(b.x-bank.x,b.y-bank.y)<80) {b.score+=b.bag; b.bag=0; net.send('bank',{slot:b.slot}); if(b.seat===mySeat()) sound.play('score');}\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.bag += 1; net.send('pickup',{slot:b.slot}); if(b.seat===mySeat()) sound.play('coin',{pitch:Math.min(5,b.bag/3)});\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), b.bag, b.caught, b.dash, b.costume] as P),\n    horse: {...horse},\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) { const p=net.latest()?.d.p.find(p=>p[1]===seat); if(p && p[8]>net.now()) {me.x=p[2];me.y=p[3];me.vx=0;me.vy=0;me.has=true;} else 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\nlet musicSection='main',lastHorseMode=0;\nconst cam = { x: W / 2, y: H / 2 };\nconst gemGlow = (() => {\n  const g = ctx.createRadialGradient(0, 0, 0, 0, 0, R_GEM * 2.8);\n  g.addColorStop(0, 'rgba(255,209,102,0.55)'); g.addColorStop(0.4, 'rgba(255,170,60,0.18)'); g.addColorStop(1, 'rgba(255,150,40,0)');\n  return g;\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 = Math.max(Math.min(cw,ch)/(phone?650:900),cw/W,ch/H);\n  const view=net.offline?0:net.viewSeat;\n  const following=hosting?[...bodies.values()].find(b=>b.seat===view):[...drawn.values()].find(b=>b.seat===view);\n  const focus=view===mySeat()&&me.has?me:following??{x:W/2,y:H/2};\n  const target={x:Math.max(cw/(2*scale),Math.min(W-cw/(2*scale),focus.x)),y:Math.max(ch/(2*scale),Math.min(H-ch/(2*scale),focus.y))};\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  ctx.shadowColor=theme.bg;ctx.shadowBlur=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, '#322441'); bg.addColorStop(1, theme.bg);\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  drawWorld(t);\n  const gemList = hosting ? gems : (net.latest()?.d.g ?? []).map(([id,x,y])=>({id,x,y}));\n  for(const g of gemList) { ctx.save(); ctx.translate(g.x,g.y); ctx.rotate(Math.sin(t/600+g.id)*.25); ctx.shadowColor='#b7f874';ctx.shadowBlur=15;\n    ctx.fillStyle=['#b7f874','#ff92d0','#ffd569'][g.id%3];ctx.beginPath();ctx.moveTo(-17,-9);ctx.lineTo(-17,9);ctx.lineTo(17,-9);ctx.lineTo(17,9);ctx.closePath();ctx.fill();ctx.beginPath();ctx.ellipse(0,0,10,8,0,0,7);ctx.fill();ctx.shadowBlur=0;ctx.strokeStyle='#fff3d6';ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(-3,-6);ctx.lineTo(3,6);ctx.stroke();ctx.restore(); }\n  const hh=hosting?horse:net.latest()?.d.horse;\n  if(hh) {drawHorse(hh,t);const intense=Math.hypot(hh.x-focus.x,hh.y-focus.y)<400&&hh.mode!==0; const desired=intense?'chase':'main';if(desired!==musicSection){musicSection=desired;sound.section(desired);}if(hh.mode===1&&lastHorseMode!==1&&intense)sound.play('alarm',{volume:.5});lastHorseMode=hh.mode;}\n  if(view===mySeat()&&me.has)drawBankGuide(me.x,me.y);\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.strokeStyle = w.colour; ctx.globalAlpha = 1 - age; ctx.lineWidth = w.knock ? 3 : 5;\n    ctx.beginPath(); ctx.arc(w.x, w.y, w.knock ? R_AV + 6 + age * 40 : R_AV + age * (KNOCK_RANGE + 10), 0, Math.PI * 2); ctx.stroke(); ctx.globalAlpha = 1;\n  }\n\n  // avatars\n  const names = new Map<number, Slot>((net.slots ?? roster.slots).map((s) => [s.slot, s]));\n  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  for (const a of list) {\n    const colour = PALETTE[a.slot % PALETTE.length] as string;\n    const data=hosting ? bodies.get(a.slot) : net.latest()?.d.p.find(p=>p[0]===a.slot);\n    const costume=Array.isArray(data)?data[10]:data?.costume??0;\n    const caught=Array.isArray(data)?data[8]:data?.caught??0;\n    const bag=Array.isArray(data)?data[7]:data?.bag??0;\n    drawKid(a.x,a.y,costume,colour,a.mine,t);\n    if(caught>net.now()) {ctx.strokeStyle='#ff9b38';ctx.lineWidth=5;ctx.strokeRect(a.x-30,a.y-36,60,65);for(let x=-20;x<=20;x+=20){ctx.beginPath();ctx.moveTo(a.x+x,a.y-36);ctx.lineTo(a.x+x,a.y+29);ctx.stroke();}}\n    if(bag) {ctx.fillStyle='#ffce74';ctx.font='bold 14px system-ui';ctx.textAlign='center';ctx.fillText('🍬 '+bag,a.x,a.y+44);}\n    ctx.globalAlpha = 1;\n    ctx.font = `600 ${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.8)';\n    ctx.fillText(a.mine ? 'You' : label(a.name, a.bot), a.x, a.y - R_AV - 12);\n  }\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 = '#e8ecf5';\n  ctx.font = `700 ${phone ? 20 : 24}px ui-sans-serif, system-ui, sans-serif`;\n  const left = r ? Math.max(0, Math.ceil((r.endsAt - now) / 1000)) : 0;\n  const clock = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;\n  ctx.fillText(r ? (r.phase === 'live' ? `Round ${r.n} · ${clock}` : `Next round in ${left}`) : 'Joining…', pad, top + 18);\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 sorted=[...list].sort((a,b)=>(scores.get(b.slot)??0)-(scores.get(a.slot)??0));\n  const own=sorted.find(a=>a.mine);\n  const rows=phone&&own?[...sorted.filter(a=>!a.mine).slice(0,3),own]:sorted.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.fillText(`${a.mine ? 'YOU · BANKED' : label(a.name.slice(0,11), a.bot)}  ${scores.get(a.slot) ?? 0}`, cw - pad, top + 18 + i * (phone ? 20 : 22));\n  });\n  // role badge\n  ctx.textAlign = 'left'; ctx.font = '600 11px ui-monospace, Menlo, monospace'; ctx.fillStyle = 'rgba(125,240,255,0.7)';\n  \n  drawControls(cw,ch,phone);\n  if (r && r.phase === 'over' && r.results) {\n    const w = Math.min(360, cw - 40); const h = 64 + Math.min(6, r.results.length) * 26;\n    const x = (cw - w) / 2; const y = (ch - h) / 2;\n    ctx.fillStyle = 'rgba(6,10,20,0.86)'; ctx.fillRect(x, y, w, h);\n    ctx.strokeStyle = 'rgba(125,240,255,0.5)'; ctx.strokeRect(x, y, w, h);\n    ctx.textAlign = 'center'; ctx.fillStyle = '#7df0ff'; ctx.font = '700 18px ui-sans-serif, system-ui, sans-serif';\n    ctx.fillText('THE CANDY COUNT', cw / 2, y + 28);\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 < 180 && 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 · DASH to escape · RESCUE to help' : 'WASD / arrows · Space dash · E rescue', cw / 2, ch - pad - 115);\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\nfunction stepHorse(dt:number,now:number):void {\n  if(now>horse.until) {\n    if(horse.mode===1) {horse.mode=2;horse.until=now+850;}\n    else if(horse.mode===2) {horse.mode=0;horse.until=now+1700;}\n    else {\n      const candidates=[...bodies.values()].filter(b=>b.caught<now&&b.immune<now&&!banks.some(a=>Math.hypot(a.x-b.x,a.y-b.y)<105));\n      candidates.sort((a,b)=>(Math.hypot(a.x-horse.x,a.y-horse.y)-a.bag*25)-(Math.hypot(b.x-horse.x,b.y-horse.y)-b.bag*25));\n      const b=candidates[0]; if(b){horse.target=b.slot;const d=Math.hypot(b.x-horse.x,b.y-horse.y)||1;horse.dx=(b.x-horse.x)/d;horse.dy=(b.y-horse.y)/d;horse.mode=1;horse.until=now+1100;}\n      else horse.until=now+1000;\n    }\n  }\n  if(horse.mode===2){horse.x=Math.max(40,Math.min(W-40,horse.x+horse.dx*720*dt));horse.y=Math.max(40,Math.min(H-40,horse.y+horse.dy*720*dt));}\n  else if(horse.mode===0){horse.x+=Math.cos(now/1600)*60*dt;horse.y+=Math.sin(now/1700)*60*dt;}\n  for(const b of bodies.values()) if(b.caught<now&&b.immune<now&&Math.hypot(b.x-horse.x,b.y-horse.y)<50&&!banks.some(a=>Math.hypot(a.x-b.x,a.y-b.y)<105)){\n    const spill=Math.ceil(b.bag/2);b.bag-=spill;\n    for(let i=0;i<spill;i++) gems.push({id:++gemSeq,x:Math.max(30,Math.min(W-30,b.x+rnd(-75,75))),y:Math.max(30,Math.min(H-30,b.y+rnd(-75,75)))});\n    if(gems.length>65) gems=gems.slice(-65);\n    b.caught=now+8000;b.immune=now+11000;b.vx=0;b.vy=0;\n    if(!b.bot&&b.seat!==null&&b.seat!==mySeat()) net.take(b.seat,8000);\n    net.send('caught',{slot:b.slot}); if(b.seat!==null) net.spotlight(b.seat);if(b.seat===mySeat())sound.play('hit'); sound.duck(.4,.5);\n  }\n}\nfunction pumpkin(x:number,y:number,r=18):void {\n  ctx.save();ctx.translate(x,y);ctx.fillStyle='#f28a32';ctx.shadowColor='#ff9b38';ctx.shadowBlur=12;ctx.beginPath();ctx.ellipse(0,0,r,r*.8,0,0,7);ctx.fill();ctx.shadowBlur=0;\n  ctx.fillStyle='#ffec89';for(const xx of [-r*.35,r*.35]){ctx.beginPath();ctx.moveTo(xx-4,-3);ctx.lineTo(xx+4,-3);ctx.lineTo(xx,-9);ctx.fill();}\n  ctx.beginPath();ctx.moveTo(-r*.5,3);ctx.lineTo(0,9);ctx.lineTo(r*.5,3);ctx.closePath();ctx.fill();ctx.fillStyle='#8fa962';ctx.fillRect(-2,-r-3,5,8);ctx.restore();\n}\nfunction drawWorld(t:number):void {\n  ctx.drawImage(groundLayer,0,0); \n  ctx.strokeStyle='#786078';ctx.lineWidth=2;ctx.setLineDash([9,18]);ctx.beginPath();ctx.moveTo(0,500);ctx.lineTo(W,500);ctx.moveTo(800,0);ctx.lineTo(800,H);ctx.stroke();ctx.setLineDash([]);\n  for(let i=0;i<10;i++) {\n    const x=100+(i%5)*310,y=i<5?80:745;\n    ctx.fillStyle='#120d1e';ctx.fillRect(x+10,y+20,185,145);ctx.fillStyle=['#44334e','#3e365b','#4c3546'][i%3];ctx.fillRect(x,y,180,140);\n    ctx.fillStyle='#191123';ctx.beginPath();ctx.moveTo(x-14,y);ctx.lineTo(x+80,y-75);ctx.lineTo(x+197,y+8);ctx.closePath();ctx.fill();\n    for(let j=0;j<2;j++){ctx.fillStyle='#ffe19c';ctx.shadowColor='#ffb55e';ctx.shadowBlur=14;ctx.fillRect(x+25+j*93,y+25,30,40);ctx.shadowBlur=0;ctx.strokeStyle='#392237';ctx.lineWidth=4;ctx.strokeRect(x+25+j*93,y+25,30,40);}\n    ctx.fillStyle='#1c1328';ctx.fillRect(x+72,y+72,36,68);pumpkin(x+145,y+146);\n    ctx.strokeStyle='#746079';ctx.lineWidth=5;ctx.beginPath();ctx.moveTo(x,y+185);ctx.lineTo(x+180,y+185);ctx.stroke();\n    for(let j=0;j<7;j++)ctx.fillRect(x+j*28,y+169,5,32);\n  }\n  for(const a of banks) {ctx.fillStyle='rgba(161,238,150,.12)';ctx.beginPath();ctx.arc(a.x,a.y,90,0,7);ctx.fill();ctx.strokeStyle='#b8f795';ctx.lineWidth=3;ctx.stroke();ctx.fillStyle='#b8f795';ctx.font='bold 19px system-ui';ctx.textAlign='center';ctx.fillText('SAFE HOUSE',a.x,a.y-105);ctx.font='14px system-ui';ctx.fillText('BANK CANDY',a.x,a.y+8);}\n  for(let i=0;i<14;i++)pumpkin(40+i*115, i%2?650:350,12);\n  ctx.strokeStyle='#8c6d93';ctx.lineWidth=5;ctx.strokeRect(0,0,W,H);\n  ctx.fillStyle='rgba(181,147,205,.045)';for(let i=0;i<4;i++){ctx.beginPath();ctx.ellipse((t/70+i*450)%(W+400)-200,220+i*190,240,45,0,0,7);ctx.fill();}\n}\nfunction drawKid(x:number,y:number,costume:number,colour:string,mine:boolean,t:number):void {\n  ctx.save();ctx.translate(x,y);ctx.fillStyle='rgba(0,0,0,.3)';ctx.beginPath();ctx.ellipse(0,22,25,9,0,0,7);ctx.fill();\n  if(mine){ctx.strokeStyle='#fff3ce';ctx.lineWidth=3;ctx.beginPath();ctx.ellipse(0,25,30,12,0,0,7);ctx.stroke();}\n  ctx.fillStyle=colour;ctx.beginPath();ctx.arc(0,4,23,Math.PI,0);ctx.lineTo(23,24);ctx.lineTo(-23,24);ctx.closePath();ctx.fill();\n  if(costume===0){ctx.fillStyle='#54347b';ctx.beginPath();ctx.moveTo(-30,-8);ctx.lineTo(26,-8);ctx.lineTo(5,-50);ctx.closePath();ctx.fill();ctx.fillStyle='#ffc96c';ctx.fillRect(-19,-14,39,5);ctx.fillStyle='#ffe1b2';ctx.beginPath();ctx.arc(0,0,13,0,7);ctx.fill();}\n  if(costume===1){ctx.fillStyle='#eee6fb';ctx.beginPath();ctx.arc(0,0,24,Math.PI,0);ctx.lineTo(24,27);ctx.lineTo(12,20);ctx.lineTo(0,28);ctx.lineTo(-12,20);ctx.lineTo(-24,27);ctx.fill();}\n  if(costume===2)pumpkin(0,-5,24);\n  if(costume!==2){ctx.fillStyle='#1b1226';ctx.beginPath();ctx.ellipse(-7,-2,3,5,0,0,7);ctx.ellipse(7,-2,3,5,0,0,7);ctx.fill();}\n  ctx.fillStyle='#c78a48';ctx.fillRect(18,7,14,19);ctx.fillStyle=colour;ctx.fillRect(-7,19,14,5);ctx.restore();\n}\nfunction drawHorse(h:Horse,t:number):void {\n  ctx.save();ctx.translate(h.x,h.y);\n  if(h.mode===1){ctx.rotate(Math.atan2(h.dy,h.dx));ctx.fillStyle='rgba(255,117,70,.2)';ctx.fillRect(0,-25,650,50);ctx.strokeStyle='#ffae68';ctx.setLineDash([15,10]);ctx.strokeRect(0,-25,650,50);ctx.setLineDash([]);ctx.rotate(-Math.atan2(h.dy,h.dx));}\n  ctx.fillStyle='rgba(0,0,0,.35)';ctx.beginPath();ctx.ellipse(0,30,48,14,0,0,7);ctx.fill();ctx.fillStyle='#100b1b';ctx.beginPath();ctx.ellipse(0,12,40,22,0,0,7);ctx.fill();ctx.fillRect(25,-15,15,35);\n  for(const x of [-25,20]){ctx.fillRect(x,20,8,25+Math.sin(t/100+x)*6);}\n  ctx.fillStyle='#392044';ctx.beginPath();ctx.moveTo(-20,5);ctx.lineTo(0,-45);ctx.lineTo(20,10);ctx.closePath();ctx.fill();pumpkin(0,-48,20);ctx.restore();\n}\nfunction drawControls(w:number,h:number,phone:boolean):void {\n  const mine=hosting?[...bodies.values()].find(b=>b.seat===mySeat()):null;\n  const p=!hosting?net.latest()?.d.p.find(p=>p[1]===mySeat()):null;\n  const bag=mine?.bag??p?.[7]??0, caught=mine?.caught??p?.[8]??0,dash=mine?.dash??p?.[9]??0;\n  ctx.textAlign='left';ctx.font='bold 14px system-ui';ctx.fillStyle='#ffd569';ctx.fillText('Bag '+bag+' · Bank at safe houses',16,phone?60:72);\n  if(caught>net.now()){ctx.textAlign='center';ctx.fillStyle='#ffc96c';ctx.font='bold 22px system-ui';ctx.fillText('CAUGHT! Rescue or '+Math.ceil((caught-net.now())/1000)+'s',w/2,h/2+95);}\n  if(phone)for(const [x,name] of [[w-55,'DASH'],[w-140,'RESCUE']] as [number,string][]){ctx.fillStyle='rgba(42,24,58,.85)';ctx.strokeStyle=name==='DASH'?'#ffb454':'#b8f795';ctx.lineWidth=2;ctx.beginPath();ctx.arc(x,h- sixty(),34,0,7);ctx.fill();ctx.stroke();ctx.fillStyle='#fff3dc';ctx.font='bold 12px system-ui';ctx.textAlign='center';ctx.fillText(name==='DASH'&&dash>net.now()?String(Math.ceil((dash-net.now())/1000)):name,x,h-56);}\n}\nfunction sixty(){return 60;}\nlet muted=false;\nfunction unlockAudio(){}\nconst mute=document.createElement('button');mute.textContent='♫ Sound';mute.setAttribute('aria-label','Toggle sound');mute.style.cssText='position:fixed;top:145px;left:16px;background:#281833;color:#ffdfa6;border:1px solid #725272;border-radius:12px;padding:8px;z-index:5';mute.onclick=()=>{muted=!muted;sound.mute(muted);mute.textContent=muted?'♫ Muted':'♫ Sound';};document.body.append(mute);\nconst costumeButton=document.createElement('button');costumeButton.textContent='Change costume';costumeButton.style.cssText='position:fixed;top:145px;left:110px;background:#281833;color:#ffdfa6;border:1px solid #725272;border-radius:12px;padding:8px;z-index:5';costumeButton.onclick=()=>{const seat=mySeat();if(seat===null)return;if(hosting){const b=[...bodies.values()].find(b=>b.seat===seat);if(b)b.costume=(b.costume+1)%3;}else net.press('costume');};document.body.append(costumeButton);\n\nconst groundLayer=(()=>{const c=document.createElement('canvas');c.width=W;c.height=H;const g=c.getContext('2d')!;\n  g.fillStyle='#322441';g.fillRect(0,0,W,H);\n  for(let y=0;y<H;y+=34)for(let x=0;x<W;x+=40){const road=(y>330&&y<670)||(x>675&&x<925);g.fillStyle=road?((x/40+y/34)%3<1?'#53445e':'#4a3d54'):((x/40+y/34)%3<1?'#392b48':'#352741');g.fillRect(x+(y/34%2)*8,y,35,28);}\n  for(let i=0;i<170;i++){const x=(i*163)%W,y=(i*97)%H;g.save();g.translate(x,y);g.rotate(i*1.8);g.fillStyle=['#bc783c','#a76159','#d9a253','#735b83'][i%4];g.beginPath();g.ellipse(0,0,5,2,0,0,7);g.fill();g.restore();}\n  for(const x of [125,435,745,1055,1365])for(const y of [310,700]){const glow=g.createRadialGradient(x,y,0,x,y,110);glow.addColorStop(0,'rgba(255,182,88,.16)');glow.addColorStop(1,'rgba(255,182,88,0)');g.fillStyle=glow;g.fillRect(x-110,y-110,220,220);}\n  return c;\n})();\nfunction drawBankGuide(x:number,y:number):void {\n  const mine=hosting?[...bodies.values()].find(b=>b.seat===mySeat()):null;\n  const bag=mine?.bag??net.latest()?.d.p.find(p=>p[1]===mySeat())?.[7]??0;if(!bag)return;\n  const b=[...banks].sort((a,b)=>Math.hypot(a.x-x,a.y-y)-Math.hypot(b.x-x,b.y-y))[0];const angle=Math.atan2(b.y-y,b.x-x);\n  ctx.save();ctx.translate(x,y);ctx.rotate(angle);ctx.fillStyle=theme.good;ctx.beginPath();ctx.moveTo(52,0);ctx.lineTo(38,-8);ctx.lineTo(38,8);ctx.closePath();ctx.fill();ctx.restore();\n  ctx.fillStyle=theme.good;ctx.textAlign='center';ctx.font='bold 12px system-ui';ctx.fillText('BANK → '+Math.round(Math.hypot(b.x-x,b.y-y)/10)+'m',x,y+60);\n}\n","src/sound.d.ts":"/** Types for sound.js (the Homie plugin's sound skill): a game's effects and music from sound.json. */\nexport interface PlayOptions { volume?: number; pitch?: number; pan?: number; jitter?: number }\nexport interface SoundState { unlocked: boolean; running: boolean; loaded: number; music: { name: string; section: string; since: number } | null; missing: string[]; errors: string[] }\nexport interface Sound {\n  /** Resolves once sound.json and every file it lists have loaded (or failed, which state().errors names). */\n  ready: Promise<unknown>;\n  /** Play an effect by name: a random variant, a little pitch jitter, at most a few of one name at once. Dropped before the first gesture. */\n  play(name: string, options?: PlayOptions): AudioBufferSourceNode | null;\n  /** Start a score's loops (its first section unless one is named); it starts on the first touch or key. */\n  music(name: string, options?: { section?: string; fade?: number }): void;\n  /** Switch to another section's loop exactly on the next bar line. */\n  section(name: string, options?: { fade?: number }): void;\n  stopMusic(options?: { fade?: number }): void;\n  /** Music down to `to` (0..1) for `seconds`, then back. */\n  duck(to?: number, seconds?: number): void;\n  volume(levels: { master?: number; sfx?: number; music?: number }): void;\n  mute(on?: boolean): void;\n  readonly context: AudioContext | null;\n  state(): SoundState;\n}\nexport function createSound(options?: { base?: string; manifest?: string; maxPerName?: number; maxVoices?: number }): Sound;\n","src/sound.js":"/*\n * sound.js: a game's sound effects and music, from the files the Homie plugin's sound skill made\n * (sound.json beside them lists what exists). Plain JavaScript, no dependencies.\n *\n *   import { createSound } from './sound.js';            // bundle games: from src/; static games: from sound/\n *   const sound = createSound({ base: 'sound/' });        // where sound.json and the files are served, relative to the page\n *   sound.play('jump');                                   // a random variant, a little pitch jitter, never more than 4 at once\n *   sound.play('coin', { pitch: combo, volume: 0.8, pan: -0.3 });\n *   sound.music('theme');                                 // the theme's first loop, from the first touch on\n *   sound.section('chase');                               // the next loop, starting exactly on the next bar line\n *   sound.duck(0.4, 0.5);                                 // music down to 40% for half a second (a big hit, a line of speech)\n *   sound.volume({ sfx: 1, music: 0.6 }); sound.mute(true);\n *\n * Sound starts on the first touch, click or key, with no \"tap for sound\" screen: browsers keep audio\n * suspended until a person has touched the page, so the first gesture resumes it (a held stick counts\n * when the finger lifts). A sound asked for before that is dropped, not queued: a late hit is worse than none.\n * window.__homieSound holds counters for a playtest (what played, when); it never controls anything.\n */\nexport function createSound({ base = 'sound/', manifest = 'sound.json', maxPerName = 4, maxVoices = 24 } = {}) {\n  const AC = window.AudioContext || window.webkitAudioContext;\n  const ctx = AC ? new AC({ latencyHint: 'interactive' }) : null;\n  const url = (f) => new URL(f, new URL(base, document.baseURI)).toString();\n  const buses = {};\n  const buffers = new Map();\n  const live = new Map();\n  const stats = { unlocked: false, unlockedAt: null, plays: [], missing: [], errors: [], music: null };\n  let meta = { sfx: {}, music: {} };\n  let muted = false;\n  const levels = { master: 1, sfx: 1, music: 0.7 };\n  let track = null;\n\n  if (ctx) {\n    buses.master = ctx.createGain();\n    // A gentle safety limiter: many hits at once must not clip.\n    const comp = ctx.createDynamicsCompressor();\n    comp.threshold.value = -6; comp.knee.value = 6; comp.ratio.value = 8; comp.attack.value = 0.003; comp.release.value = 0.15;\n    buses.master.connect(comp).connect(ctx.destination);\n    buses.sfx = ctx.createGain(); buses.sfx.connect(buses.master);\n    buses.music = ctx.createGain(); buses.music.connect(buses.master);\n    buses.duck = ctx.createGain(); buses.duck.connect(buses.music);\n    applyLevels();\n    try { if (navigator.audioSession) navigator.audioSession.type = 'playback'; } catch (e) { /* not iOS */ }\n    const unlock = () => {\n      if (ctx.state !== 'running') ctx.resume().catch(() => {});\n      try { const b = ctx.createBuffer(1, 1, 22050); const s = ctx.createBufferSource(); s.buffer = b; s.connect(ctx.destination); s.start(0); } catch (e) { /* closed */ }\n      if (!stats.unlocked && ctx.state === 'running') { stats.unlocked = true; stats.unlockedAt = performance.now(); }\n      if (track && !track.started && ctx.state === 'running') startTrack();\n    };\n    for (const ev of ['pointerdown', 'pointerup', 'touchstart', 'touchend', 'keydown', 'click']) window.addEventListener(ev, unlock, { capture: true, passive: true });\n    ctx.addEventListener('statechange', () => { if (ctx.state === 'running') { stats.unlocked = true; stats.unlockedAt = stats.unlockedAt || performance.now(); if (track && !track.started) startTrack(); } });\n  }\n\n  const ready = fetch(url(manifest)).then((r) => { if (!r.ok) throw new Error(`${manifest}: HTTP ${r.status}`); return r.json(); }).then((m) => {\n    meta = { sfx: m.sfx || {}, music: m.music || {} };\n    const variants = Object.values(meta.sfx).flat();\n    const loops = Object.values(meta.music).flatMap((t) => Object.values(t.loops || {}));\n    return Promise.all([...variants, ...loops].map(loadFirst));\n  }).catch((e) => { stats.errors.push(String(e && e.message || e)); });\n\n  function load(file) {\n    if (!ctx || buffers.has(file)) return buffers.get(file);\n    const p = fetch(url(file)).then((r) => { if (!r.ok) throw new Error(`${file}: HTTP ${r.status}`); return r.arrayBuffer(); })\n      .then((b) => new Promise((ok, no) => ctx.decodeAudioData(b, ok, no)))\n      .then((buf) => { buffers.set(file, buf); return buf; })\n      .catch((e) => { stats.errors.push(String(e && e.message || e)); buffers.delete(file); return null; });\n    buffers.set(file, p);\n    return p;\n  }\n\n  /** A file listed as [ogg, wav]: the first this browser decodes wins (older Safari cannot decode Ogg). */\n  function loadFirst(list) {\n    const files = Array.isArray(list) ? list : [list];\n    const key = files.join('|');\n    if (buffers.has(key)) return buffers.get(key);\n    const p = files.reduce((prev, f) => prev.then((buf) => buf || load(f).then((b) => (b instanceof AudioBuffer ? b : null))), Promise.resolve(null))\n      .then((buf) => { if (buf) buffers.set(key, buf); else buffers.delete(key); return buf; });\n    buffers.set(key, p);\n    return p;\n  }\n  const loopBuffer = (list) => buffers.get(Array.isArray(list) ? list.join('|') : list);\n\n  function applyLevels() {\n    if (!ctx) return;\n    const t = ctx.currentTime;\n    buses.master.gain.setTargetAtTime(muted ? 0 : levels.master, t, 0.02);\n    buses.sfx.gain.setTargetAtTime(levels.sfx, t, 0.02);\n    buses.music.gain.setTargetAtTime(levels.music, t, 0.05);\n  }\n\n  function play(name, { volume = 1, pitch = 0, pan = 0, jitter = 0.35 } = {}) {\n    stats.plays.push({ name, t: Math.round(performance.now()) });\n    if (stats.plays.length > 500) stats.plays.splice(0, 250);\n    if (!ctx || ctx.state !== 'running') return null;\n    const variants = meta.sfx[name];\n    if (!variants || !variants.length) { if (!stats.missing.includes(name)) stats.missing.push(name); return null; }\n    const buf = loopBuffer(variants[Math.floor(Math.random() * variants.length)]);\n    if (!(buf instanceof AudioBuffer)) return null;\n    const mine = live.get(name) || [];\n    while (mine.length >= maxPerName) { try { mine.shift().stop(); } catch (e) { /* ended */ } }\n    let total = 0; for (const v of live.values()) total += v.length;\n    if (total >= maxVoices) return null;\n    const src = ctx.createBufferSource();\n    src.buffer = buf;\n    src.playbackRate.value = 2 ** ((pitch + (Math.random() * 2 - 1) * jitter) / 12);\n    const g = ctx.createGain(); g.gain.value = volume;\n    let node = src.connect(g);\n    if (pan && ctx.createStereoPanner) { const p = ctx.createStereoPanner(); p.pan.value = Math.max(-1, Math.min(1, pan)); node = node.connect(p); }\n    node.connect(buses.sfx);\n    src.onended = () => { const i = mine.indexOf(src); if (i >= 0) mine.splice(i, 1); };\n    mine.push(src); live.set(name, mine);\n    src.start();\n    return src;\n  }\n\n  function startTrack() {\n    if (!track || !ctx || ctx.state !== 'running') return;\n    const buf = loopBuffer(track.loops[track.section]);\n    if (!(buf instanceof AudioBuffer)) { ready.then(() => { if (track && !track.started) startTrack(); }); return; }\n    track.started = true;\n    track.t0 = ctx.currentTime + 0.05;\n    track.node = loopNode(buf, track.t0, track.fade);\n    stats.music = { name: track.name, section: track.section, since: Math.round(performance.now()) };\n  }\n\n  function loopNode(buf, at, fade) {\n    const src = ctx.createBufferSource(); src.buffer = buf; src.loop = true;\n    const g = ctx.createGain(); g.gain.setValueAtTime(0, at); g.gain.linearRampToValueAtTime(1, at + Math.max(0.005, fade));\n    src.connect(g).connect(buses.duck);\n    src.start(at);\n    return { src, g };\n  }\n\n  let loaded = false;\n  ready.then(() => { loaded = true; });\n  function music(name, opts = {}) {\n    // Called before sound.json has arrived (the usual case: at the top of the game): start it once it has.\n    if (!loaded) { ready.then(() => music(name, opts)); return; }\n    const { section = null, fade = 0.4 } = opts;\n    const t = meta.music[name];\n    if (!t) { stats.errors.push(`no music \"${name}\" in ${manifest}`); return; }\n    stopMusic({ fade: 0.2 });\n    const first = section || Object.keys(t.loops || {})[0];\n    track = { name, loops: t.loops, bar: t.barSeconds, section: first, started: false, fade };\n    startTrack();\n  }\n\n  /** Switch loops on the next bar line (every loop shares the tempo, so bar lines line up). */\n  function section(name, { fade = 0.03 } = {}) {\n    if (!track || !track.loops[name] || name === track.section) return;\n    track.section = name;\n    if (!track.started) return;\n    const buf = loopBuffer(track.loops[name]);\n    if (!(buf instanceof AudioBuffer)) return;\n    const now = ctx.currentTime;\n    const at = track.t0 + Math.ceil((now + 0.02 - track.t0) / track.bar) * track.bar;\n    const old = track.node;\n    old.g.gain.setValueAtTime(1, at - fade); old.g.gain.linearRampToValueAtTime(0, at);\n    try { old.src.stop(at + 0.05); } catch (e) { /* */ }\n    track.node = loopNode(buf, at - fade, fade);\n    track.t0 = at;\n    stats.music = { name: track.name, section: name, since: Math.round(performance.now()) };\n  }\n\n  function stopMusic({ fade = 0.5 } = {}) {\n    if (!track) return;\n    if (track.node && ctx) { const t = ctx.currentTime; track.node.g.gain.setTargetAtTime(0, t, fade / 3); try { track.node.src.stop(t + fade + 0.1); } catch (e) { /* */ } }\n    track = null; stats.music = null;\n  }\n\n  function duck(to = 0.4, seconds = 0.4) {\n    if (!ctx) return;\n    const t = ctx.currentTime; const g = buses.duck.gain;\n    g.cancelScheduledValues(t); g.setTargetAtTime(to, t, 0.015); g.setTargetAtTime(1, t + seconds, 0.12);\n  }\n\n  const api = {\n    ready, play, music, section, stopMusic, duck,\n    volume(v) { Object.assign(levels, v || {}); applyLevels(); },\n    mute(on = true) { muted = !!on; applyLevels(); },\n    get context() { return ctx; },\n    state: () => ({ unlocked: stats.unlocked, running: ctx ? ctx.state === 'running' : false, loaded: new Set([...buffers.values()].filter((b) => b instanceof AudioBuffer)).size, music: stats.music, missing: stats.missing.slice(), errors: stats.errors.slice(0, 10) }),\n  };\n  try { window.__homieSound = { get stats() { return { ...api.state(), plays: stats.plays.slice(-100), unlockedAt: stats.unlockedAt }; } }; } catch (e) { /* frozen global */ }\n  return api;\n}\n","style.json":"{\n  \"v\": 1,\n  \"palette\": {\n    \"bg\": \"#140c22\",\n    \"ink\": \"#fff3dc\",\n    \"accent\": \"#ff9b38\",\n    \"accent2\": \"#ba9aff\",\n    \"danger\": \"#ff705d\",\n    \"good\": \"#b8f795\",\n    \"gold\": \"#ffd569\",\n    \"ramp\": [\n      \"#140c22\",\n      \"#322441\",\n      \"#533e65\",\n      \"#806592\",\n      \"#ba9aff\",\n      \"#ff9b38\",\n      \"#ffd569\",\n      \"#b8f795\",\n      \"#fff3dc\"\n    ]\n  },\n  \"fonts\": {\n    \"display\": \"Bricolage Grotesque\",\n    \"body\": \"Inter\"\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\": \"#140c22\",\n    \"ground\": \"#56457a\",\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\": \"toon\",\n  \"materials\": {\n    \"model\": \"toon\",\n    \"outline\": true,\n    \"texelDensity\": 128,\n    \"atlas\": false\n  },\n  \"rev\": {\n    \"style.palette\": 2,\n    \"style.ui\": 1,\n    \"style.light\": 2,\n    \"style.camera\": 1\n  }\n}\n"}}
