{"v":1,"kind":"homie-game-source","id":"lantern-ferry","credit":{"studio":"Copperwind Arcade","game":"Lantern Ferry","page":"https://codex.homie.rocks/lantern-ferry/"},"license":{"kind":"remix-with-credit","spdx":null},"files":{"game.json":"{\n  \"id\": \"lantern-ferry\",\n  \"name\": \"Lantern Ferry\",\n  \"blurb\": \"Carry lights across a little harbor. Pass lanterns to friends and light four islands together in 75 seconds.\",\n  \"players\": {\n    \"min\": 1,\n    \"max\": 4\n  },\n  \"roundSeconds\": 75,\n  \"entry\": \"src/main.ts\",\n  \"netplay\": {\n    \"v\": 1,\n    \"public\": true,\n    \"movement\": \"owner\"\n  },\n  \"landing\": {\n    \"pitch\": \"A little harbor. A boat each. Light it together.\",\n    \"about\": \"Pick up a lantern at the dock and sail to its marked island. A handoff can light two lamps, and every island needs a passed lantern for its final lamp. Empty bot boats sail over to help, so a solo visitor can cooperate too. Light the willow, coral reef, moon observatory, and fern garden together before sunset.\",\n    \"howToPlay\": [\n      \"Sail into the central dock to pick up a lantern.\",\n      \"Follow its numeral to the matching island; delivery is automatic.\",\n      \"Pass to a nearby empty boat with Space or PASS. Handoffs can light two lamps.\",\n      \"Each island\\u2019s final lamp needs a handoff. Light all four islands in 75 seconds.\"\n    ],\n    \"controls\": {\n      \"phone\": \"Drag lower-left to sail; double-tap there to pass.\",\n      \"computer\": \"WASD or arrows to sail. Space to pass a lantern.\"\n    },\n    \"players\": {\n      \"one\": \"ferryperson\",\n      \"many\": \"ferrypeople\"\n    },\n    \"hero\": {\n      \"alt\": \"Small boats carrying golden lanterns between mint islands in a teal harbor.\",\n      \"tint\": 15\n    },\n    \"credits\": [\n      {\n        \"role\": \"Design and procedural artwork\",\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    \"Cooperative\",\n    \"Arcade\"\n  ],\n  \"launch\": \"public\",\n  \"scoring\": \"together\",\n  \"screen\": {\n    \"share\": {\n      \"desk\": \"top-right\",\n      \"phone\": {\n        \"at\": \"top-right\",\n        \"y\": 100\n      },\n      \"sideways\": \"top-right\"\n    },\n    \"chat\": {\n      \"lines\": \"sheet-only\"\n    }\n  },\n  \"chat\": {\n    \"lines\": {\n      \"gg\": \"Lovely sailing!\",\n      \"pass\": \"Pass a lantern!\",\n      \"dock\": \"Back to the dock!\",\n      \"island\": \"This island needs a light!\"\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>Lantern Ferry</title><link rel=\"icon\" href=\"data:,\"><style>html,body{margin:0;height:100%;background:#123e4a;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 #84dac5;border-radius:20px;background:#123e4a;color:#fff3d8;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    \"pickup\": [\n      [\n        \"pickup-1.ogg\",\n        \"pickup-1.wav\"\n      ],\n      [\n        \"pickup-2.ogg\",\n        \"pickup-2.wav\"\n      ]\n    ],\n    \"delivery\": [\n      [\n        \"delivery-1.ogg\",\n        \"delivery-1.wav\"\n      ]\n    ],\n    \"pass\": [\n      [\n        \"pass-1.ogg\",\n        \"pass-1.wav\"\n      ],\n      [\n        \"pass-2.ogg\",\n        \"pass-2.wav\"\n      ]\n    ],\n    \"ui\": [\n      [\n        \"ui-1.ogg\",\n        \"ui-1.wav\"\n      ],\n      [\n        \"ui-2.ogg\",\n        \"ui-2.wav\"\n      ]\n    ],\n    \"win\": [\n      [\n        \"win-1.ogg\",\n        \"win-1.wav\"\n      ]\n    ]\n  },\n  \"music\": {\n    \"harbor-lights\": {\n      \"bpm\": 96,\n      \"beatsPerBar\": 4,\n      \"barSeconds\": 2.5,\n      \"loops\": {\n        \"main\": [\n          \"harbor-lights-main-loop-8bars.ogg\",\n          \"harbor-lights-main-loop-8bars.wav\"\n        ]\n      }\n    }\n  }\n}\n","src/guidance.ts":"import {distance,isRelayed,needsRelay,passTarget,type CargoBoat,type Harbor} from './rules.ts';\n\n/** Guidance follows the next useful action; it never changes pass eligibility. */\nexport function handoffGuide<B extends CargoBoat>(from:B,boats:B[],world:Harbor,now:number){\n if(!from.cargo){\n  const sender=boats.filter(b=>b.slot!==from.slot&&needsRelay(b,world)).sort((a,b)=>distance(a,from)-distance(b,from)||a.slot-b.slot)[0];\n  return {mode:sender?'receive':'pickup',target:sender??null} as const;\n }\n if(isRelayed(from))return {mode:'deliver',target:null} as const;\n const ready=passTarget(from,boats,now);\n if(ready)return {mode:'pass',target:ready} as const;\n const empty=boats.filter(b=>b.slot!==from.slot&&!b.cargo).sort((a,b)=>distance(a,from)-distance(b,from)||a.slot-b.slot)[0];\n if(empty)return {mode:distance(empty,from)>180?'approach':'wait',target:empty} as const;\n return {mode:needsRelay(from,world)?'blocked':'deliver',target:null} as const;\n}\n","src/landmarks.ts":"/** Four recognizable silhouettes, drawn locally in the harbor's existing vector style. */\nexport function landmark(ctx:CanvasRenderingContext2D,index:number,x:number,y:number,c:{ink:string;good:string;danger:string;accent2:string;gold:string}){\n  ctx.save();ctx.translate(x,y-9);ctx.lineCap='round';ctx.lineJoin='round';\n  if(index===0){\n    // Willow: a branching trunk and long, drooping leaves.\n    ctx.strokeStyle=c.ink;ctx.lineWidth=6;ctx.beginPath();ctx.moveTo(0,30);ctx.lineTo(0,-13);ctx.moveTo(0,6);ctx.lineTo(-22,-12);ctx.moveTo(0,-2);ctx.lineTo(20,-20);ctx.stroke();\n    ctx.strokeStyle=c.good;ctx.lineWidth=7;\n    for(const [px,py] of [[-25,-10],[-14,-24],[0,-30],[15,-26],[27,-12]]){ctx.beginPath();ctx.moveTo(px,py);ctx.quadraticCurveTo(px-10,py+6,px-7,py+31);ctx.stroke();}\n  }else if(index===1){\n    // Coral: a warm branching reef with a shell at its foot.\n    ctx.strokeStyle=c.danger;ctx.lineWidth=8;\n    for(const [px,py] of [[-28,-16],[-14,-34],[10,-31],[28,-13]]){ctx.beginPath();ctx.moveTo(0,27);ctx.quadraticCurveTo(px,5,px,py);ctx.stroke();ctx.beginPath();ctx.moveTo(px,py+13);ctx.lineTo(px+(px<0?-10:10),py+3);ctx.stroke();}\n    ctx.fillStyle=c.ink;ctx.beginPath();ctx.arc(0,27,13,Math.PI,Math.PI*2);ctx.closePath();ctx.fill();\n  }else if(index===2){\n    // Moon: a crescent observatory held above a small stepped plinth.\n    ctx.fillStyle=c.ink;ctx.beginPath();ctx.arc(0,-7,29,.55,Math.PI*1.74);ctx.quadraticCurveTo(-3,-3,25,8);ctx.closePath();ctx.fill();\n    ctx.fillStyle=c.accent2;ctx.fillRect(-3,17,6,17);ctx.fillRect(-20,32,40,5);ctx.fillRect(-12,25,24,5);\n    for(const [px,py] of [[26,-28],[31,-7]]){ctx.fillStyle=c.gold;ctx.beginPath();ctx.moveTo(px,py-5);ctx.lineTo(px+4,py);ctx.lineTo(px,py+5);ctx.lineTo(px-4,py);ctx.closePath();ctx.fill();}\n  }else{\n    // Fern: two tall fronds, each with a ladder of pointed leaflets.\n    ctx.strokeStyle=c.good;ctx.lineWidth=3;\n    for(const side of [-1,1]){ctx.save();ctx.rotate(side*.27);ctx.beginPath();ctx.moveTo(side*8,31);ctx.quadraticCurveTo(side*8,-2,side*19,-34);ctx.stroke();\n      for(let row=0;row<5;row++){const py=19-row*10,px=side*(10+row*1.7);ctx.fillStyle=row%2?c.good:c.accent2;\n        for(const sign of [-1,1]){ctx.beginPath();ctx.moveTo(px,py+5);ctx.quadraticCurveTo(px+sign*22,py+4,px+sign*(20-row*2),py-9);ctx.quadraticCurveTo(px+sign*4,py-8,px,py+5);ctx.fill();}}\n      ctx.restore();}\n  }\n  ctx.restore();\n}\n","src/main.ts":"import {createRoom, createControls, createLabels, exposePort, PALETTE, q, type BodyBase, type LabelIn, type LabelBox} from '@homie-rocks/studio/port';\nimport style from '../style.json';\nimport {landmark} from './landmarks';\nimport {handoffGuide} from './guidance';\nimport {installThumbPass} from './thumb-pass';\nimport {relayRendezvous,botCanOfferRelay} from './rendezvous';\nimport {createSound} from './sound.js';\nimport {WIDTH as W, HEIGHT as H, DOCK, ISLANDS, freshHarbor, completionDeadline, visit, passLantern, passTarget, distance, needsRelay, isRelayed, claimCourier, type Harbor} from './rules';\n\nconst C=style.palette, SPEED=240, R=22;\ninterface Boat extends BodyBase {x:number;y:number;vx:number;vy:number;cargo:number;readyAt:number;passes:number;relayFrom:number;assists:number;restUntil:number;lookAt:number;tx:number;ty:number;creditGeneration:number;relayGeneration:number;creditSeat:number}\nlet harbor=freshHarbor();\nconst me={x:450,y:360,vx:0,vy:0,has:false};\nlet frames=0, last=performance.now();\nconst room=createRoom<Boat,Harbor,Harbor>({\n  game:'lantern-ferry',maxPlayers:4,minBodies:3,roundSeconds:75,breakSeconds:7,\n  botName:s=>['Pip','Moss','Pebble','Cove'][s%4],\n  spawn:(s,i)=>({...s,score:0,x:DOCK.x+Math.cos(i*Math.PI/2)*85,y:DOCK.y+Math.sin(i*Math.PI/2)*85,vx:0,vy:0,cargo:0,readyAt:0,passes:0,relayFrom:-1,assists:0,restUntil:0,lookAt:0,tx:DOCK.x,ty:DOCK.y,creditGeneration:0,relayGeneration:0,creditSeat:s.seat??-1}),\n  pack:b=>[q(b.x,1),q(b.y,1),q(b.vx,1),q(b.vy,1),b.cargo,b.readyAt,b.passes,b.restUntil,b.relayFrom,b.assists,b.creditGeneration,b.relayGeneration,b.creditSeat],\n  unpack:(f,b)=>{[b.x,b.y,b.vx,b.vy,b.cargo,b.readyAt,b.passes]=f;b.restUntil=Number.isFinite(f[7])?f[7]:0;b.relayFrom=Number.isFinite(f[8])?f[8]:-1;b.assists=Number.isFinite(f[9])?f[9]:0;b.creditGeneration=Number(f[10])||0;b.relayGeneration=Number(f[11])||0;b.creditSeat=Number.isFinite(f[12])?f[12]:-1;},\n  discrete:[4,5,6,7,8,9,10,11,12],\n  onRoundStart:()=>{harbor=freshHarbor();},\n  onRoundEnd:results=>{for(const row of results){row.place=1;row.score=harbor.delivered;}},\n  onTakeover:b=>{claimCourier(b);b.creditSeat=b.seat??-1;},\n  fastWorld:()=>harbor,saveWorld:()=>harbor,\n  loadWorld:(checkpoint,fast)=>{const w=fast??checkpoint;harbor=w?{...freshHarbor(),...w,lights:[...w.lights]}:freshHarbor();},\n  adopt:b=>{me.x=b.x;me.y=b.y;me.vx=0;me.vy=0;me.has=true;},\n  local:()=>me.has?({...mine(),...me} as Boat):null,\n  netplay:{caps:['skill','agents']},\n});\nconst net=room.net;\nnet.on('role',e=>{\n  // A new browser can become host of an empty checkpoint without a join event.\n  // A surviving replica becoming host keeps the credit it actually earned.\n  if(e.role==='host'&&e.prev===null){const b=[...room.bodies.values()].find(b=>!b.bot&&b.seat===room.mySeat());if(b){claimCourier(b);b.creditSeat=b.seat??-1;}}\n});\nconst mine=()=>room.view().find(b=>!b.bot&&b.seat===room.mySeat());\nconst input=createControls({actions:{pass:['Space']},touch:{buttons:[{id:'pass',label:'PASS',bottom:35,when:()=>!net.watching}]}});\nlet thumbPass=false;\ninstallThumbPass(()=>{\n  if(net.watching||room.round?.phase!=='live')return false;\n  const all=room.view(),body=all.find(b=>!b.bot&&b.seat===room.mySeat());\n  const from=body&&me.has?{...body,x:me.x,y:me.y}:body;\n  return !!from&&!isRelayed(from)&&!!passTarget(from,all,net.now());\n},()=>{thumbPass=true;});\nconst sound=createSound({base:'sound/'});sound.music('harbor-lights');sound.volume({master:.65,music:.6});\nconst mute=document.createElement('button');mute.textContent='Sound on';mute.className='sound';mute.setAttribute('aria-label','Mute sound');\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);\nconst canvas=document.querySelector('canvas')!;const ctx=canvas.getContext('2d',{alpha:false})!;\nlet dpr=1;function resize(){dpr=Math.min(2,devicePixelRatio||1);canvas.width=innerWidth*dpr;canvas.height=innerHeight*dpr;}addEventListener('resize',resize);resize();\ninterface Effect {x:number;y:number;at:number;kind:string;text:string;island?:number;completed?:boolean}\nconst effects:Effect[]=[];\nlet heardRound=0;\nfunction effect(data:{x:number;y:number;kind:string;seat:number|null;cargo?:number;amount?:number;island?:number;completed?:boolean}){\n  const local=data.seat!==null&&data.seat===room.mySeat();\n  effects.push({...data,at:performance.now(),text:local?(data.kind==='delivery'?`+${data.amount??1} ${data.amount===2?'relay lights!':'light'}`:data.kind==='pass'?'Passed!':''):''});\n  if(data.seat===room.mySeat()||data.kind==='delivery')sound.play(data.kind,{pitch:data.amount===2?4:0});\n}\nnet.on('event',e=>{if(e.k==='harbor')effect(e.d as Parameters<typeof effect>[0]);});\nfunction signal(b:Boat,kind:string,detail:{amount?:number;island?:number;completed?:boolean}={}){const d={x:b.x,y:b.y,kind,seat:b.seat,cargo:b.cargo,...detail};effect(d);room.send('harbor',d);if(b.seat!==null&&kind==='delivery')net.spotlight(b.seat);}\nfunction pass(b:Boat){\n  const target=passLantern(b,[...room.bodies.values()],harbor,net.now());\n  if(target){signal(b,'pass');return;}\n  if(b.seat===room.mySeat())sound.play('ui');\n}\nfunction move(o:{x:number;y:number;vx:number;vy:number},x:number,y:number,dt:number,speed=SPEED){\n  const n=Math.max(1,Math.hypot(x,y));const a=1-Math.exp(-12*dt);\n  o.vx+=(x/n*speed-o.vx)*a;o.vy+=(y/n*speed-o.vy)*a;\n  o.x=Math.max(R,Math.min(W-R,o.x+o.vx*dt));o.y=Math.max(R,Math.min(H-R,o.y+o.vy*dt));\n}\nfunction bot(b:Boat,dt:number){\n  const now=net.now(),skill=room.skillOf(b);\n  const all=[...room.bodies.values()],humans=all.filter(o=>!o.bot);\n  // Bots meet relays away from the dock; once a human sails out, meet that boat.\n  const carrier=!b.cargo?humans.filter(o=>o.cargo&&!isRelayed(o)&&now>=o.readyAt).sort((a,c)=>distance(a,b)-distance(c,b))[0]:null;\n  const receiver=needsRelay(b,harbor)?humans.filter(o=>!o.cargo&&now>=o.readyAt).sort((a,c)=>distance(a,b)-distance(c,b))[0]:null;\n  if(receiver&&botCanOfferRelay(b,receiver)&&distance(b,receiver)<150&&now>=b.readyAt){pass(b);move(b,0,0,dt);return;}\n  if(!carrier&&!receiver&&!b.cargo&&now<b.restUntil){move(b,0,0,dt);return;}\n  const partner=carrier??receiver;\n  const target=partner?relayRendezvous(carrier?.cargo??b.cargo,partner):(b.cargo?ISLANDS[b.cargo-1]:DOCK);\n  if(partner&&target===partner&&distance(b,partner)<105){move(b,0,0,dt);return;}\n  if(!b.lookAt||now>=b.lookAt){b.lookAt=now+skill.reactionMs;b.tx=target.x+(Math.random()-.5)*36*skill.aimNoise;b.ty=target.y+(Math.random()-.5)*36*skill.aimNoise;}\n  const dx=b.tx-b.x,dy=b.ty-b.y,n=Math.hypot(dx,dy);\n  move(b,n>12?dx/n:0,n>12?dy/n:0,dt,115+skill.positioning*45);\n}\nfunction step(dt:number){\n  // A fresh browser can inherit an empty room's checkpoint as host before it ever owned a local boat.\n  if(!me.has&&!net.watching){const b=mine();if(b){me.x=b.x;me.y=b.y;me.vx=b.vx||0;me.vy=b.vy||0;me.has=true;}}\n  const live=room.round?.phase==='live';const v=net.watching?{x:0,y:0}:input.move();\n  // Keep a diagonal thumb drag aligned with the taller portrait projection.\n  const magnitude=Math.hypot(v.x,v.y);if(magnitude){v.y/=portraitStretch();const mapped=Math.hypot(v.x,v.y);v.x=v.x/mapped*magnitude;v.y=v.y/mapped*magnitude;}\n  const buttonPressed=input.pressed('pass'),pressed=buttonPressed||thumbPass;thumbPass=false;\n  if(pressed&&live&&!net.watching)sound.play('ui');\n  if(me.has&&live&&!net.watching)move(me,v.x,v.y,dt);\n  if(room.hosting){\n    // Restoring an empty checkpoint may reconcile a human without the join callback.\n    for(const b of room.bodies.values())if(!b.bot&&b.seat!==null&&b.creditSeat!==b.seat){claimCourier(b);b.creditSeat=b.seat;}\n    if(live){\n      const all=[...room.bodies.values()];\n      for(const b of all){\n        if(b.bot)bot(b,dt);\n        else if(b.seat===room.mySeat()){b.x=me.x;b.y=me.y;b.vx=me.vx;b.vy=me.vy;if(pressed)pass(b);}\n        else {const a=room.avatar(b);if(a&&a.length>=4&&a.slice(0,4).every(n=>typeof n==='number'&&Number.isFinite(n))){room.bound(b,{x:Number(a[0]),y:Number(a[1])},SPEED,dt);b.vx=Number(a[2]);b.vy=Number(a[3]);}if(room.presses(b).pass)pass(b);}\n        const destination=b.cargo-1,before=b.score,wasLit=harbor.lights[destination]>=3;\n        const event=visit(b,harbor,all,net.now());if(event){signal(b,event,{amount:b.score-before,island:destination,completed:event==='delivery'&&!wasLit&&harbor.lights[destination]>=3});if(b.bot&&event==='delivery')b.restUntil=net.now()+12000;}\n      }\n    }\n    const round=room.round;\n    if(round?.phase==='live'){\n      const endsAt=completionDeadline(harbor.lights,round.endsAt,net.now());\n      if(endsAt<round.endsAt){round.endsAt=endsAt;net.round(round);net.checkpointNow();}\n    }\n    room.update();\n  }else if(me.has&&!net.watching){room.input([q(me.x,1),q(me.y,1),q(me.vx,1),q(me.vy,1)]);if(pressed&&live)room.press('pass');}\n}\nconst camera={x:W/2,y:H/2,scale:1,stretch:1};\nfunction portraitStretch(){return innerWidth<innerHeight&&innerWidth<560?Math.max(1,Math.min(1.8,(innerHeight-300)/(H*(innerWidth/(W+80))))):1;}\nlet worldStretch=1;\nconst nameAvoid:LabelBox[]=[];\nconst nameLabels=createLabels({gap:4,selfPad:3,avoid:()=>nameAvoid});\nlet lastDraw=performance.now();\nfunction circle(x:number,y:number,r:number,color:string){ctx.fillStyle=color;ctx.beginPath();ctx.arc(x,y,r,0,Math.PI*2);ctx.fill();}\nconst count=(n:number,one:string,many=one+'s')=>`${n} ${n===1?one:many}`;\nfunction text(s:string,x:number,y:number,size=15,color=C.ink,align:CanvasTextAlign='center'){ctx.save();if(worldStretch!==1){ctx.scale(1,1/worldStretch);y*=worldStretch;}ctx.font=`700 ${size}px ui-rounded,system-ui,sans-serif`;ctx.textAlign=align;ctx.fillStyle=color;ctx.fillText(s,x,y);ctx.restore();}\nfunction lantern(x:number,y:number,scale=1){\n  ctx.save();ctx.translate(x,y);ctx.scale(scale,scale);\n  const g=ctx.createRadialGradient(0,0,2,0,0,40);g.addColorStop(0,'#ffcf6a66');g.addColorStop(1,'#ffcf6a00');circle(0,0,40,g as unknown as string);\n  ctx.strokeStyle=C.gold;ctx.lineWidth=2;ctx.beginPath();ctx.arc(0,-12,5,Math.PI,0);ctx.stroke();\n  ctx.fillStyle=C.gold;ctx.beginPath();ctx.roundRect(-9,-12,18,24,5);ctx.fill();ctx.fillStyle=C.ink;ctx.fillRect(-2,-8,4,16);ctx.restore();\n}\nfunction draw(t:number){\n  const cw=innerWidth,ch=innerHeight,phone=Math.min(cw,ch)<560;\n  const boats=room.view();const view=room.viewSeat();const self=boats.find(b=>!b.bot&&b.seat===view);\n  if(self&&self.seat===room.mySeat()&&!net.watching&&me.has){self.x=me.x;self.y=me.y;self.vx=me.vx;self.vy=me.vy;}\n  const world=room.fast()??freshHarbor();const clock=room.clock();\n  const relayed=!!self?.cargo&&self.relayFrom>=0&&self.relayFrom!==self.slot;\n  const recipient=self&&clock.phase==='live'?passTarget(self,boats,net.now()):null;\n  const guide=self?handoffGuide(self,boats,world,net.now()):null;\n  const meeting=clock.phase==='live'?guide?.target:null;\n  const scale=phone?Math.min(cw/(W+80),ch/(H+80)):Math.min(cw/(W+60),ch/(H+80));\n  const stretch=portraitStretch(),objectZoom=stretch>1?1.25:1,halfW=cw/scale/2,halfH=ch/(scale*stretch)/2;\n  camera.scale=scale;camera.stretch=stretch;camera.x=self?Math.max(Math.min(halfW,W/2),Math.min(Math.max(W-halfW,W/2),self.x)):W/2;\n  camera.y=self?Math.max(Math.min(halfH,H/2),Math.min(Math.max(H-halfH,H/2),self.y)):H/2;\n  const instructionY=ch-(phone?(cw<ch?150:18):52);\n  nameAvoid.length=0;\n  nameAvoid.push({left:20,right:cw-20,top:instructionY-18,bottom:instructionY+6});\n  const reserve=(x:number,y:number,w:number)=>{const px=cw/2+(x-camera.x)*scale,py=ch/2+(y-camera.y)*scale*stretch;nameAvoid.push({left:px-w/2,right:px+w/2,top:py-16,bottom:py+4});};\n  reserve(DOCK.x,DOCK.y+91,130);for(const is of ISLANDS){reserve(is.x,is.y-88,120);const px=cw/2+(is.x-camera.x)*scale,py=ch/2+(is.y-camera.y)*scale*stretch+68*scale*objectZoom+12;nameAvoid.push({left:px-60,right:px+60,top:py-10,bottom:py+28});}\n  ctx.setTransform(dpr,0,0,dpr,0,0);ctx.fillStyle=C.bg;ctx.fillRect(0,0,cw,ch);\n  ctx.save();ctx.translate(cw/2,ch/2);ctx.scale(scale,scale*stretch);ctx.translate(-camera.x,-camera.y);worldStretch=stretch;\n  // Each finished island lights a broad patch of water, so progress transforms the harbor.\n  for(let i=0;i<ISLANDS.length;i++)if(world.lights[i]>=3){\n    const island=ISLANDS[i],reach=265+Math.sin(t/1800+i)*8;\n    const light=ctx.createRadialGradient(island.x,island.y,42,island.x,island.y,reach);\n    light.addColorStop(0,C.gold+'88');light.addColorStop(.45,C.gold+'55');light.addColorStop(1,C.gold+'00');\n    circle(island.x,island.y,reach,light as unknown as string);\n    ctx.strokeStyle=C.gold+'66';ctx.lineWidth=2;\n    for(let r=0;r<3;r++){\n      const phase=(t/4800+r/3+i*.17)%1;ctx.globalAlpha=(1-phase)*.6;\n      ctx.beginPath();ctx.arc(island.x,island.y,85+phase*175,0,Math.PI*2);ctx.stroke();\n    }\n    ctx.globalAlpha=1;\n  }\n  // Current lines and ripples make an inhabited harbor, with no heavy image downloads.\n  ctx.lineWidth=2;ctx.strokeStyle=C.accent2+'22';\n  for(let y=-80;y<H+100;y+=45)for(let x=-90;x<W+100;x+=72){const xx=x+Math.sin(t/3400+y)*9;ctx.beginPath();ctx.moveTo(xx,y);ctx.quadraticCurveTo(xx+15,y+7,xx+30,y);ctx.stroke();}\n  ctx.strokeStyle=C.accent2+'66';ctx.lineWidth=3;ctx.setLineDash([5,13]);ctx.strokeRect(15,15,W-30,H-30);ctx.setLineDash([]);\n  if(self&&!net.watching&&clock.phase==='live'){const target=meeting??(self.cargo?ISLANDS[self.cargo-1]:DOCK);ctx.strokeStyle=meeting?C.good+'aa':C.gold+'88';ctx.lineWidth=1.5/scale;ctx.setLineDash([6/scale,8/scale]);ctx.beginPath();ctx.moveTo(self.x,self.y);ctx.lineTo(target.x,target.y);ctx.stroke();ctx.setLineDash([]);}\n  if(self&&recipient&&!relayed&&!net.watching){ctx.strokeStyle=C.good;ctx.lineWidth=3/scale;ctx.beginPath();ctx.moveTo(self.x,self.y);ctx.lineTo(recipient.x,recipient.y);ctx.stroke();}\n  // Spread positions in portrait, while preserving the proportions of each landmark.\n  const objectAt=(x:number,y:number)=>{ctx.save();ctx.translate(x,y);ctx.scale(objectZoom,objectZoom/stretch);ctx.translate(-x,-y);};\n  objectAt(DOCK.x,DOCK.y);\n  circle(DOCK.x,DOCK.y,74,'#082f3a');circle(DOCK.x,DOCK.y,61,C.ink);\n  ctx.strokeStyle='#af9470';ctx.lineWidth=2;for(let y=-40;y<=40;y+=16){ctx.beginPath();ctx.moveTo(DOCK.x-44,DOCK.y+y);ctx.lineTo(DOCK.x+44,DOCK.y+y);ctx.stroke();}\n  lantern(DOCK.x,DOCK.y-10,1.4);ctx.restore();text('LANTERN DOCK',DOCK.x,DOCK.y+91,Math.max(14,12/scale));\n  ISLANDS.forEach((is,i)=>{\n    const lit=world.lights[i],target=self?.cargo===i+1;objectAt(is.x,is.y);\n    if(target){ctx.strokeStyle=C.gold;ctx.lineWidth=3;ctx.setLineDash([8,8]);ctx.beginPath();ctx.arc(is.x,is.y,83+Math.sin(t/250)*3,0,Math.PI*2);ctx.stroke();ctx.setLineDash([]);}\n    circle(is.x,is.y+7,70,'#092e35');circle(is.x,is.y,68,lit>=3?C.gold:C.accent2);circle(is.x,is.y,54,lit>=3?'#ba8a3e':'#427e69');\n    if(lit>=3){const glow=ctx.createRadialGradient(is.x,is.y,35,is.x,is.y,100);glow.addColorStop(0,'#ffcf6a55');glow.addColorStop(1,'#ffcf6a00');circle(is.x,is.y,100,glow as unknown as string);}\n    if(lit>0&&lit<3){\n      // Each delivery lights a substantial part of the island, before its final relay.\n      const end=-Math.PI/2+Math.PI*2*lit/3;\n      ctx.fillStyle='#ba8a3e';ctx.beginPath();ctx.moveTo(is.x,is.y);ctx.arc(is.x,is.y,54,-Math.PI/2,end);ctx.closePath();ctx.fill();\n      ctx.strokeStyle=C.gold;ctx.lineWidth=5;ctx.beginPath();ctx.arc(is.x,is.y,61,-Math.PI/2,end);ctx.stroke();\n    }\n    landmark(ctx,i,is.x,is.y,C);\n    ctx.restore();text(`${is.mark} · ${is.name}`,is.x,is.y-88,Math.max(17,12/scale));\n  });\n  const nameInputs:LabelIn[]=[];\n  for(const b of boats){\n    const own=b.seat===view&&!b.bot;const a=Math.hypot(b.vx,b.vy)>8?Math.atan2(b.vy*stretch,b.vx)+Math.PI/2:0;\n    ctx.save();ctx.translate(b.x,b.y);ctx.scale(objectZoom,objectZoom/stretch);ctx.rotate(a);\n    ctx.strokeStyle=C.ink+'55';ctx.lineWidth=2;\n    for(let i=0;i<3;i++){ctx.beginPath();ctx.arc(0,22+i*8,9+i*5,.3,Math.PI-.3);ctx.stroke();}\n    ctx.fillStyle=own?C.ink:PALETTE[(b.seat??b.slot)%PALETTE.length];ctx.beginPath();ctx.moveTo(0,-30);ctx.quadraticCurveTo(32,3,19,27);ctx.quadraticCurveTo(0,37,-19,27);ctx.quadraticCurveTo(-32,3,0,-30);ctx.fill();\n    ctx.fillStyle='#6b523c';ctx.beginPath();ctx.roundRect(-14,-5,28,25,6);ctx.fill();ctx.restore();\n    objectAt(b.x,b.y);\n    if(b.cargo){lantern(b.x,b.y-6,.9);worldStretch=1;text(ISLANDS[b.cargo-1].mark,b.x+27,b.y-18,15,C.gold);worldStretch=stretch;}\n    if(b.cargo&&b.relayFrom>=0&&b.relayFrom!==b.slot){ctx.strokeStyle=C.good;ctx.lineWidth=4;ctx.beginPath();ctx.arc(b.x,b.y-5,19,0,Math.PI*2);ctx.stroke();}\n    if((recipient??meeting)?.slot===b.slot){ctx.strokeStyle=C.good;ctx.lineWidth=4;ctx.setLineDash([6,5]);ctx.beginPath();ctx.arc(b.x,b.y,44,0,Math.PI*2);ctx.stroke();ctx.setLineDash([]);}\n    if(own){ctx.strokeStyle=C.gold;ctx.lineWidth=3;ctx.beginPath();ctx.arc(b.x,b.y,38,0,Math.PI*2);ctx.stroke();}\n    ctx.restore();\n    const base=own&&!net.watching?'You':`${b.name}${b.bot&&!b.name.includes('AI')?' · bot':''}`;\n    const label=base+(b.cargo&&b.relayFrom>=0&&b.relayFrom!==b.slot?' · relay':recipient?.slot===b.slot?' · PASS':meeting?.slot===b.slot?(b.cargo?' · LANTERN':' · EMPTY'):'');\n    const px=cw/2+(b.x-camera.x)*scale,py=ch/2+(b.y-camera.y)*scale*stretch;ctx.font='700 12px system-ui';\n    const celebrating=b.bot&&effects.some(e=>e.completed&&e.island!==undefined&&t-e.at<1000&&distance(b,ISLANDS[e.island])<105);\n    if(!celebrating)nameInputs.push({key:b.slot,text:label,x:px,y:own&&stretch>1&&py<205?py+70*scale*objectZoom:py-45*scale*objectZoom,w:ctx.measureText(label).width,h:15,below:py+57*scale*objectZoom,self:own,rank:b.bot?1:0,body:{left:px-22*scale*objectZoom,right:px+22*scale*objectZoom,top:py-30*scale*objectZoom,bottom:py+32*scale*objectZoom}});\n  }\n  for(let i=effects.length-1;i>=0;i--){const e=effects[i],age=(t-e.at)/1200;if(age>1){effects.splice(i,1);continue;}ctx.globalAlpha=1-age;ctx.strokeStyle=C.gold;ctx.lineWidth=3;ctx.beginPath();ctx.arc(e.x,e.y,25+age*60,0,Math.PI*2);ctx.stroke();if(e.completed&&e.island!==undefined){const island=ISLANDS[e.island];ctx.lineWidth=5;ctx.beginPath();ctx.arc(island.x,island.y,80+age*120,0,Math.PI*2);ctx.stroke();}if(e.text)text(e.text,e.x,e.y-80-age*32,14/scale,C.gold);ctx.globalAlpha=1;}\n  ctx.restore();worldStretch=1;\n  // Screen-space lamp strips stay legible when a boat crosses an island.\n  for(let i=0;i<ISLANDS.length;i++){\n    const is=ISLANDS[i],lit=world.lights[i];\n    const x=cw/2+(is.x-camera.x)*scale,y=ch/2+(is.y-camera.y)*scale*stretch+68*scale*objectZoom+12;\n    ctx.fillStyle=C.bg+'ee';ctx.beginPath();ctx.roundRect(x-25,y-10,50,20,7);ctx.fill();\n    if(clock.phase!=='over')text(lit>=3?'LIT':lit===2?'NEEDS A RELAY':`${lit} / 3`,x,y+23,11,lit>=3?C.gold:C.ink);\n    for(let j=0;j<3;j++){\n      const lx=x+(j-1)*15;ctx.strokeStyle=j===2?C.good:C.ink;ctx.lineWidth=1.5;\n      ctx.fillStyle=j<lit?C.gold:C.bg;ctx.beginPath();ctx.roundRect(lx-3,y-5,6,10,2);ctx.fill();ctx.stroke();\n      if(j===2){ctx.beginPath();ctx.arc(lx,y,7,0,Math.PI*2);ctx.stroke();}\n    }\n  }\n  const dt=Math.min(.05,(t-lastDraw)/1000);lastDraw=t;\n  for(const l of nameLabels.place(nameInputs,dt)){if(!l.alpha)continue;ctx.globalAlpha=l.alpha;if(l.moved&&l.body){ctx.strokeStyle=C.accent2+'88';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(l.cx,l.bottom);ctx.lineTo((l.body.left+l.body.right)/2,(l.body.top+l.body.bottom)/2);ctx.stroke();}ctx.fillStyle=C.bg+'ee';ctx.beginPath();ctx.roundRect(l.left-4,l.top-2,l.right-l.left+8,l.bottom-l.top+4,5);ctx.fill();ctx.textBaseline='middle';text(l.text,l.cx,l.cy,12,l.self?C.ink:C.accent2);ctx.textBaseline='alphabetic';ctx.globalAlpha=1;}\n  // A destination cue always stays visible, even when the phone camera crops the island.\n  if(self&&clock.phase==='live'){const target=meeting??(self.cargo?ISLANDS[self.cargo-1]:DOCK);const sx=cw/2+(target.x-camera.x)*scale,sy=ch/2+(target.y-camera.y)*scale*stretch;\n    if(sx<28||sx>cw-28||sy<125||sy>ch-105){const x=Math.max(30,Math.min(cw-30,sx)),y=Math.max(135,Math.min(ch-120,sy));ctx.save();ctx.translate(x,y);ctx.rotate(Math.atan2(sy-y,sx-x));ctx.fillStyle=C.gold;ctx.beginPath();ctx.moveTo(15,0);ctx.lineTo(-10,-9);ctx.lineTo(-10,9);ctx.closePath();ctx.fill();ctx.restore();}}\n  const lit=world.lights.filter(n=>n>=3).length;\n  text(`${count(world.delivered,'light')} · ${lit}/4 islands`,16,30,phone?18:23,C.ink,'left');\n  text(clock.phase==='over'?`Next harbor in ${clock.secondsLeft}`:`${clock.secondsLeft}s · Light the harbor together`,16,54,13,C.accent2,'left');\n  const action=phone?'double-tap':'Space';\n  const targetName=self?.cargo?ISLANDS[self.cargo-1].name:'';\n  const partner=meeting?.name.slice(0,12)??'an empty boat';\n  const instruction=clock.phase==='over'?(lit===4?'Every island lit · well sailed!':`${4-lit} islands unfinished · relay each final lamp`):\n    lit===4?'Harbor complete · well sailed!':\n    guide?.mode==='pass'?`${targetName} lantern · ${action} → ${partner}`:\n    guide?.mode==='approach'?`${targetName} lantern · meet ${partner}, then ${action}`:\n    guide?.mode==='wait'?`${targetName} lantern · wait, then ${action} → ${partner}`:\n    guide?.mode==='receive'?`Meet ${partner} to receive a lantern`:\n    guide?.mode==='blocked'?`${targetName}'s final lamp · wait for an empty boat`:\n    self?.cargo?(relayed?`Deliver the relay to ${targetName}`:`Deliver to ${targetName} · or pass to an empty boat`):\n    'Pick up a lantern at the dock';\n  // Keep a single readable line even with a long generated player name.\n  const instructionSize=Math.min(phone?14:17,(cw-28)/Math.max(1,instruction.length*.56));\n  if(phone&&cw>ch){ctx.fillStyle=C.bg+'ee';ctx.beginPath();ctx.roundRect(20,instructionY-12,cw-40,20,8);ctx.fill();}\n  text(instruction,cw/2,instructionY,instructionSize,meeting?C.good:C.ink);\n  if(phone&&recipient&&!relayed&&!net.watching)text(`TO ${recipient.name.slice(0,12)}`,cw-64,ch-111,12,C.good);\n  if(clock.phase==='live'&&clock.secondsLeft<=10)text(`${clock.secondsLeft}s`,cw/2,phone&&cw<ch?160:86,clock.secondsLeft<=3?38:30,C.gold);\n  if(phone&&!net.watching&&cw<ch)text('Drag to sail · double-tap left to pass',16,ch-110,12,C.accent2,'left');\n  if(clock.phase==='over'){\n    if(heardRound!==clock.n){heardRound=clock.n;sound.play(lit===4?'win':'delivery');}\n    const width=Math.min(360,cw-30),height=206;ctx.fillStyle='#092e35ee';ctx.beginPath();ctx.roundRect((cw-width)/2,(ch-height)/2,width,height,20);ctx.fill();\n    text(lit===4?'The harbor is glowing!':'A little brighter, together',cw/2,ch/2-64,22,C.gold);\n    text(`${count(world.delivered,'light')} · ${count(world.passes,'pass','passes')}`,cw/2,ch/2-33,17);\n    text(`${lit} of 4 islands fully lit`,cw/2,ch/2-7,16,C.accent2);\n    if(self)text(`You: ${count(self.score,'light')} · ${count(self.assists??0,'useful pass','useful passes')}`,cw/2,ch/2+27,15);\n    text(`Sail again in ${clock.secondsLeft}`,cw/2,ch/2+68,15);\n  }\n  if(!net.offline&&!net.connected)text('Reconnecting…',cw/2,90,14,C.gold);\n}\nfunction frame(t:number){const dt=Math.min(.05,Math.max(0,(t-last)/1000));last=t;step(dt);draw(t);frames++;requestAnimationFrame(frame);}\nnet.expose({frames:()=>frames,self:()=>me.has?{x:me.x,y:me.y}:null,peer:(seat:number)=>{const b=room.view().find(b=>b.seat===seat);return b?{x:b.x,y:b.y}:null;},scores:()=>room.view().map(b=>({seat:b.seat,score:b.score,bot:b.bot})),harbor:()=>room.fast(),boats:()=>room.view().map(b=>({slot:b.slot,seat:b.seat,cargo:b.cargo,score:b.score,passes:b.passes,assists:b.assists,relayFrom:b.relayFrom,bot:b.bot,creditGeneration:b.creditGeneration,relayGeneration:b.relayGeneration})),recipient:()=>{const all=room.view(),b=all.find(b=>!b.bot&&b.seat===room.mySeat());const from=b&&me.has?{...b,x:me.x,y:me.y}:b;const target=from?passTarget(from,all,net.now()):null;return target?{slot:target.slot,name:target.name}:null;},camera:()=>({...camera,view:room.viewSeat()})});\nexposePort(net,{view:'top',size:R,self:()=>me.has&&!net.watching?{x:me.x,y:me.y}:null,score:()=>mine()?.score??0});\nrequestAnimationFrame(frame);\n","src/rendezvous.ts":"import {DOCK,ISLANDS,distance,type CargoBoat} from './rules.ts';\n\n// A short intentional departure is enough to invite a bot to meet the player.\n// This stays outside the 66-unit pickup radius, so standing at the dock cannot relay everything.\nexport const RELAY_DEPARTURE=100;\n\n/** Waiting near an island puts a relay beyond the dock's pickup + pass reach. */\nexport function relayRendezvous(cargo:number,partner:{x:number;y:number}){\n  if(distance(partner,DOCK)>RELAY_DEPARTURE)return partner;\n  const island=ISLANDS[cargo-1];\n  if(!island)return partner;\n  const dx=island.x-DOCK.x,dy=island.y-DOCK.y,n=Math.hypot(dx,dy);\n  return {x:DOCK.x+dx/n*290,y:DOCK.y+dy/n*290};\n}\n/** Bots do not turn a fresh dock pickup straight back into an automatic relay. */\nexport function botCanOfferRelay(from:CargoBoat,to:CargoBoat){\n  return distance(from,DOCK)>246||distance(to,DOCK)>RELAY_DEPARTURE;\n}\n","src/rules.ts":"/** Pure delivery rules: the same world is checkpointed and sent in every snapshot. */\nexport const WIDTH = 900;\nexport const HEIGHT = 720;\nexport const DOCK = { x: 450, y: 360 };\nexport const ISLANDS = [\n  { x: 190, y: 170, name: 'Willow', mark: 'I' },\n  { x: 710, y: 170, name: 'Coral', mark: 'II' },\n  { x: 710, y: 550, name: 'Moon', mark: 'III' },\n  { x: 190, y: 550, name: 'Fern', mark: 'IV' },\n];\nexport interface CargoBoat { slot: number; x: number; y: number; cargo: number; readyAt: number; score: number; passes: number; relayFrom?: number; assists?: number; creditGeneration?:number; relayGeneration?:number }\n/** Personal credit belongs to an occupant; cargo and crew progress belong to the room. */\nexport function claimCourier(b:CargoBoat):void {b.score=0;b.passes=0;b.assists=0;b.creditGeneration=(b.creditGeneration??0)+1;}\nexport interface Harbor { lights: number[]; delivered: number; passes: number; serial: number; bonus: number }\nexport const freshHarbor = (): Harbor => ({ lights: [0, 0, 0, 0], delivered: 0, passes: 0, serial: 0, bonus: 0 });\nexport const distance = (a: {x:number;y:number}, b: {x:number;y:number}) => Math.hypot(a.x-b.x,a.y-b.y);\nexport function chooseIsland(world: Harbor, boats: CargoBoat[]): number {\n  // Completed islands never receive new cargo; pending relays reserve two lights.\n  const reserved = world.lights.map((n, i) => n >= 3 ? Infinity : n + boats.reduce((sum,b) => sum + (b.cargo===i+1 ? (isRelayed(b)?2:1) : 0),0));\n  const lowest=Math.min(...reserved);\n  return Number.isFinite(lowest)?reserved.indexOf(lowest):-1;\n}\nexport const isRelayed=(b:CargoBoat)=>b.relayFrom!==undefined&&b.relayFrom>=0&&b.relayFrom!==b.slot;\nexport const needsRelay=(b:CargoBoat,world:Harbor)=>!!b.cargo&&!isRelayed(b)&&world.lights[b.cargo-1]>=2;\n\nexport function visit(b: CargoBoat, world: Harbor, boats: CargoBoat[], now: number): 'pickup'|'delivery'|null {\n  // Another courier may have completed this destination while the cargo was in transit.\n  if(b.cargo&&world.lights[b.cargo-1]>=3){b.cargo=chooseIsland(world,boats.filter(o=>o!==b))+1;world.serial++;}\n  if (now < b.readyAt) return null;\n  if (!b.cargo && distance(b, DOCK) < 66) {\n    const destination=chooseIsland(world,boats);if(destination<0)return null;\n    // Keep a receiver available once any cargo needs a handoff; otherwise every\n    // boat can become loaded and no one can perform the required final relay.\n    if(boats.length>1&&boats.every(o=>o===b||o.cargo)&&(world.lights[destination]>=2||boats.some(o=>needsRelay(o,world))))return null;\n    b.cargo = destination + 1; b.relayFrom=-1; b.readyAt = now + 650; world.serial++;\n    return 'pickup';\n  }\n  const island = ISLANDS[b.cargo - 1];\n  if (island && distance(b, island) < 74) {\n    const relayed=isRelayed(b);\n    if(needsRelay(b,world))return null;\n    const sender=relayed?boats.find(o=>o.slot===b.relayFrom):undefined;\n    const amount=Math.min(relayed?2:1,3-world.lights[b.cargo-1]);\n    const extra=Math.max(0,amount-1);\n    world.lights[b.cargo-1]+=amount; world.delivered+=amount; world.bonus+=extra; world.serial++;\n    if(sender&&(sender.creditGeneration??0)===(b.relayGeneration??0))sender.assists=(sender.assists??0)+1;\n    b.cargo=0; b.relayFrom=-1; b.score+=amount; b.readyAt=now+900;\n    return 'delivery';\n  }\n  return null;\n}\nexport function passTarget<B extends CargoBoat>(from: CargoBoat, boats: B[], now: number): B | null {\n  if (!from.cargo || now < from.readyAt) return null;\n  return boats.filter(b => b.slot !== from.slot && !b.cargo && now >= b.readyAt && distance(b,from) <= 180)\n    .sort((a,b) => distance(a,from)-distance(b,from) || a.slot-b.slot)[0]??null;\n}\nexport function passLantern(from: CargoBoat, boats: CargoBoat[], world: Harbor, now: number): CargoBoat | null {\n  const to=passTarget(from,boats,now);\n  if (!to) return null;\n  to.cargo=from.cargo; to.relayFrom=from.relayFrom!==undefined&&from.relayFrom>=0?from.relayFrom:from.slot;\n  to.relayGeneration=from.relayFrom!==undefined&&from.relayFrom>=0?(from.relayGeneration??0):(from.creditGeneration??0);\n  from.cargo=0; from.relayFrom=-1; from.readyAt=now+1300; to.readyAt=now+650;\n  from.passes++; world.passes++; world.serial++;\n  return to;\n}\n\n/** Finishing the shared objective shortens, but never extends, the live round. */\nexport function completionDeadline(lights: number[], endsAt: number, now: number): number {\n  return lights.length===ISLANDS.length&&lights.every(n=>n>=3)?Math.min(endsAt,now+3000):endsAt;\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","src/thumb-pass.ts":"/** Two deliberate taps near the same point; releasing a drag never counts. */\nexport class DoubleTap {\n  private down:{x:number;y:number;at:number;moved:boolean}|null=null;\n  private previous:{x:number;y:number;at:number}|null=null;\n  start(x:number,y:number,at:number){this.down={x,y,at,moved:false};}\n  move(x:number,y:number){if(this.down&&Math.hypot(x-this.down.x,y-this.down.y)>12)this.down.moved=true;}\n  cancel(){this.down=null;this.previous=null;}\n  end(x:number,y:number,at:number){\n    this.move(x,y);const down=this.down;this.down=null;\n    if(!down||down.moved||at-down.at>220){this.previous=null;return false;}\n    const prior=this.previous;\n    if(prior&&at-prior.at<=320&&Math.hypot(x-prior.x,y-prior.y)<=36){this.previous=null;return true;}\n    this.previous={x,y,at};return false;\n  }\n}\n\nexport function installThumbPass(ready:()=>boolean,pass:()=>void){\n  const taps=new DoubleTap();let finger:number|null=null;\n  const reset=()=>{finger=null;taps.cancel();};\n  window.addEventListener('touchstart',e=>{\n    if(e.touches.length!==1||!ready()){reset();return;}\n    const t=e.changedTouches[0],target=e.target;\n    if(!t||t.clientX>innerWidth*.55||t.clientY<innerHeight*.3||(target instanceof Element&&target.closest('button,a,input,select,textarea'))){reset();return;}\n    finger=t.identifier;taps.start(t.clientX,t.clientY,performance.now());\n  },{passive:true});\n  window.addEventListener('touchmove',e=>{\n    if(!ready()||e.touches.length!==1){reset();return;}\n    for(const t of Array.from(e.changedTouches))if(t.identifier===finger)taps.move(t.clientX,t.clientY);\n  },{passive:true});\n  window.addEventListener('touchend',e=>{\n    for(const t of Array.from(e.changedTouches))if(t.identifier===finger){\n      finger=null;\n      if(!ready()){reset();return;}\n      if(taps.end(t.clientX,t.clientY,performance.now()))pass();\n    }\n  },{passive:true});\n  window.addEventListener('touchcancel',reset,{passive:true});\n  window.addEventListener('blur',reset);\n  document.addEventListener('visibilitychange',()=>{if(document.hidden)reset();});\n}\n","style.json":"{\n  \"v\": 1,\n  \"palette\": {\n    \"bg\": \"#123e4a\",\n    \"ink\": \"#fff3d8\",\n    \"accent\": \"#ffcf6a\",\n    \"accent2\": \"#84dac5\",\n    \"danger\": \"#ef8d85\",\n    \"good\": \"#a8edc0\",\n    \"gold\": \"#ffcf6a\",\n    \"ramp\": [\n      \"#123e4a\",\n      \"#225868\",\n      \"#347687\",\n      \"#fff3d8\",\n      \"#84dac5\",\n      \"#ffcf6a\"\n    ]\n  },\n  \"fonts\": {\n    \"display\": \"Rajdhani\",\n    \"body\": \"Inter\"\n  },\n  \"light\": {\n    \"time\": \"night\",\n    \"key\": [\n      0.4,\n      -1,\n      0.3\n    ],\n    \"intensity\": 1.1,\n    \"hardness\": 0.4,\n    \"sky\": \"#123e4a\",\n    \"ground\": \"#407c7b\",\n    \"fog\": 0.6,\n    \"bloom\": 0.5\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\": true,\n    \"atlas\": false\n  },\n  \"rev\": {\n    \"style.palette\": 2,\n    \"style.ui\": 1,\n    \"style.light\": 2,\n    \"style.camera\": 2\n  }\n}\n"}}
