{"v":1,"kind":"homie-game-source","id":"cinder-circuit","credit":{"studio":"Ember Kites","game":"Cinder Circuit","page":"https://ember-kites.ryan-d2f.workers.dev/cinder-circuit/"},"license":{"kind":"remix-with-credit","spdx":null},"files":{"game.json":"{\n  \"id\": \"cinder-circuit\",\n  \"name\": \"Cinder Circuit\",\n  \"blurb\": \"Ride the changing wind, catch glowing cinders and dash past rival kites. A sixty-second multiplayer sky scramble.\",\n  \"players\": {\n    \"min\": 1,\n    \"max\": 8\n  },\n  \"roundSeconds\": 60,\n  \"entry\": \"src/main.ts\",\n  \"netplay\": {\n    \"v\": 1,\n    \"public\": true,\n    \"movement\": \"owner\"\n  },\n  \"landing\": {\n    \"pitch\": \"Catch the sparks. Ride the changing wind.\",\n    \"about\": \"A dusk festival with a competitive streak. Steer a ribbon-tailed kite through glowing cinders, chase the double-score updraft, and use a gust dash to scatter your rivals. The wind turns every ten seconds. One minute. One sky. Another round.\",\n    \"howToPlay\": [\n      \"Catch golden cinders to score.\",\n      \"Cinders inside the glowing updraft count double.\",\n      \"Dash near rivals to scatter their sparks; recharge for 1.6 seconds.\",\n      \"Watch the wind change every ten seconds. Most cinders at the bell wins.\"\n    ],\n    \"controls\": {\n      \"phone\": \"Drag to fly. Tap DASH with your other thumb.\",\n      \"computer\": \"WASD or arrow keys to fly. Space to dash.\"\n    },\n    \"players\": {\n      \"one\": \"pilot\",\n      \"many\": \"pilots\"\n    },\n    \"hero\": {\n      \"alt\": \"Colorful ribbon-tailed kites chasing glowing sparks above copper mountains at dusk.\",\n      \"focus\": \"50% 50%\",\n      \"tint\": 25\n    },\n    \"credits\": [\n      {\n        \"role\": \"Design and procedural art\",\n        \"name\": \"Ember Kites\"\n      },\n      {\n        \"role\": \"Multiplayer foundation\",\n        \"name\": \"Homie · Gem Rush starter\"\n      },\n      {\n        \"role\": \"Music and sound\",\n        \"name\": \"Synthesized locally from code\"\n      }\n    ]\n  },\n  \"from\": {\n    \"starter\": \"gem-rush\",\n    \"studio\": \"0.30.2\"\n  },\n  \"genre\": [\n    \"Arcade\",\n    \"Party\"\n  ],\n  \"screen\": {\n    \"chat\": {\n      \"lines\": \"sheet-only\"\n    },\n    \"share\": {\n      \"phone\": {\n        \"at\": \"top-right\",\n        \"y\": 10\n      },\n      \"desk\": \"top-right\"\n    }\n  },\n  \"chat\": {\n    \"lines\": {\n      \"gg\": \"Good flight!\",\n      \"wind\": \"The wind is turning!\",\n      \"spark\": \"Catch those sparks!\"\n    }\n  },\n  \"cover\": \"cover.jpg\"\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>Cinder Circuit</title>\n<link rel=\"icon\" href=\"data:,\">\n<style>\n  html, body { margin: 0; height: 100%; background: #04060c; overflow: hidden; overscroll-behavior: none; -webkit-user-select: none; user-select: none; }\n  canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }\n</style>\n</head>\n<body>\n<canvas id=\"c\"></canvas>\n<script type=\"module\" crossorigin src=\"./assets/main.js\"></script>\n</body>\n</html>\n","lab.json":"{\n  \"v\": 1,\n  \"default\": \"knock\",\n  \"takes\": {\n    \"knock\": {\n      \"note\": \"Bump the dummy beside you\",\n      \"seconds\": 2.2,\n      \"seed\": 7,\n      \"stage\": \"dummy\",\n      \"device\": \"desk\",\n      \"fps\": 60,\n      \"view\": \"close\",\n      \"track\": \"speed\",\n      \"inputs\": [\n        { \"at\": 0.5, \"key\": \"Space\", \"hold\": 0.1 }\n      ]\n    }\n  }\n}\n","public/sound/sound.json":"{\n  \"v\": 1,\n  \"sfx\": {\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    \"jump\": [\n      [\n        \"jump-1.ogg\",\n        \"jump-1.wav\"\n      ],\n      [\n        \"jump-2.ogg\",\n        \"jump-2.wav\"\n      ],\n      [\n        \"jump-3.ogg\",\n        \"jump-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    \"win\": [\n      [\n        \"win-1.ogg\",\n        \"win-1.wav\"\n      ]\n    ]\n  },\n  \"music\": {\n    \"cinder-flight\": {\n      \"bpm\": 112,\n      \"beatsPerBar\": 4,\n      \"barSeconds\": 2.142857142857143,\n      \"loops\": {\n        \"main\": [\n          \"cinder-flight-main-loop-8bars.ogg\",\n          \"cinder-flight-main-loop-8bars.wav\"\n        ]\n      }\n    }\n  }\n}\n","src/main.ts":"/*\n * CINDER CIRCUIT — Ember Kites, built on the netplay reference game (@homie-rocks/studio/netplay/NETPLAY.md).\n *\n * Fly kites, collect cinders, ride the wind; 60 s rounds that cycle forever; the round starts\n * the moment the first browser arrives, with five 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 * WATCH ANY PLAYER (contract revision 5): a watcher (/<game>/watch) never\n * takes a seat. `net.viewSeat` says whose view to draw; the camera follows that\n * player's body as their own browser does, the board marks their row, and with\n * nobody followed the whole arena shows. Waves call `net.spotlight(seat)`, so a\n * watcher on Auto cuts to whoever just bumped.\n *\n * SERVERS AND THE SKILL DIAL (contract revision 6): the bots play at the room's\n * dial (`net.skillOf(slot)`: how late they see, how well they aim, whether they\n * fight for the hot zone, how often they bump), which the party votes in the\n * play page; a hybrid server's AI seats are kept as AI bodies (the Roster's\n * reserve, marked \" · AI\"), an AI with a pass takes one, and a humans-only\n * server's bots are off. The game says so: `caps: ['skill', 'agents']`.\n *\n * THE GAME LAB (`homie-studio lab gem-rush`): the knock is tuned in the lab. Its numbers are tunables.json (read with\n * lab.tunables, so the lab's sliders move them and write kept values back), and the game tells the lab what the knock\n * is doing: its phases, the bumped body's speed and distance, poses for the onion skin and the spacing arc, and preset\n * views. Outside the lab every lab call is a no-op. lab.json holds the take the lab plays.\n *\n * Canvas 2D on purpose: the point is the contract, in ~700 readable lines.\n */\nimport { createNetplay, Roster, q, lerp, capMove, PALETTE, AI_MARK, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';\n// The port toolkit: its probe (what `homie-studio port check` reads for the owner tests, and sandbox + audio shims),\n// and a flat world on every screen (port/view.ts: the camera, and name labels that never pile up).\nimport { BUBBLE_FONT, createBubbles, createLabels, exposePort, fitView, paintBubbles, type BubbleIn, type BubbleOut, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';\n// The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).\nimport { lab } from '@homie-rocks/studio/lab';\nimport tuning from '../tunables.json';\nimport style from '../style.json';\nimport { createSound } from './sound.js';\nconst sound = createSound({ base: 'sound/' });\nsound.music('cinder-flight');\nsound.volume({master:0.6,music:0.8});\nconst mute = document.createElement('button'); mute.textContent = 'Sound on'; mute.setAttribute('aria-label','Mute sound');\nmute.style.cssText='position:fixed;left:14px;top:82px;z-index:10;border:1px solid #9e7668;border-radius:18px;background:#2c1b32;color:#fff2dd;padding:7px 10px;font:600 12px system-ui;cursor:pointer';\nlet muted=false; mute.onclick=()=>{muted=!muted;sound.mute(muted);mute.textContent=muted?'Sound off':'Sound on';mute.setAttribute('aria-label',muted?'Enable sound':'Mute sound');};document.body.append(mute);\nlet heardScore=0, heardRound=0;\nconst ink = style.palette;\nconst wind = () => { const n = round ? Math.floor(Math.max(0, net.now() - round.startedAt) / 10000) % 4 : 0; return { x: [1,0,-1,0][n], y: [0,1,0,-1][n], name: ['EAST →','SOUTH ↓','WEST ←','NORTH ↑'][n] }; };\nconst burstAt = new Map<number, number>();\n\n/* ------------------------------------------------------------------ rules */\nconst W = 1600;\nconst H = 1000;\nconst R_AV = 22;\nconst R_GEM = 13;\nconst SPEED = 340;\nconst BOT_SPEED = 250;\nconst ROUND_MS = 60_000;\nconst BREAK_MS = 7_000;\nconst GEM_COUNT = 26;\nconst MIN_SLOTS = 6; // 1 human + 5 bots from the first frame\nconst MAX_SLOTS = 8;\n/** The knock's numbers (tunables.json): the file's values, or the Game Lab's sliders while it plays a take. */\nconst T = lab.tunables(tuning);\nconst ZONE_MS = 12_000;\n/** The contract's 12 colours (PALETTE, NETPLAY.md section 3): a person wears their seat's, so the watch page's strip matches. */\nconst colourOf = (slot: number, seat: number | null): string => PALETTE[(seat ?? slot) % PALETTE.length] as string;\nconst BOT_NAMES = ['Moth', 'Saffron', 'Juniper', 'Wren', 'Poppy', 'Clover', 'Finch', 'Ash'];\nconst botName = (slot: number): string => BOT_NAMES[slot % BOT_NAMES.length] as string;\n/** An AI's name already ends in \" · AI\" (the relay sees to it); a plain bot says bot. */\nconst label = (name: string, bot: boolean): string => (name.endsWith(AI_MARK) ? name : bot ? `${name} · bot` : name);\n\n/* ------------------------------------------------------------ wire shapes */\n/** Snapshot: compact arrays, quantized. ~40 B per body, ~16 B per gem. (Reset epochs ride in the helper's `c` table.) */\ntype P = [slot: number, seat: number, x: number, y: number, score: number, vx: number, vy: number];\ntype G = [id: number, x: number, y: number];\ninterface Snap { r: [n: number, phase: number, startedAt: number, endsAt: number]; p: P[]; g: G[] }\n/**\n * Replica input: the avatar (owner movement) AND the stick intent (host movement), so either mode reads the same\n * frame. The helper stamps the reset epoch the replica has adopted.\n */\ntype Avatar = [x: number, y: number, vx: number, vy: number, mx: number, my: number];\n/** A body's bump, while it lasts: its direction (kvx, kvy), where its slide starts (kx, ky) and when (kat, after the hit-stop). */\ninterface Knock { kvx: number; kvy: number; kx: number; ky: number; kat: number; knockUntil: number }\ninterface Body extends Knock { nextBurst?: number; slot: number; seat: number | null; name: string; bot: boolean; x: number; y: number; vx: number; vy: number; score: number; tx: number; ty: number }\ninterface Gem { id: number; x: number; y: number }\n/** Slow state, on the keyed state channel (net.state('zone', ...)), not in the 20 Hz snapshot. */\ninterface Zone { n: number; x: number; y: number; r: number; until: number }\ninterface Ckpt { round: RoundInfo; bodies: Body[]; gems: Gem[]; roster: Slot[]; tick: number; gemSeq: number }\n\n/* --------------------------------------------------------------- the net */\n/**\n * `?movement=host` plays host movement: the host moves every body from the seats' stick intents (its rules own\n * movement), and each replica PREDICTS its own body by replaying its unacknowledged intents on the host's position.\n * Default `owner`: each browser moves its own body and the host bounds it.\n */\nconst MOVEMENT: 'owner' | 'host' = (() => { try { return new URLSearchParams(location.search).get('movement') === 'host' ? 'host' : 'owner'; } catch { return 'owner'; } })();\n// caps: its bots read the dial ('skill'), and its Roster takes an AI's slot for it ('agents': the join passes p.agent).\nconst net = createNetplay<Snap, Avatar, Ckpt>({ game: 'cinder-circuit', maxPlayers: MAX_SLOTS, movement: MOVEMENT, snapshotHz: 20, inputHz: 20, checkpointMs: 1000, caps: ['skill', 'agents'], checkpoint: () => checkpoint() });\n/** The Roster keeps the server's AI seats (revision 6): the policy is read whenever it fills. */\nconst policy = () => net.policy;\n\n/* ------------------------------------------------------------ host state */\nlet roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });\nlet bodies = new Map<number, Body>();\nlet gems: Gem[] = [];\nlet gemSeq = 0;\nlet round: RoundInfo | null = null;\nlet zone: Zone | null = null;\nlet tick = 0;\nlet hosting = false;\nlet cheatResets = 0;\nlet controlResets = 0;\nlet predictionError = 0;\n/** Gems each slot picked up this round (a gem, whatever it scored): the dial's numbers (`pickups` probe). */\nlet pickups = new Map<number, number>();\n\n/* ------------------------------------------------ this browser's avatar */\nconst me = { x: W / 2, y: H / 2, vx: 0, vy: 0, kvx: 0, kvy: 0, kx: 0, ky: 0, kat: 0, knockUntil: 0, has: false };\n/** Offline (no shell) plays as a local seat 0, with keys or touch. */\nconst mySeat = (): number | null => (net.offline ? 0 : net.seat);\n/** Whose view to draw: my own seat, or for a watcher the player it follows (null: the whole arena). */\nconst viewSeat = (): number | null => (net.offline ? 0 : net.viewSeat);\n\n/* ------------------------------------------------------- replica view */\nconst drawn = new Map<number, { x: number; y: number; seat: number; score: number; slot: number }>();\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 => ({ id: ++gemSeq, x: rnd(60, W - 60), y: rnd(60, H - 60) });\n\nfunction bodyFor(slot: Slot): Body {\n  const sp = spawnPoint(slot.slot);\n  return { slot: slot.slot, seat: slot.seat, name: slot.name, bot: slot.bot, x: sp.x, y: sp.y, vx: 0, vy: 0, score: 0, tx: sp.x, ty: sp.y, kvx: 0, kvy: 0, kx: sp.x, ky: sp.y, kat: 0, knockUntil: 0 };\n}\n\nfunction syncBodiesFromRoster(): void {\n  const seen = new Set<number>();\n  for (const s of roster.slots) {\n    seen.add(s.slot);\n    const b = bodies.get(s.slot);\n    if (!b) { bodies.set(s.slot, bodyFor(s)); continue; }\n    b.seat = s.seat; b.name = s.name; b.bot = s.bot;\n  }\n  for (const k of [...bodies.keys()]) if (!seen.has(k)) bodies.delete(k);\n}\n\nfunction publishRoster(): void { net.roster(roster.toJSON()); }\n\n/** The host moved body `b` itself: tell its owner (a replica adopts it; the host's own body is `me`). */\nfunction hostMoved(b: Body): void {\n  if (b.bot || b.seat === null) return;\n  if (b.seat === mySeat()) { me.x = b.x; me.y = b.y; me.vx = 0; me.vy = 0; me.has = true; return; }\n  net.reset(b.seat);\n}\n\nfunction newZone(n: number): Zone {\n  return { n, x: rnd(220, W - 220), y: rnd(200, H - 200), r: 150, until: net.now() + ZONE_MS };\n}\nfunction setZone(z: Zone): void { zone = z; net.state('zone', z); }\n\nfunction startRound(n: number): void {\n  const now = net.now();\n  roster.trim();\n  syncBodiesFromRoster();\n  let i = 0;\n  for (const b of [...bodies.values()].sort((a, c) => a.slot - c.slot)) {\n    const sp = spawnPoint(i++);\n    b.x = sp.x; b.y = sp.y; b.vx = 0; b.vy = 0; b.score = 0; b.tx = sp.x; b.ty = sp.y; b.kvx = 0; b.kvy = 0; b.knockUntil = 0; b.nextBurst = 0;\n    hostMoved(b);\n  }\n  gems = Array.from({ length: GEM_COUNT }, newGem);\n  pickups = new Map();\n  sight.clear();\n  round = { n, phase: 'live', startedAt: now, endsAt: now + ROUND_MS };\n  if (lab.stage === 'dummy') stageDummy();\n  setZone(newZone((zone?.n ?? 0) + 1));\n  net.round(round);\n  publishRoster();\n  net.snapshot(buildSnap(), tick, true);\n}\n\nfunction endRound(): void {\n  if (!round) return;\n  const now = net.now();\n  const ranked = [...bodies.values()].sort((a, b) => b.score - a.score || Number(a.bot) - Number(b.bot) || a.slot - b.slot);\n  const agentSlot = (slot: number): boolean => Boolean(roster.slots.find((x) => x.slot === slot)?.agent);\n  const results: RoundResult[] = ranked.map((b, i) => ({ slot: b.slot, seat: b.seat, name: b.name, score: b.score, bot: b.bot, place: i + 1, ...(agentSlot(b.slot) ? { agent: true as const } : {}) }));\n  round = { n: round.n, phase: 'over', startedAt: now, endsAt: now + BREAK_MS, results };\n  net.round(round);\n}\n\n/* ---------------------------------------------------- becoming the host */\nfunction becomeHost(e: RoleChange<Snap, Ckpt>): void {\n  hosting = true;\n  if (e.promoted) restore(e);\n  else {\n    roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });\n    bodies = new Map();\n    const seat = mySeat();\n    if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name);\n    syncBodiesFromRoster();\n    startRound((e.round?.n ?? 0) + 1);\n  }\n  // Whoever is connected now is who plays: seats that left during the gap become bots.\n  const peers = net.offline ? [{ seat: 0, name: 'You' }] : [...net.peers.values()].filter((p) => p.seat !== null).map((p) => ({ seat: p.seat, name: p.name, agent: p.agent ?? null }));\n  const { claimed } = roster.reconcile(peers);\n  syncBodiesFromRoster();\n  for (const s of claimed) { const b = bodies.get(s.slot); if (b && b.seat !== mySeat()) hostMoved(b); }\n  // My own body is wherever my local avatar is: it was client-owned a moment ago and still is.\n  const mine = mySeat() !== null ? [...bodies.values()].find((b) => b.seat === mySeat()) : undefined;\n  if (mine && me.has) { mine.x = me.x; mine.y = me.y; }\n  else if (mine) { me.x = mine.x; me.y = mine.y; me.has = true; }\n  // The 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 });\n    bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));\n    gems = ck.gems.map((g) => ({ ...g }));\n    gemSeq = ck.gemSeq;\n    round = ck.round;\n    tick = ck.tick;\n  } else {\n    roster = Roster.from(e.roster ?? [], { min: MIN_SLOTS, max: MAX_SLOTS, botName, 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    for (const [slot, seat, x, y, score] of s.d.p) {\n      let b = bodies.get(slot);\n      if (!b) {\n        const rs0 = roster.slots.find((r) => r.slot === slot) ?? { slot, seat: seat >= 0 ? seat : null, name: seat >= 0 ? `Player ${seat + 1}` : botName(slot), bot: seat < 0 };\n        b = bodyFor(rs0);\n        bodies.set(slot, b);\n      }\n      b.x = x; b.y = y; b.score = score; b.tx = x; b.ty = y;\n    }\n    gems = s.d.g.map(([id, x, y]) => ({ id, x, y }));\n    gemSeq = Math.max(gemSeq, ...gems.map((g) => g.id));\n    const [n, ph, startedAt, endsAt] = s.d.r;\n    round = { n, phase: ph ? 'over' : 'live', startedAt, endsAt, ...(round?.n === n && round.results ? { results: round.results } : {}) };\n    tick = Math.max(tick, s.k);\n  }\n  // A round message the relay kept is at least as authoritative as the checkpoint's copy.\n  if (e.round && (!round || e.round.n > round.n || (e.round.n === round.n && e.round.phase === 'over' && round.phase === 'live'))) round = e.round;\n  if (!round) { startRound(1); return; }\n  net.round(round);\n}\n\nfunction checkpoint(): Ckpt {\n  return {\n    round: round ?? { n: 0, phase: 'live', startedAt: 0, endsAt: 0 },\n    bodies: [...bodies.values()].map((b) => ({ ...b })),\n    gems: gems.map((g) => ({ ...g })),\n    roster: roster.toJSON(),\n    tick,\n    gemSeq,\n  };\n}\n\nnet.on('role', (e) => {\n  if (e.role === 'host') becomeHost(e);\n  else { hosting = false; }\n});\nnet.on('join', (p) => {\n  if (!hosting || p.seat === null) return;\n  // An AI takes a seat kept for AI, a person never does (revision 6: the Roster needs p.agent for that).\n  const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null);\n  if (!c) return; // full: they watch\n  syncBodiesFromRoster();\n  const b = bodies.get(c.slot.slot);\n  // The arriving human takes over the bot's body where it stands, score and all (reset = \"adopt this position\").\n  if (b) { b.vx = 0; b.vy = 0; b.kvx = 0; b.kvy = 0; b.knockUntil = 0; hostMoved(b); }\n  publishRoster();\n  net.snapshot(buildSnap(), tick, true);\n});\nnet.on('leave', (p) => {\n  if (!hosting || p.seat === null) return;\n  roster.release(p.seat); // their body stays, driven by a bot\n  syncBodiesFromRoster();\n  publishRoster();\n});\n// The server's policy changed: its AI seats come (or go between rounds) at once.\nnet.on('policy', () => { if (!hosting) return; roster.fill(); syncBodiesFromRoster(); publishRoster(); });\nnet.on('event', (e) => {\n  if (e.k === 'wave') addWave(e.d as { slot: number });\n  if (e.k === 'knock') addWave(e.d as { slot: number }, true);\n});\nnet.on('round', (r) => { round = hosting ? round : r; });\nnet.on('state', (e) => { if (e.k === 'zone' && !hosting) zone = (e.d as Zone | null) ?? null; if (!firstStateAt) firstStateAt = performance.now(); });\nnet.on('snapshot', () => { if (!firstSnapAt) firstSnapAt = performance.now(); });\n/** My body's control changed: on a reset, stand where the host put me (a new round, a takeover, a knockback's end). */\nnet.on('control', (e) => {\n  if (!e.reset) return;\n  controlResets += 1;\n  const p = (e.snap as Snapshot<Snap>).d.p.find((x) => x[1] === net.seat);\n  if (p) { me.x = p[2]; me.y = p[3]; me.vx = 0; me.vy = 0; me.has = true; }\n});\n\n/* ----------------------------------------------------------------- input */\nconst keys = new Set<string>();\naddEventListener('keydown', (e) => {\n  if (['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) e.preventDefault();\n  if (e.code === 'Space' && !keys.has('Space')) wave();\n  keys.add(e.code);\n});\naddEventListener('keyup', (e) => keys.delete(e.code));\naddEventListener('blur', () => keys.clear());\n\nconst stick = { id: -1, ox: 0, oy: 0, x: 0, y: 0, active: false };\nconst canvas = document.getElementById('c') as HTMLCanvasElement;\nconst ctx = canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D;\ncanvas.addEventListener('pointerdown', (e) => {\n  if (e.pointerType === 'mouse') return;\n  if (e.clientX > innerWidth - 110 && e.clientY > innerHeight - 150) { 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  if (round?.phase !== 'live' || net.now() < (from.nextBurst ?? 0)) return;\n  from.nextBurst = net.now() + 1600;\n  const v = Math.hypot(from.vx, from.vy); const w = wind();\n  const dx = v > 50 ? from.vx / v : w.x; const dy = v > 50 ? from.vy / v : w.y;\n  const o = from.seat === mySeat() && !from.bot ? me : from;\n  o.kvx = dx; o.kvy = dy; o.kx = o.x; o.ky = o.y; o.kat = net.now(); o.knockUntil = o.kat + T.knockMs;\n  if (o === from && from.seat !== null) net.take(from.seat, T.knockMs);\n  addWave({ slot: from.slot }); net.send('wave', { slot: from.slot });\n  for (const b of bodies.values()) {\n    if (b === from || Math.hypot(b.x - from.x, b.y - from.y) > T.knockRange) continue;\n    knock(b, from.x, from.y, from.slot);\n    const dropped = Math.min(2, b.score); b.score -= dropped;\n    for (let j=0;j<dropped;j++) { const g = newGem(); g.x = Math.max(40,Math.min(W-40,b.x+rnd(-90,90))); g.y = Math.max(40,Math.min(H-40,b.y+rnd(-90,90))); if(gems.length<40) gems.push(g); }\n  }\n}\n/**\n * Host: knock a body back. The hit LANDS first: for T.hitStopMs the body holds where it was hit (it flashes, squashes\n * and shakes on every screen), then it SLIDES T.knockDistance along the hit, fast at first and easing into the stop\n * (T.knockEase), so it arrives at rest, the same distance at any frame rate, and its owner steers again with no pop.\n * A replica's body is TAKEN for the whole bump (its owner's avatar frames are ignored; the host drives it; the owner\n * draws its body from snapshots), then the helper GIVES it back with a reset, so the owner stands where it ended.\n */\nfunction knock(b: Body, fx: number, fy: number, by: number): void {\n  let dx = b.x - fx; let dy = b.y - fy;\n  const len = Math.hypot(dx, dy);\n  if (len < 1) { const a = Math.random() * Math.PI * 2; dx = Math.cos(a); dy = Math.sin(a); } else { dx /= len; dy /= len; }\n  const at = net.now() + T.hitStopMs;\n  const o: Knock & { x: number; y: number } = !b.bot && b.seat !== null && b.seat === mySeat() ? me : b;\n  o.kvx = dx; o.kvy = dy; o.kx = o.x; o.ky = o.y; o.kat = at; o.knockUntil = at + T.knockMs;\n  if (o === b && !b.bot && b.seat !== null) net.take(b.seat, T.hitStopMs + T.knockMs);\n  labKnock(b);\n  const d = { slot: b.slot, dx: q(dx, 2), dy: q(dy, 2), by };\n  addWave(d, true); net.send('knock', d);\n}\n\n/** Where a bumped body is now: held through the hit-stop, then eased out along the hit. Sets its velocity too. */\nfunction slide(o: Knock & { x: number; y: number; vx: number; vy: number }, now: number, dt: number): void {\n  const u = Number.isFinite(o.kat) ? Math.max(0, Math.min(1, (now - o.kat) / Math.max(1, T.knockMs))) : 1;\n  const e = 1 - (1 - u) ** Math.max(1, T.knockEase);\n  const x = Math.max(R_AV, Math.min(W - R_AV, o.kx + o.kvx * T.knockDistance * e));\n  const y = Math.max(R_AV, Math.min(H - R_AV, o.ky + o.kvy * T.knockDistance * e));\n  if (u >= 1) { o.vx = 0; o.vy = 0; } else if (dt > 0) { o.vx = (x - o.x) / dt; o.vy = (y - o.y) / dt; }\n  o.x = x; o.y = y;\n}\n/** The frame a bump is over: the body lands exactly where its slide ends, at rest (at 12 fps the last step is long). */\nfunction landed(o: Knock & { x: number; y: number; vx: number; vy: number }): void {\n  if (!o.kat) return;\n  slide(o, o.kat + T.knockMs, 0);\n  o.kat = 0;\n}\nfunction addWave(d: { slot: number; dx?: number; dy?: number; by?: number }, isKnock = false): void {\n  const pos = hosting ? bodies.get(d.slot) : [...drawn.values()].find((x) => x.slot === d.slot);\n  // The action, for a watcher on Auto: whoever waved (a person, never a bot).\n  const waver = pos && 'bot' in pos ? (pos.bot ? null : pos.seat) : pos && pos.seat >= 0 ? pos.seat : null;\n  if (!isKnock) net.spotlight(waver);\n  if (pos && Math.hypot(pos.x-me.x,pos.y-me.y)<650) sound.play(isKnock?'hit':'jump',{volume:pos.seat===mySeat() ? .65 : .22});\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; burstAt.set(d.slot, net.now()); }\n  if (isKnock) bumped(d, at); else pushes.set(d.slot, performance.now());\n}\n\n/* ---------------------------------------------------------------- simulate */\n/** The one movement rule, shared by the owner, the host (host movement) and a replica's prediction. */\nfunction integrate(o: { x: number; y: number; vx: number; vy: number }, mx: number, my: number, dt: number): 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  const gust = round?.phase === \"live\" && Math.hypot(mx,my)>0.05 ? wind() : {x:0,y:0};\n  const lift = 1 + (mx * gust.x + my * gust.y) * .22;\n  o.vx += (mx * SPEED * lift - o.vx) * k;\n  o.vy += (my * SPEED * lift - 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()) { slide(me, net.now(), dt); return; }\n  landed(me);\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 { slide(b, net.now(), dt); }\n\n/**\n * THE BOTS, AT THE ROOM'S DIAL (NETPLAY.md section 17). A bot re-reads the world only every `reactionMs` (what it\n * last noticed is where it heads, and a miss costs it that long again), aims up to 200 px off at aimNoise 1, leaves\n * the hot zone to the people at positioning 0 and fights for it at 1, and waves at a rival in reach about\n * `aggression x 0.8` times a second. Rookie is beatable by anyone, Maxed by few: measured with the\n * `pickups` probe over 60 s rounds, a Maxed bot picks up a little over twice the gems of a Rookie.\n */\nconst sight = new Map<number, { at: number; tx: number; ty: number; arrived?: boolean }>();\n\nfunction stepBots(dt: number): void {\n  const taken = new Set<number>();\n  const now = net.now();\n  for (const b of bodies.values()) {\n    if (!b.bot) continue;\n    if (b.knockUntil > now) { stepKnocked(b, dt); continue; }\n    landed(b);\n    if (lab.stage === 'dummy') { standStill(b, dt); continue; }\n    const s: Skill = net.skillOf(b.slot); // Fair when nobody set a dial\n    let eye = sight.get(b.slot);\n    if (!eye || now - eye.at >= s.reactionMs) { // REACTION TIME\n      const g = pickGem(b, taken, s); // POSITIONING\n      const miss = 200 * s.aimNoise; // AIM NOISE: up to 200 px off at 1\n      eye = { at: now, tx: g ? g.x + (Math.random() - 0.5) * miss : b.tx, ty: g ? g.y + (Math.random() - 0.5) * miss : b.ty };\n      sight.set(b.slot, eye);\n      if (g) taken.add(g.id);\n    }\n    b.tx = eye.tx; b.ty = eye.ty;\n    const rival = nearestBody(b, T.knockRange); // AGGRESSION: waves at a rival in reach\n    if (rival && round?.phase === 'live' && Math.random() < s.aggression * 0.8 * dt) hostWave(b);\n    const dx = b.tx - b.x; const dy = b.ty - b.y; const dist = Math.hypot(dx, dy);\n    const len = dist || 1;\n    // Arrived where it aimed: it stands there, and notices what it missed only a reaction later.\n    if (dist < 6 && !eye.arrived) { eye.arrived = true; eye.at = now; }\n    const humanLead = Math.max(0, ...[...bodies.values()].filter(p=>!p.bot).map(p=>p.score));\n    const ahead = b.score > humanLead + 5;\n    const speed = dist < 6 ? 0 : ahead ? 30 : BOT_SPEED * .8;\n    const k = Math.min(1, dt * 5);\n    const gust = ahead ? {x:0,y:0} : wind();\n    b.vx += ((dx / len) * speed + gust.x * 65 - b.vx) * k;\n    b.vy += ((dy / len) * speed + gust.y * 65 - 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\n/** Positioning: 0 leaves the hot zone to the people, 1 fights for it. */\nfunction pickGem(b: Body, taken: Set<number>, s: Skill): Gem | null {\n  let best: Gem | null = null; let bd = Infinity;\n  for (const g of gems) {\n    if (taken.has(g.id)) continue;\n    const hot = zone !== null && Math.hypot(g.x - zone.x, g.y - zone.y) < zone.r;\n    const d = Math.hypot(g.x - b.x, g.y - b.y) * (hot ? 1.5 - s.positioning : 1);\n    if (d < bd) { bd = d; best = g; }\n  }\n  return best;\n}\n\n/** The nearest other body within `range` (a bot's rival), or null. */\nfunction nearestBody(b: Body, range: number): Body | null {\n  let best: Body | null = null; let bd = range;\n  for (const o of bodies.values()) {\n    if (o === b) continue;\n    const d = Math.hypot(o.x - b.x, o.y - b.y);\n    if (d < bd) { bd = d; best = o; }\n  }\n  return best;\n}\n\nfunction stepHost(dt: number): void {\n  tick += 1;\n  const seat = mySeat();\n  const now0 = net.now();\n  for (const b of bodies.values()) {\n    if (b.bot) continue;\n    if (b.seat === seat) {\n      me.has = true;\n      b.x = me.x; b.y = me.y; b.vx = me.vx; b.vy = me.vy;\n      continue;\n    }\n    if (b.seat === null) continue;\n    const ctl = net.control(b.seat);\n    if (ctl.taken || b.knockUntil > now0) { stepKnocked(b, dt); if (net.takePresses(b.seat)['wave']) hostWave(b); continue; } // the host drives the knockback\n    if (MOVEMENT === 'host') {\n      // Host movement: the seat sends intents; the rules move the body.\n      const f = net.inputOf(b.seat);\n      integrate(b, f ? Number(f.a?.[4]) : 0, f ? Number(f.a?.[5]) : 0, dt);\n      if (net.takePresses(b.seat)['wave']) hostWave(b);\n      continue;\n    }\n    const a = net.avatar(b.seat); // null while taken, or until the owner has adopted the last reset\n    if (a && Array.isArray(a)) {\n      // Client-owned movement, bounded: to the arena, and to 1.3x top speed per frame. A legitimate avatar catches\n      // up within a frame; a teleport crawls, and a claim more than half a second of running away is reset.\n      const claim = { x: Math.max(R_AV, Math.min(W - R_AV, Number(a[0]))), y: Math.max(R_AV, Math.min(H - R_AV, Number(a[1]))) };\n      const m = capMove(b, claim, SPEED * 1.3 * dt + 6);\n      if (m.over > SPEED * 0.5) { net.reset(b.seat); cheatResets += 1; }\n      else { b.x = m.x; b.y = m.y; b.vx = Number(a[2]) || 0; b.vy = Number(a[3]) || 0; }\n    }\n    if (net.takePresses(b.seat)['wave']) hostWave(b);\n  }\n  stepBots(dt);\n  const now = net.now();\n  if (round && round.phase === 'live') {\n    if (!zone || now >= zone.until) setZone(newZone((zone?.n ?? 0) + 1));\n    const humanScores = [...bodies.values()].filter(p => !p.bot).map(p => p.score);\n    const humanLead = humanScores.length ? Math.max(...humanScores) : Infinity;\n    for (const b of bodies.values()) {\n      // Festival bots yield pickups when far ahead; their navigation still obeys the room skill dial.\n      for (let i = 0; i < gems.length; i += 1) {\n        if (b.bot && b.score > humanLead + 5) break;\n        const g = gems[i] as Gem;\n        if (Math.hypot(g.x - b.x, g.y - b.y) < R_AV + R_GEM) {\n          b.score += zone && Math.hypot(g.x - zone.x, g.y - zone.y) < zone.r ? 2 : 1;\n          pickups.set(b.slot, (pickups.get(b.slot) ?? 0) + 1);\n          gems[i] = newGem();\n        }\n      }\n    }\n    if (now >= round.endsAt) endRound();\n  } else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1);\n  if (net.snapshotDue()) net.snapshot(buildSnap(), tick);\n}\n\nfunction buildSnap(): Snap {\n  const r = round ?? { n: 0, phase: 'live', startedAt: 0, endsAt: 0 };\n  return {\n    r: [r.n, r.phase === 'over' ? 1 : 0, r.startedAt, r.endsAt],\n    p: [...bodies.values()].map((b) => [b.slot, b.seat ?? -1, q(b.x, 1), q(b.y, 1), b.score, q(b.vx, 0), q(b.vy, 0)] as P),\n    g: gems.map((g) => [g.id, q(g.x, 0), q(g.y, 0)] as G),\n  };\n}\n\nfunction stepReplica(dt: number): void {\n  const seat = net.seat;\n  if (seat !== null) {\n    // Host-driven (host movement, or taken for a knockback): predict from the host's position and my intents.\n    if (!net.owned) predictMe(dt);\n    // Only once I have a body (the first control event adopts it): before that my position is nobody's.\n    if (me.has) {\n      const v = moveVector();\n      // Stick direction as held \"buttons\": a new direction is a press edge, so it is sent within 16 ms.\n      const held = [v.x > 0.2 ? 'R' : v.x < -0.2 ? 'L' : '', v.y > 0.2 ? 'D' : v.y < -0.2 ? 'U' : ''].filter(Boolean);\n      net.input([q(me.x, 1), q(me.y, 1), q(me.vx, 0), q(me.vy, 0), q(v.x, 2), q(v.y, 2)], held);\n    }\n  }\n  const smp = net.sample();\n  drawn.clear();\n  if (!smp) return;\n  const bySlot = new Map<number, P>(smp.a.d.p.map((p) => [p[0], p]));\n  for (const pb of smp.b.d.p) {\n    const pa = bySlot.get(pb[0]) ?? pb;\n    drawn.set(pb[0], { slot: pb[0], seat: pb[1], x: lerp(pa[2], pb[2], smp.alpha), y: lerp(pa[3], pb[3], smp.alpha), score: pb[4] });\n  }\n  const [n, ph, startedAt, endsAt] = smp.b.d.r;\n  if (!round || round.n !== n || (round.phase === 'over') !== (ph === 1)) {\n    const kept = net.roundInfo && net.roundInfo.n === n ? net.roundInfo : null;\n    round = kept ?? { n, phase: ph ? 'over' : 'live', startedAt, endsAt };\n  }\n}\n\n/* -------------------------------------------------------------- how a bump looks */\n/*\n * Every screen draws a bump from the knock event (the host's own, or the one it sends): the hit lands (the body flashes\n * white and holds, squashed against the hit, shaking), flies (stretched along the hit, less as it slows), stops\n * (squashed, then a wobble that dies away: overlap), throws sparks, and kicks the camera of whoever was in it. The\n * waver's own body pushes out a little when it waves. Sparks and the camera's kick roll lab.random(): their own dice, so\n * the world's (Math.random) stay the same as a build without them, which the Game Lab needs to compare the two.\n */\nconst bumps = new Map<number, { at: number; dx: number; dy: number }>();\nconst pushes = new Map<number, number>();\nconst sparks: { x: number; y: number; vx: number; vy: number; at: number; life: number }[] = [];\nlet kickAt = -1e9;\nfunction bumped(d: { slot: number; dx?: number; dy?: number; by?: number }, at: { x: number; y: number }): void {\n  const now = performance.now();\n  const dx = Number(d.dx) || 0; const dy = Number(d.dy) || 0;\n  bumps.set(d.slot, { at: now, dx, dy });\n  const base = Math.atan2(dy, dx);\n  for (let i = 0; i < T.sparks; i += 1) {\n    const a = base + (lab.random() - 0.5) * 1.6;\n    const v = 420 + lab.random() * 520;\n    sparks.push({ x: at.x - dx * R_AV * 0.6, y: at.y - dy * R_AV * 0.6, vx: Math.cos(a) * v, vy: Math.sin(a) * v, at: now, life: 170 + lab.random() * 170 });\n  }\n  if (sparks.length > 160) sparks.splice(0, sparks.length - 160);\n  // The camera kicks for whoever was in it: the body bumped, or the one who waved.\n  const mine = hosting ? [...bodies.values()].find((x) => x.seat !== null && x.seat === mySeat())?.slot : [...drawn.values()].find((x) => x.seat === net.seat)?.slot;\n  if (mine !== undefined && (d.slot === mine || d.by === mine)) kickAt = now;\n}\n/** A body's look this frame: offset, stretch along an angle, and how white it flashes. */\nfunction bodyFx(slot: number, t: number): { ox: number; oy: number; sx: number; sy: number; ang: number; flash: number } {\n  const out = { ox: 0, oy: 0, sx: 1, sy: 1, ang: 0, flash: 0 };\n  const p = pushes.get(slot);\n  if (p !== undefined) { const u = (t - p) / 170; if (u >= 0 && u < 1) { const k = 0.14 * Math.sin(Math.PI * u); out.sx = 1 + k; out.sy = 1 + k; } }\n  const b = bumps.get(slot);\n  if (!b) return out;\n  const age = t - b.at;\n  const hs = T.hitStopMs; const end = hs + T.knockMs;\n  if (age > end + T.settleMs + 200) { bumps.delete(slot); return out; }\n  out.ang = Math.atan2(b.dy, b.dx);\n  out.flash = T.flashMs > 0 ? Math.max(0, 1 - age / T.flashMs) : 0;\n  let k = 0;\n  if (age < hs) { k = -T.squash * 0.45; out.ox = Math.sin(age * 0.9) * 3; out.oy = Math.cos(age * 1.3) * 3; }\n  else if (age < end) k = T.squash * (1 - (age - hs) / T.knockMs) ** Math.max(0, T.knockEase - 1);\n  else if (T.settleMs > 0 && age < end + T.settleMs) { const w = (age - end) / T.settleMs; k = -T.squash * 0.8 * Math.exp(-3 * w) * Math.sin(2.5 * Math.PI * w); }\n  out.sx *= 1 + k; out.sy *= 1 / (1 + k);\n  return out;\n}\nfunction drawSparks(t: number, scale: number): void {\n  ctx.lineCap = 'round';\n  for (let i = sparks.length - 1; i >= 0; i -= 1) {\n    const sp = sparks[i] as (typeof sparks)[number];\n    const age = t - sp.at;\n    if (age > sp.life) { sparks.splice(i, 1); continue; }\n    if (age < T.hitStopMs * 0.5) continue;\n    const u = age / sp.life; const s = age / 1000 * (1 - u * 0.5);\n    const x = sp.x + sp.vx * s; const y = sp.y + sp.vy * s;\n    ctx.strokeStyle = u < 0.4 ? '#ffffff' : '#ffd166'; ctx.globalAlpha = 1 - u; ctx.lineWidth = (2.5 + 4 * (1 - u)) / Math.max(0.5, scale);\n    ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x - sp.vx * 0.022 * (1 - u), y - sp.vy * 0.022 * (1 - u)); ctx.stroke();\n  }\n  ctx.globalAlpha = 1; ctx.lineCap = 'butt';\n}\n\n/* -------------------------------------------------------------- the Game Lab */\n/*\n * What the lab shows of a knock (lab.json's take \"knock\"): the body bumped last is the subject. Every frame its phase,\n * its speed and its distance from where it was hit go to the lab, and its pose feeds the onion skin and the spacing\n * arc (dots far apart: fast; close together: slow). Only in the lab: outside it, labKnock and labReport never run.\n */\ntype Pose = { x: number; y: number; sx?: number; sy?: number; a?: number };\n/**\n * The take's stage \"dummy\" (lab.stage, only ever set by the lab): you stand left of the middle with a bot a short step\n * to your right, and every bot stands still unless it is bumped, like a training dummy. The knock alone, every time.\n */\nfunction stageDummy(): void {\n  const seat = mySeat();\n  const list = [...bodies.values()].sort((a, c) => a.slot - c.slot);\n  const mine = list.find((b) => !b.bot && b.seat === seat);\n  const bots = list.filter((b) => b.bot);\n  if (mine) { mine.x = W / 2 - 160; mine.y = H / 2; hostMoved(mine); }\n  bots.forEach((b, i) => { b.x = i === 0 ? W / 2 - 160 + 92 : W - 180; b.y = i === 0 ? H / 2 : 150 + (i - 1) * 700; b.tx = b.x; b.ty = b.y; });\n}\n/** A dummy at rest: it eases to a stop wherever the last bump left it. */\nfunction standStill(b: Body, dt: number): void {\n  const k = Math.min(1, dt * 5);\n  b.vx -= b.vx * k; b.vy -= b.vy * k;\n  b.x = Math.max(R_AV, Math.min(W - R_AV, b.x + b.vx * dt)); b.y = Math.max(R_AV, Math.min(H - R_AV, b.y + b.vy * dt));\n}\nconst subject = { slot: -1, at: 0, x0: 0, y0: 0, px: 0, py: 0, has: false };\nfunction labKnock(b: Body): void {\n  if (!lab.on) return;\n  subject.slot = b.slot; subject.at = net.now(); subject.x0 = b.x; subject.y0 = b.y; subject.px = b.x; subject.py = b.y; subject.has = true;\n}\n/** Where the subject is now (the host's own body is `me`), or null. */\nfunction subjectAt(): Pose | null {\n  if (!subject.has) return null;\n  const b = bodies.get(subject.slot);\n  if (!b) return null;\n  return !b.bot && b.seat !== null && b.seat === mySeat() ? me : b;\n}\nfunction labReport(dt: number): void {\n  const at = subjectAt();\n  if (!at || dt <= 0) { lab.phase(null); return; }\n  const age = net.now() - subject.at;\n  lab.track('speed', Math.hypot(at.x - subject.px, at.y - subject.py) / dt, 'px/s');\n  lab.track('distance', Math.hypot(at.x - subject.x0, at.y - subject.y0), 'px');\n  subject.px = at.x; subject.py = at.y;\n  const fx = bodyFx(subject.slot, performance.now());\n  lab.track('stretch', (fx.sx - 1) * 100, '%');\n  if (age < T.hitStopMs) lab.phase('HIT-STOP', 'The hit lands: hold, flash, squash');\n  else if (age < T.hitStopMs + T.knockMs * 0.3) lab.phase('LAUNCH', 'Leaves fast, stretched along the hit');\n  else if (age < T.hitStopMs + T.knockMs) lab.phase('SLIDE', 'Eases into the stop: no creep, no pop');\n  else if (age < T.hitStopMs + T.knockMs + T.settleMs) lab.phase('SETTLE', 'Squash on the stop, overlap on the way out');\n  else lab.phase(null);\n  lab.pose('subject', { x: at.x, y: at.y, sx: fx.sx, sy: fx.sy, a: fx.ang });\n}\n/** The lab's views: the game's own camera, close on the knock, the whole arena. */\nconst labView = lab.camera<{ zoom?: number; whole?: boolean } | null>({ game: null, close: { zoom: 2.4 }, arena: { whole: true } });\nlab.overlay('onion', (c, k) => {\n  const g = c as CanvasRenderingContext2D; const s = Number(k) || 1;\n  const ghosts = lab.past<Pose>('subject', 8, 3);\n  ghosts.forEach((p, i) => { g.globalAlpha = 0.5 * (1 - i / ghosts.length); g.strokeStyle = '#ffad3b'; g.lineWidth = 2 / s; g.beginPath(); g.ellipse(p.x, p.y, R_AV * (p.sx ?? 1), R_AV * (p.sy ?? 1), p.a ?? 0, 0, Math.PI * 2); g.stroke(); });\n  g.globalAlpha = 1;\n});\nlab.overlay('arcs', (c, k) => {\n  const g = c as CanvasRenderingContext2D; const s = Number(k) || 1;\n  const pts = lab.past<Pose>('subject', 90, 1);\n  if (pts.length < 2) return;\n  g.strokeStyle = 'rgba(124,196,255,.55)'; g.lineWidth = 1.5 / s;\n  g.beginPath(); pts.forEach((p, i) => (i ? g.lineTo(p.x, p.y) : g.moveTo(p.x, p.y))); g.stroke();\n  g.fillStyle = '#7cc4ff';\n  for (const p of pts) { g.beginPath(); g.arc(p.x, p.y, 2.5 / s, 0, Math.PI * 2); g.fill(); }\n});\nlab.overlay('reach', (c, k) => {\n  if (!me.has) return;\n  const g = c as CanvasRenderingContext2D; const s = Number(k) || 1;\n  g.strokeStyle = 'rgba(255,255,255,.35)'; g.lineWidth = 1.5 / s; g.setLineDash([8 / s, 6 / s]);\n  g.beginPath(); g.arc(me.x, me.y, T.knockRange, 0, Math.PI * 2); g.stroke(); g.setLineDash([]);\n});\n\n/* ------------------------------------------------------------------ draw */\nlet dpr = 1;\nfunction resize(): void {\n  dpr = Math.min(2, devicePixelRatio || 1);\n  canvas.width = Math.round(innerWidth * dpr);\n  canvas.height = Math.round(innerHeight * dpr);\n}\naddEventListener('resize', resize);\nresize();\n\nconst cam = { x: W / 2, y: H / 2, scale: 0 };\nconst labels = createLabels({\n  screen: () => ({ w: innerWidth, h: innerHeight }),\n  avoid: () => [{ left: Math.max(0, innerWidth - 190), top: 0, right: innerWidth, bottom: Math.min(innerWidth, innerHeight) <= 540 ? 205 : 230 }],\n});\n// Room chat (NETPLAY.md section 19): what a player says in the room's chat (the play page's Chat, or a quick line) shows\n// over their body for a few seconds, when they want it there. The studio taking a message down takes its bubble too.\nconst bubbles = createBubbles({ measure: (t) => { ctx.font = BUBBLE_FONT; return ctx.measureText(t).width; }, screen: () => ({ w: innerWidth, h: innerHeight }) });\nnet.on('say', (s) => bubbles.say(s.seat, s.text, { id: s.id, kind: s.kind }));\nnet.on('unchat', (e) => { for (const id of e.ids) bubbles.remove(id); });\nlet shownBubbles: BubbleOut[] = [];\nlet shownLabels: LabelOut[] = [];\nlet lastDraw = 0;\n/** Where a seat's body is drawn now (host: the real body; replica: interpolated), or null. */\nfunction seatPos(seat: number): { x: number; y: number } | null {\n  if (seat === mySeat() && me.has && !net.watching) return me;\n  if (hosting) { const b = [...bodies.values()].find((x) => !x.bot && x.seat === seat); return b ? { x: b.x, y: b.y } : null; }\n  const d = [...drawn.values()].find((x) => x.seat === seat);\n  return d ? { x: d.x, y: d.y } : null;\n}\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  // The view: my own body, or (a watcher) the followed player's, drawn exactly as their own browser frames it.\n  const view = viewSeat();\n  const followed = net.watching && view !== null ? seatPos(view) : null;\n  const overview = net.watching ? !followed : mySeat() === null;\n  // The camera: close on the body whose view this is, never past the arena's edge (except as far as it takes to keep\n  // that body clear of the HUD), and never so far out that an empty band shows beside the arena (held upright, a\n  // phone showed the arena as a band with a third of the screen dark below or above it; now the arena fills the\n  // screen). Following nobody: the whole arena.\n  const focus = net.watching ? followed : overview || !me.has ? null : me;\n  // The Game Lab's views (outside the lab: the game's own camera, always).\n  const lv = labView();\n  const want = overview || lv?.whole ? { scale: Math.min(cw / (W + 80), ch / (H + 80)), x: W / 2, y: H / 2 }\n    : fitView({ world: { w: W, h: H }, screen: { w: cw, h: ch }, readable: 0, zoom: (Math.min(cw, ch) / (phone ? 560 : 820)) * (lv?.zoom ?? 1), focus: lv?.zoom ? subjectAt() ?? focus : focus, inset: { top: phone ? 175 : 210, bottom: phone ? 145 : 50, left: 40, right: 40 } });\n  // A watcher's switch glides: the zoom and the pan ease over a few frames, never a cut.\n  cam.scale = cam.scale ? cam.scale + (want.scale - cam.scale) * 0.12 : want.scale;\n  const scale = cam.scale;\n  cam.x += (want.x - cam.x) * 0.2; cam.y += (want.y - cam.y) * 0.2;\n  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n  const bg = ctx.createRadialGradient(cw / 2, ch / 2, 0, cw / 2, ch / 2, Math.max(cw, ch) * 0.8);\n  bg.addColorStop(0, '#47233d'); bg.addColorStop(1, ink.bg);\n  ctx.fillStyle = bg; ctx.fillRect(0, 0, cw, ch);\n\n  // The camera's kick (a bump you were in): a few px, gone in a fifth of a second.\n  const kick = t - kickAt < 200 ? T.shake * (1 - (t - kickAt) / 200) ** 2 : 0;\n  ctx.save();\n  ctx.translate(cw / 2 + (kick ? (lab.random() - 0.5) * 2 * kick : 0), ch / 2 + (kick ? (lab.random() - 0.5) * 2 * kick : 0)); ctx.scale(scale, scale); ctx.translate(-cam.x, -cam.y);\n  // Layered festival sky: mountains, clouds and wind streaks, all drawn locally.\n  ctx.fillStyle = '#291a35'; ctx.fillRect(0, 0, W, H);\n  for (let band=0;band<3;band++) {\n    ctx.fillStyle = ['#38233e','#513047','#72404c'][band]; ctx.beginPath(); ctx.moveTo(0,H);\n    for(let x=0;x<=W+100;x+=80) ctx.lineTo(x,H-70-band*65-Math.sin(x*.009+band*2)*65-Math.cos(x*.022)*22);\n    ctx.lineTo(W,H);ctx.closePath();ctx.fill();\n  }\n  for(let i=0;i<35;i++) {\n    const x=(i*197+47)%W, y=(i*131+77)%(H-170);\n    ctx.fillStyle='rgba(255,222,167,.45)';ctx.beginPath();ctx.arc(x,y,1.5+Math.sin(t*.001+i)*.5,0,Math.PI*2);ctx.fill();\n  }\n  const gust=wind(); ctx.strokeStyle='rgba(255,213,156,.12)';ctx.lineWidth=2;\n  for(let i=0;i<24;i++) {\n    const x=((i*233+t*.035*gust.x)%W+W)%W, y=((i*167+t*.035*gust.y)%H+H)%H;\n    ctx.beginPath();ctx.moveTo(x,y);ctx.lineTo(x+gust.x*35,y+gust.y*35);ctx.stroke();\n  }\n  ctx.strokeStyle=ink.accent;ctx.globalAlpha=.35;ctx.lineWidth=5;ctx.strokeRect(0,0,W,H);ctx.globalAlpha=1;\n  // hot zone (keyed state): gems inside score double\n  if (zone) {\n    const pulse = 0.5 + 0.5 * Math.sin(t / 300);\n    ctx.fillStyle = `rgba(255,120,190,${0.06 + 0.05 * pulse})`; ctx.beginPath(); ctx.arc(zone.x, zone.y, zone.r, 0, Math.PI * 2); ctx.fill();\n    ctx.strokeStyle = 'rgba(255,140,200,0.6)'; ctx.lineWidth = 3; ctx.setLineDash([14, 10]); ctx.stroke(); ctx.setLineDash([]);\n    ctx.fillStyle = 'rgba(255,170,215,0.85)'; ctx.font = '700 16px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'center';\n    ctx.fillText('UPDRAFT ×2', zone.x, zone.y + 6);\n  }\n\n  // gems\n  const gemList: { x: number; y: number; id: number }[] = hosting ? gems : (net.latest()?.d.g ?? []).map(([id, x, y]) => ({ id, x, y }));\n  for (const g of gemList) {\n    const spin = t / 500 + g.id;\n    const r = R_GEM * (1 + 0.12 * Math.sin(t / 200 + g.id));\n    ctx.save(); ctx.translate(g.x, g.y);\n    ctx.globalCompositeOperation = 'lighter';\n    ctx.fillStyle = gemGlow; ctx.beginPath(); ctx.arc(0, 0, r * 2.6, 0, Math.PI * 2); ctx.fill();\n    ctx.globalCompositeOperation = 'source-over';\n    ctx.rotate(spin);\n    ctx.fillStyle = '#ffd166'; ctx.beginPath(); ctx.moveTo(0, -r); ctx.lineTo(r * 0.8, 0); ctx.lineTo(0, r); ctx.lineTo(-r * 0.8, 0); ctx.closePath(); ctx.fill();\n    ctx.restore();\n  }\n\n  // waves\n  for (let i = waves.length - 1; i >= 0; i -= 1) {\n    const w = waves[i] as (typeof waves)[number];\n    const age = (t - w.at) / T.ringMs;\n    if (age > 1) { waves.splice(i, 1); continue; }\n    ctx.strokeStyle = w.colour; ctx.globalAlpha = 1 - age; ctx.lineWidth = w.knock ? 3 : 5;\n    ctx.beginPath(); ctx.arc(w.x, w.y, w.knock ? R_AV + 6 + age * 40 : R_AV + age * (T.knockRange + 10), 0, Math.PI * 2); ctx.stroke(); ctx.globalAlpha = 1;\n  }\n\n  // avatars\n  const names = new Map<number, Slot>((net.slots ?? roster.slots).map((s) => [s.slot, s]));\n  // `mine`: the body whose view this is: my own, or the player a watcher follows (named, never \"You\").\n  const list: { slot: number; seat: number | null; x: number; y: number; bot: boolean; name: string; mine: boolean }[] = [];\n  const isView = (seat: number | null, bot: boolean): boolean => !bot && seat !== null && seat === view;\n  if (hosting) {\n    for (const b of bodies.values()) list.push({ slot: b.slot, seat: b.seat, x: b.x, y: b.y, bot: b.bot, name: b.name, mine: isView(b.seat, b.bot) });\n  } else {\n    for (const d of drawn.values()) {\n      const own = !net.watching && d.seat >= 0 && d.seat === net.seat;\n      const s = names.get(d.slot);\n      list.push({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, x: own && me.has ? me.x : d.x, y: own && me.has ? me.y : d.y, bot: d.seat < 0, name: s?.name ?? (d.seat >= 0 ? `Player ${d.seat + 1}` : botName(d.slot)), mine: isView(d.seat >= 0 ? d.seat : null, d.seat < 0) });\n    }\n  }\n  // Names go on after the arena, on the screen, so they read at one size on any screen and never pile up.\n  const fs = Math.round(Math.max(15, Math.min(22, (15 * Math.min(cw, ch)) / 720)));\n  ctx.font = `600 ${fs}px ui-sans-serif, system-ui, sans-serif`;\n  const tags: LabelIn[] = [];\n  const heads = new Map<number, { x: number; y: number; seat: number; mine: boolean }>();\n  const viewAt = list.find((a) => a.mine) ?? null;\n  for (const a of list) {\n    const colour = colourOf(a.slot, a.bot ? null : a.seat);\n    const fx = bodyFx(a.slot, t);\n    ctx.save();\n    ctx.translate(a.x + fx.ox, a.y + fx.oy); ctx.rotate(fx.ang); ctx.scale(fx.sx, fx.sy); ctx.rotate(-fx.ang);\n    ctx.globalAlpha = a.bot ? 0.85 : 1;\n    // A cloth diamond, cross spars and a flowing tail distinguish pilots from sparks.\n    ctx.rotate(Math.sin(t*.002+a.slot)*.14);\n    ctx.strokeStyle=colour;ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(0,29);\n    for(let j=1;j<13;j++) ctx.lineTo(Math.sin(t*.006-j*.55+a.slot)*j*1.2,29+j*5);ctx.stroke();\n    ctx.fillStyle = fx.flash > .4 ? '#ffffff' : colour;\n    ctx.beginPath();ctx.moveTo(0,-32);ctx.lineTo(25,-3);ctx.lineTo(0,32);ctx.lineTo(-25,-3);ctx.closePath();ctx.fill();\n    ctx.fillStyle='rgba(255,255,255,.25)';ctx.beginPath();ctx.moveTo(0,-32);ctx.lineTo(25,-3);ctx.lineTo(0,32);ctx.closePath();ctx.fill();\n    ctx.strokeStyle='rgba(255,249,231,.8)';ctx.lineWidth=1.4;ctx.beginPath();ctx.moveTo(0,-30);ctx.lineTo(0,30);ctx.moveTo(-23,-3);ctx.lineTo(23,-3);ctx.stroke();\n    ctx.restore();\n    if (a.mine) { ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(a.x, a.y, R_AV + 7, 0, Math.PI * 2); ctx.stroke(); }\n    ctx.globalAlpha = 1;\n    const text = a.mine && !net.watching ? 'You' : label(a.name, a.bot);\n    const sx = cw / 2 + (a.x - cam.x) * scale; const sy = ch / 2 + (a.y - cam.y) * scale; const r = (R_AV + (a.mine ? 7 : 0)) * scale;\n    if (sx + r < 0 || sx - r > cw || sy + r < 0 || sy - r > ch) continue; // off screen: no name at the edge\n    if (a.seat !== null && !a.bot) heads.set(a.slot, { x: sx, y: sy - r - 5, seat: a.seat, mine: a.mine });\n    // People before bots, nearer the view's body first; each keeps off the others' bodies when it can.\n    tags.push({ key: a.slot, text, x: sx, y: sy - r - 5, w: ctx.measureText(text).width, h: fs * 1.2, below: sy + r + 4 + fs * 1.2, body: { left: sx - r, top: sy - r, right: sx + r, bottom: sy + r }, self: a.mine, rank: (a.bot ? 10_000 : 0) + (viewAt ? Math.hypot(a.x - viewAt.x, a.y - viewAt.y) : 0) });\n  }\n  drawSparks(t, scale);\n  lab.draw(ctx, scale);\n  ctx.restore();\n  // The label helper always prioritizes the local name; explicitly put it below the kite near the board.\n  for (const tag of tags) {\n    if (tag.self && tag.x > cw - 220 && tag.y < (phone ? 205 : 230) && tag.below !== undefined) tag.y = tag.below;\n  }\n  shownLabels = labels.place(tags, Math.min(0.1, (t - (lastDraw || t)) / 1000));\n  lastDraw = t;\n  ctx.font = `600 ${fs}px ui-sans-serif, system-ui, sans-serif`;\n  ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.lineJoin = 'round';\n  for (const l of [...shownLabels].sort((x, y) => Number(Boolean(x.self)) - Number(Boolean(y.self)))) {\n    if (l.alpha <= 0) continue;\n    ctx.globalAlpha = l.alpha;\n    if (l.moved) {\n      // Off its own spot: a thin line to its body says whose it is.\n      const under = l.top > l.y;\n      ctx.strokeStyle = 'rgba(232,236,245,0.4)'; ctx.lineWidth = 1;\n      ctx.beginPath(); ctx.moveTo(l.cx, under ? l.top : l.bottom); ctx.lineTo(l.x, under ? (l.body?.bottom ?? l.top - 6) : l.y + 2); ctx.stroke();\n    }\n    if (l.self) { ctx.fillStyle = 'rgba(6,10,20,0.7)'; ctx.beginPath(); ctx.roundRect(l.left - 5, l.top - 1, l.right - l.left + 10, l.bottom - l.top + 2, 6); ctx.fill(); }\n    else { ctx.strokeStyle = 'rgba(4,6,12,0.85)'; ctx.lineWidth = 3; ctx.strokeText(l.text, l.cx, l.cy); }\n    ctx.fillStyle = l.self ? '#ffffff' : 'rgba(232,236,245,0.85)';\n    ctx.fillText(l.text, l.cx, l.cy);\n  }\n  ctx.globalAlpha = 1; ctx.textBaseline = 'alphabetic';\n  // Speech bubbles (room chat): over each speaker's name, the view's own player first.\n  if (bubbles.size) {\n    const anchors: BubbleIn[] = [];\n    for (const [slot, h] of heads) {\n      const l = shownLabels.find((x) => x.key === slot && x.alpha > 0 && !x.moved);\n      anchors.push({ key: h.seat, x: l ? l.cx : h.x, y: l ? l.top - 1 : h.y, self: h.mine });\n    }\n    shownBubbles = bubbles.place(anchors);\n    paintBubbles(ctx, shownBubbles);\n  } else shownBubbles = [];\n\n  const ownScore= hosting ? [...bodies.values()].find(b=>b.seat===mySeat())?.score : [...drawn.values()].find(b=>b.seat===mySeat())?.score;\n  if((ownScore??0)>heardScore) sound.play('coin'); heardScore=ownScore??0;\n  if(round?.phase==='over' && round.n!==heardRound){heardRound=round.n;sound.play('win');}\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 ranked = [...list].sort((a, b) => (scores.get(b.slot) ?? 0) - (scores.get(a.slot) ?? 0));\n  const rows = ranked.slice(0, phone ? 4 : 6);\n  // A compact board must still show the player's progress when they trail the leaders.\n  const self = ranked.find(a => a.mine);\n  if (self && !rows.includes(self)) rows[rows.length - 1] = self;\n  ctx.font = `600 ${phone ? 14 : 16}px ui-sans-serif, system-ui, sans-serif`;\n  ctx.textAlign = 'right';\n  // The play page's room button (and a server's pill beside it) sit at the top right (game.json screen.share's\n  // default): the scores start under that band, so the buttons never cover a score.\n  const board = top + 18 + 44;\n  rows.forEach((a, i) => {\n    ctx.fillStyle = a.mine ? '#ffffff' : a.bot ? 'rgba(232,236,245,0.55)' : 'rgba(232,236,245,0.85)';\n    ctx.fillText(`${ranked.indexOf(a) + 1}. ${a.mine && !net.watching ? 'You' : label(a.name, a.bot)}  ${scores.get(a.slot) ?? 0}`, cw - pad, board + i * (phone ? 20 : 22));\n  });\n  // role badge\n  ctx.textAlign = 'left'; ctx.font = '600 11px ui-monospace, Menlo, monospace'; ctx.fillStyle = 'rgba(125,240,255,0.7)';\n  if(!phone) ctx.fillText('CINDER CIRCUIT',pad,top+62);\n  ctx.fillStyle=ink.accent;ctx.font='700 12px system-ui';ctx.fillText('WIND '+wind().name, pad, top+42);\n  const mine=list.find(a=>a.mine);const cooldown=mine ? Math.max(0,1600-(net.now()-(burstAt.get(mine.slot)??-1e9))) : 0;\n  if(phone && !net.watching){ctx.fillStyle=cooldown?'#503648':'#f4b23c';ctx.beginPath();ctx.arc(cw-57,ch-85,34,0,Math.PI*2);ctx.fill();ctx.fillStyle=cooldown?ink.ink:ink.bg;ctx.textAlign='center';ctx.font='800 12px system-ui';ctx.fillText(cooldown?'WAIT':'DASH',cw-57,ch-81);}\n  if (r && r.phase === 'over' && r.results) {\n    const w = Math.min(360, cw - 40); const h = 44 + Math.min(6, r.results.length) * 26;\n    const x = (cw - w) / 2; const y = (ch - h) / 2;\n    ctx.fillStyle = 'rgba(6,10,20,0.86)'; ctx.fillRect(x, y, w, h);\n    ctx.strokeStyle = 'rgba(125,240,255,0.5)'; ctx.strokeRect(x, y, w, h);\n    ctx.textAlign = 'center'; ctx.fillStyle = '#7df0ff'; ctx.font = '700 18px ui-sans-serif, system-ui, sans-serif';\n    ctx.fillText(`Flight ${r.n} · cinders collected`, cw / 2, y + 28);\n    ctx.font = '600 16px ui-sans-serif, system-ui, sans-serif';\n    const view = viewSeat();\n    r.results.slice(0, 6).forEach((row, i) => {\n      const mine = !row.bot && row.seat !== null && row.seat === view;\n      ctx.fillStyle = mine ? '#ffffff' : row.bot ? 'rgba(232,236,245,0.6)' : '#e8ecf5';\n      ctx.fillText(`${row.place}. ${mine && !net.watching ? 'You' : label(row.name, row.bot)}${mine && net.watching ? ' ◂' : ''} — ${row.score}`, cw / 2, y + 56 + i * 26);\n    });\n  }\n  if (!net.offline && !net.connected && net.role !== 'host') {\n    ctx.textAlign = 'center'; ctx.fillStyle = '#ffd166'; ctx.font = '600 14px ui-sans-serif, system-ui, sans-serif';\n    ctx.fillText('Reconnecting…', cw / 2, ch - pad);\n  }\n  if (frames < 240 && mySeat() !== null) {\n    ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(232,236,245,0.7)'; ctx.font = '500 14px ui-sans-serif, system-ui, sans-serif';\n    ctx.fillText(phone ? 'Drag to fly · tap DASH' : 'WASD / arrows to fly · Space to dash', cw / 2, ch - pad - 18);\n  }\n}\n\n/* ------------------------------------------------------------------ loop */\nfunction frame(t: number): void {\n  const dt = lab.time.dt(t, 0.05);\n  stepMe(dt);\n  if (hosting) stepHost(dt);\n  else stepReplica(dt);\n  if (lab.on) labReport(dt);\n  draw(t);\n  frames += 1;\n  requestAnimationFrame(frame);\n}\n\nnet.expose({\n  // Room chat's bubbles on this screen now: whose, what, and where (an end-to-end test reads them).\n  bubbles: () => shownBubbles.map((b) => ({ seat: b.key, text: b.lines.join(' '), alpha: b.alpha, left: Math.round(b.left), top: Math.round(b.top), right: Math.round(b.right), bottom: Math.round(b.bottom) })),\n  self: () => (me.has ? { x: me.x, y: me.y } : null),\n  peer: (seat: number) => {\n    if (hosting) { const b = [...bodies.values()].find((x) => x.seat === seat); return b ? { x: b.x, y: b.y } : null; }\n    const d = [...drawn.values()].find((x) => x.seat === seat);\n    return d ? { x: d.x, y: d.y } : null;\n  },\n  frames: () => frames,\n  scores: () => (hosting ? [...bodies.values()].map((b) => ({ slot: b.slot, seat: b.seat, bot: b.bot, score: b.score })) : [...drawn.values()].map((d) => ({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, bot: d.seat < 0, score: d.score }))),\n  /** Host: gems each slot picked up this round, the round's age, and the dial each bot plays at (the dial's numbers). */\n  pickups: () => (hosting ? { round: round ? { n: round.n, phase: round.phase, ageMs: net.now() - round.startedAt } : null, slots: [...bodies.values()].map((b) => ({ slot: b.slot, bot: b.bot, gems: pickups.get(b.slot) ?? 0, level: b.bot ? net.skillOf(b.slot).level : null })) } : null),\n  /** Host: when each bot last looked (its reaction clock). */\n  sight: () => (hosting ? [...sight.entries()].map(([slot, e]) => ({ slot, at: e.at })) : null),\n  waves: () => wavesSeen,\n  knocks: () => knocksSeen,\n  zone: () => zone,\n  owned: () => net.owned,\n  cheatResets: () => cheatResets,\n  movement: () => MOVEMENT,\n  controlResets: () => controlResets,\n  predictionError: () => predictionError,\n  /** Where the camera looks and whose view it is (a watcher following a player: their body, under the camera). */\n  camera: () => ({ x: cam.x, y: cam.y, scale: cam.scale, view: viewSeat() }),\n  /** The names as drawn (boxes, never the text): the e2e probe counts overlaps and checks your own. */\n  labels: () => shownLabels.map((l) => ({ self: Boolean(l.self), alpha: l.alpha, moved: l.moved, left: Math.round(l.left), top: Math.round(l.top), right: Math.round(l.right), bottom: Math.round(l.bottom) })),\n  /** performance.now() of the first keyed-state value and the first live snapshot this browser received. */\n  arrivals: () => ({ firstStateAt, firstSnapAt }),\n  /** Harness hook (host only): knock the body of `seat` back, toward the middle of the arena. */\n  debugKnock: (seat: number) => {\n    if (!hosting) return false;\n    const b = [...bodies.values()].find((x) => x.seat === seat);\n    if (!b) return false;\n    knock(b, b.x < W / 2 ? b.x - 1 : b.x + 1, b.y, b.slot); // 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","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    const soften = ctx.createBiquadFilter(); soften.type = \"lowpass\"; soften.frequency.value = 6500; soften.Q.value = 0.5;\n    const air = ctx.createBiquadFilter(); air.type = \"notch\"; air.frequency.value = 10230; air.Q.value = 1;\n    buses.master.connect(comp).connect(soften).connect(air).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\": \"#201527\",\n    \"ink\": \"#fff2dd\",\n    \"accent\": \"#ffba62\",\n    \"accent2\": \"#86d9ce\",\n    \"danger\": \"#ff5a4e\",\n    \"good\": \"#5fdc8b\",\n    \"gold\": \"#f2c14e\",\n    \"ramp\": [\n      \"#120c0a\",\n      \"#2b1d16\",\n      \"#4a2e20\",\n      \"#6e3b22\",\n      \"#a4512a\",\n      \"#e0702f\",\n      \"#f4b23c\",\n      \"#ffd98a\",\n      \"#f6e7d3\",\n      \"#7ad38a\",\n      \"#3f6b4a\",\n      \"#5a4a6e\"\n    ]\n  },\n  \"fonts\": {\n    \"display\": \"Fredoka\",\n    \"body\": \"Nunito\"\n  },\n  \"light\": {\n    \"time\": \"golden\",\n    \"key\": [\n      -0.85,\n      -0.55,\n      -0.25\n    ],\n    \"intensity\": 2.4,\n    \"hardness\": 0.55,\n    \"sky\": \"#f9d594\",\n    \"ground\": \"#9fc973\",\n    \"fog\": 0.45,\n    \"bloom\": 0.25\n  },\n  \"camera\": {\n    \"angle\": \"top-down\",\n    \"projection\": \"perspective\",\n    \"pitch\": 80,\n    \"distance\": 24,\n    \"fov\": 35\n  },\n  \"render\": \"vector-2d\",\n  \"materials\": {\n    \"model\": \"flat\",\n    \"outline\": false,\n    \"texelDensity\": null,\n    \"atlas\": true\n  },\n  \"rev\": {\n    \"style.palette\": 1,\n    \"style.ui\": 1,\n    \"style.light\": 1,\n    \"style.camera\": 1\n  }\n}\n","tunables.json":"{\n  \"knockRange\": { \"value\": 110, \"min\": 60, \"max\": 200, \"step\": 1, \"unit\": \"px\", \"group\": \"Knock\", \"note\": \"How near a body must be for a wave to bump it\" },\n  \"hitStopMs\": { \"value\": 70, \"min\": 0, \"max\": 200, \"step\": 5, \"unit\": \"ms\", \"group\": \"Knock\", \"note\": \"The hit lands: the bumped body holds, squashed and shaking, before it flies\" },\n  \"knockDistance\": { \"value\": 185, \"min\": 40, \"max\": 400, \"step\": 5, \"unit\": \"px\", \"group\": \"Knock\", \"note\": \"How far a bump sends a body, the same at any frame rate\" },\n  \"knockMs\": { \"value\": 420, \"min\": 120, \"max\": 1000, \"step\": 10, \"unit\": \"ms\", \"group\": \"Knock\", \"note\": \"How long the slide takes\" },\n  \"knockEase\": { \"value\": 4, \"min\": 1, \"max\": 6, \"step\": 0.1, \"group\": \"Knock\", \"note\": \"1 slides at one speed; higher leaves faster and eases into the stop\" },\n  \"squash\": { \"value\": 0.35, \"min\": 0, \"max\": 0.8, \"step\": 0.01, \"group\": \"Body\", \"note\": \"How much a flying body stretches along the hit, and squashes when it stops\" },\n  \"settleMs\": { \"value\": 260, \"min\": 0, \"max\": 700, \"step\": 10, \"unit\": \"ms\", \"group\": \"Body\", \"note\": \"The wobble after the stop (overlap)\" },\n  \"flashMs\": { \"value\": 110, \"min\": 0, \"max\": 300, \"step\": 5, \"unit\": \"ms\", \"group\": \"Hit\", \"note\": \"How long the bumped body flashes white\" },\n  \"shake\": { \"value\": 7, \"min\": 0, \"max\": 24, \"step\": 0.5, \"unit\": \"px\", \"group\": \"Hit\", \"note\": \"The camera's kick when you bump or are bumped\" },\n  \"sparks\": { \"value\": 9, \"min\": 0, \"max\": 24, \"step\": 1, \"group\": \"Hit\", \"note\": \"Sparks thrown off the hit\" },\n  \"ringMs\": { \"value\": 900, \"min\": 200, \"max\": 2000, \"step\": 10, \"unit\": \"ms\", \"group\": \"Wave\", \"note\": \"How long a wave's ring lasts\" }\n}\n"}}
