SOURCE / PINNED RELEASE
Made of little things.
Limb Race
- Release
- 6a8ae4fc626e…
- Author-recorded commit
- 008c01ee6454…
- License
- LICENSE
- Author’s source reference
- nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-c2869f12ad8
Archive hash verified: 7be8492d1f89b891…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import './styles.css';
import './napplet-settings.js';
import { identity, themeGet, themeOnChanged, type Theme } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { Race, TRACKS, STEP, MAX_STROKES, MAX_POINTS, cloneDesign, presetDesign, formatTime, PHYSICS_VERSION, type Design, type Mode, type Material, type Point } from './physics.js';
import { drawPad, drawTrack, thumbnail } from './render.js';
import { hasSharedBoard, callBoard, readBoard, readSharedRun, submitRun, loadHistory, saveHistory, type BoardRow, type Run, type Edit } from './scores.js';
import {setupLevels} from './level-editor.js';
import {levelTrack, type LevelSnapshot} from './level-model.js';
const $=<T extends HTMLElement>(id:string)=>document.getElementById(id) as T;
const drawing=$<HTMLCanvasElement>('draw-canvas'),trackCanvas=$<HTMLCanvasElement>('track-canvas');
const nameInput=$<HTMLInputElement>('racer-name');
let track=TRACKS[0],mode:Mode='classic',material:Material='rigid',design:Design=[],race=new Race(track,design,material);
let phase:'ready'|'running'|'paused'|'finished'|'failed'='ready',dark=false;
let history:Run[]=[],board:'personal'|'shared'='personal',sharedRows:BoardRow[]=[],actor='',sharedError='',sharedLoading=false;
let edits:Edit[]=[],latestRun:Run|null=null,inspected:Run|null=null,inspectDesign:Design=[];
let pointer:number|null=null,stroke:Point[]|null=null,boardRequest=0,dirty=false,saveTimer=0,raf=0,last=0,accumulator=0;
let drawUndo:Design[]=[];
let wasStalled=false,sharedNext:number|null=null,inspectRequest=0;
let activeLevel:LevelSnapshot|undefined;
const cleanup:(()=>void)[]=[];
function hint(text:string):void{$('hint').textContent=text;}
function locked():boolean{return mode==='classic'&&(phase==='running'||phase==='paused');}
function setPressed(id:string,pressed:boolean):void{$(id).setAttribute('aria-pressed',String(pressed));}
function currentRuns():Run[]{return history.filter(r=>r.track===track.id&&r.mode===mode&&r.material===material).sort((a,b)=>a.ms-b.ms);}
function renderBest():void{const best=currentRuns()[0];$('best').textContent=`PERSONAL BEST ${best?formatTime(best.ms):'—'}`;}
function updateControls():void{
const busy=phase==='running'||phase==='paused';
($('material') as HTMLButtonElement).disabled=busy;
$('material').textContent=material==='bendy'?'Bendy':'Rigid';setPressed('material',material==='bendy');
$('material').title=`Limb physics: ${material}. Switch to ${material==='rigid'?'bendy':'rigid'}.`;
$('board-mode').textContent=`${mode==='live'?'LIVE':'CLASSIC'} · ${material.toUpperCase()}`;
$('race').textContent=phase==='running'?'Rolling…':phase==='paused'?'Resume →':phase==='finished'||phase==='failed'?'Race again →':'Let it roll →';
($('race') as HTMLButtonElement).disabled=phase==='running'||design.length===0;
($('pause') as HTMLButtonElement).disabled=phase!=='running'&&phase!=='paused';
$('pause').textContent=phase==='paused'?'▷':'Ⅱ';$('pause').setAttribute('aria-label',phase==='paused'?'Resume race':'Pause race');
for(const button of document.querySelectorAll<HTMLButtonElement>('[data-preset],#clear,#undo'))button.disabled=locked();
($('undo') as HTMLButtonElement).disabled=locked()||!drawUndo.length;
$('pad-badge').textContent=locked()?'DESIGN LOCKED FOR THIS RACE':mode==='live'?'LIVE · EVERY STROKE CHANGES YOUR RACER':'YOUR SKETCH → YOUR RACER';
$('ink-count').textContent=`${design.length} / ${MAX_STROKES} strokes`;
$('race-state').textContent={ready:'READY WHEN YOU ARE',running:'FIND YOUR RHYTHM',paused:'TAKE A BREATHER',finished:'ACROSS THE LINE',failed:'TIME FOR A NEW IDEA'}[phase];
drawing.setAttribute('aria-disabled',String(locked()));drawPad(drawing,design,locked());renderBest();
}
function reset():void{
pointer=null;stroke=null;wasStalled=false;race=new Race(track,design,material);phase='ready';edits=[];latestRun=null;accumulator=0;
$('race-message').hidden=true;$('share-result').hidden=true;$('time').innerHTML='00.00<span>s</span>';$('progress-fill').style.width='0%';updateControls();
}
function recordEdit():void{
if(mode==='live'&&(phase==='running'||phase==='paused')){
const entry={at:Math.round(race.elapsed*1000),design:cloneDesign(design)};
if(edits.length>=24)edits.splice(1,1);edits.push(entry);
}
}
function syncDrawing(commit=true):void{
dirty=true;
if(phase==='finished'||phase==='failed')reset();
if(phase==='ready'||mode==='live')race.setDesign(design);
if(commit){recordEdit();queueSave();}
updateControls();
}
function saveUndo():void{drawUndo.push(cloneDesign(design));if(drawUndo.length>30)drawUndo.shift();}
async function persist():Promise<void>{
try {if(!await saveHistory(history,design,nameInput.value||'Guest racer'))hint('Progress is kept for this session. Saving is unavailable in this host.');}
catch{hint('Race kept for this session, but could not be saved. Try another race to retry.');}
}
function queueSave():void{clearTimeout(saveTimer);saveTimer=window.setTimeout(()=>void persist(),350);}
function start():void{
if(phase==='paused'){resume();return;}
if(!design.length){hint('Draw some limbs, or pick a starter shape below.');return;}
wasStalled=false;race=new Race(track,design,material);phase='running';latestRun=null;edits=[{at:0,design:cloneDesign(design)}];accumulator=0;last=performance.now();
$('race-message').hidden=true;$('share-result').hidden=true;
hint(mode==='live'?'Keep drawing! New limbs change your racer instantly.':'Your design is locked. Watch those little legs go.');updateControls();
}
function pause():void{
if(phase!=='running')return;phase='paused';$('message-title').textContent='A little breather';$('message-detail').textContent='Your time is paused. Ready when you are.';$('message-action').textContent='Keep going →';$('share-result').hidden=true;$('race-message').hidden=false;updateControls();
}
function resume():void{if(phase!=='paused')return;phase='running';last=performance.now();accumulator=0;$('race-message').hidden=true;updateControls();}
function finish():void{
phase=race.finished?'finished':'failed';
$('message-title').textContent=race.finished?'Look at you go.':'A fresh pair of legs?';
$('message-detail').textContent=race.finished?`${formatTime(race.elapsed*1000)} · ${track.name}`:'Try longer legs or a different bend.';
$('message-action').textContent='Draw & race again →';$('race-message').hidden=false;
if(race.finished){
const run:Run={id:globalThis.crypto?.randomUUID?.()??`${Date.now()}-${Math.random().toString(36).slice(2)}`,track:track.id,mode,material,physics:PHYSICS_VERSION,ms:Math.round(race.elapsed*1000),name:nameInput.value.trim()||'Guest racer',actor:actor||undefined,created:Date.now(),design:cloneDesign(race.design),edits:edits.map(e=>({at:e.at,design:cloneDesign(e.design)}))};
if(activeLevel)run.level=structuredClone(activeLevel);
history.unshift(run);history=history.slice(0,40);latestRun=run;dirty=true;void persist();
$('share-result').hidden=!runtimeHasDomain('cvm')||!hasSharedBoard(track.id,mode,material);$('share-result').textContent='Share time ↗';($('share-result') as HTMLButtonElement).disabled=false;
hint(hasSharedBoard(track.id,mode,material)?'Saved with your limbs. Share your time to join this track’s board.':'Personal time saved with your limbs. Can you beat it?');renderScores();
}else hint('No time recorded. Redraw, then give it another spin.');
updateControls();
}
function pointFrom(event:PointerEvent):Point{
const rect=drawing.getBoundingClientRect(),r=Math.min(rect.width,rect.height)*.43;
let x=(event.clientX-rect.left-rect.width/2)/r,y=(event.clientY-rect.top-rect.height/2)/r;
x=Math.max(-1,Math.min(1,x));y=Math.max(-1,Math.min(1,y));return{x:Math.round(x*1000)/1000,y:Math.round(y*1000)/1000};
}
drawing.addEventListener('pointerdown',(e)=>{
if(locked()||pointer!==null)return;
if(design.length>=MAX_STROKES){hint('Eight strokes is the limit. Undo or clear to make room.');return;}
if(design.reduce((n,s)=>n+s.length,0)>=MAX_POINTS-2){hint('This drawing is full. Undo or clear to make room.');return;}
e.preventDefault();drawing.focus({preventScroll:true});saveUndo();pointer=e.pointerId;drawing.setPointerCapture(pointer);
const p=pointFrom(e);stroke=[{x:0,y:0},p];design.push(stroke);syncDrawing(false);
});
drawing.addEventListener('pointermove',(e)=>{
if(e.pointerId!==pointer||!stroke)return;e.preventDefault();
const p=pointFrom(e),previous=stroke.at(-1)!;if(Math.hypot(p.x-previous.x,p.y-previous.y)<.045)return;
if(design.reduce((n,s)=>n+s.length,0)>=MAX_POINTS){hint('Ink limit reached. Lift your finger to finish this stroke.');return;}
stroke.push(p);syncDrawing(false);
});
function endStroke(e?:PointerEvent):void{if(pointer===null||(e&&pointer!==e.pointerId))return;const id=pointer;pointer=null;stroke=null;if(drawing.hasPointerCapture(id))drawing.releasePointerCapture(id);syncDrawing();}
drawing.addEventListener('pointerup',endStroke);drawing.addEventListener('pointercancel',endStroke);drawing.addEventListener('lostpointercapture',endStroke);
for(const button of document.querySelectorAll<HTMLButtonElement>('[data-preset]'))button.addEventListener('click',()=>{if(locked())return;saveUndo();design=presetDesign(button.dataset.preset!);syncDrawing();hint(mode==='live'?'Starter shape loaded. Tweak it as you roll.':'A starting point. Add your own twist, or let it roll.');});
$('undo').addEventListener('click',()=>{if(locked()||!drawUndo.length)return;design=drawUndo.pop()!;syncDrawing();});
$('clear').addEventListener('click',()=>{if(locked())return;saveUndo();design=[];syncDrawing();hint(mode==='live'&&phase==='running'?'Your hub is still moving. Draw its new legs.':'Fresh paper. Try something unexpected.');});
$('race').addEventListener('click',start);$('pause').addEventListener('click',()=>phase==='paused'?resume():pause());$('restart').addEventListener('click',()=>{reset();hint('Back to the start. Your drawing is ready.');});
$('message-action').addEventListener('click',()=>phase==='paused'?resume():reset());
for(const m of ['classic','live'] as const)$(m).addEventListener('click',()=>{if(mode===m)return;endStroke();mode=m;setPressed('classic',m==='classic');setPressed('live',m==='live');$('board-mode').textContent=m==='live'?'LIVE DRAW':'CLASSIC';reset();renderScores();if(board==='shared')void refreshScores();hint(m==='live'?'Redraw while racing. Your changes take effect instantly.':'Draw first, race second. One design all the way to the finish.');});
$('material').addEventListener('click',()=>{if(phase==='running'||phase==='paused')return;endStroke();material=material==='rigid'?'bendy':'rigid';reset();renderScores();if(board==='shared')void refreshScores();hint(material==='bendy'?'Bendy limbs flex under load and spring back. Long legs can buckle.':'Rigid limbs keep their drawn shape.');});
$('track-picker').addEventListener('click',()=>{endStroke();pause();$<HTMLDialogElement>('tracks-dialog').showModal();});
TRACKS.forEach((t,i)=>{
const button=document.createElement('button');button.className=`track-option${i===0?' selected':''}`;button.dataset.track=t.id;button.setAttribute('aria-pressed',String(i===0));
const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');svg.classList.add('track-mini');svg.setAttribute('viewBox','0 0 100 50');
const path=document.createElementNS('http://www.w3.org/2000/svg','path');const profile=[{x:0,y:t.ground[0].y},...t.ground.filter(p=>p.x>=0&&p.x<=t.length),{x:t.length,y:t.ground.at(-1)!.y}],top=Math.min(...profile.map(p=>p.y)),span=Math.max(120,Math.max(...profile.map(p=>p.y))-top);path.setAttribute('d',`M0 50 ${profile.map(p=>`L${p.x/t.length*100} ${8+(p.y-top)/span*32}`).join(' ')} L100 50Z`);path.setAttribute('fill',t.color);path.setAttribute('stroke','#51745d');svg.append(path);
const text=document.createElement('span'),title=document.createElement('strong'),desc=document.createElement('small');title.textContent=`${String(i+1).padStart(2,'0')} · ${t.name}`;desc.textContent=t.description;text.append(title,desc);
const difficulty=document.createElement('span');difficulty.className='difficulty';difficulty.textContent=t.difficulty.toUpperCase();button.append(svg,text,difficulty);
button.addEventListener('click',()=>{selectTrack(t);$<HTMLDialogElement>('tracks-dialog').close();});$('track-options').append(button);
});
function selectTrack(next:typeof track,snapshot?:LevelSnapshot):void {
endStroke();inspectRequest++;track=next;activeLevel=snapshot?structuredClone(snapshot):undefined;reset();
$('track-name').textContent=track.name;const index=TRACKS.findIndex(t=>t.id===track.id);
$('track-number').textContent=index<0?'CUSTOM':`${String(index+1).padStart(2,'0')} / ${String(TRACKS.length).padStart(2,'0')}`;
$('difficulty').textContent=index<0?'PLAYER MADE':track.difficulty.toUpperCase();
document.querySelectorAll<HTMLElement>('.track-option').forEach(b=>{const chosen=b.dataset.track===track.id;b.classList.toggle('selected',chosen);b.setAttribute('aria-pressed',String(chosen));});
hint(track.description);renderScores();if(board==='shared')void refreshScores();
}
setupLevels({onPlay:snapshot=>{selectTrack(levelTrack(snapshot),snapshot);$('app').classList.remove('show-scores');hint('Your custom track is ready. Draw limbs, then race.');},onNotice:hint});
$('track-number').textContent=`01 / ${String(TRACKS.length).padStart(2,'0')}`;
for(const button of document.querySelectorAll<HTMLButtonElement>('[data-close]'))button.addEventListener('click',()=>$<HTMLDialogElement>(button.dataset.close!).close());
$('scores-toggle').addEventListener('click',()=>{const visible=$('app').classList.toggle('show-scores');$('scores-toggle').setAttribute('aria-label',visible?'Show drawing':'Show scores');if(visible){endStroke();pause();}});
function emptyScores(title:string,detail:string):void{
const empty=document.createElement('div');empty.className='empty-scores';const mark=document.createElement('span');mark.className='empty-sketch';mark.textContent='⌁';const strong=document.createElement('strong');strong.textContent=title;const text=document.createElement('span');text.textContent=detail;empty.append(mark,strong,text);$('score-list').append(empty);
}
function renderScores():void{
$('score-list').replaceChildren();setPressed('personal',board==='personal');setPressed('shared',board==='shared');
const local=currentRuns();
if(board==='shared'&&!hasSharedBoard(track.id,mode,material)){emptyScores('A personal challenge.','Your times and limb drawings are kept in Your runs on this course.');$('score-status').textContent='Personal records only on this course.';return;}
const rows=board==='personal'?local:sharedRows;
if(!rows.length)emptyScores(board==='personal'?'A blank slate. A fast future.':sharedLoading?'Finding the fastest inventions…':sharedError?'The board is taking a breather.':'The first time could be yours.',board==='personal'?'Finish a race to save your time and the limbs that got you there.':sharedError||'Finish a race and share your time.');
rows.forEach((row,i)=>{
const run='ms'in row?row:undefined;const r=document.createElement(run||('hasData'in row&&row.hasData)?'button':'div');r.className='score-row';
const rank=document.createElement('span');rank.className='rank';rank.textContent=String(i+1).padStart(2,'0');
const label=document.createElement('span');label.className='row-name';label.textContent=row.name;
const sub=document.createElement('small');sub.textContent=run?'View limbs ↗':`${row.actor?.slice(0,6)}…${row.actor?.slice(-4)}${row.actor===actor?' · you':''}`;label.append(sub);
const shape=run?thumbnail(run.design):document.createElement('span');if(!run){shape.className='no-design';shape.textContent=(row as BoardRow).hasData?'✎':'—';shape.title=(row as BoardRow).hasData?'Inspect the finish drawing':'No drawing on this older entry';}
const time=document.createElement('span');time.className='score-time';time.textContent=formatTime(run?run.ms:(row as BoardRow).score);r.append(rank,label,shape,time);
if(run)r.addEventListener('click',()=>inspect(run));else if((row as BoardRow).hasData)r.addEventListener('click',()=>void inspectShared(row as BoardRow));$('score-list').append(r);
});
if(board==='shared'&&sharedNext!==null&&!sharedLoading){const more=document.createElement('button');more.id='more-scores';more.textContent='Load more times';more.addEventListener('click',()=>void refreshScores(true));$('score-list').append(more);}
$('score-status').textContent=board==='personal'?'Drawings stay on this device · tap a run to inspect.':sharedError||'Tap a racer to view their finish drawing · client-reported times.';
}
async function refreshScores(more=false):Promise<void>{
if(more&&sharedLoading)return;
const offset=more?sharedNext??0:0;
const request=++boardRequest,selectedTrack=track.id,selectedMode=mode,selectedMaterial=material;sharedLoading=true;sharedError='';if(!more){sharedRows=[];sharedNext=null;}if(board==='shared')renderScores();
if(!hasSharedBoard(selectedTrack,selectedMode,selectedMaterial)){sharedLoading=false;return;}
try{const result=await readBoard(selectedTrack,selectedMode,selectedMaterial,offset);if(request!==boardRequest||selectedTrack!==track.id||selectedMode!==mode||selectedMaterial!==material)return;sharedRows=more?[...sharedRows,...result.rows.filter(r=>!sharedRows.some(old=>old.actor===r.actor))]:result.rows;sharedNext=Number.isSafeInteger(result.nextOffset)&&result.nextOffset!==null&&result.nextOffset>offset?result.nextOffset:null;}
catch(error){if(request!==boardRequest)return;sharedError=error instanceof Error?error.message:'Scores unavailable. Try again.';}
finally{if(request===boardRequest){sharedLoading=false;if(board==='shared')renderScores();}}
}
$('personal').addEventListener('click',()=>{board='personal';renderScores();});$('shared').addEventListener('click',()=>{board='shared';void refreshScores();});
$('refresh').addEventListener('click',()=>board==='shared'?void refreshScores():renderScores());
$('share-result').addEventListener('click',async()=>{
const run=latestRun;if(!run)return;const button=$<HTMLButtonElement>('share-result');button.disabled=true;button.textContent='Sharing…';
try{await submitRun(run);if(latestRun!==run)return;button.textContent='Time shared ✓';hint('Your best time and its finish drawing are on the shared board.');if(board==='shared')void refreshScores();}
catch(error){if(latestRun!==run)return;button.disabled=false;button.textContent='Retry sharing';hint(error instanceof Error?error.message:'Could not share your time. Try again.');}
});
async function inspectShared(row:BoardRow):Promise<void>{
endStroke();pause();const ticket=++inspectRequest,selected=track.id,selectedMode=mode,selectedMaterial=material;
$('score-status').textContent='Loading the drawing from this run…';
try {const run=await readSharedRun(selected,selectedMode,selectedMaterial,row);if(ticket!==inspectRequest||selected!==track.id||selectedMode!==mode||selectedMaterial!==material||board!=='shared')return;inspect(run);$('score-status').textContent='Time and finish drawing from the same personal best.';}
catch(error){if(ticket===inspectRequest)$('score-status').textContent=error instanceof Error?error.message:'Could not load this drawing. Retry.';}
}
function inspect(run:Run):void{
endStroke();pause();inspected=run;inspectDesign=cloneDesign(run.design);$('design-title').textContent=`${formatTime(run.ms)} · ${run.name}`;
$('design-detail').textContent=`${run.level?.title??TRACKS.find(t=>t.id===run.track)?.name} · ${run.material==='bendy'?'Bendy':'Rigid'} · ${run.mode==='classic'?'Classic · one locked design':run.shared?'Live Draw · finish drawing only':'Live Draw · initial + up to 23 recent stroke snapshots'} · ${run.shared?'Shared personal best':'Saved locally'}`;
$('edit-controls').hidden=run.mode!=='live'||run.edits.length<2;const slider=$<HTMLInputElement>('edit-slider');slider.max=String(Math.max(0,run.edits.length-1));slider.value=slider.max;
if(run.mode==='live'&&run.edits.length>0){inspectDesign=cloneDesign(run.edits.at(-1)!.design);$('edit-time').textContent=formatTime(run.edits.at(-1)!.at);}
$<HTMLDialogElement>('design-dialog').showModal();drawPad($('inspect-canvas'),inspectDesign,false);
}
$('edit-slider').addEventListener('input',()=>{const e=inspected?.edits[Number($<HTMLInputElement>('edit-slider').value)];if(e){inspectDesign=cloneDesign(e.design);$('edit-time').textContent=formatTime(e.at);drawPad($('inspect-canvas'),inspectDesign,false);}});
$('use-design').addEventListener('click',()=>{saveUndo();design=cloneDesign(inspectDesign);if(inspected)material=inspected.material;reset();syncDrawing();$<HTMLDialogElement>('design-dialog').close();$('app').classList.remove('show-scores');hint('Drawing loaded. Make it faster.');});
nameInput.addEventListener('input',()=>{dirty=true;queueSave();});
function applyTheme(theme:Theme):void{
const root=document.documentElement.style;root.setProperty('--bg',theme.colors.background);root.setProperty('--fg',theme.colors.text);root.setProperty('--primary',theme.colors.primary);
const color=theme.colors.background;const values=color.match(/[\da-f]{2}/gi);
if(color.startsWith('#')&&values?.length===3)dark=values.reduce((sum,v)=>sum+parseInt(v,16),0)<390;
else{const match=color.match(/[\d.]+/g);if(match&&match.length>=3)dark=Number(match[0])+Number(match[1])+Number(match[2])<390;}
document.documentElement.classList.toggle('dark',dark);
}
async function init():Promise<void>{
if(runtimeHasDomain('theme'))try{applyTheme(await themeGet());const sub=themeOnChanged(applyTheme);cleanup.push(()=>sub.close());}catch{/* Fallback palette remains usable. */}
try{const saved=await loadHistory();if(saved){history=[...history,...saved.runs.filter(r=>!history.some(h=>h.id===r.id))].sort((a,b)=>b.created-a.created).slice(0,40);if(!dirty&&phase==='ready'){design=saved.design;nameInput.value=saved.name;race.setDesign(design);}renderScores();updateControls();}}catch{hint('Saved runs could not be read. New races still work.');}
if(runtimeHasDomain('cvm'))try{const session=await callBoard('soy_session') as {actor?:string};actor=session?.actor||'';}catch{/* Board refresh supplies the retryable error. */}
async function loadIdentity():Promise<void>{
try{const key=await identity.getPublicKey();$('identity-status').textContent=key?`${key.slice(0,4)}…`:'guest';
if(key){const profile=await identity.getProfile();if(!dirty&&profile){const name=profile.displayName||profile.name;if(typeof name==='string')nameInput.value=name.slice(0,24);}}
}catch{$('identity-status').textContent='guest';}
}
if(runtimeHasDomain('identity')){void loadIdentity();try{const sub=identity.onChanged(()=>{actor='';void loadIdentity();void callBoard('soy_session').then(v=>{actor=(v as {actor?:string}).actor||'';}).catch(()=>{});if(board==='shared')void refreshScores();});cleanup.push(()=>sub.close());}catch{/* Optional identity. */}}
}
function frame(now:number):void{
const delta=Math.min(.08,(now-last)/1000);last=now;
if(phase==='running'){accumulator+=delta;while(accumulator>=STEP&&phase==='running'){race.step();accumulator-=STEP;if(race.finished||race.failed){endStroke();finish();}}
if(race.stalled&&!wasStalled){hint(mode==='live'?'Stuck at the edge? Draw longer legs to reach over it.':'Stuck at the edge? Restart and draw longer legs.');$('race-state').textContent='TRY A LITTLE MORE REACH';}
if(!race.stalled&&wasStalled)$('race-state').textContent='FIND YOUR RHYTHM';wasStalled=race.stalled;
$('time').innerHTML=`${race.elapsed.toFixed(2).padStart(5,'0')}<span>s</span>`;
$('progress-fill').style.width=`${Math.max(0,Math.min(100,(race.x-130)/(track.length-130)*100))}%`;
}
drawTrack(trackCanvas,track,race,phase!=='ready',dark);raf=requestAnimationFrame(frame);
}
const resize=new ResizeObserver(()=>{drawPad(drawing,design,locked());if($<HTMLDialogElement>('design-dialog').open)drawPad($('inspect-canvas'),inspectDesign,false);});resize.observe(drawing);
window.addEventListener('blur',()=>{endStroke();pause();});document.addEventListener('visibilitychange',()=>{if(document.hidden){endStroke();pause();}});
window.addEventListener('pagehide',()=>{cancelAnimationFrame(raf);clearTimeout(saveTimer);resize.disconnect();cleanup.forEach(fn=>fn());},{once:true});
renderScores();updateControls();void init();last=performance.now();raf=requestAnimationFrame(frame);
