SOURCE / PINNED RELEASE
Made of little things.
Crossfire
- Release
- 9614abe48623…
- Author-recorded commit
- 6f6c8299e2fb…
- License
- LICENSE
- Author’s source reference
- nostr://npub1n8ga89w8h6tvwamxusfyzexw8gjy84yxu9rxgnmk955cxtml4ujswzxydd/wss%3A%2F%2Fgit.napplet.soy%2F/n-347ab685aef
Archive hash verified: 34d5295141b419db…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { identity, cvm, webrtc, themeGet, themeOnChanged, type Subscription, type Theme } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import backend from '../soy-backend.json';
import { W,H,walls,createWorld,step,idle,movePlayer,player,colors,type Input,type World } from './game.js';
import {TICK,Prediction,Interpolation,Encoder,Decoder,validCommands,type Command} from './netcode.js';
import './styles.css';
const $=<T extends HTMLElement>(s:string)=>document.querySelector<T>(s)!;
const canvas=$<HTMLCanvasElement>('canvas'),ctx=canvas.getContext('2d')!;
const status=$('#status'),menu=$('#menu'),find=$<HTMLButtonElement>('#find'),leave=$<HTMLButtonElement>('#leave'),notice=$('#notice');
let world=createWorld(), mine=0,playing=false,solo=false,host=false,connected=false,session='',room='',op=0,actor='',authority='',lastPeer=0;
let inputs:Input[]=[idle(),idle()],eventSub:Subscription|undefined,themeSub:Subscription|undefined;
let pendingSend=false,received=-1,lastNet=0,roundEnd=0,last=performance.now(),acc=0,themeBg='#101b22',themeFg='#e6f1ed';
let zoom=1,ox=0,oy=0;const pressed=new Set<string>();let touchMove={x:0,y:0},angle=0,firing=false,touchFire=false;
const protocol='crossfire-8-v5';
const prediction=new Prediction(),interpolation=new Interpolation(),encoder=new Encoder(),decoder=new Decoder();
let tick=0,forceFull=true,lastProfile=0,visual:World|undefined;
const queues=new Map<string,Command[]>(),acks=new Map<string,number>(),budgets=new Map<string,number>();
let myName='Guest',identitySub:Subscription|undefined,identityRevision=0;
const cleanName=(v:unknown,max=32)=>typeof v==='string'?v.replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g,'').trim().slice(0,max):'';
async function loadIdentity(){
const revision=++identityRevision;let name='Guest';
try{const key=await identity.getPublicKey();if(key){name=key.slice(0,8)+'…';try{const p=await identity.getProfile();const raw=p as Record<string,unknown>|null;name=cleanName(p?.displayName)||cleanName(raw?.display_name)||cleanName(p?.name)||name;}catch{/* Keep public-key fallback. */}}}catch{/* Guest fallback. */}
if(revision!==identityRevision)return;myName=name;$('#player-name').textContent=name+' (You)';
}
function playerLabel(i:number){const p=world.p[i];const name=i===mine?myName:solo?'Target':p.name||'Guest '+(i+1);return name+(i===mine?' (You)':'');}
let members=new Set<string>(),peerTimes=new Map<string,number>(),receivedBy=new Map<string,number>(),pollTimer=0;
let roomBusy=false,networkRevision=0,networkKey='';
let joining=false,lobbyBusy=false,lobbyTimer=0;
const rooms=$('#rooms'),refresh=$<HTMLButtonElement>('#refresh');
const connectedPeers=new Set<string>();
function message(s:string){status.textContent=s;status.title=s;}
async function call(tool:string,args:Record<string,unknown>={}):Promise<Record<string,unknown>>{
const r=await cvm.callTool(backend.provider,tool,args);
if(r.isError)throw Error(r.content?.filter(c=>c.type==='text').map(c=>c.text).join(' ')||'Matchmaking unavailable');
if(!r.structuredContent)throw Error('Matchmaking returned no result');return r.structuredContent as Record<string,unknown>;
}
function resetControls(){pressed.clear();touchMove={x:0,y:0};firing=false;touchFire=false;}
async function stop(text='Ready to play'){
op++;networkRevision++;networkKey='';connectedPeers.clear();queues.clear();acks.clear();budgets.clear();playing=false;connected=false;resetControls();document.body.classList.remove('playing');menu.hidden=false;leave.hidden=true;find.disabled=false;find.textContent='Host game';joining=false;notice.hidden=true;lobbyEnabled();
eventSub?.close();eventSub=undefined;clearInterval(pollTimer);const oldSession=session,oldRoom=room;session='';room='';message(text);
await Promise.allSettled([oldSession?webrtc.close(oldSession,'Left arena'):Promise.resolve(),oldRoom?call('soy_room_leave',{room:oldRoom}):Promise.resolve()]);
}
function begin(training:boolean){solo=training;world=createWorld(training?['you','bot']:[actor]);mine=0;inputs=Array.from({length:8},idle);received=-1;acc=0;tick=0;prediction.reset();interpolation.reset();encoder.reset();decoder.reset();forceFull=true;lastProfile=0;visual=undefined;roundEnd=0;playing=true;document.body.classList.add('playing');menu.hidden=true;leave.hidden=false;canvas.focus();message(training?'Practice · moving target':'Arena open · waiting for players');}
function applyMembers(snapshot:Record<string,unknown>){
if(!Array.isArray(snapshot.peers))throw Error('Invalid room membership');
members=new Set(snapshot.peers.filter((p):p is string=>typeof p==='string'));
if(!members.has(actor))throw Error('Room membership expired · find another match');
if(!members.has(authority))throw Error('Host left · find another arena');
if(host){
for(const id of queues.keys())if(!members.has(id)){queues.delete(id);acks.delete(id);budgets.delete(id);receivedBy.delete(id);peerTimes.delete(id);}
for(const p of world.p)if(!members.has(p.id))p.active=false;
for(const id of members){if(!world.p.some(p=>p.id===id&&p.active)){
let i=world.p.findIndex(p=>!p.active);if(i<0)i=world.p.length;if(i>=8)continue;
world.p[i]=player(id,i);inputs[i]=idle();queues.set(id,[]);acks.set(id,0);budgets.set(id,12);world.b=world.b.filter(b=>b.owner!==i);
}}
}
}
async function pollRoom(token:number){
if(roomBusy||token!==op||!room)return;roomBusy=true;
try{const snapshot=await call('soy_room_status',{room});if(token===op){applyMembers(snapshot);await connectTopology(token);}}
catch(e){if(token===op)void stop(e instanceof Error?e.message:'Room unavailable');}finally{roomBusy=false;}
}
async function connectTopology(token:number){
const key=authority;
if(key===networkKey&&session)return;networkKey=key;
const revision=++networkRevision,old=session;session='';connected=false;connectedPeers.clear();
if(old)await webrtc.close(old,'Arena membership changed').catch(()=>{});
if(token!==op||revision!==networkRevision)return;
const result=await webrtc.open({scope:{type:'room',room,...(host?{}:{peers:[authority]})},channel:'game',protocol});
if(token!==op||revision!==networkRevision){await webrtc.close(result.session.id,'Cancelled');return;}
session=result.session.id;lastPeer=performance.now();
}
function lobbyEnabled(){
$('#game-name').toggleAttribute('disabled',joining);find.disabled=joining;refresh.disabled=joining||lobbyBusy;
$('#practice').toggleAttribute('disabled',joining);
rooms.querySelectorAll<HTMLButtonElement>('button').forEach(b=>b.disabled=joining||b.dataset.full==='true');
}
async function listRooms(){
if(playing||joining||lobbyBusy)return;
const token=op;lobbyBusy=true;lobbyEnabled();
if(!rooms.children.length)rooms.textContent='Loading games…';
try{
const result=await call('soy_room_list',{napplet:backend.napplet,protocol});
if(token!==op||playing||joining)return;
if(!Array.isArray(result.rooms))throw Error('Invalid room list');
rooms.replaceChildren();
for(const raw of result.rooms){
if(!raw||typeof raw!=='object')continue;
const c=raw as Record<string,unknown>;
if(typeof c.room!=='string'||typeof c.name!=='string'||!/^Crossfire [a-f0-9]{16}(?: \| .{1,48})?$/.test(c.name)||!Array.isArray(c.peers)||!c.peers.some(p=>typeof p==='string'&&p.startsWith(String(c.name).slice(10,26))))continue;
const row=document.createElement('div');row.className='room-row';
const name=document.createElement('span');name.textContent=cleanName(c.name.slice(29),48)||'Arena '+c.room.slice(-6).toUpperCase();name.title=name.textContent;
const count=document.createElement('small');count.textContent=c.peers.length+'/8';
const button=document.createElement('button');button.textContent=c.peers.length>=8?'Full':'Join';button.dataset.full=String(c.peers.length>=8);button.disabled=c.peers.length>=8;
button.setAttribute('aria-label',button.textContent+' '+name.textContent);
button.onclick=()=>void matchmaking(c.room as string);
row.append(name,count,button);rooms.append(row);
}
if(!rooms.children.length)rooms.textContent='No games yet. Host one to get started.';
}catch(e){if(token===op&&!playing&&!joining){rooms.textContent=e instanceof Error?e.message:'Could not load games. Refresh to retry';}}
finally{lobbyBusy=false;lobbyEnabled();}
}
async function matchmaking(selectedRoom?:string){
if(joining)return;
await stop();const token=++op;joining=true;lobbyEnabled();leave.hidden=false;message(selectedRoom?'Joining arena…':'Creating arena…');
try{
const identity=await call('soy_session');if(token!==op)return;
if(typeof identity.actor!=='string')throw Error('Session unavailable');actor=identity.actor;
const snapshot=selectedRoom
?await call('soy_room_join',{room:selectedRoom})
:await call('soy_room_create',{napplet:backend.napplet,protocol,name:'Crossfire '+actor.slice(0,16)+' | '+(cleanName($<HTMLInputElement>('#game-name').value,48)||myName+'’s arena'),capacity:8,listed:true});
if(typeof snapshot.room!=='string')throw Error('Invalid arena response');
if(token!==op){await call('soy_room_leave',{room:snapshot.room});return;}room=snapshot.room;
const peers=Array.isArray(snapshot.peers)?snapshot.peers:[];
const prefix=String(snapshot.name).slice(10,26);authority=String(peers.find(p=>typeof p==='string'&&p.startsWith(prefix))||'');
if(!authority)throw Error('Arena host unavailable');host=authority===actor;
begin(false);applyMembers(snapshot);lastPeer=performance.now();peerTimes.clear();receivedBy.clear();
eventSub=webrtc.onEvent(e=>{
if(token!==op||e.sessionId!==session)return;
if(e.type==='peer'){
if(e.state==='joined'){connectedPeers.add(e.pubkey);connected=true;forceFull=true;}
if(e.state==='left'){connectedPeers.delete(e.pubkey);connected=connectedPeers.size>0;if(host){queues.delete(e.pubkey);acks.delete(e.pubkey);budgets.delete(e.pubkey);receivedBy.delete(e.pubkey);peerTimes.delete(e.pubkey);const i=world.p.findIndex(p=>p.id===e.pubkey);if(i>=0)inputs[i]=idle();}}
}
if(e.type==='closed')void stop(e.reason||'Connection closed · try again');
if(e.type==='message'&&members.has(e.from)&&e.payload&&typeof e.payload==='object'){
const m=e.payload as Record<string,unknown>;
if(!host&&e.from===authority){
const state=decoder.decode(m);
if(state){const i=state.world.p.findIndex(p=>p.id===actor&&p.active);if(i>=0){world=state.world;mine=i;received=decoder.n;lastPeer=performance.now();prediction.reconcile(world.p[i],state.ack[i]??0);interpolation.push(state.t,world);}}
}
if(host&&m.type==='input'&&validCommands(m.commands)){
const i=world.p.findIndex(p=>p.id===e.from&&p.active);if(i<0)return;
if(typeof m.name==='string')world.p[i].name=cleanName(m.name)||'Guest';
const queue=queues.get(e.from)??[];
for(const c of m.commands){if(c[0]<=(receivedBy.get(e.from)??0)||queue.length>=120)continue;
receivedBy.set(e.from,c[0]);queue.push({n:c[0],u:{x:c[1],y:c[2],a:c[3],fire:c[4]===1}});
}
queues.set(e.from,queue);peerTimes.set(e.from,performance.now());
}
}
});
// Stable star: guests allow only the host; host authorizes senders using live room membership.
await connectTopology(token);if(token!==op)return;
pollTimer=window.setInterval(()=>void pollRoom(token),2000);
message(host?'Arena open · friends can join from the lobby':'Connecting to arena…');
}catch(e){if(token===op){await stop(e instanceof Error?e.message:'Could not join · refresh and retry');void listRooms();}}
finally{if(token===op){joining=false;lobbyEnabled();}}
}
find.onclick=()=>void matchmaking();refresh.onclick=()=>void listRooms();leave.onclick=()=>void stop().then(()=>listRooms());$('#practice').onclick=()=>{void stop().then(()=>{mine=0;host=true;begin(true);});};
canvas.addEventListener('keydown',e=>{if(['KeyW','KeyA','KeyS','KeyD','ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code)){e.preventDefault();pressed.add(e.code);}});
canvas.addEventListener('keyup',e=>{pressed.delete(e.code);});window.addEventListener('blur',resetControls);document.addEventListener('visibilitychange',()=>{if(document.hidden)resetControls();});
function aim(e:PointerEvent){const r=canvas.getBoundingClientRect();const p=visual?.p[mine]||world.p[mine];angle=Math.atan2((e.clientY-r.top-oy)/zoom-p.y,(e.clientX-r.left-ox)/zoom-p.x);}
canvas.onpointermove=e=>{if(e.pointerType!=='touch')aim(e);};canvas.onpointerdown=e=>{if(e.pointerType==='touch')return;canvas.focus();canvas.setPointerCapture(e.pointerId);aim(e);firing=true;};canvas.onpointerup=canvas.onpointercancel=()=>{firing=false;};canvas.oncontextmenu=e=>e.preventDefault();
for(const id of ['move','aim']){const el=$('#'+id);let pointer=-1;
const update=(e:PointerEvent)=>{const r=el.getBoundingClientRect(),x=(e.clientX-r.left-r.width/2)/30,y=(e.clientY-r.top-r.height/2)/30,len=Math.max(1,Math.hypot(x,y));el.querySelector<HTMLElement>('i')!.style.transform=`translate(${x/len*24}px,${y/len*24}px)`;if(id==='move')touchMove={x:x/len,y:y/len};else {angle=Math.atan2(y,x);touchFire=Math.hypot(x,y)>.2;}};
el.onpointerdown=e=>{if(pointer!==-1)return;pointer=e.pointerId;el.setPointerCapture(pointer);update(e);};el.onpointermove=e=>{if(e.pointerId===pointer)update(e);};el.onpointerup=el.onpointercancel=e=>{if(e.pointerId!==pointer)return;pointer=-1;el.querySelector<HTMLElement>('i')!.style.transform='';if(id==='move')touchMove={x:0,y:0};else touchFire=false;};
}
async function send(){
if(pendingSend||!session||!connected)return;pendingSend=true;const id=session;
let payload:unknown,batch:number[][]=[];
if(host){payload=encoder.encode(world,tick*TICK*1000,world.p.map(p=>acks.get(p.id)??0),forceFull);forceFull=false;}
else {
batch=prediction.batch();
if(!batch.length){pendingSend=false;return;}
const profile=performance.now()-lastProfile>1000;
payload={type:'input',commands:batch,...(profile?{name:myName}:{})};
if(profile)lastProfile=performance.now();
}
try{await webrtc.send(id,payload);if(session===id&&batch.length)prediction.sent=batch.at(-1)![0];}
catch(e){forceFull=true;const reason=e instanceof Error?e.message:'retry';if(session===id&&!/backpressure|connecting/i.test(reason))void stop('Connection interrupted: '+reason);}finally{pendingSend=false;}
}
function draw(dt:number){
visual=!host&&!solo&&playing?interpolation.render(dt):undefined;
if(visual){const local=prediction.render(dt,angle);if(local)visual.p[mine]=local;visual.b=visual.b.filter(b=>b.owner!==mine).concat(prediction.fx.map(b=>({...b,owner:mine})));}
const scene=visual||world;
const r=canvas.getBoundingClientRect(),dpr=Math.min(devicePixelRatio||1,2);if(canvas.width!==Math.round(r.width*dpr)||canvas.height!==Math.round(r.height*dpr)){canvas.width=Math.round(r.width*dpr);canvas.height=Math.round(r.height*dpr);}
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.fillStyle=themeBg;ctx.fillRect(0,0,r.width,r.height);
zoom=Math.max(r.width/W,r.height/H);if(!playing)zoom=Math.min(r.width/W,r.height/H);
const me=scene.p[mine];ox=playing?Math.min(0,Math.max(r.width-W*zoom,r.width/2-me.x*zoom)):(r.width-W*zoom)/2;oy=playing?Math.min(0,Math.max(r.height-H*zoom,r.height/2-me.y*zoom)):(r.height-H*zoom)/2;
ctx.translate(ox,oy);ctx.scale(zoom,zoom);ctx.strokeStyle=themeFg;ctx.globalAlpha=.065;ctx.lineWidth=1;
for(let x=0;x<=W;x+=40){ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H);ctx.stroke();}for(let y=0;y<=H;y+=40){ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(W,y);ctx.stroke();}ctx.globalAlpha=.5;ctx.strokeRect(2,2,W-4,H-4);
for(const [x,y,w,h] of walls){ctx.globalAlpha=.09;ctx.fillStyle=themeFg;ctx.fillRect(x,y,w,h);ctx.globalAlpha=.35;ctx.strokeRect(x,y,w,h);ctx.globalAlpha=.16;ctx.fillRect(x,y,w,5);}ctx.globalAlpha=1;
scene.b.forEach(b=>{ctx.strokeStyle=colors[b.owner];ctx.lineWidth=4;ctx.beginPath();ctx.moveTo(b.x-Math.cos(b.a)*10,b.y-Math.sin(b.a)*10);ctx.lineTo(b.x,b.y);ctx.stroke();});
scene.p.forEach((p,i)=>{if(p.dead||!p.active)return;ctx.save();ctx.translate(p.x,p.y);const color=colors[i];if(p.shield){ctx.strokeStyle=color;ctx.globalAlpha=.45;ctx.lineWidth=2;ctx.beginPath();ctx.arc(0,0,23,0,Math.PI*2);ctx.stroke();ctx.globalAlpha=1;}ctx.rotate(p.a);ctx.fillStyle=color;ctx.fillRect(4,-5,24,10);ctx.beginPath();ctx.arc(0,0,14,0,Math.PI*2);ctx.fill();ctx.strokeStyle=themeFg;ctx.lineWidth=1.5;ctx.stroke();ctx.fillStyle='#18303a';ctx.beginPath();ctx.arc(3,0,6,0,Math.PI*2);ctx.fill();ctx.rotate(-p.a);ctx.fillStyle=themeFg;ctx.globalAlpha=.25;ctx.fillRect(-19,-30,38,5);ctx.globalAlpha=1;ctx.fillStyle=color;ctx.fillRect(-18,-29,36*p.hp/100,3);ctx.font='bold 9px monospace';ctx.textAlign='center';ctx.fillStyle=themeFg;ctx.fillText(playerLabel(i),0,-37,150);ctx.restore();});
$('#health').innerHTML=`${me.hp} <small>HP</small>`;$('#score').textContent=playing?`${world.p.filter(p=>p.active).length}/8 · ${world.p[mine].score}/7 KOs`:'FIRST TO 7';
const text=world.winner>=0?(world.winner===mine?'You win!':`${playerLabel(world.winner)} wins!`)+' · Next round shortly':me.dead?`Respawning in ${Math.ceil(me.dead)}…`:'';notice.textContent=text;notice.hidden=!text;
}
function loop(now:number){const dt=Math.min((now-last)/1000,.05);last=now;
if(playing){inputs[mine]={x:Math.max(-1,Math.min(1,touchMove.x+Number(pressed.has('KeyD')||pressed.has('ArrowRight'))-Number(pressed.has('KeyA')||pressed.has('ArrowLeft')))),y:Math.max(-1,Math.min(1,touchMove.y+Number(pressed.has('KeyS')||pressed.has('ArrowDown'))-Number(pressed.has('KeyW')||pressed.has('ArrowUp')))),a:angle,fire:firing||touchFire||pressed.has('Space')};
if(solo){const bot=world.p[1],p=world.p[0];inputs[1]={x:Math.sin(now/900)*.55,y:Math.cos(now/1200)*.55,a:Math.atan2(p.y-bot.y,p.x-bot.x),fire:Math.sin(now/1400)>.25};}
if(host&&world.winner>=0){if(!roundEnd)roundEnd=now;if(now-roundEnd>5000){world.p=world.p.map((p,i)=>({...player(p.id,i),name:p.name,spawn:p.spawn+1,active:p.active}));world.b=[];world.winner=-1;roundEnd=0;}}
acc+=dt;while(acc>=TICK){
if(host){
world.p[mine].name=myName;
if(!solo)world.p.forEach((p,i)=>{if(i===mine||!p.active)return;
let budget=Math.min(12,(budgets.get(p.id)??0)+1);const queue=queues.get(p.id)||[];let fire=false;
for(let n=0;n<4&&budget>=1&&queue.length;n++,budget--){const c=queue.shift()!;if(world.winner<0)movePlayer(p,c.u,TICK);inputs[i]={...c.u,x:0,y:0};fire||=c.u.fire;acks.set(p.id,c.n);}
if(fire)inputs[i].fire=true;budgets.set(p.id,budget);
});
step(world,inputs,TICK);tick++;
}else if(received>=0)prediction.advance(inputs[mine],world.winner);
acc-=TICK;
}
if(!solo&&host){world.p.forEach((p,i)=>{if(i!==mine&&now-(peerTimes.get(p.id)??0)>500)inputs[i]=idle();});}
if(!solo&&now-lastNet>=50){lastNet=now;void send();}if(!solo&&!host&&session&&now-lastPeer>(received<0?60000:45000))void stop('Peer stopped responding · rejoin');
if(!solo&&session){const count=world.p.filter(p=>p.active).length;message(!host&&received>=0&&now-lastPeer>2000?'Reconnecting to arena…':count>1&&connected&&(host||received>=0)?`Live · ${count} players · first to 7`:host?'Arena open · waiting for players':'Connecting to arena…');}
}draw(dt);requestAnimationFrame(loop);}
function applyTheme(t:Theme){themeBg=t.colors.background;themeFg=t.colors.text;const s=document.documentElement.style;s.setProperty('--bg',themeBg);s.setProperty('--fg',themeFg);s.setProperty('--primary',t.colors.primary);}
if(runtimeHasDomain('identity')){void loadIdentity();try{identitySub=identity.onChanged(()=>void loadIdentity());}catch{/* Profile is still available on initial load. */}}
if(runtimeHasDomain('theme')){try{void themeGet().then(applyTheme).catch(()=>{});themeSub=themeOnChanged(applyTheme);}catch{/* Older hosts may expose a partial theme implementation. */}}
lobbyTimer=window.setInterval(()=>{if(!document.hidden)void listRooms();},5000);void listRooms();
window.addEventListener('pagehide',()=>{clearInterval(lobbyTimer);void stop();themeSub?.close();identitySub?.close();});requestAnimationFrame(loop);