{"v":1,"kind":"homie-game-source","id":"signal-bloom","credit":{"studio":"Copperwind Arcade","game":"Signal Bloom","page":"https://codex.homie.rocks/signal-bloom/"},"license":{"kind":"remix-with-credit","spdx":null},"files":{"game.json":"{\n  \"id\": \"signal-bloom\",\n  \"name\": \"Signal Bloom\",\n  \"blurb\": \"Tap when the rings meet. Grow a glowing flower, build a streak, and race your friends through a final flourish.\",\n  \"players\": {\n    \"min\": 1,\n    \"max\": 4\n  },\n  \"roundSeconds\": 45,\n  \"entry\": \"src/main.ts\",\n  \"netplay\": {\n    \"v\": 1,\n    \"public\": true,\n    \"movement\": \"host\"\n  },\n  \"landing\": {\n    \"pitch\": \"One tap. Two rings. A garden in bloom.\",\n    \"about\": \"A tiny timing contest for friends and strangers. Tap when the shrinking ring touches the flower rim. Accurate taps grow petals; four in a row earn a bonus. The last nine seconds speed up into a shared final flourish.\",\n    \"howToPlay\": [\n      \"Watch the moving ring shrink toward the ivory rim.\",\n      \"Tap once when they meet: a good bloom earns two points, a perfect bloom three.\",\n      \"Four accurate taps in a row earn two bonus points. Early or late resets the streak.\",\n      \"If every active flower hits a pulse, the next turns gold: each hit earns two extra points. After three pulses without a tap, a flower rests until it taps again. Gold needs at least two active flowers.\",\n      \"The pulse speeds up at the end. Most points after 45 seconds wins.\",\n      \"Joining after the first pulse? Practise now; your ranked round starts with everyone else next round.\"\n    ],\n    \"controls\": {\n      \"phone\": \"Tap anywhere on the garden when the rings meet. One tap per pulse.\",\n      \"computer\": \"Space, Enter, or click when the rings meet. One tap per pulse.\"\n    },\n    \"players\": {\n      \"one\": \"gardener\",\n      \"many\": \"gardeners\"\n    },\n    \"hero\": {\n      \"alt\": \"Luminous coral and mint flowers with moving timing rings in an indigo greenhouse.\",\n      \"tint\": 15\n    },\n    \"credits\": [\n      {\n        \"role\": \"Design and procedural art\",\n        \"name\": \"Copperwind Arcade\"\n      },\n      {\n        \"role\": \"Multiplayer foundation\",\n        \"name\": \"Homie\"\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    \"Rhythm\",\n    \"Party\"\n  ],\n  \"launch\": \"public\",\n  \"screen\": {\n    \"share\": {\n      \"desk\": \"top-right\",\n      \"phone\": {\n        \"at\": \"top-right\",\n        \"y\": 65\n      },\n      \"sideways\": \"top-right\"\n    },\n    \"chat\": {\n      \"lines\": \"sheet-only\"\n    }\n  },\n  \"chat\": {\n    \"lines\": {\n      \"gg\": \"Beautiful garden!\",\n      \"again\": \"One more bloom?\",\n      \"perfect\": \"Perfect timing!\"\n    }\n  },\n  \"cover\": \"cover.jpg\"\n}\n","index.html":"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1,viewport-fit=cover,user-scalable=no\"><title>Signal Bloom</title><link rel=\"icon\" href=\"data:,\"><style>html,body{margin:0;height:100%;background:#202341;overflow:hidden;overscroll-behavior:none;user-select:none}canvas{position:fixed;inset:0;width:100%;height:100%;touch-action:none}.sound{position:fixed;left:14px;top:70px;z-index:10;border:1px solid #89dfd5;border-radius:20px;background:#202341;color:#fff1d8;padding:7px 11px;font:600 12px system-ui;cursor:pointer}</style></head><body><canvas></canvas><script type=\"module\" crossorigin src=\"./assets/main.js\"></script></body></html>\n","public/sound/sound.json":"{\n  \"v\": 1,\n  \"sfx\": {\n    \"tap\": [\n      [\n        \"tap-1.ogg\",\n        \"tap-1.wav\"\n      ],\n      [\n        \"tap-2.ogg\",\n        \"tap-2.wav\"\n      ]\n    ],\n    \"bloom\": [\n      [\n        \"bloom-1.ogg\",\n        \"bloom-1.wav\"\n      ],\n      [\n        \"bloom-2.ogg\",\n        \"bloom-2.wav\"\n      ]\n    ],\n    \"miss\": [\n      [\n        \"miss-1.ogg\",\n        \"miss-1.wav\"\n      ]\n    ],\n    \"win\": [\n      [\n        \"win-1.ogg\",\n        \"win-1.wav\"\n      ]\n    ]\n  },\n  \"music\": {\n    \"signal-glow\": {\n      \"bpm\": 120,\n      \"beatsPerBar\": 4,\n      \"barSeconds\": 2,\n      \"loops\": {\n        \"main\": [\n          \"signal-glow-main-loop-8bars.ogg\",\n          \"signal-glow-main-loop-8bars.wav\"\n        ]\n      }\n    }\n  }\n}\n","src/main.ts":"import {createRoom, PALETTE, type BodyBase} from '@homie-rocks/studio/port';\nimport {pulseAt,attempt,practiceArrival,practiceTarget,separatePractice,freshGarden,bloomTogether,sharedFlowers,type Garden,type Bloom} from './rules';\nimport style from '../style.json';\nimport {createSound} from './sound.js';\nimport {paintSharedBloom} from './shared-bloom';\nconst C=style.palette;\ninterface Flower extends BodyBase,Bloom {plannedPulse:number;plannedAt:number;bonusHit:number;practice:boolean;lateRound:number;practiceGoal?:number}\nlet frames=0;\nlet garden=freshGarden();\nconst touchDevice=matchMedia('(pointer: coarse)').matches||navigator.maxTouchPoints>0;\nconst reducedMotion=matchMedia('(prefers-reduced-motion: reduce)');\nconst growth:Array<{slot:number;at:number}>=[];\nconst room=createRoom<Flower,Garden,Garden>({\n game:'signal-bloom',maxPlayers:4,minBodies:3,movement:'host',roundSeconds:45,breakSeconds:7,\n botName:s=>['Fern','Flicker','Dew','Aster'][s%4],\n spawn:s=>({...s,score:0,streak:0,lastPulse:-1,grade:0,at:0,error:0,plannedPulse:-1,plannedAt:0,bonusHit:-1,practice:false,lateRound:0,hits:0,practiceGoal:0}),\n pack:b=>[b.streak,b.lastPulse,b.grade,b.at,b.error,b.bonusHit,b.practice?1:0,b.lateRound,b.hits??-1,b.practiceGoal??-1],\n unpack:(f,b)=>{[b.streak,b.lastPulse,b.grade,b.at,b.error]=f;b.bonusHit=Number.isFinite(f[5])?f[5]:-1;b.practice=f[6]===1;b.lateRound=Number.isFinite(f[7])?f[7]:0;b.hits=Number.isFinite(f[8])&&f[8]>=0?f[8]:undefined;b.practiceGoal=Number.isFinite(f[9])&&f[9]>=0?f[9]:undefined;},\n discrete:[0,1,2,3,4,5,6,7,8,9],\n onRoundStart:()=>{garden=freshGarden();},fastWorld:()=>({...garden,activeHost:room.net.host?.id??'offline',practiceHost:room.net.host?.id??'offline'}),saveWorld:()=>garden,\n loadWorld:(saved,fast)=>{garden={...freshGarden(),...(fast??saved??{})};},\n onRoundEnd:rows=>{garden.resultOwners={...garden.owners};garden.practiceResults=separatePractice(rows,new Set([...room.bodies.values()].filter(b=>b.practice).map(b=>b.slot)));},\n onTakeover:admit,netplay:{caps:['skill','agents']},\n});\nconst net=room.net;\n// Only opt a browser into unranked results after it acknowledges the matching UI.\n// Older browsers keep their established ranked behavior; connection IDs prevent stale seat reuse.\nconst announcePractice=()=>{if(net.connected&&!room.hosting&&!net.watching)net.send('practice-ready',{v:1});};\nvoid net.ready.then(announcePractice);setInterval(announcePractice,500);\nfunction occupant(b:Flower):string|null{\n if(b.bot||b.seat===null)return null;\n if(b.seat===room.mySeat())return net.id??(net.offline?'offline':null);\n return [...net.peers.values()].find(p=>p.seat===b.seat)?.id??null;\n}\nfunction admit(b:Flower){\n // A new occupant must not inherit the previous flower's in-flight celebration.\n garden.sharedSlots=garden.sharedSlots?.filter(slot=>slot!==b.slot);\n Object.assign(b,{score:0,streak:0,lastPulse:-1,grade:0,at:0,error:0,bonusHit:-1,practice:false,hits:0,practiceGoal:0});\n b.lateRound=room.round&&practiceArrival(room.round.startedAt,net.now(),room.round.phase)?room.round.n:0;\n const id=occupant(b);if(id)(garden.owners??={})[b.slot]=id;\n}\nfunction acknowledgePractice(b:Flower){\n const r=room.round;if(!r||b.practice||b.lateRound!==r.n)return;\n b.practice=true;b.practiceGoal=r.phase==='live'?practiceTarget(r.startedAt,net.now(),b.lastPulse,b.hits??0):0;\n // A browser can finish loading just after the final pulse. Correct its result before the next start.\n if(r.phase==='over'&&r.results){\n  const moved=separatePractice(r.results,new Set([b.slot]));\n  garden.practiceResults=[...(garden.practiceResults??[]),...moved];\n  net.round(r);\n }\n}\n\n// Watching and TV are distinct shell modes; only a seated player owns a flower.\nconst playing=()=>!net.watching&&room.mySeat()!==null;\nconst sound=createSound({base:'sound/'});sound.music('signal-glow');sound.volume({master:.65,music:.5});\nconst canvas=document.querySelector('canvas')!,ctx=canvas.getContext('2d',{alpha:false})!;\nlet dpr=1;const resize=()=>{dpr=Math.min(devicePixelRatio||1,2);canvas.width=innerWidth*dpr;canvas.height=innerHeight*dpr;};addEventListener('resize',resize);resize();\nconst mute=document.createElement('button');mute.className='sound';mute.textContent='Sound on';mute.setAttribute('aria-label','Mute sound');let 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 lastTapPulse=-1,lastTapRound=-1,tapAt=-1e9,heardRound=-1;\nconst heard=new Map<number,number>();\nlet gardenRound=-1;\nfunction apply(b:Flower,at:number){\n if(room.round?.phase!=='live')return;\n const p=pulseAt(room.round.startedAt,at),bonus=garden.bonusPulse===p.id?2:0;\n if(attempt(b,room.round.startedAt,at,net.now(),bonus)){\n  if(b.grade>0){b.bonusHit=bonus?p.id:-1;if(b.seat!==null)net.spotlight(b.seat);}\n  bloomTogether(garden,[...room.bodies.values()],p.id);\n }\n}\nnet.on('event',e=>{\n if(room.hosting&&e.k==='practice-ready'&&(e.d as {v?:number})?.v===1&&e.id&&net.peers.get(e.id)?.seat===e.from){\n  const b=[...room.bodies.values()].find(b=>!b.bot&&b.seat===e.from);if(b)acknowledgePractice(b);return;\n }\n if(!room.hosting||e.k!=='bloom'||e.from===null||room.round?.phase!=='live')return;\n const d=e.d as {at?:unknown;round?:unknown};\n if(!d||d.round!==room.round.n||typeof d.at!=='number')return;\n const b=[...room.bodies.values()].find(b=>!b.bot&&b.seat===e.from);if(b)apply(b,d.at);\n});\nfunction tap(){\n if(!playing()||room.round?.phase!=='live')return;\n const at=net.now(),p=pulseAt(room.round.startedAt,at);\n if(lastTapRound===room.round.n&&lastTapPulse===p.id)return;\n lastTapRound=room.round.n;lastTapPulse=p.id;tapAt=performance.now();sound.play('tap');\n if(room.hosting){const b=room.mine();if(b)apply(b,at);}else net.send('bloom',{round:room.round.n,at});\n}\ncanvas.addEventListener('pointerdown',e=>{e.preventDefault();tap();});\naddEventListener('keydown',e=>{if(['Space','Enter'].includes(e.code)){e.preventDefault();if(!e.repeat)tap();}});\nfunction step(){\n if(!room.hosting)return;\n // An empty room can resume from a checkpoint without emitting onTakeover.\n // Compare connection identity, including the new host's own flower, before accepting input.\n for(const b of room.bodies.values()){const id=occupant(b);if(id&&garden.owners?.[b.slot]!==id)admit(b);}\n const mine=room.mine();if(mine)acknowledgePractice(mine);\n const r=room.round,now=net.now();\n if(r?.phase==='live')for(const b of room.bodies.values()){\n  if(!b.bot)continue;const p=pulseAt(r.startedAt,now),s=room.skillOf(b);\n  if(b.plannedPulse!==p.id){b.plannedPulse=p.id;const width=70+s.aimNoise*300+s.reactionMs*.25;b.plannedAt=p.target+(Math.random()-.5)*2*width;}\n  if(b.lastPulse<p.id&&now>=b.plannedAt)apply(b,now);\n }\n room.update();\n}\nfunction circle(x:number,y:number,r:number,color:string){ctx.fillStyle=color;ctx.beginPath();ctx.arc(x,y,Math.max(1,r),0,Math.PI*2);ctx.fill();}\nfunction text(s:string,x:number,y:number,size=16,color=C.ink,align:CanvasTextAlign='center'){ctx.font=`700 ${size}px ui-rounded,system-ui,sans-serif`;ctx.fillStyle=color;ctx.textAlign=align;ctx.fillText(s,x,y);}\nfunction fitted(s:string,width:number,size:number){\n ctx.font=`700 ${size}px ui-rounded,system-ui,sans-serif`;if(ctx.measureText(s).width<=width)return s;\n while(s.length&&ctx.measureText(s+'…').width>width)s=s.slice(0,-1);return s+'…';\n}\nfunction flower(b:Flower,x:number,y:number,r:number,p:ReturnType<typeof pulseAt>,own:boolean,t:number){\n const color=own?C.accent2:PALETTE[(b.seat??b.slot)%PALETTE.length];\n const practice=b.practice&&room.fast()?.practiceHost===(net.host?.id??'offline');\n // Each success grows a petal; the center stays clear for the timing ring.\n const petals=Math.min(12,3+Math.floor(b.score/3));\n for(let j=0;j<petals;j++){const a=j/petals*Math.PI*2+t/18000;ctx.save();ctx.translate(x+Math.cos(a)*r*.74,y+Math.sin(a)*r*.74);ctx.rotate(a);ctx.fillStyle=color+'88';ctx.beginPath();ctx.ellipse(0,0,r*.42,r*.2,0,0,Math.PI*2);ctx.fill();ctx.restore();}\n circle(x,y,r*.59,C.bg);\n const success=b.grade>0&&net.now()-b.at<400;\n const gold=(room.fast()?.bonusPulse??-1)===p.id&&room.round?.phase==='live';\n ctx.strokeStyle=success||gold?C.gold:C.ink;ctx.lineWidth=own?4:2;ctx.beginPath();ctx.arc(x,y,r,0,Math.PI*2);ctx.stroke();\n // The moving circle meets the fixed rim at phase .72; position works without color or audio.\n const moving=r*(2.08-1.5*p.phase);\n ctx.strokeStyle=lastTapRound===room.round?.n&&lastTapPulse===p.id&&own?color+'55':color;\n ctx.lineWidth=own?6:3;ctx.beginPath();ctx.arc(x,y,moving,0,Math.PI*2);ctx.stroke();\n const perfect=Math.abs(p.phase-.72)*p.period<=65;\n if(perfect){ctx.strokeStyle=C.gold;ctx.lineWidth=2;ctx.beginPath();ctx.arc(x,y,r+9,0,Math.PI*2);ctx.stroke();}\n text(String(b.score),x,y+7,own?32:21,C.ink);\n if(!(own&&p.finale&&room.round?.phase==='live')){\n  const label=own&&playing()&&b.seat===room.mySeat()&&!b.bot?(practice?'YOUR PRACTICE FLOWER':'YOUR FLOWER'):`${b.name}${b.bot&&!b.name.includes('AI')?' · bot':''}`;\n  const width=own?innerWidth*.65:innerWidth>innerHeight*1.25?innerWidth*.20:innerWidth/Math.max(2,room.view().length)-10;\n  text(fitted(label,width,own?13:12),x,y-r*2.12,own?13:12,color);\n  if(practice&&!own)text('PRACTICE',x,y-r*2.12-14,10,C.accent2);\n }\n const age=net.now()-b.at;\n // Freeze the missed ring where this attempt landed, including across the faster finale boundary.\n if(own&&age<1100&&b.grade<0){const period=b.lastPulse<18?2000:1500;const missed=r*(1-1.5*b.error/period);ctx.save();ctx.globalAlpha=Math.min(1,(1100-age)/250);ctx.strokeStyle=C.danger;ctx.lineWidth=3;ctx.setLineDash([5,5]);ctx.beginPath();ctx.arc(x,y,Math.max(1,missed),0,Math.PI*2);ctx.stroke();ctx.restore();}\n if(age<1100&&b.grade){const extra=b.bonusHit===b.lastPulse?2:0;const label=b.grade===3?`PERFECT +${3+extra}`:b.grade===2?`BLOOM +${2+extra}`:own&&playing()?(b.grade===-1?'EARLY · wait longer':'LATE · tap sooner'):b.grade===-1?'EARLY':'LATE';text(label,x,y+r*1.46,own?16:12,b.grade>0?C.gold:C.danger);}\n else if(own)text(b.streak?`${b.streak} in a row`:playing()?'Match the two rings':'Watching the garden',x,y+r*1.46,16,C.ink);\n if(own&&t-tapAt<180){ctx.globalAlpha=1-(t-tapAt)/180;ctx.strokeStyle=C.ink;ctx.lineWidth=2;ctx.beginPath();ctx.arc(x,y,r+(t-tapAt)*.2,0,Math.PI*2);ctx.stroke();ctx.globalAlpha=1;}\n}\nfunction draw(t:number){\n const cw=innerWidth,ch=innerHeight,side=cw>ch*1.25,small=cw<600;\n ctx.setTransform(dpr,0,0,dpr,0,0);ctx.fillStyle=C.bg;ctx.fillRect(0,0,cw,ch);\n // Glasshouse ribs and leaves frame the action without competing with the target ring.\n ctx.strokeStyle=C.accent2+'19';ctx.lineWidth=2;\n for(let x=-100;x<cw+150;x+=90){ctx.beginPath();ctx.moveTo(x,ch);ctx.quadraticCurveTo(cw/2,ch*.2,cw/2,-50);ctx.stroke();}\n for(let i=0;i<24;i++){const x=(i*137)%cw,y=ch-12-(i%3)*11;ctx.fillStyle=C.accent2+'33';ctx.beginPath();ctx.ellipse(x,y,8,23,Math.sin(i),0,Math.PI*2);ctx.fill();}\n const r=room.round,clock=room.clock(),p=pulseAt(r?.startedAt??net.now(),net.now());\n const all=room.view(),view=room.viewSeat(),world=room.fast()??freshGarden();\n // Stamp the current authority, not the checkpoint: an older elected host cannot implement this rule.\n const activeRule=world.activeHost===(net.host?.id??'offline');\n const active=activeRule?sharedFlowers(all,p.id):all,resting=all.length-active.length;\n const gold=r?.phase==='live'&&world.bonusPulse===p.id;\n if(gold){const glow=ctx.createRadialGradient(cw/2,ch*.62,20,cw/2,ch*.62,Math.max(cw,ch)*.7);glow.addColorStop(0,C.gold+'24');glow.addColorStop(1,C.gold+'00');ctx.fillStyle=glow;ctx.fillRect(0,80,cw,ch-80);}const own=all.find(b=>!b.bot&&b.seat===view)??all[0];\n const practiceRule=world.practiceHost===(net.host?.id??'offline');\n const practising=practiceRule&&own?.practice&&playing();\n const rivals=all.filter(b=>b!==own);\n if(gardenRound!==r?.n){gardenRound=r?.n??-1;growth.length=0;heard.clear();}\n const gardenY=side?ch*.85:ch*.405;\n const positions=new Map<number,{x:number;y:number;r:number}>();\n for(const b of all){if(b.at>(heard.get(b.slot)??0)){heard.set(b.slot,b.at);if(b.grade>0&&net.now()-b.at<500)growth.push({slot:b.slot,at:t});if(b.seat===room.mySeat()&&!net.watching)sound.play(b.grade>0?'bloom':'miss');}}\n text('SIGNAL BLOOM',16,29,small?18:23,C.ink,'left');\n text(clock.phase==='over'?`New garden in ${clock.secondsLeft}`:`${clock.secondsLeft}s · ${p.finale?'FINAL FLOURISH':'Tap when the rings meet'}`,16,54,13,p.finale?C.gold:C.accent2,'left');\n if(own){\n  if(side){positions.set(own.slot,{x:cw*.30,y:ch*.51,r:Math.min(ch*.15,cw*.1)});rivals.forEach((b,i)=>positions.set(b.slot,{x:cw*(i===2?.77:.64+(i%2)*.22),y:ch*(i<2?.38:.68),r:Math.min(ch*(rivals.length>2?.08:.12),65)}));}\n  else {rivals.forEach((b,i)=>positions.set(b.slot,{x:cw*(i+1)/(rivals.length+1),y:ch*.26,r:Math.min(cw/(rivals.length+1)*.22,35)}));positions.set(own.slot,{x:cw/2,y:ch*.65,r:Math.min(cw*.2,ch*.105,88)});}\n }\n if(r?.phase==='live')paintSharedBloom(ctx,world,net.now(),positions,cw,ch,gardenY,C,reducedMotion.matches);\n for(const b of all){const a=positions.get(b.slot);if(a)flower(b,a.x,a.y,a.r,p,b===own,t);}\n // Everyone grows one vine: score milestones add colored leaves in the shared bed.\n ctx.strokeStyle=C.accent2+'88';ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(24,gardenY);ctx.quadraticCurveTo(cw/2,gardenY+14,cw-24,gardenY);ctx.stroke();\n all.forEach((b,i)=>{const color=b===own?C.accent2:PALETTE[(b.seat??b.slot)%PALETTE.length];const leaves=Math.min(24,Math.ceil(b.score/3));for(let j=0;j<leaves;j++){const x=24+(j*all.length+i+.5)/(24*all.length)*(cw-48),y=gardenY+Math.sin(x/cw*Math.PI)*7;ctx.save();ctx.translate(x,y);ctx.rotate((j+i)%2?-.8:.8);ctx.fillStyle=color;ctx.beginPath();ctx.ellipse(0,(j+i)%2?-7:7,Math.max(2,cw/(24*all.length)*.35),11,0,0,Math.PI*2);ctx.fill();ctx.restore();}});\n const sharedNow=world.lastShared===p.id;\n const sharedLabel=gold?'GOLD PULSE · hit for +2':p.id>=23?'LAST PULSE · make it bloom':sharedNow?'TOGETHER! Next pulse is gold':active.length<2?'Need two active flowers for gold':resting?`${active.length} active hit → +2 · ${resting} resting`:`All ${all.length} hit → next pulse +2`;\n if(r?.phase==='live')text(sharedLabel,cw/2,gardenY-24,small?13:15,C.gold);\n if(r?.phase==='live')all.forEach((b,i)=>{const hit=b.lastPulse===p.id&&b.grade>0;const x=cw/2+(i-(all.length-1)/2)*20;if(!active.includes(b)){ctx.strokeStyle=C.accent2+'88';ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(x-4,gardenY-7);ctx.lineTo(x+4,gardenY-7);ctx.stroke();}else circle(x,gardenY-7,5,hit?C.gold:C.accent2+'44');});\n for(let i=growth.length-1;i>=0;i--){const e=growth[i],age=(t-e.at)/850;if(age>=1){growth.splice(i,1);continue;}const a=positions.get(e.slot),b=all.find(b=>b.slot===e.slot);if(!a||!b)continue;const color=b===own?C.accent2:PALETTE[(b.seat??b.slot)%PALETTE.length];const x=a.x+(cw/2-a.x)*age,y=a.y+(gardenY-a.y)*age-Math.sin(age*Math.PI)*45;ctx.globalAlpha=1-age*.6;circle(x,y,5+Math.sin(age*Math.PI)*4,color);ctx.globalAlpha=1;}\n if(clock.phase==='live'&&p.finale&&own){const a=positions.get(own.slot)!;const left=Math.max(1,24-p.id),pulse=1-p.phase;const y=side?ch*.20:ch*.85;text(`${left} ${left===1?'PULSE':'PULSES'} LEFT`,a.x,y,20+Math.round(pulse*5),C.gold);}\n if(playing()){const restingOwn=activeRule&&r?.phase==='live'&&own&&!active.includes(own);\n  const goal=own?.practiceGoal,hits=own?.hits??0;\n  const targetHint=goal===undefined?'PRACTICE · compete next round':goal===0?'PRACTICE · next round starts soon':hits>=goal?'READY! · compete next round':r?.phase==='over'?`Practice ${hits}/${goal} · fresh start next`:`PRACTICE · land ${goal} ${goal===1?'hit':'hits'}: ${hits}/${goal}`;\n  text(practising?targetHint:restingOwn?(touchDevice?'Resting · tap to rejoin':'Resting · Space or click to rejoin'):touchDevice?'Tap anywhere · once per pulse':'Space / Enter or click · once per pulse',cw/2,ch-(small?65:28),small?13:16);}\n if(clock.phase==='over'){\n  if(heardRound!==clock.n){heardRound=clock.n;sound.play('win');}\n  const rows=room.round?.results??[];\n  const practice=practiceRule?world.practiceResults??[]:[];\n  const isMe=(b:{slot?:number;seat:number|null;bot:boolean})=>!net.watching&&!b.bot&&b.seat===room.mySeat()&&(!practiceRule||world.resultOwners?.[b.slot??-1]===(net.id??'offline'));\n  const winner=all.find(b=>b.bot===rows[0]?.bot&&(b.bot?b.name===rows[0]?.name:b.seat===rows[0]?.seat));\n  const victoryColor=winner===own?C.accent2:PALETTE[((winner?.seat??winner?.slot)??0)%PALETTE.length];\n  const bloomRadius=Math.min(cw*.38,ch*.28);\n  ctx.save();ctx.translate(cw/2,ch/2);for(let layer=0;layer<3;layer++)for(let j=0;j<12;j++){const a=j*Math.PI/6+layer*.2+t/18000;ctx.save();ctx.rotate(a);ctx.fillStyle=victoryColor+(layer===0?'22':layer===1?'44':'66');ctx.beginPath();ctx.ellipse(bloomRadius*.65,0,bloomRadius*(.52-layer*.07),bloomRadius*.18,0,0,Math.PI*2);ctx.fill();ctx.restore();}ctx.restore();\n  const w=Math.min(360,cw-30),h=108+(rows.length+practice.length)*27;\n  ctx.fillStyle='#202341f5';ctx.strokeStyle=C.accent2;ctx.lineWidth=2;ctx.beginPath();ctx.roundRect((cw-w)/2,(ch-h)/2,w,h,20);ctx.fill();ctx.stroke();\n  text(rows.filter(b=>b.place===1).length>1?'Shared first place':'A garden in bloom',cw/2,(ch-h)/2+32,23,C.gold);\n  text(`${world.shared} shared ${world.shared===1?'bloom':'blooms'} unlocked gold pulses`,cw/2,(ch+h)/2-18,12,C.gold);\n  rows.forEach((b,i)=>text(`${b.place}${rows.filter(o=>o.place===b.place).length>1?' (tie)':''}. ${isMe(b)?'You':b.name+(b.bot&&!b.name.includes('AI')?' · bot':'')} · ${b.score}`,cw/2,(ch-h)/2+63+i*27,16,isMe(b)?C.ink:C.accent2));\n  practice.forEach((b,i)=>text(`${isMe(b)?'You':b.name} · practice ${b.score}`,cw/2,(ch-h)/2+63+(rows.length+i)*27,15,C.accent2));\n }\n if(!net.offline&&!net.connected)text('Reconnecting…',cw/2,90,14,C.gold);\n}\nfunction frame(t:number){step();draw(t);frames++;requestAnimationFrame(frame);}\nnet.expose({frames:()=>frames,scores:()=>room.view().map(b=>({seat:b.seat,score:b.score,bot:b.bot})),pulse:()=>room.round?pulseAt(room.round.startedAt,net.now()):null,flowers:()=>room.view().map(b=>({seat:b.seat,score:b.score,streak:b.streak,lastPulse:b.lastPulse,grade:b.grade,error:b.error,bonusHit:b.bonusHit,slot:b.slot,practice:b.practice,lateRound:b.lateRound,hits:b.hits,practiceGoal:b.practiceGoal})),garden:()=>room.fast(),camera:()=>({view:room.viewSeat()})});\nrequestAnimationFrame(frame);\n","src/rules.ts":"/** Deterministic pulse clock shared by rendering, host scoring, and migration. */\nexport const ROUND_MS=45000, FINALE_MS=36000, PERIOD=2000, FINAL_PERIOD=1500;\nexport interface Pulse {id:number;start:number;target:number;period:number;phase:number;finale:boolean}\nexport function pulseAt(startedAt:number,now:number):Pulse {\n const age=Math.max(0,now-startedAt);\n // First 18 pulses last 36 s, then six faster pulses finish the 45-second round.\n const finale=age>=36000,period=finale?FINAL_PERIOD:PERIOD;\n const base=startedAt+(finale?36000:0),local=Math.floor(Math.max(0,now-base)/period);\n const start=base+local*period;\n return {id:(finale?18:0)+local,start,target:start+period*.72,period,phase:Math.max(0,(now-start)/period),finale};\n}\nexport interface Bloom {score:number;streak:number;lastPulse:number;grade:number;at:number;error:number;hits?:number}\n/** Grade: 3 perfect, 2 good, -1 early, -2 late. One attempt per pulse. */\nexport function attempt(b:Bloom,startedAt:number,at:number,receivedAt:number,bonus=0):boolean {\n if(!Number.isFinite(at)||at<startedAt||at>=startedAt+ROUND_MS||at>receivedAt+40||at<receivedAt-250)return false;\n const p=pulseAt(startedAt,at);if(p.id<=b.lastPulse)return false;\n b.lastPulse=p.id;b.at=receivedAt;b.error=Math.round(at-p.target);\n const error=Math.abs(b.error);\n if(error<=155){b.grade=error<=65?3:2;b.streak++;b.hits=(b.hits??0)+1;b.score+=(b.grade===3?3:2)+(b.streak%4===0?2:0)+bonus;}\n else {b.grade=b.error<0?-1:-2;b.streak=0;}\n return true;\n}\n\n/** Competition ranks: equal scores share a place, and the next place skips those seats. */\nexport function sharePlaces<T extends {score:number;place:number}>(rows:T[]):void {\n const sorted=[...rows].sort((a,b)=>b.score-a.score);\n let place=0;\n sorted.forEach((row,i)=>{if(i===0||row.score!==sorted[i-1].score)place=i+1;row.place=place;});\n}\n\n/** A short goal bounded by the unused scoring opportunities left when the player is ready. */\nexport function practiceTarget(startedAt:number,now:number,lastPulse=-1,hits=0):number {\n let available=0;\n for(let id=0;id<24;id++){\n  const start=startedAt+(id<18?id*PERIOD:FINALE_MS+(id-18)*FINAL_PERIOD);\n  const target=start+(id<18?PERIOD:FINAL_PERIOD)*.72;\n  if(id>lastPulse&&now<=target-350)available++;\n }\n return Math.min(3,hits+available);\n}\nexport interface PracticeResult {slot:number;seat:number|null;name:string;score:number;bot:boolean}\n/** A full opening pulse is the admission window; afterwards this round is a warm-up. */\nexport const practiceArrival=(startedAt:number,now:number,phase:string):boolean=>phase==='live'&&now-startedAt>=PERIOD;\nexport function separatePractice<T extends PracticeResult & {place:number}>(rows:T[],practiceSlots:Set<number>):PracticeResult[]{\n const practice=rows.filter(r=>practiceSlots.has(r.slot));\n for(let i=rows.length-1;i>=0;i--)if(practiceSlots.has(rows[i].slot))rows.splice(i,1);\n sharePlaces(rows);\n return practice.map(({slot,seat,name,score,bot})=>({slot,seat,name,score,bot}));\n}\nexport interface Garden {bonusPulse:number;lastShared:number;shared:number;activeHost?:string;practiceHost?:string;practiceResults?:PracticeResult[];owners?:Record<number,string>;resultOwners?:Record<number,string>;sharedAt?:number;sharedSlots?:number[]}\nexport const freshGarden=():Garden=>({bonusPulse:-1,lastShared:-1,shared:0});\n/** Three completed pulses without input pauses a flower; any attempt rejoins immediately. */\nexport const sharedFlowers=<T extends Bloom>(flowers:T[],pulse:number):T[]=>flowers.filter(b=>pulse-b.lastPulse<=3);\n/** At least two active flowers must succeed. Duplicate snapshots cannot re-award the next pulse. */\nexport function bloomTogether(g:Garden,flowers:(Bloom & {slot?:number})[],pulse:number):boolean {\n const active=sharedFlowers(flowers,pulse);\n if(pulse>=23||pulse<=g.lastShared||active.length<2||!active.every(b=>b.lastPulse===pulse&&b.grade>0))return false;\n g.lastShared=pulse;g.bonusPulse=pulse+1;g.shared++;g.sharedAt=Math.max(...active.map(b=>b.at));g.sharedSlots=active.flatMap(b=>typeof b.slot==='number'?[b.slot]:[]);return true;\n}\n","src/shared-bloom.ts":"import type {Garden} from './rules';\n\ntype Plot={x:number;y:number;r:number};\ntype Colors={gold:string;ink:string};\n\n/** A host-timed, bounded celebration: joining or migrating cannot replay an old success. */\nexport function paintSharedBloom(ctx:CanvasRenderingContext2D,world:Garden,now:number,plots:Map<number,Plot>,width:number,height:number,vineY:number,colors:Colors,reducedMotion:boolean):void {\n const age=now-(world.sharedAt??-Infinity);\n if(age<0||age>1100||!world.sharedSlots?.length)return;\n const opacity=Math.min(1,age/80,Math.max(0,(1100-age)/350));\n const travel=reducedMotion?1:Math.min(1,age/450),cx=width/2,cy=vineY;\n ctx.save();\n // Keep the connection behind the flower names and hit feedback, including four-player phones.\n // Intersect separate exclusions: overlapping even-odd holes would reveal the beam again.\n const exclude=(x:number,y:number,w:number,h:number)=>{\n  ctx.beginPath();ctx.rect(0,0,width,height);ctx.rect(x,y,w,h);ctx.clip('evenodd');\n };\n const largest=Math.max(...[...plots.values()].map(p=>p.r));\n // The shared caption sits 24 px above the bed in both orientations.\n exclude(0,vineY-43,width,26);\n for(const p of plots.values()){\n  const labelWidth=p.r===largest?Math.min(width*.65,Math.max(170,p.r*3.5)):width>height*1.25?width*.20:width/plots.size-10;\n  exclude(p.x-labelWidth/2,p.y-p.r*2.12-29,labelWidth,35);\n  exclude(p.x-labelWidth/2,p.y+p.r*1.46-20,labelWidth,25);\n }\n ctx.lineCap='round';\n for(const slot of world.sharedSlots){\n  const p=plots.get(slot);if(!p)continue;\n  const dx=cx-p.x,dy=cy-p.y,d=Math.hypot(dx,dy)||1;\n  const sx=p.x+dx/d*p.r,sy=p.y+dy/d*p.r;\n  const qx=sx+(cx-sx)*.5+(p.x<cx?-28:28),qy=(sy+cy)/2;\n  ctx.globalAlpha=opacity*.2;ctx.strokeStyle=colors.gold;ctx.lineWidth=15;\n  ctx.beginPath();ctx.moveTo(sx,sy);ctx.quadraticCurveTo(qx,qy,cx,cy);ctx.stroke();\n  ctx.globalAlpha=opacity*.85;ctx.lineWidth=3;ctx.stroke();\n  if(!reducedMotion&&travel<1){\n   const u=1-travel,x=u*u*sx+2*u*travel*qx+travel*travel*cx,y=u*u*sy+2*u*travel*qy+travel*travel*cy;\n   ctx.globalAlpha=opacity;ctx.fillStyle=colors.ink;ctx.beginPath();ctx.arc(x,y,7,0,Math.PI*2);ctx.fill();\n  }\n }\n ctx.restore();\n if(travel<1)return;\n const burst=reducedMotion ? .65 : Math.min(1,(age-450)/180),fade=reducedMotion ? .65 : Math.max(0,1-(age-450)/650);\n ctx.save();ctx.globalAlpha=opacity;ctx.translate(cx,cy);ctx.scale(1,.52);\n for(let i=0;i<8;i++){\n  ctx.save();ctx.rotate(i*Math.PI/4);ctx.fillStyle=colors.gold;\n  ctx.beginPath();ctx.ellipse(16*burst,0,18*burst,8,0,0,Math.PI*2);ctx.fill();ctx.restore();\n }\n ctx.fillStyle=colors.ink;ctx.beginPath();ctx.arc(0,0,6,0,Math.PI*2);ctx.fill();ctx.restore();\n // A wave travels out along the shared bed, rather than flashing the whole screen.\n ctx.save();ctx.globalAlpha=opacity*fade;ctx.strokeStyle=colors.gold;ctx.lineWidth=5;\n const spread=reducedMotion?width*.3:Math.min(width/2-24,(age-450)/650*(width/2-24));\n ctx.beginPath();ctx.moveTo(cx-spread,cy);ctx.quadraticCurveTo(cx,cy+10,cx+spread,cy);ctx.stroke();ctx.restore();\n}\n","src/sound.d.ts":"/** Types for sound.js (the Homie plugin's sound skill): a game's effects and music from sound.json. */\nexport interface PlayOptions { volume?: number; pitch?: number; pan?: number; jitter?: number }\nexport interface SoundState { unlocked: boolean; running: boolean; loaded: number; music: { name: string; section: string; since: number } | null; missing: string[]; errors: string[] }\nexport interface Sound {\n  /** Resolves once sound.json and every file it lists have loaded (or failed, which state().errors names). */\n  ready: Promise<unknown>;\n  /** Play an effect by name: a random variant, a little pitch jitter, at most a few of one name at once. Dropped before the first gesture. */\n  play(name: string, options?: PlayOptions): AudioBufferSourceNode | null;\n  /** Start a score's loops (its first section unless one is named); it starts on the first touch or key. */\n  music(name: string, options?: { section?: string; fade?: number }): void;\n  /** Switch to another section's loop exactly on the next bar line. */\n  section(name: string, options?: { fade?: number }): void;\n  stopMusic(options?: { fade?: number }): void;\n  /** Music down to `to` (0..1) for `seconds`, then back. */\n  duck(to?: number, seconds?: number): void;\n  volume(levels: { master?: number; sfx?: number; music?: number }): void;\n  mute(on?: boolean): void;\n  readonly context: AudioContext | null;\n  state(): SoundState;\n}\nexport function createSound(options?: { base?: string; manifest?: string; maxPerName?: number; maxVoices?: number }): Sound;\n","src/sound.js":"/*\n * sound.js: a game's sound effects and music, from the files the Homie plugin's sound skill made\n * (sound.json beside them lists what exists). Plain JavaScript, no dependencies.\n *\n *   import { createSound } from './sound.js';            // bundle games: from src/; static games: from sound/\n *   const sound = createSound({ base: 'sound/' });        // where sound.json and the files are served, relative to the page\n *   sound.play('jump');                                   // a random variant, a little pitch jitter, never more than 4 at once\n *   sound.play('coin', { pitch: combo, volume: 0.8, pan: -0.3 });\n *   sound.music('theme');                                 // the theme's first loop, from the first touch on\n *   sound.section('chase');                               // the next loop, starting exactly on the next bar line\n *   sound.duck(0.4, 0.5);                                 // music down to 40% for half a second (a big hit, a line of speech)\n *   sound.volume({ sfx: 1, music: 0.6 }); sound.mute(true);\n *\n * Sound starts on the first touch, click or key, with no \"tap for sound\" screen: browsers keep audio\n * suspended until a person has touched the page, so the first gesture resumes it (a held stick counts\n * when the finger lifts). A sound asked for before that is dropped, not queued: a late hit is worse than none.\n * window.__homieSound holds counters for a playtest (what played, when); it never controls anything.\n */\nexport function createSound({ base = 'sound/', manifest = 'sound.json', maxPerName = 4, maxVoices = 24 } = {}) {\n  const AC = window.AudioContext || window.webkitAudioContext;\n  const ctx = AC ? new AC({ latencyHint: 'interactive' }) : null;\n  const url = (f) => new URL(f, new URL(base, document.baseURI)).toString();\n  const buses = {};\n  const buffers = new Map();\n  const live = new Map();\n  const stats = { unlocked: false, unlockedAt: null, plays: [], missing: [], errors: [], music: null };\n  let meta = { sfx: {}, music: {} };\n  let muted = false;\n  const levels = { master: 1, sfx: 1, music: 0.7 };\n  let track = null;\n\n  if (ctx) {\n    buses.master = ctx.createGain();\n    // A gentle safety limiter: many hits at once must not clip.\n    const comp = ctx.createDynamicsCompressor();\n    comp.threshold.value = -6; comp.knee.value = 6; comp.ratio.value = 8; comp.attack.value = 0.003; comp.release.value = 0.15;\n    buses.master.connect(comp).connect(ctx.destination);\n    buses.sfx = ctx.createGain(); buses.sfx.connect(buses.master);\n    buses.music = ctx.createGain(); buses.music.connect(buses.master);\n    buses.duck = ctx.createGain(); buses.duck.connect(buses.music);\n    applyLevels();\n    try { if (navigator.audioSession) navigator.audioSession.type = 'playback'; } catch (e) { /* not iOS */ }\n    const unlock = () => {\n      if (ctx.state !== 'running') ctx.resume().catch(() => {});\n      try { const b = ctx.createBuffer(1, 1, 22050); const s = ctx.createBufferSource(); s.buffer = b; s.connect(ctx.destination); s.start(0); } catch (e) { /* closed */ }\n      if (!stats.unlocked && ctx.state === 'running') { stats.unlocked = true; stats.unlockedAt = performance.now(); }\n      if (track && !track.started && ctx.state === 'running') startTrack();\n    };\n    for (const ev of ['pointerdown', 'pointerup', 'touchstart', 'touchend', 'keydown', 'click']) window.addEventListener(ev, unlock, { capture: true, passive: true });\n    ctx.addEventListener('statechange', () => { if (ctx.state === 'running') { stats.unlocked = true; stats.unlockedAt = stats.unlockedAt || performance.now(); if (track && !track.started) startTrack(); } });\n  }\n\n  const ready = fetch(url(manifest)).then((r) => { if (!r.ok) throw new Error(`${manifest}: HTTP ${r.status}`); return r.json(); }).then((m) => {\n    meta = { sfx: m.sfx || {}, music: m.music || {} };\n    const variants = Object.values(meta.sfx).flat();\n    const loops = Object.values(meta.music).flatMap((t) => Object.values(t.loops || {}));\n    return Promise.all([...variants, ...loops].map(loadFirst));\n  }).catch((e) => { stats.errors.push(String(e && e.message || e)); });\n\n  function load(file) {\n    if (!ctx || buffers.has(file)) return buffers.get(file);\n    const p = fetch(url(file)).then((r) => { if (!r.ok) throw new Error(`${file}: HTTP ${r.status}`); return r.arrayBuffer(); })\n      .then((b) => new Promise((ok, no) => ctx.decodeAudioData(b, ok, no)))\n      .then((buf) => { buffers.set(file, buf); return buf; })\n      .catch((e) => { stats.errors.push(String(e && e.message || e)); buffers.delete(file); return null; });\n    buffers.set(file, p);\n    return p;\n  }\n\n  /** A file listed as [ogg, wav]: the first this browser decodes wins (older Safari cannot decode Ogg). */\n  function loadFirst(list) {\n    const files = Array.isArray(list) ? list : [list];\n    const key = files.join('|');\n    if (buffers.has(key)) return buffers.get(key);\n    const p = files.reduce((prev, f) => prev.then((buf) => buf || load(f).then((b) => (b instanceof AudioBuffer ? b : null))), Promise.resolve(null))\n      .then((buf) => { if (buf) buffers.set(key, buf); else buffers.delete(key); return buf; });\n    buffers.set(key, p);\n    return p;\n  }\n  const loopBuffer = (list) => buffers.get(Array.isArray(list) ? list.join('|') : list);\n\n  function applyLevels() {\n    if (!ctx) return;\n    const t = ctx.currentTime;\n    buses.master.gain.setTargetAtTime(muted ? 0 : levels.master, t, 0.02);\n    buses.sfx.gain.setTargetAtTime(levels.sfx, t, 0.02);\n    buses.music.gain.setTargetAtTime(levels.music, t, 0.05);\n  }\n\n  function play(name, { volume = 1, pitch = 0, pan = 0, jitter = 0.35 } = {}) {\n    stats.plays.push({ name, t: Math.round(performance.now()) });\n    if (stats.plays.length > 500) stats.plays.splice(0, 250);\n    if (!ctx || ctx.state !== 'running') return null;\n    const variants = meta.sfx[name];\n    if (!variants || !variants.length) { if (!stats.missing.includes(name)) stats.missing.push(name); return null; }\n    const buf = loopBuffer(variants[Math.floor(Math.random() * variants.length)]);\n    if (!(buf instanceof AudioBuffer)) return null;\n    const mine = live.get(name) || [];\n    while (mine.length >= maxPerName) { try { mine.shift().stop(); } catch (e) { /* ended */ } }\n    let total = 0; for (const v of live.values()) total += v.length;\n    if (total >= maxVoices) return null;\n    const src = ctx.createBufferSource();\n    src.buffer = buf;\n    src.playbackRate.value = 2 ** ((pitch + (Math.random() * 2 - 1) * jitter) / 12);\n    const g = ctx.createGain(); g.gain.value = volume;\n    let node = src.connect(g);\n    if (pan && ctx.createStereoPanner) { const p = ctx.createStereoPanner(); p.pan.value = Math.max(-1, Math.min(1, pan)); node = node.connect(p); }\n    node.connect(buses.sfx);\n    src.onended = () => { const i = mine.indexOf(src); if (i >= 0) mine.splice(i, 1); };\n    mine.push(src); live.set(name, mine);\n    src.start();\n    return src;\n  }\n\n  function startTrack() {\n    if (!track || !ctx || ctx.state !== 'running') return;\n    const buf = loopBuffer(track.loops[track.section]);\n    if (!(buf instanceof AudioBuffer)) { ready.then(() => { if (track && !track.started) startTrack(); }); return; }\n    track.started = true;\n    track.t0 = ctx.currentTime + 0.05;\n    track.node = loopNode(buf, track.t0, track.fade);\n    stats.music = { name: track.name, section: track.section, since: Math.round(performance.now()) };\n  }\n\n  function loopNode(buf, at, fade) {\n    const src = ctx.createBufferSource(); src.buffer = buf; src.loop = true;\n    const g = ctx.createGain(); g.gain.setValueAtTime(0, at); g.gain.linearRampToValueAtTime(1, at + Math.max(0.005, fade));\n    src.connect(g).connect(buses.duck);\n    src.start(at);\n    return { src, g };\n  }\n\n  let loaded = false;\n  ready.then(() => { loaded = true; });\n  function music(name, opts = {}) {\n    // Called before sound.json has arrived (the usual case: at the top of the game): start it once it has.\n    if (!loaded) { ready.then(() => music(name, opts)); return; }\n    const { section = null, fade = 0.4 } = opts;\n    const t = meta.music[name];\n    if (!t) { stats.errors.push(`no music \"${name}\" in ${manifest}`); return; }\n    stopMusic({ fade: 0.2 });\n    const first = section || Object.keys(t.loops || {})[0];\n    track = { name, loops: t.loops, bar: t.barSeconds, section: first, started: false, fade };\n    startTrack();\n  }\n\n  /** Switch loops on the next bar line (every loop shares the tempo, so bar lines line up). */\n  function section(name, { fade = 0.03 } = {}) {\n    if (!track || !track.loops[name] || name === track.section) return;\n    track.section = name;\n    if (!track.started) return;\n    const buf = loopBuffer(track.loops[name]);\n    if (!(buf instanceof AudioBuffer)) return;\n    const now = ctx.currentTime;\n    const at = track.t0 + Math.ceil((now + 0.02 - track.t0) / track.bar) * track.bar;\n    const old = track.node;\n    old.g.gain.setValueAtTime(1, at - fade); old.g.gain.linearRampToValueAtTime(0, at);\n    try { old.src.stop(at + 0.05); } catch (e) { /* */ }\n    track.node = loopNode(buf, at - fade, fade);\n    track.t0 = at;\n    stats.music = { name: track.name, section: name, since: Math.round(performance.now()) };\n  }\n\n  function stopMusic({ fade = 0.5 } = {}) {\n    if (!track) return;\n    if (track.node && ctx) { const t = ctx.currentTime; track.node.g.gain.setTargetAtTime(0, t, fade / 3); try { track.node.src.stop(t + fade + 0.1); } catch (e) { /* */ } }\n    track = null; stats.music = null;\n  }\n\n  function duck(to = 0.4, seconds = 0.4) {\n    if (!ctx) return;\n    const t = ctx.currentTime; const g = buses.duck.gain;\n    g.cancelScheduledValues(t); g.setTargetAtTime(to, t, 0.015); g.setTargetAtTime(1, t + seconds, 0.12);\n  }\n\n  const api = {\n    ready, play, music, section, stopMusic, duck,\n    volume(v) { Object.assign(levels, v || {}); applyLevels(); },\n    mute(on = true) { muted = !!on; applyLevels(); },\n    get context() { return ctx; },\n    state: () => ({ unlocked: stats.unlocked, running: ctx ? ctx.state === 'running' : false, loaded: new Set([...buffers.values()].filter((b) => b instanceof AudioBuffer)).size, music: stats.music, missing: stats.missing.slice(), errors: stats.errors.slice(0, 10) }),\n  };\n  try { window.__homieSound = { get stats() { return { ...api.state(), plays: stats.plays.slice(-100), unlockedAt: stats.unlockedAt }; } }; } catch (e) { /* frozen global */ }\n  return api;\n}\n","style.json":"{\n  \"v\": 1,\n  \"palette\": {\n    \"bg\": \"#202341\",\n    \"ink\": \"#fff1d8\",\n    \"accent\": \"#ffad98\",\n    \"accent2\": \"#89dfd5\",\n    \"danger\": \"#ed91ac\",\n    \"good\": \"#b6eea5\",\n    \"gold\": \"#ffda84\",\n    \"ramp\": [\n      \"#202341\",\n      \"#353956\",\n      \"#fff1d8\",\n      \"#ffad98\",\n      \"#89dfd5\",\n      \"#ffda84\"\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\": \"#ffd2c6\",\n    \"ground\": \"#acd0c3\",\n    \"fog\": 0.45,\n    \"bloom\": 0.25\n  },\n  \"camera\": {\n    \"angle\": \"top-down\",\n    \"projection\": \"orthographic\",\n    \"pitch\": 90,\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\": 2,\n    \"style.ui\": 1,\n    \"style.light\": 2,\n    \"style.camera\": 2\n  }\n}\n"}}
