{"v":1,"kind":"homie-game-source","id":"crumple","files":{"game.json":"{\n  \"id\": \"crumple\",\n  \"name\": \"Crumple\",\n  \"blurb\": \"A paper-plane dogfight for up to six. Steer, throw crumpled paper balls, and crumple everyone. Most hits in two minutes wins.\",\n  \"players\": {\n    \"min\": 1,\n    \"max\": 6\n  },\n  \"roundSeconds\": 120,\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  \"cover\": \"cover.jpg\",\n  \"landing\": {\n    \"pitch\": \"Fold. Fly. Crumple everyone.\",\n    \"about\": \"Up to six paper planes in a construction-paper sky, throwing crumpled paper balls at each other. Land a hit and the other plane crumples into a ball, then unfolds and flies back two seconds later. Most hits in two minutes wins. Put it on a TV and everyone flies from their phone; bots fill any empty seats.\",\n    \"howToPlay\": [\n      \"Your plane always flies forward. Just steer.\",\n      \"Throw paper balls at the other planes. A hit crumples them and scores you a point.\",\n      \"Pick your fold: the fast Dart or the tight-turning Glider.\",\n      \"Grab the paper comet for a three-second speed boost.\",\n      \"Most hits when the two minutes are up wins.\"\n    ],\n    \"controls\": {\n      \"phone\": \"Left thumb steers, right thumb throws, the little plane button switches fold\",\n      \"computer\": \"A / D or ← → to turn, Space to throw, F to switch fold\",\n      \"tv\": \"Open it on the TV, then scan the code: each phone is a controller\"\n    },\n    \"players\": { \"one\": \"pilot\", \"many\": \"pilots\" },\n    \"hero\": {\n      \"alt\": \"Paper planes in coral, blue, yellow and mint fly through a navy paper sky past cream cut-paper clouds, one throwing a crumpled paper ball.\",\n      \"focus\": \"55% 45%\",\n      \"tint\": 30\n    },\n    \"credits\": [\n      { \"role\": \"Made by\", \"name\": \"Paper Comets\" },\n      { \"role\": \"Music and sound\", \"name\": \"Synthesized from code with Homie (no samples, no model)\" }\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>Crumple · Paper Comets</title>\n<link rel=\"icon\" href=\"data:,\">\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Patrick+Hand+SC&display=swap\">\n<style>\n  html, body { margin: 0; height: 100%; background: #12162c; 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    \"throw\": [\n      [\n        \"throw-1.ogg\",\n        \"throw-1.wav\"\n      ],\n      [\n        \"throw-2.ogg\",\n        \"throw-2.wav\"\n      ],\n      [\n        \"throw-3.ogg\",\n        \"throw-3.wav\"\n      ]\n    ],\n    \"crumple\": [\n      [\n        \"crumple-1.ogg\",\n        \"crumple-1.wav\"\n      ],\n      [\n        \"crumple-2.ogg\",\n        \"crumple-2.wav\"\n      ],\n      [\n        \"crumple-3.ogg\",\n        \"crumple-3.wav\"\n      ]\n    ],\n    \"unfold\": [\n      [\n        \"unfold-1.ogg\",\n        \"unfold-1.wav\"\n      ],\n      [\n        \"unfold-2.ogg\",\n        \"unfold-2.wav\"\n      ],\n      [\n        \"unfold-3.ogg\",\n        \"unfold-3.wav\"\n      ]\n    ],\n    \"go\": [\n      [\n        \"go-1.ogg\",\n        \"go-1.wav\"\n      ]\n    ],\n    \"tick\": [\n      [\n        \"tick-1.ogg\",\n        \"tick-1.wav\"\n      ]\n    ],\n    \"win\": [\n      [\n        \"win-1.ogg\",\n        \"win-1.wav\"\n      ]\n    ],\n    \"boost\": [\n      [\n        \"boost-1.ogg\",\n        \"boost-1.wav\"\n      ]\n    ]\n  },\n  \"music\": {\n    \"crumple-theme\": {\n      \"bpm\": 120,\n      \"beatsPerBar\": 4,\n      \"barSeconds\": 2,\n      \"loops\": {\n        \"main\": [\n          \"crumple-theme-main-loop-8bars.ogg\",\n          \"crumple-theme-main-loop-8bars.wav\"\n        ],\n        \"final\": [\n          \"crumple-theme-final-loop-4bars.ogg\",\n          \"crumple-theme-final-loop-4bars.wav\"\n        ]\n      }\n    }\n  }\n}\n","src/main.ts":"/*\n * CRUMPLE — Paper Comets, game 01. A paper-plane dogfight for up to six.\n * The netplay v1 contract (@homie-rocks/studio/netplay/NETPLAY.md) is kept from the Gem Rush starter.\n *\n * Planes always fly forward: steer, and throw crumpled paper balls. A hit crumples the plane: the thrower scores\n * one, and the plane unfolds somewhere safe two seconds later. Most hits in two minutes wins. The sky wraps around.\n *\n * One browser is host: it runs the balls, hits, scores, bots and the clock, and broadcasts snapshots. Each browser\n * flies its own plane (owner movement, bounded by the host). A crumpled replica's plane is TAKEN by the host for the\n * crumple and GIVEN back with a reset at its new spot. If the host leaves, a survivor continues the same round.\n */\nimport { createNetplay, Roster, q, 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.\nimport { exposePort } from '@homie-rocks/studio/port';\n// Effects and the theme, synthesized for free by the sound skill (music/crumple-sfx, music/crumple-theme).\nimport { createSound } from './sound.js';\nconst sound = createSound({ base: 'sound/' });\nsound.music('crumple-theme'); // starts on the first touch or key\n\n/* ------------------------------------------------------------------ rules */\nconst W = 1200;\nconst H = 750;\nconst R_PLANE = 22;\nconst R_BALL = 8;\n/** Two folds: the Dart is fast and turns wide; the Glider is slower and turns tight. */\nconst FOLDS = [\n  { name: 'Dart', speed: 255, turn: 2.9 },\n  { name: 'Glider', speed: 205, turn: 4.3 },\n];\nconst foldOf = (i: number): { name: string; speed: number; turn: number } => FOLDS[mod(i, FOLDS.length)] as { name: string; speed: number; turn: number };\nconst MAX_SPEED = 255;\nconst BOOST = 1.45;\nconst BOOST_MS = 3000;\nconst COMET_EVERY_MS = 9000;\nconst R_COMET = 16;\nconst BALL_SPEED = 640;\nconst BALL_MS = 1100;\nconst FIRE_MS = 400;\nconst BOT_FIRE_MS = 950;\nconst CRUMPLE_MS = 2000;\nconst SAFE_MS = 1200;\nconst ROUND_MS = 120_000;\nconst BREAK_MS = 10_000;\nconst MIN_SLOTS = 4; // 1 human + 3 bots from the first frame\nconst MAX_SLOTS = 6;\nconst PAPER = ['#ff7a59', '#ffd166', '#06d6a0', '#6cc4f0', '#ff8fc8', '#f6efe0'];\nconst BOT_NAMES = ['Crease', 'Flap', 'Pleat', 'Scrap', 'Dart', 'Fold'];\nconst FONT = '\"Patrick Hand SC\", \"Patrick Hand\", \"Comic Sans MS\", system-ui, sans-serif';\nconst INK = '#1d2340';\nconst CREAM = '#f6efe0';\nconst botName = (slot: number): string => BOT_NAMES[slot % BOT_NAMES.length] as string;\nconst label = (name: string, bot: boolean): string => (bot ? `${name} · bot` : name);\nconst colourOf = (slot: number): string => PAPER[slot % PAPER.length] as string;\n\n/* --------------------------------------------------------------- geometry */\nconst TAU = Math.PI * 2;\nconst mod = (v: number, m: number): number => ((v % m) + m) % m;\nconst wrapD = (d: number, m: number): number => mod(d + m / 2, m) - m / 2;\nconst wdist = (ax: number, ay: number, bx: number, by: number): number => Math.hypot(wrapD(bx - ax, W), wrapD(by - ay, H));\nconst angDiff = (a: number, b: number): number => mod(b - a + Math.PI, TAU) - Math.PI;\nconst lerpW = (a: number, b: number, t: number, m: number): number => mod(a + wrapD(b - a, m) * t, m);\nconst lerpA = (a: number, b: number, t: number): number => a + angDiff(a, b) * t;\nconst clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v));\nconst rnd = (a: number, b: number): number => a + Math.random() * (b - a);\nfunction seeded(seed: number): () => number {\n  let s = seed >>> 0;\n  return () => {\n    s = (s + 0x6d2b79f5) >>> 0;\n    let t = s;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\n/* ------------------------------------------------------------ wire shapes */\ntype P = [slot: number, seat: number, x: number, y: number, a: number, score: number, down: number, safe: number, fold: number, boost: number, nextFold: number];\ntype B = [id: number, x: number, y: number, slot: number];\ninterface Snap { r: [n: number, phase: number, startedAt: number, endsAt: number]; p: P[]; b: B[]; c: number[] }\n/** Replica input: where my plane is and which way it points. */\ntype Avatar = [x: number, y: number, a: number];\ninterface Body { slot: number; seat: number | null; name: string; bot: boolean; x: number; y: number; a: number; score: number; downUntil: number; safeUntil: number; lastFire: number; fold: number; nextFold: number; boostUntil: number }\ninterface Ball { id: number; x: number; y: number; vx: number; vy: number; slot: number; until: number }\ninterface Comet { x: number; y: number }\ninterface Ckpt { round: RoundInfo; bodies: Body[]; balls: Ball[]; roster: Slot[]; tick: number; ballSeq: number; comet: Comet | null; cometAt: number }\ninterface Hit { slot: number; by: number; x: number; y: number }\n\n/* --------------------------------------------------------------- the net */\nconst net = createNetplay<Snap, Avatar, Ckpt>({ game: 'crumple', maxPlayers: MAX_SLOTS, movement: 'owner', snapshotHz: 20, inputHz: 20, checkpointMs: 1000, checkpoint: () => checkpoint() });\n\n/* ------------------------------------------------------------ host state */\nlet roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName });\nlet bodies = new Map<number, Body>();\nlet balls: Ball[] = [];\nlet ballSeq = 0;\nlet comet: Comet | null = null;\nlet cometAt = 0;\nlet round: RoundInfo | null = null;\nlet tick = 0;\nlet hosting = false;\nlet cheatResets = 0;\nlet controlResets = 0;\nlet hitsSeen = 0;\n\n/* ------------------------------------------------- this browser's plane */\nconst me = { x: W / 2, y: H / 2, a: 0, downUntil: 0, has: false, fold: 0, boostUntil: 0 };\n/** Offline (no shell) plays as a local seat 0. */\nconst mySeat = (): number | null => (net.offline ? 0 : net.seat);\n\n/* ------------------------------------------------------- replica view */\ninterface Drawn { slot: number; seat: number; x: number; y: number; a: number; score: number; down: boolean; safe: boolean; fold: number; boost: boolean }\nconst drawn = new Map<number, Drawn>();\nconst drawnBalls: { id: number; x: number; y: number }[] = [];\nlet firstStateAt = 0;\nlet firstSnapAt = 0;\nlet frames = 0;\n\n/* ---------------------------------------------------------------- spawns */\nfunction ringSpot(i: number, n: number): { x: number; y: number; a: number } {\n  const t = (i / Math.max(1, n)) * TAU;\n  return { x: W / 2 + Math.cos(t) * 330, y: H / 2 + Math.sin(t) * 220, a: mod(t + Math.PI / 2, TAU) };\n}\n/** Somewhere far from every plane still flying. */\nfunction safeSpot(except: number): { x: number; y: number; a: number } {\n  const now = net.now();\n  let best = { x: rnd(80, W - 80), y: rnd(80, H - 80), a: rnd(0, TAU) };\n  let bestD = -1;\n  for (let i = 0; i < 14; i += 1) {\n    const c = { x: rnd(80, W - 80), y: rnd(80, H - 80), a: rnd(0, TAU) };\n    let d = Infinity;\n    for (const b of bodies.values()) if (b.slot !== except && b.downUntil <= now) d = Math.min(d, wdist(b.x, b.y, c.x, c.y));\n    if (d > bestD) { bestD = d; best = c; }\n  }\n  return best;\n}\nfunction bodyFor(slot: Slot): Body {\n  const sp = ringSpot(slot.slot, MAX_SLOTS);\n  const fold = slot.bot ? slot.slot % FOLDS.length : 0; // bots mix the folds; people start as a Dart\n  return { slot: slot.slot, seat: slot.seat, name: slot.name, bot: slot.bot, x: sp.x, y: sp.y, a: sp.a, score: 0, downUntil: 0, safeUntil: 0, lastFire: 0, fold, nextFold: fold, boostUntil: 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 plane 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.a = b.a; me.downUntil = b.downUntil; me.fold = b.fold; me.boostUntil = b.boostUntil; me.has = true; return; }\n  net.reset(b.seat);\n}\n\nfunction startRound(n: number): void {\n  const now = net.now();\n  roster.trim();\n  syncBodiesFromRoster();\n  const list = [...bodies.values()].sort((a, c) => a.slot - c.slot);\n  list.forEach((b, i) => {\n    const sp = ringSpot(i, list.length);\n    b.x = sp.x; b.y = sp.y; b.a = sp.a; b.score = 0; b.downUntil = 0; b.safeUntil = now + SAFE_MS; b.lastFire = 0;\n    b.fold = b.nextFold; b.boostUntil = 0;\n    hostMoved(b);\n  });\n  balls = [];\n  comet = null;\n  cometAt = now + 4000;\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) return;\n  const now = net.now();\n  const ranked = [...bodies.values()].sort((a, b) => b.score - a.score || Number(a.bot) - Number(b.bot) || a.slot - b.slot);\n  const results: RoundResult[] = ranked.map((b, i) => ({ slot: b.slot, seat: b.seat, name: b.name, score: b.score, bot: b.bot, place: i + 1 }));\n  round = { n: round.n, phase: 'over', startedAt: now, endsAt: now + BREAK_MS, results };\n  balls = [];\n  comet = null;\n  net.round(round);\n}\n\n/* ---------------------------------------------------- becoming the host */\nfunction becomeHost(e: RoleChange<Snap, Ckpt>): void {\n  hosting = true;\n  if (e.promoted) restore(e);\n  else {\n    roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName });\n    bodies = new Map();\n    const seat = mySeat();\n    if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name);\n    syncBodiesFromRoster();\n    startRound((e.round?.n ?? 0) + 1);\n  }\n  // Whoever is connected now is who plays: seats that left during the gap become bots.\n  const peers = net.offline ? [{ seat: 0, name: 'You' }] : [...net.peers.values()].filter((p) => p.seat !== null);\n  const { claimed } = roster.reconcile(peers);\n  syncBodiesFromRoster();\n  for (const s of claimed) { const b = bodies.get(s.slot); if (b && b.seat !== mySeat()) hostMoved(b); }\n  // My own plane is wherever my local one 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; mine.a = me.a; me.fold = mine.fold; me.boostUntil = mine.boostUntil; me.downUntil = mine.downUntil; }\n  else if (mine) { me.x = mine.x; me.y = mine.y; me.a = mine.a; me.fold = mine.fold; me.boostUntil = mine.boostUntil; 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 });\n    bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));\n    balls = ck.balls.map((b) => ({ ...b }));\n    ballSeq = ck.ballSeq;\n    comet = ck.comet ? { ...ck.comet } : null;\n    cometAt = ck.cometAt ?? 0;\n    round = ck.round;\n    tick = ck.tick;\n  } else {\n    roster = Roster.from(e.roster ?? [], { min: MIN_SLOTS, max: MAX_SLOTS, botName });\n    bodies = new Map();\n    syncBodiesFromRoster();\n  }\n  // The snapshot is newer than the checkpoint (20 Hz vs 1 Hz): positions, headings and scores from it.\n  const s = e.snap;\n  if (s && (!e.ckpt || s.st >= e.ckpt.st)) {\n    const now = net.now();\n    for (const [slot, seat, x, y, a, score, down, , fold, boost, nextFold] 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.a = a; b.score = score;\n      b.downUntil = down ? now + CRUMPLE_MS / 2 : 0;\n      b.fold = fold ?? b.fold; b.nextFold = nextFold ?? b.nextFold;\n      b.boostUntil = boost ? now + BOOST_MS / 2 : 0;\n    }\n    const [cx, cy] = s.d.c ?? [];\n    comet = cx !== undefined && cy !== undefined ? { x: cx, y: cy } : comet;\n    balls = []; // in flight for under a second: let them go rather than guess their speed\n    ballSeq = Math.max(ballSeq, ...s.d.b.map((x) => x[0]), 0);\n    const [n, ph, startedAt, endsAt] = s.d.r;\n    round = { n, phase: ph ? 'over' : 'live', startedAt, endsAt, ...(round?.n === n && round.results ? { results: round.results } : {}) };\n    tick = Math.max(tick, s.k);\n  }\n  // A round message the relay kept is at least as authoritative as the checkpoint's copy.\n  if (e.round && (!round || e.round.n > round.n || (e.round.n === round.n && e.round.phase === 'over' && round.phase === 'live'))) round = e.round;\n  if (!round) { startRound(1); return; }\n  net.round(round);\n}\n\nfunction checkpoint(): Ckpt {\n  return {\n    round: round ?? { n: 0, phase: 'live', startedAt: 0, endsAt: 0 },\n    bodies: [...bodies.values()].map((b) => ({ ...b })),\n    balls: balls.map((b) => ({ ...b })),\n    roster: roster.toJSON(),\n    tick,\n    ballSeq,\n    comet: comet ? { ...comet } : null,\n    cometAt,\n  };\n}\n\nnet.on('role', (e) => {\n  if (e.role === 'host') becomeHost(e);\n  else { hosting = false; }\n});\nnet.on('join', (p) => {\n  if (!hosting || p.seat === null) return;\n  const c = roster.claim(p.seat, p.name);\n  if (!c) return; // full: they watch\n  syncBodiesFromRoster();\n  const b = bodies.get(c.slot.slot);\n  // The arriving human takes over the bot's plane where it flies, score and all.\n  if (b) {\n    if (b.downUntil > net.now()) { const sp = safeSpot(b.slot); b.x = sp.x; b.y = sp.y; b.a = sp.a; }\n    b.downUntil = 0; b.safeUntil = net.now() + SAFE_MS;\n    hostMoved(b);\n  }\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 plane stays, flown by a bot\n  syncBodiesFromRoster();\n  publishRoster();\n});\nnet.on('event', (e) => {\n  if (e.k === 'hit') addHit(e.d as Hit);\n  if (e.k === 'comet') addGrab(e.d as Hit);\n});\nnet.on('round', (r) => { round = hosting ? round : r; });\nnet.on('state', () => { if (!firstStateAt) firstStateAt = performance.now(); });\nnet.on('snapshot', () => { if (!firstSnapAt) firstSnapAt = performance.now(); });\n/** My plane's control changed: on a reset, fly from where the host put me (a new round, a takeover, an unfold). */\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.a = p[4]; me.fold = p[8] ?? me.fold; me.has = true; }\n});\n\n/* ----------------------------------------------------------------- input */\nconst keys = new Set<string>();\naddEventListener('keydown', (e) => {\n  if (['KeyA', 'KeyD', 'KeyW', 'KeyS', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) e.preventDefault();\n  if (e.code === 'KeyF' && !e.repeat) toggleFold();\n  keys.add(e.code);\n});\naddEventListener('keyup', (e) => keys.delete(e.code));\naddEventListener('blur', () => { keys.clear(); firing.clear(); });\n\nconst canvas = document.getElementById('c') as HTMLCanvasElement;\nconst ctx = canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D;\nconst coarse = matchMedia('(pointer: coarse)').matches;\nconst stick = { id: -1, ox: 0, oy: 0, x: 0, y: 0, active: false };\nconst firing = new Set<number>();\n/** Where the phone's two buttons sit: throw in the lower right, the fold switch just above it. */\nfunction buttons(): { fire: { x: number; y: number; r: number }; fold: { x: number; y: number; r: number } } {\n  const cw = innerWidth; const ch = innerHeight;\n  const pad = Math.min(cw, ch) <= 540 ? 18 : 28;\n  const fire = { x: cw - pad - 50, y: ch - pad - 50, r: 50 };\n  return { fire, fold: { x: fire.x, y: fire.y - fire.r - 46, r: 30 } };\n}\ncanvas.addEventListener('pointerdown', (e) => {\n  const f = buttons().fold;\n  if (coarse && mySeat() !== null && Math.hypot(e.clientX - f.x, e.clientY - f.y) < f.r + 8) { toggleFold(); return; }\n  // A mouse click throws. On touch: the right side throws, the left side is a stick.\n  if (e.pointerType === 'mouse' || e.clientX > innerWidth * 0.55 || stick.active) firing.add(e.pointerId);\n  else { 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 endPointer = (e: PointerEvent): void => {\n  firing.delete(e.pointerId);\n  if (e.pointerId === stick.id) { stick.active = false; stick.id = -1; }\n};\ncanvas.addEventListener('pointerup', endPointer);\ncanvas.addEventListener('pointercancel', endPointer);\n\n/** Turn: keys turn at the fold's steady rate; the stick points where the plane should head. */\nfunction steer(o: { a: number }, dt: number, turn: number): void {\n  let k = 0;\n  if (keys.has('KeyA') || keys.has('ArrowLeft')) k -= 1;\n  if (keys.has('KeyD') || keys.has('ArrowRight')) k += 1;\n  if (k !== 0) { o.a += k * turn * dt; return; }\n  if (stick.active) {\n    const dx = stick.x - stick.ox; const dy = stick.y - stick.oy;\n    if (Math.hypot(dx, dy) > 12) o.a += clamp(angDiff(o.a, Math.atan2(dy, dx)), -turn * dt, turn * dt);\n  }\n}\n/** The one flight rule: always forward, and the sky wraps around. */\nfunction fly(o: { x: number; y: number; a: number }, dt: number, speed: number): void {\n  o.a = mod(o.a, TAU);\n  o.x = mod(o.x + Math.cos(o.a) * speed * dt, W);\n  o.y = mod(o.y + Math.sin(o.a) * speed * dt, H);\n}\n\n/** A replica reads its own state (down, fold, boost) from the host's latest snapshot. */\nfunction myRow(): P | undefined { return net.latest()?.d.p.find((x) => x[1] === net.seat); }\nfunction myDown(): boolean {\n  if (hosting) return me.downUntil > net.now();\n  const p = myRow();\n  return !!p && p[6] === 1;\n}\nfunction myBoost(): boolean {\n  if (hosting) return me.boostUntil > net.now();\n  return myRow()?.[9] === 1;\n}\nfunction myNextFold(): number {\n  if (hosting) { const seat = mySeat(); return [...bodies.values()].find((b) => b.seat === seat)?.nextFold ?? me.fold; }\n  return myRow()?.[10] ?? me.fold;\n}\nfunction myFold(): number {\n  if (hosting) return me.fold;\n  return myRow()?.[8] ?? me.fold;\n}\n\n/** Choose the other fold: it takes effect at your next unfold, or at once between rounds. */\nfunction toggleFold(): void {\n  const seat = mySeat();\n  if (seat === null) return;\n  if (hosting) { const b = [...bodies.values()].find((x) => x.seat === seat); if (b) hostToggleFold(b); }\n  else net.press('fold');\n  sound.play('unfold', { volume: 0.5 });\n}\nfunction hostToggleFold(b: Body): void {\n  b.nextFold = (b.nextFold + 1) % FOLDS.length;\n  // Between rounds or while crumpled, the new fold is ready at once; mid-flight, it waits for the next unfold.\n  if (round?.phase === 'over' || b.downUntil > net.now()) { b.fold = b.nextFold; if (b.seat === mySeat()) me.fold = b.fold; }\n}\n\nlet lastLocalFire = -Infinity;\nfunction fire(): void {\n  const seat = mySeat();\n  if (seat === null || myDown()) return;\n  if (hosting) {\n    const b = [...bodies.values()].find((x) => x.seat === seat);\n    if (b) hostFire(b);\n  } else net.press('fire');\n}\n/** Host: throw a paper ball from the nose of `b`. Throwing ends the safe blink after an unfold. */\nfunction hostFire(b: Body): void {\n  const now = net.now();\n  if (!round || round.phase !== 'live' || b.downUntil > now || now - b.lastFire < FIRE_MS * 0.85) return;\n  b.lastFire = now;\n  b.safeUntil = 0;\n  const cx = Math.cos(b.a); const cy = Math.sin(b.a);\n  balls.push({ id: ++ballSeq, x: mod(b.x + cx * (R_PLANE + 6), W), y: mod(b.y + cy * (R_PLANE + 6), H), vx: cx * BALL_SPEED, vy: cy * BALL_SPEED, slot: b.slot, until: now + BALL_MS });\n}\n\n/**\n * Host: crumple a plane. The thrower scores; the plane unfolds at a safe spot after CRUMPLE_MS. A replica's plane is\n * TAKEN for the crumple (its owner's frames are ignored) and the helper GIVES it back with a reset at the new spot.\n */\nfunction crumple(b: Body, by: Body | null): void {\n  const now = net.now();\n  const hx = b.x; const hy = b.y;\n  if (by && by !== b) by.score += 1;\n  const sp = safeSpot(b.slot);\n  b.x = sp.x; b.y = sp.y; b.a = sp.a;\n  b.downUntil = now + CRUMPLE_MS;\n  b.safeUntil = b.downUntil + SAFE_MS;\n  b.fold = b.nextFold; // a chosen fold is put on at the unfold\n  b.boostUntil = 0;\n  if (!b.bot && b.seat !== null) {\n    if (b.seat === mySeat()) { me.x = sp.x; me.y = sp.y; me.a = sp.a; me.downUntil = b.downUntil; me.fold = b.fold; me.boostUntil = 0; }\n    else net.take(b.seat, CRUMPLE_MS);\n  }\n  const ev: Hit = { slot: b.slot, by: by?.slot ?? -1, x: q(hx, 0), y: q(hy, 0) };\n  addHit(ev);\n  net.send('hit', ev);\n}\n\n/** Host: a comet appears now and then, far from everyone; the first plane to touch it is boosted for BOOST_MS. */\nfunction stepComet(now: number): void {\n  if (!comet) {\n    if (now >= cometAt) { const sp = safeSpot(-1); comet = { x: sp.x, y: sp.y }; }\n    return;\n  }\n  for (const b of bodies.values()) {\n    if (b.downUntil > now || wdist(b.x, b.y, comet.x, comet.y) > R_PLANE + R_COMET) continue;\n    b.boostUntil = now + BOOST_MS;\n    if (b.seat !== null && b.seat === mySeat()) me.boostUntil = b.boostUntil;\n    const ev: Hit = { slot: b.slot, by: b.slot, x: q(comet.x, 0), y: q(comet.y, 0) };\n    addGrab(ev);\n    net.send('comet', ev);\n    comet = null;\n    cometAt = now + COMET_EVERY_MS;\n    return;\n  }\n}\n\n/* ---------------------------------------------------------------- simulate */\nfunction stepMe(dt: number): void {\n  if (mySeat() === null) { me.has = false; return; }\n  if (!net.owned || myDown()) return;\n  const f = foldOf(myFold());\n  steer(me, dt, f.turn);\n  fly(me, dt, f.speed * (myBoost() ? BOOST : 1));\n}\n\nfunction stepBots(dt: number, now: number): void {\n  for (const b of bodies.values()) {\n    if (!b.bot || b.downUntil > now) continue;\n    let tgt: Body | null = null; let td = Infinity;\n    for (const o of bodies.values()) {\n      if (o === b || o.downUntil > now) continue;\n      const d = wdist(b.x, b.y, o.x, o.y);\n      if (d < td) { td = d; tgt = o; }\n    }\n    const toTarget = tgt ? Math.atan2(wrapD(tgt.y - b.y, H), wrapD(tgt.x - b.x, W)) : b.a + 0.4;\n    // A comet close by is worth a detour.\n    const goComet = !!comet && wdist(b.x, b.y, comet.x, comet.y) < 280;\n    const aim = goComet && comet ? Math.atan2(wrapD(comet.y - b.y, H), wrapD(comet.x - b.x, W)) : toTarget;\n    const wobble = Math.sin(now / 650 + b.slot * 1.7) * 0.45; // beatable on purpose\n    const f = foldOf(b.fold);\n    b.a += clamp(angDiff(b.a, aim + wobble), -f.turn * 0.75 * dt, f.turn * 0.75 * dt);\n    fly(b, dt, f.speed * 0.92 * (b.boostUntil > now ? BOOST : 1));\n    if (tgt && td < 520 && Math.abs(angDiff(b.a, toTarget)) < 0.3 && now - b.lastFire > BOT_FIRE_MS) hostFire(b);\n  }\n}\n\nfunction stepBalls(dt: number, now: number): void {\n  for (let i = balls.length - 1; i >= 0; i -= 1) {\n    const ball = balls[i] as Ball;\n    if (now >= ball.until) { balls.splice(i, 1); continue; }\n    ball.x = mod(ball.x + ball.vx * dt, W);\n    ball.y = mod(ball.y + ball.vy * dt, H);\n    for (const b of bodies.values()) {\n      if (b.slot === ball.slot || b.downUntil > now || b.safeUntil > now) continue;\n      if (wdist(b.x, b.y, ball.x, ball.y) < R_PLANE + R_BALL) {\n        balls.splice(i, 1);\n        crumple(b, bodies.get(ball.slot) ?? null);\n        break;\n      }\n    }\n  }\n}\n\nfunction stepHost(dt: number): void {\n  tick += 1;\n  const seat = mySeat();\n  const now = net.now();\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.a = me.a; continue; }\n    if (b.seat === null) continue;\n    const presses = net.takePresses(b.seat);\n    if (presses['fold']) hostToggleFold(b);\n    if (net.control(b.seat).taken || b.downUntil > now) continue; // crumpled: the host holds the pen\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 flight, bounded: to 1.3x the fastest fold per frame (more while boosted; wrap-aware). A real\n      // plane catches up within a frame; a teleport crawls, and a claim more than half a second ahead is reset.\n      const top = MAX_SPEED * (b.boostUntil > now ? BOOST : 1);\n      const cx = mod(Number(a[0]) || 0, W); const cy = mod(Number(a[1]) || 0, H);\n      const d = wdist(b.x, b.y, cx, cy);\n      const allow = top * 1.3 * dt + 6;\n      if (d <= allow) { b.x = cx; b.y = cy; }\n      else if (d - allow > top * 0.5) { net.reset(b.seat); cheatResets += 1; }\n      else { const t = allow / d; b.x = lerpW(b.x, cx, t, W); b.y = lerpW(b.y, cy, t, H); }\n      b.a = mod(Number(a[2]) || 0, TAU);\n    }\n    if (presses['fire']) hostFire(b);\n  }\n  stepBots(dt, now);\n  if (round && round.phase === 'live') {\n    stepBalls(dt, now);\n    stepComet(now);\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  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), q(mod(b.a, TAU), 2), b.score, b.downUntil > now ? 1 : 0, b.safeUntil > now ? 1 : 0, b.fold, b.boostUntil > now ? 1 : 0, b.nextFold] as P),\n    b: balls.map((x) => [x.id, q(x.x, 0), q(x.y, 0), x.slot] as B),\n    c: comet ? [q(comet.x, 0), q(comet.y, 0)] : [],\n  };\n}\n\nfunction stepReplica(): void {\n  // Only once I have a plane (the first control event adopts it): before that my position is nobody's.\n  if (net.seat !== null && me.has) net.input([q(me.x, 1), q(me.y, 1), q(mod(me.a, TAU), 2)], []);\n  const smp = net.sample();\n  drawn.clear();\n  drawnBalls.length = 0;\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    const t = wdist(pa[2], pa[3], pb[2], pb[3]) > 120 ? 1 : smp.alpha; // an unfold jumps, it never slides\n    drawn.set(pb[0], { slot: pb[0], seat: pb[1], x: lerpW(pa[2], pb[2], t, W), y: lerpW(pa[3], pb[3], t, H), a: lerpA(pa[4], pb[4], t), score: pb[5], down: pb[6] === 1, safe: pb[7] === 1, fold: pb[8] ?? 0, boost: pb[9] === 1 });\n  }\n  const ballA = new Map<number, B>(smp.a.d.b.map((x) => [x[0], x]));\n  for (const bb of smp.b.d.b) {\n    const ba = ballA.get(bb[0]) ?? bb;\n    drawnBalls.push({ id: bb[0], x: lerpW(ba[1], bb[1], smp.alpha, W), y: lerpW(ba[2], bb[2], smp.alpha, H) });\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/* --------------------------------------------------------------- effects */\ninterface Bit { vx: number; vy: number; r: number; vr: number; c: string }\nconst effects: { x: number; y: number; at: number; colour: string; id: number; bits: Bit[] }[] = [];\nfunction mySlot(): number | null {\n  const seat = mySeat();\n  if (seat === null) return null;\n  if (hosting) return [...bodies.values()].find((b) => b.seat === seat)?.slot ?? null;\n  return [...drawn.values()].find((d) => d.seat === seat)?.slot ?? null;\n}\nfunction addHit(h: Hit): void {\n  hitsSeen += 1;\n  const colour = colourOf(h.slot);\n  const other = h.by >= 0 ? colourOf(h.by) : CREAM;\n  const bits = Array.from({ length: 16 }, (_, i): Bit => {\n    const a = rnd(0, TAU); const s = rnd(70, 280);\n    return { vx: Math.cos(a) * s, vy: Math.sin(a) * s, r: rnd(0, TAU), vr: rnd(-9, 9), c: i % 3 === 0 ? CREAM : i % 3 === 1 ? colour : other };\n  });\n  effects.push({ x: h.x, y: h.y, at: performance.now(), colour, id: hitsSeen, bits });\n  sound.play('crumple', { pan: panOf(h.x) });\n  sound.duck(0.5, 0.35);\n  if (mySlot() === h.slot) { try { navigator.vibrate?.(90); } catch { /* not every phone */ } }\n}\nconst grabs: { x: number; y: number; at: number }[] = [];\nfunction addGrab(h: Hit): void {\n  grabs.push({ x: h.x, y: h.y, at: performance.now() });\n  sound.play('boost', { pan: panOf(h.x), volume: mySlot() === h.slot ? 1 : 0.6 });\n  if (mySlot() === h.slot) { try { navigator.vibrate?.([30, 40, 30]); } catch { /* not every phone */ } }\n}\n\n/* ----------------------------------------------------------------- sound */\nconst panOf = (x: number): number => clamp((x / W) * 2 - 1, -1, 1) * 0.6;\nconst ballsHeard = new Set<number>();\nconst downBefore = new Map<number, boolean>();\nconst heard = { liveRound: -1, overRound: -1, finalRound: -1, tickAt: -1 };\n/**\n * Every browser plays what it sees: a ball that appears, a plane that unfolds, the round's start, its last 15 s\n * and its end. Read from what is drawn, so the host and every replica hear the same game.\n */\nfunction soundFrame(planes: Shown[], ballList: { id: number; x: number }[], now: number): void {\n  for (const b of ballList) if (!ballsHeard.has(b.id)) { ballsHeard.add(b.id); sound.play('throw', { pan: panOf(b.x), volume: 0.8 }); }\n  if (ballsHeard.size > 400) { const keep = new Set(ballList.map((b) => b.id)); for (const id of ballsHeard) if (!keep.has(id)) ballsHeard.delete(id); }\n  for (const p of planes) {\n    if (downBefore.get(p.slot) && !p.down) sound.play('unfold', { pan: panOf(p.x), volume: p.mine ? 1 : 0.6 });\n    downBefore.set(p.slot, p.down);\n  }\n  const r = round;\n  if (!r) return;\n  if (r.phase === 'live') {\n    if (heard.liveRound !== r.n) { heard.liveRound = r.n; sound.play('go'); sound.section('main'); }\n    const left = Math.ceil((r.endsAt - now) / 1000);\n    if (left <= 15 && left > 0 && heard.finalRound !== r.n) { heard.finalRound = r.n; sound.section('final'); }\n    if (left <= 5 && left > 0 && heard.tickAt !== left) { heard.tickAt = left; sound.play('tick'); }\n  } else if (heard.overRound !== r.n) { heard.overRound = r.n; sound.play('win'); sound.section('main'); }\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 grain = (() => {\n  const c = document.createElement('canvas'); c.width = 160; c.height = 160;\n  const g = c.getContext('2d') as CanvasRenderingContext2D;\n  const r = seeded(7);\n  for (let i = 0; i < 1400; i += 1) { g.fillStyle = r() < 0.5 ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.08)'; g.fillRect(r() * 160, r() * 160, 1 + r() * 1.5, 1 + r() * 1.5); }\n  return ctx.createPattern(c, 'repeat');\n})();\nconst STARS = (() => { const r = seeded(42); return Array.from({ length: 34 }, () => ({ x: r() * W, y: r() * H, s: 3 + r() * 5, rot: r() * TAU, a: 0.2 + r() * 0.35 })); })();\nconst CLOUDS = [\n  { y: 140, x0: 80, v: 14, k: 1.1 },\n  { y: 400, x0: 760, v: -10, k: 1.3 },\n  { y: 620, x0: 300, v: 12, k: 0.9 },\n  { y: 270, x0: 1050, v: -16, k: 1.0 },\n];\nconst BLOBS: [number, number, number][] = [[-62, 12, 40], [-20, -16, 52], [34, -8, 48], [76, 14, 36], [8, 20, 46]];\n\nfunction shade(hex: string, f: number): string {\n  const n = parseInt(hex.slice(1), 16);\n  const m = (v: number): number => Math.round(clamp(v * f, 0, 255));\n  return `rgb(${m((n >> 16) & 255)},${m((n >> 8) & 255)},${m(n & 255)})`;\n}\n/** Draw at (x, y), and again across an edge the shape hangs over. */\nfunction wrapped(x: number, y: number, s: number, fn: (x: number, y: number) => void): void {\n  const xs = [x]; if (x < s) xs.push(x + W); else if (x > W - s) xs.push(x - W);\n  const ys = [y]; if (y < s) ys.push(y + H); else if (y > H - s) ys.push(y - H);\n  for (const X of xs) for (const Y of ys) fn(X, Y);\n}\n\nfunction drawPlane(x: number, y: number, a: number, s: number, colour: string, alpha: number, fold = 0, boost = false, t = 0): void {\n  ctx.save(); ctx.translate(x, y); ctx.rotate(a); ctx.globalAlpha = alpha;\n  // The Dart is long and narrow; the Glider is short with broad wings.\n  const glider = fold === 1;\n  const nose = s * (glider ? 1.05 : 1.4); const tipX = -s * (glider ? 0.8 : 1); const tipY = s * (glider ? 1.3 : 0.85); const notch = -s * (glider ? 0.3 : 0.5);\n  if (boost) {\n    // paper streamers stream off the tail while boosted\n    for (let i = 0; i < 3; i += 1) {\n      const wob = Math.sin(t / 60 + i * 2) * s * 0.18;\n      ctx.fillStyle = i === 1 ? '#ffd166' : 'rgba(255,209,102,0.55)';\n      ctx.fillRect(notch - s * (1.1 + i * 0.25), (i - 1) * s * 0.35 + wob - s * 0.06, s * (0.9 - i * 0.15), s * 0.12);\n    }\n  }\n  // Its shadow falls down-right on the sky, whichever way the plane points.\n  const so = s * 0.3; const sx = Math.cos(-a) * so - Math.sin(-a) * so; const sy = Math.sin(-a) * so + Math.cos(-a) * so;\n  ctx.fillStyle = 'rgba(8,10,28,0.4)';\n  ctx.beginPath(); ctx.moveTo(nose + sx, sy); ctx.lineTo(tipX + sx, -tipY + sy); ctx.lineTo(notch + sx, sy); ctx.lineTo(tipX + sx, tipY + sy); ctx.closePath(); ctx.fill();\n  ctx.fillStyle = colour;\n  ctx.beginPath(); ctx.moveTo(nose, 0); ctx.lineTo(tipX, -tipY); ctx.lineTo(notch, 0); ctx.closePath(); ctx.fill();\n  ctx.fillStyle = shade(colour, 0.78);\n  ctx.beginPath(); ctx.moveTo(nose, 0); ctx.lineTo(notch, 0); ctx.lineTo(tipX, tipY); ctx.closePath(); ctx.fill();\n  ctx.fillStyle = shade(colour, 0.6); // the keel under the centre fold\n  ctx.beginPath(); ctx.moveTo(nose, 0); ctx.lineTo(notch, 0); ctx.lineTo(notch - s * 0.15, s * 0.18); ctx.closePath(); ctx.fill();\n  ctx.strokeStyle = 'rgba(255,255,255,0.45)'; ctx.lineWidth = Math.max(1, s * 0.06);\n  ctx.beginPath(); ctx.moveTo(nose, 0); ctx.lineTo(tipX, -tipY); ctx.stroke();\n  ctx.restore();\n}\n\nfunction drawBall(x: number, y: number, id: number, s: number, colour: string, spin = 0): void {\n  const r = seeded(id * 7919 + 1);\n  const pts: [number, number][] = [];\n  for (let i = 0; i < 8; i += 1) { const t = (i / 8) * TAU + spin; const rr = s * (0.78 + r() * 0.4); pts.push([Math.cos(t) * rr, Math.sin(t) * rr]); }\n  const path = (ox: number, oy: number): void => {\n    ctx.beginPath();\n    pts.forEach(([px, py], i) => (i ? ctx.lineTo(x + px + ox, y + py + oy) : ctx.moveTo(x + px + ox, y + py + oy)));\n    ctx.closePath();\n  };\n  path(s * 0.3, s * 0.35); ctx.fillStyle = 'rgba(8,10,28,0.35)'; ctx.fill();\n  path(0, 0); ctx.fillStyle = colour; ctx.fill();\n  ctx.strokeStyle = 'rgba(29,35,64,0.38)'; ctx.lineWidth = Math.max(1, s * 0.12);\n  ctx.beginPath();\n  for (let i = 0; i < 3; i += 1) {\n    const a0 = r() * TAU + spin;\n    ctx.moveTo(x + Math.cos(a0) * s * 0.6, y + Math.sin(a0) * s * 0.6);\n    ctx.lineTo(x + Math.cos(a0 + 2.2) * s * 0.25, y + Math.sin(a0 + 2.2) * s * 0.25);\n  }\n  ctx.stroke();\n}\n\nfunction drawStar(x: number, y: number, s: number, rot: number, alpha: number): void {\n  ctx.save(); ctx.translate(x, y); ctx.rotate(rot); ctx.globalAlpha = alpha; ctx.fillStyle = CREAM;\n  ctx.beginPath();\n  for (let i = 0; i < 8; i += 1) { const r = i % 2 ? s * 0.35 : s; const t = (i / 8) * TAU; ctx.lineTo(Math.cos(t) * r, Math.sin(t) * r); }\n  ctx.closePath(); ctx.fill(); ctx.restore();\n}\n\n/** The comet: a cut-paper star with a torn paper tail, bobbing so it catches the eye. */\nfunction drawComet(x: number, y: number, s: number, t: number): void {\n  const bob = Math.sin(t / 260) * s * 0.18;\n  ctx.save(); ctx.translate(x, y + bob);\n  // tail first, behind the star\n  const tails: [string, number][] = [['rgba(255,122,89,0.75)', 0.38], ['rgba(255,209,102,0.9)', 0], ['rgba(246,239,224,0.7)', -0.38]];\n  tails.forEach(([c, off], i) => {\n    ctx.save(); ctx.rotate(-2.4 + off * 0.5 + Math.sin(t / 200 + i) * 0.06);\n    ctx.fillStyle = c;\n    ctx.beginPath(); ctx.moveTo(s * 0.4, -s * 0.22); ctx.lineTo(s * (2.6 - i * 0.3), -s * 0.08); ctx.lineTo(s * (2.4 - i * 0.3), s * 0.1); ctx.lineTo(s * 0.4, s * 0.22); ctx.closePath(); ctx.fill();\n    ctx.restore();\n  });\n  // a soft halo so it reads on a phone\n  ctx.globalAlpha = 0.25 + 0.15 * Math.sin(t / 180); ctx.fillStyle = '#ffd166';\n  ctx.beginPath(); ctx.arc(0, 0, s * 1.6, 0, TAU); ctx.fill(); ctx.globalAlpha = 1;\n  // the star, with its paper shadow\n  const star = (ox: number, oy: number): void => {\n    ctx.beginPath();\n    for (let i = 0; i < 10; i += 1) { const r = i % 2 ? s * 0.45 : s; const a = (i / 10) * TAU - Math.PI / 2 + t / 900; ctx.lineTo(ox + Math.cos(a) * r, oy + Math.sin(a) * r); }\n    ctx.closePath();\n  };\n  star(s * 0.2, s * 0.25); ctx.fillStyle = 'rgba(8,10,28,0.4)'; ctx.fill();\n  star(0, 0); ctx.fillStyle = '#ffd166'; ctx.fill();\n  ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = Math.max(1, s * 0.08); ctx.stroke();\n  ctx.restore();\n}\n\nfunction drawCloud(cx: number, cy: number, k: number): void {\n  const path = (ox: number, oy: number): Path2D => {\n    const p = new Path2D();\n    for (const [bx, by, br] of BLOBS) { const X = cx + bx * k + ox; const Y = cy + by * k + oy; p.moveTo(X + br * k, Y); p.arc(X, Y, br * k, 0, TAU); }\n    return p;\n  };\n  ctx.fillStyle = 'rgba(8,10,28,0.32)'; ctx.fill(path(10, 12));\n  ctx.fillStyle = CREAM; ctx.fill(path(0, 0));\n}\n\ninterface Shown { slot: number; seat: number | null; x: number; y: number; a: number; bot: boolean; name: string; mine: boolean; down: boolean; safe: boolean; score: number; fold: number; boost: boolean }\nfunction shownPlanes(now: number): Shown[] {\n  const list: Shown[] = [];\n  if (hosting) {\n    for (const b of bodies.values()) {\n      const mine = b.seat !== null && b.seat === mySeat();\n      list.push({ slot: b.slot, seat: b.seat, x: mine ? me.x : b.x, y: mine ? me.y : b.y, a: mine ? me.a : b.a, bot: b.bot, name: b.name, mine, down: b.downUntil > now, safe: b.safeUntil > now, score: b.score, fold: b.fold, boost: b.boostUntil > now });\n    }\n    return list;\n  }\n  const names = new Map<number, Slot>((net.slots ?? []).map((s) => [s.slot, s]));\n  for (const d of drawn.values()) {\n    const mine = d.seat >= 0 && d.seat === net.seat;\n    const own = mine && me.has;\n    list.push({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, x: own ? me.x : d.x, y: own ? me.y : d.y, a: own ? me.a : d.a, bot: d.seat < 0, name: names.get(d.slot)?.name ?? (d.seat >= 0 ? `Player ${d.seat + 1}` : botName(d.slot)), mine, down: d.down, safe: d.safe, score: d.score, fold: d.fold, boost: d.boost });\n  }\n  return list;\n}\n\nfunction draw(t: number): void {\n  const cw = innerWidth; const ch = innerHeight;\n  const now = net.now();\n  const scale = Math.min(cw / (W + 24), ch / (H + 24));\n  const ox = (cw - W * scale) / 2; const oy = (ch - H * scale) / 2;\n  const S = Math.max(R_PLANE, 15 / scale); // never smaller than about 15 px on any screen\n  const SB = Math.max(R_BALL, 6 / scale);\n  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n  ctx.fillStyle = '#12162c'; ctx.fillRect(0, 0, cw, ch);\n  ctx.save();\n  ctx.translate(ox, oy); ctx.scale(scale, scale);\n  ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.fillRect(8, 10, W, H); // the sheet's own shadow\n  ctx.fillStyle = INK; ctx.fillRect(0, 0, W, H);\n  ctx.save();\n  ctx.beginPath(); ctx.rect(0, 0, W, H); ctx.clip();\n  if (grain) { ctx.fillStyle = grain; ctx.fillRect(0, 0, W, H); }\n  for (const s of STARS) drawStar(s.x, s.y, s.s, s.rot, s.a * (0.75 + 0.25 * Math.sin(t / 900 + s.x)));\n\n  // paper balls\n  const ballList = hosting ? balls.map((b) => ({ id: b.id, x: b.x, y: b.y })) : drawnBalls;\n  for (const b of ballList) wrapped(b.x, b.y, SB * 1.5, (x, y) => drawBall(x, y, b.id, SB, CREAM, t / 120));\n\n  // the comet, if one is out\n  const c = hosting ? (comet ? [comet.x, comet.y] : []) : (net.latest()?.d.c ?? []);\n  if (c.length === 2) wrapped(c[0] as number, c[1] as number, S * 2, (x, y) => drawComet(x, y, Math.max(R_COMET, 13 / scale), t));\n\n  // planes\n  const planes = shownPlanes(now);\n  soundFrame(planes, ballList, now);\n  for (const p of planes) {\n    if (p.down) continue;\n    const alpha = p.safe ? (Math.floor(t / 120) % 2 ? 0.35 : 0.9) : 1;\n    wrapped(p.x, p.y, S * 1.6, (x, y) => drawPlane(x, y, p.a, S, colourOf(p.slot), alpha, p.fold, p.boost, t));\n  }\n\n  // a comet grabbed: a ring of paper stars bursts out\n  for (let i = grabs.length - 1; i >= 0; i -= 1) {\n    const g = grabs[i] as (typeof grabs)[number];\n    const age = (t - g.at) / 700;\n    if (age > 1) { grabs.splice(i, 1); continue; }\n    for (let k = 0; k < 8; k += 1) {\n      const a = (k / 8) * TAU;\n      drawStar(g.x + Math.cos(a) * S * (0.6 + age * 3), g.y + Math.sin(a) * S * (0.6 + age * 3), S * 0.35 * (1 - age * 0.5), a + age * 4, 1 - age);\n    }\n  }\n\n  // crumples: the plane balls up and paper confetti flies\n  for (let i = effects.length - 1; i >= 0; i -= 1) {\n    const e = effects[i] as (typeof effects)[number];\n    const age = (t - e.at) / 1000;\n    if (age > 1.2) { effects.splice(i, 1); continue; }\n    if (age < 0.7) { ctx.globalAlpha = 1 - age / 0.7; drawBall(e.x, e.y + age * 40, e.id + 99, S * (0.9 - age * 0.4), e.colour, age * 6); }\n    for (const b of e.bits) {\n      const k = age * (1 - age * 0.35);\n      ctx.save(); ctx.globalAlpha = Math.max(0, 1 - age / 1.2);\n      ctx.translate(e.x + b.vx * k, e.y + b.vy * k + age * age * 120); ctx.rotate(b.r + b.vr * age);\n      ctx.fillStyle = b.c; ctx.fillRect(-S * 0.14, -S * 0.08, S * 0.28, S * 0.16);\n      ctx.restore();\n    }\n    ctx.globalAlpha = 1;\n  }\n\n  // drifting paper clouds: a plane can hide under one\n  for (const c of CLOUDS) drawCloud(mod(c.x0 + (c.v * now) / 1000, W + 400) - 200, c.y, c.k);\n\n  // labels, and a ring round my own plane (drawn over the clouds, so I never lose myself)\n  ctx.textAlign = 'center'; ctx.font = `${Math.round(15 / scale)}px ${FONT}`;\n  for (const p of planes) {\n    if (p.down) continue;\n    if (p.mine) {\n      ctx.strokeStyle = 'rgba(246,239,224,0.85)'; ctx.lineWidth = 2.5 / scale; ctx.setLineDash([6 / scale, 5 / scale]);\n      ctx.beginPath(); ctx.arc(p.x, p.y, S * 1.8, 0, TAU); ctx.stroke(); ctx.setLineDash([]);\n    }\n    ctx.fillStyle = p.mine ? CREAM : 'rgba(246,239,224,0.7)';\n    ctx.fillText(p.mine ? 'You' : label(p.name, p.bot), p.x, p.y - S * 1.9);\n  }\n  ctx.restore();\n  ctx.restore();\n\n  hud(cw, ch, planes, now);\n  if (coarse && mySeat() !== null) touchControls();\n}\n\nfunction paperTag(x: number, y: number, text: string, size: number, rot: number, bg = CREAM, fg = INK): number {\n  ctx.save(); ctx.font = `${size}px ${FONT}`;\n  const w = ctx.measureText(text).width + size * 0.9; const h = size * 1.45;\n  ctx.translate(x, y); ctx.rotate(rot);\n  ctx.fillStyle = 'rgba(0,0,0,0.3)'; ctx.fillRect(3, 4, w, h);\n  ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);\n  ctx.fillStyle = fg; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(text, size * 0.45, h / 2 + 1);\n  ctx.restore();\n  return h;\n}\n\nfunction hud(cw: number, ch: number, planes: Shown[], now: number): void {\n  const phone = Math.min(cw, ch) <= 540;\n  const pad = phone ? 12 : 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 r = round;\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 text = r ? (r.phase === 'live' ? `Round ${r.n}  ·  ${clock}` : `Next round in ${left}`) : 'Joining…';\n  const hot = r && r.phase === 'live' && left <= 15;\n  const th = paperTag(pad, top, text, phone ? 20 : 26, -0.025, hot ? '#ffd166' : CREAM);\n\n  // the scoreboard, under the clock (the room button lives top right)\n  const rows = [...planes].sort((a, b) => b.score - a.score || a.slot - b.slot).slice(0, phone ? 4 : 6);\n  const fs = phone ? 15 : 18; const lh = fs * 1.35;\n  ctx.font = `${fs}px ${FONT}`; ctx.textAlign = 'left'; ctx.textBaseline = 'middle';\n  rows.forEach((p, i) => {\n    const y = top + th + 16 + i * lh;\n    ctx.fillStyle = colourOf(p.slot);\n    ctx.beginPath(); ctx.moveTo(pad + 16, y); ctx.lineTo(pad, y - 6); ctx.lineTo(pad + 4, y); ctx.lineTo(pad, y + 6); ctx.closePath(); ctx.fill();\n    ctx.fillStyle = p.mine ? CREAM : p.bot ? 'rgba(246,239,224,0.6)' : 'rgba(246,239,224,0.88)';\n    ctx.fillText(`${p.score}  ${p.mine ? 'You' : label(p.name, p.bot)}`, pad + 24, y);\n  });\n  ctx.textBaseline = 'alphabetic';\n\n  // my fold, and the one I've picked for my next unfold\n  if (mySeat() !== null) {\n    const cur = foldOf(myFold()).name; const next = foldOf(myNextFold()).name;\n    const label2 = next !== cur ? `${cur}  →  ${next} next` : coarse ? `${cur}` : `${cur}  ·  F to switch`;\n    paperTag(pad, top + th + 26 + rows.length * lh, label2, phone ? 14 : 16, 0.02, myBoost() ? '#ffd166' : 'rgba(246,239,224,0.92)');\n  }\n\n  if (r && r.phase === 'over' && r.results) {\n    const rs = r.results.slice(0, 6);\n    const w = Math.min(380, cw - 40); const h = 76 + rs.length * 30;\n    ctx.save(); ctx.translate(cw / 2, ch / 2); ctx.rotate(-0.02);\n    ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.fillRect(-w / 2 + 6, -h / 2 + 8, w, h);\n    ctx.fillStyle = CREAM; ctx.fillRect(-w / 2, -h / 2, w, h);\n    const win = rs[0];\n    const winMine = !!win && win.seat !== null && win.seat === mySeat();\n    ctx.textAlign = 'center'; ctx.fillStyle = INK; ctx.font = `30px ${FONT}`;\n    ctx.fillText(win ? `${winMine ? 'You win!' : `${label(win.name, win.bot)} wins!`}` : `Round ${r.n}`, 0, -h / 2 + 42);\n    ctx.font = `20px ${FONT}`;\n    rs.forEach((row, i) => {\n      const mine = row.seat !== null && row.seat === mySeat();\n      ctx.fillStyle = mine ? '#c2410c' : row.bot ? 'rgba(29,35,64,0.6)' : INK;\n      ctx.fillText(`${row.place}. ${mine ? 'You' : label(row.name, row.bot)}  —  ${row.score} ${row.score === 1 ? 'hit' : 'hits'}`, 0, -h / 2 + 78 + i * 30);\n    });\n    ctx.restore();\n  }\n  if (!net.offline && !net.connected && net.role !== 'host') {\n    ctx.textAlign = 'center'; ctx.fillStyle = '#ffd166'; ctx.font = `16px ${FONT}`;\n    ctx.fillText('Reconnecting…', cw / 2, ch - pad);\n  }\n  if (frames < 300 && mySeat() !== null) {\n    ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(246,239,224,0.8)'; ctx.font = `${phone ? 16 : 18}px ${FONT}`;\n    ctx.fillText(coarse ? 'Left thumb steers  ·  right thumb throws' : 'A / D or ← → to turn  ·  Space to throw', cw / 2, ch - pad - 18);\n  }\n}\n\nfunction touchControls(): void {\n  const { fire, fold } = buttons();\n  ctx.globalAlpha = firing.size ? 0.55 : 0.3;\n  ctx.fillStyle = CREAM; ctx.beginPath(); ctx.arc(fire.x, fire.y, fire.r, 0, TAU); ctx.fill();\n  ctx.globalAlpha = 1;\n  drawBall(fire.x, fire.y, 3, 18, CREAM);\n  // the fold switch: a tiny plane of the fold you'll fly next\n  ctx.globalAlpha = 0.3; ctx.fillStyle = CREAM; ctx.beginPath(); ctx.arc(fold.x, fold.y, fold.r, 0, TAU); ctx.fill(); ctx.globalAlpha = 1;\n  drawPlane(fold.x + 3, fold.y, -Math.PI / 2, 11, CREAM, 0.95, myNextFold());\n  if (stick.active) {\n    ctx.strokeStyle = 'rgba(246,239,224,0.4)'; ctx.lineWidth = 3;\n    ctx.beginPath(); ctx.arc(stick.ox, stick.oy, 56, 0, TAU); 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(246,239,224,0.6)'; ctx.beginPath(); ctx.arc(stick.ox + dx * m, stick.oy + dy * m, 24, 0, TAU); ctx.fill();\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  if ((keys.has('Space') || firing.size > 0) && t - lastLocalFire >= FIRE_MS) { lastLocalFire = t; fire(); }\n  stepMe(dt);\n  if (hosting) stepHost(dt);\n  else stepReplica();\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  hits: () => hitsSeen,\n  owned: () => net.owned,\n  cheatResets: () => cheatResets,\n  movement: () => 'owner',\n  controlResets: () => controlResets,\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): crumple the plane of `seat`. */\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    crumple(b, null);\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_PLANE,\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.a = b.a; me.has = true; }\n  }\n});\nrequestAnimationFrame(frame);\n","src/sound.d.ts":"// Types for sound.js (the sound skill's player). Written by hand: the toolkit's own copy was missing.\nexport interface PlayOptions { pitch?: number; volume?: number; pan?: number }\nexport interface Sound {\n  play(name: string, opts?: PlayOptions): void;\n  music(slug: string): void;\n  section(name: string): void;\n  duck(level: number, seconds: number): void;\n  volume(levels: { sfx?: number; music?: number; master?: number }): void;\n  mute(on: boolean): void;\n  ready: Promise<unknown>;\n}\nexport function createSound(opts?: { 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"}}
