Liveloop

An interactive timeline for social media. Every post is a tiny app you can play, save, and remix.

Product

  • Feed
  • Create
  • Claude Code plugin
  • Blog

Legal

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA

Project

  • Templates
© 2026 Liveloop. All rights reserved.
LiveloopVersion history

v1Current

@liveloop · 5/17/2026, 12:33:33 PM

Initial version — all lines are new.

+<style>
+ html,body{height:100%;margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#0a0a12;color:#f8fafc;overflow:hidden;}
+ #app{position:relative;height:100%;}
+ .screen{position:absolute;inset:0;display:none;flex-direction:column;align-items:center;justify-content:center;gap:.85rem;padding:1.5rem;box-sizing:border-box;text-align:center;}
+ h1{margin:0;font-size:1.6rem;}
+ .sub{margin:0;color:#94a3b8;font-size:.85rem;max-width:24ch;line-height:1.45;}
+ button{font:inherit;font-weight:700;border:0;border-radius:9999px;padding:.7rem 1.6rem;cursor:pointer;touch-action:manipulation;background:#22c55e;color:#04120a;font-size:1rem;}
+ button.alt{background:#1e293b;color:#e2e8f0;}
+ input{font:inherit;font-size:1.5rem;text-align:center;text-transform:uppercase;letter-spacing:.35em;width:7rem;padding:.55rem;border-radius:.6rem;border:2px solid #334155;background:#0f172a;color:#fff;}
+ input.name-field{text-transform:none;letter-spacing:normal;font-size:1.05rem;width:11rem;}
+ .code{font-size:2.6rem;font-weight:800;letter-spacing:.35em;color:#fbbf24;}
+ .err{color:#f87171;font-size:.85rem;}
+ #cv{position:absolute;inset:0;width:100%;height:100%;display:none;touch-action:none;}
+</style>
+<div id="app">
+ <div class="screen" id="screen"></div>
+ <canvas id="cv"></canvas>
+</div>
+<script>
+(function(){
+ var L=window.liveloop, RT=L&&L.realtime;
+ var screen=document.getElementById('screen'), cv=document.getElementById('cv'), ctx=cv.getContext('2d');
+ var W=0,H=0;
+ var myId=null, players=[], role='', code='', phase='menu', hostId='', guestId='';
+ var lobbyId=null, lobbyMates=[], lobbyCand=null, queueTimer=null, shownCount=-1, joinStamp=0;
+ var myName='', oppName='Opponent';
+ var R=0.028, HPY=0.93, GPY=0.07, PHW=0.17;
+ var ball={x:0.5,y:0.5,vx:0,vy:0}, myX=0.5, oppX=0.5, sh=0, sg=0, winner=null;
+ var lastBcast=0, lastPad=0, lastT=0;
+
+ function onTap(el,fn){
+ var sx=0,sy=0,armed=false;
+ el.addEventListener('pointerdown',function(e){ armed=true;sx=e.clientX;sy=e.clientY; });
+ el.addEventListener('pointercancel',function(){ armed=false; });
+ el.addEventListener('pointerup',function(e){
+ if(!armed) return; armed=false;
+ if(Math.abs(e.clientX-sx)<16&&Math.abs(e.clientY-sy)<16) fn();
+ });
+ }
+ function fit(){
+ var r=cv.getBoundingClientRect(); if(r.width<=0) return;
+ var dpr=Math.min(window.devicePixelRatio||1,2);
+ W=r.width; H=r.height;
+ cv.width=Math.round(W*dpr); cv.height=Math.round(H*dpr);
+ ctx.setTransform(dpr,0,0,dpr,0,0);
+ }
+ window.addEventListener('resize',fit);
+ function gen4(){
+ var A='ABCDEFGHJKLMNPQRSTUVWXYZ23456789', s='';
+ for(var i=0;i<4;i++) s+=A.charAt(Math.floor(Math.random()*A.length));
+ return s;
+ }
+ function el(tag,cls,txt){ var e=document.createElement(tag); if(cls)e.className=cls; if(txt!=null)e.textContent=txt; return e; }
+ function show(){ cv.style.display='none'; screen.style.display='flex'; screen.innerHTML=''; for(var i=0;i<arguments.length;i++) screen.appendChild(arguments[i]); }
+ function showCanvas(){ screen.style.display='none'; cv.style.display='block'; fit(); }
+
+ if(!RT){ show(el('h1',null,'🏓 Ping Pong'), el('p','err','Realtime is not available here — open this artifact from the Liveloop feed.')); return; }
+
+ function menu(){
+ phase='menu';
+ var nameInp=el('input','name-field');
+ nameInp.maxLength=16; nameInp.placeholder='Your name'; nameInp.value=myName;
+ var quick=el('button',null,'Start game');
+ var friend=el('button','alt','Play a friend');
+ onTap(quick,function(){ commitName(nameInp); startQuickMatch(); });
+ onTap(friend,function(){ commitName(nameInp); friendMenu(); });
+ show(el('h1',null,'🏓 Ping Pong'),
+ el('p','sub','Type a name, tap Start, and we’ll match you with another player — or play a friend with a code.'),
+ nameInp, quick, friend);
+ }
+ function commitName(inp){
+ var n=(((inp&&inp.value)||'').trim()).slice(0,16) || 'Player';
+ myName=n;
+ if(L&&L.storage){ try{ L.storage.set({name:n}); }catch(e){} }
+ }
+ function loadName(){
+ if(!L||!L.storage) return;
+ L.storage.get().then(function(s){
+ if(s&&typeof s.name==='string'&&s.name) myName=s.name;
+ if(phase==='menu') menu();
+ },function(){});
+ }
+ function stopQueue(){ if(queueTimer){ clearInterval(queueTimer); queueTimer=null; } }
+ function backToMenu(){ try{ RT.leave(); }catch(e){} stopQueue(); phase='menu'; players=[]; menu(); }
+ function errScreen(msg){
+ stopQueue(); phase='menu';
+ var back=el('button','alt','Back'); onTap(back,menu);
+ show(el('h1',null,'Something went wrong'), el('p','err',msg), back);
+ }
+
+ // --- Quick match: a shared lobby channel + deterministic pairing ---
+ function startQuickMatch(){
+ try{ RT.leave(); }catch(e){}
+ stopQueue();
+ phase='searching'; lobbyCand=null; lobbyMates=[]; shownCount=-1;
+ searching();
+ RT.join('_lobby').then(function(res){
+ if(phase!=='searching') return;
+ if(!res||!res.ok){ errScreen((res&&res.error)||'Could not reach matchmaking.'); return; }
+ lobbyId=res.playerId; lobbyMates=res.players||[];
+ queueTimer=setInterval(queueTick,1000);
+ queueTick();
+ });
+ }
+ function searching(){
+ var n=lobbyMates.length;
+ var cancel=el('button','alt','Cancel'); onTap(cancel,backToMenu);
+ show(el('h1',null,'Looking for an opponent…'),
+ el('p','sub', n>1 ? (n+' players in the queue — pairing you up.') : 'Waiting for another player to tap Start.'),
+ cancel);
+ }
+ function queueTick(){
+ if(phase!=='searching') return;
+ var sorted=lobbyMates.slice().sort(), cand=null;
+ if(sorted.length>=2){
+ if(lobbyId===sorted[0]) cand=sorted[1];
+ else if(lobbyId===sorted[1]) cand=sorted[0];
+ }
+ lobbyCand=cand;
+ if(cand) RT.send({t:'match',with:cand});
+ if(lobbyMates.length!==shownCount){ shownCount=lobbyMates.length; searching(); }
+ }
+ function roomFor(a,b){
+ var s=[a,b].slice().sort().join('|'), h=5381;
+ for(var i=0;i<s.length;i++){ h=(((h<<5)+h)+s.charCodeAt(i))>>>0; }
+ return 'g'+h.toString(36);
+ }
+ function beginMatch(partner){
+ if(phase!=='searching') return;
+ stopQueue();
+ phase='connecting';
+ show(el('h1',null,'Opponent found!'), el('p','sub','Starting the match…'));
+ RT.join(roomFor(lobbyId,partner)).then(function(res){
+ if(phase!=='connecting') return;
+ if(!res||!res.ok){ errScreen((res&&res.error)||'Could not start the match.'); return; }
+ myId=res.playerId; players=res.players||[];
+ phase='lobby';
+ var stamp=++joinStamp;
+ show(el('h1',null,'Match ready'), el('p','sub','Waiting for your opponent to load…'));
+ checkStart();
+ // If the opponent never loads (rare pairing desync), re-queue.
+ setTimeout(function(){
+ if(phase==='lobby' && joinStamp===stamp) startQuickMatch();
+ }, 9000);
+ });
+ }
+
+ // --- Play a friend: a private room code ---
+ function friendMenu(){
+ phase='menu';
+ var create=el('button',null,'Create game');
+ var join=el('button','alt','Join with code');
+ var back=el('button','alt','Back');
+ onTap(create,doCreate); onTap(join,joinScreen); onTap(back,menu);
+ show(el('h1',null,'Play a friend'),
+ el('p','sub','Create a game and share the code, or join one a friend shared.'),
+ create, join, back);
+ }
+ function joinScreen(){
+ var inp=el('input'); inp.maxLength=4; inp.placeholder='CODE';
+ var go=el('button',null,'Join'); var back=el('button','alt','Back');
+ onTap(go,function(){ doJoin((inp.value||'').toUpperCase().replace(/[^A-Z0-9]/g,'')); });
+ onTap(back,friendMenu);
+ show(el('h1',null,'Join a game'), el('p','sub','Enter the 4-letter code your friend shared.'), inp, go, back);
+ }
+ function doCreate(){ try{ RT.leave(); }catch(e){} code=gen4(); connect(code,true); }
+ function doJoin(c){ if(c.length<3){ joinScreen(); return; } try{ RT.leave(); }catch(e){} code=c; connect(c,false); }
+ function connect(c,created){
+ phase='connecting';
+ show(el('h1',null,'Connecting…'), el('p','sub','Reaching the game room.'));
+ RT.join(c).then(function(res){
+ if(phase!=='connecting') return;
+ if(!res||!res.ok){
+ var back=el('button','alt','Back'); onTap(back,friendMenu);
+ show(el('h1',null,'Could not connect'), el('p','err',(res&&res.error)||'Unknown error.'), back);
+ return;
+ }
+ myId=res.playerId; players=res.players||[];
+ friendLobby(created);
+ checkStart();
+ });
+ }
+ function friendLobby(created){
+ phase='lobby';
+ var leave=el('button','alt','Leave'); onTap(leave,backToMenu);
+ if(created){
+ show(el('h1',null,'Share this code'), el('div','code',code),
+ el('p','sub','Your friend taps “Play a friend” → “Join with code”. Waiting…'),
+ leave);
+ } else {
+ show(el('h1',null,'Joined '+code), el('p','sub','Waiting for the other player…'), leave);
+ }
+ }
+
+ function checkStart(){
+ if(phase!=='lobby'&&phase!=='connecting') return;
+ if(players.length>=2) startGame();
+ }
+ function startGame(){
+ var sorted=players.slice().sort();
+ hostId=sorted[0]; guestId=sorted[1];
+ role = myId===hostId?'host' : myId===guestId?'guest' : 'spectator';
+ sh=0; sg=0; winner=null; myX=0.5; oppX=0.5; oppName='Opponent';
+ if(role==='host') serve(Math.random()<0.5?1:-1);
+ phase='playing';
+ showCanvas();
+ lastT=0;
+ requestAnimationFrame(loop);
+ }
+ function serve(dir){
+ ball={x:0.5,y:0.5,vx:(Math.random()-0.5)*0.007,vy:dir*0.012};
+ }
+ function clampSpeed(){
+ if(ball.vy>0.024) ball.vy=0.024; if(ball.vy<-0.024) ball.vy=-0.024;
+ if(ball.vx>0.016) ball.vx=0.016; if(ball.vx<-0.016) ball.vx=-0.016;
+ }
+ function simulate(dt){
+ if(winner) return;
+ ball.x+=ball.vx*dt; ball.y+=ball.vy*dt;
+ if(ball.x<R){ ball.x=R; ball.vx=Math.abs(ball.vx); }
+ if(ball.x>1-R){ ball.x=1-R; ball.vx=-Math.abs(ball.vx); }
+ if(ball.vy>0 && ball.y+R>=HPY && ball.y<HPY && Math.abs(ball.x-myX)<=PHW+R){
+ ball.y=HPY-R; ball.vy=-Math.abs(ball.vy)*1.04; ball.vx+=(ball.x-myX)*0.05; clampSpeed();
+ }
+ if(ball.vy<0 && ball.y-R<=GPY && ball.y>GPY && Math.abs(ball.x-oppX)<=PHW+R){
+ ball.y=GPY+R; ball.vy=Math.abs(ball.vy)*1.04; ball.vx+=(ball.x-oppX)*0.05; clampSpeed();
+ }
+ if(ball.y>1.12){ sg++; serve(-1); }
+ if(ball.y<-0.12){ sh++; serve(1); }
+ if(sh>=7) winner='host';
+ if(sg>=7) winner='guest';
+ }
+ function loop(t){
+ if(phase!=='playing'){ return; }
+ var dt=lastT?Math.min((t-lastT)/16.67,3):1; lastT=t;
+ if(role==='host'){
+ simulate(dt);
+ if(t-lastBcast>60){
+ lastBcast=t;
+ RT.send({t:'s',bx:ball.x,by:ball.y,bvx:ball.vx,bvy:ball.vy,hp:myX,sh:sh,sg:sg,w:winner,nm:myName});
+ }
+ } else {
+ ball.x+=ball.vx*dt; ball.y+=ball.vy*dt;
+ if(t-lastPad>70){ lastPad=t; RT.send({t:'p',x:myX,nm:myName}); }
+ }
+ render();
+ if(winner){ over(); return; }
+ requestAnimationFrame(loop);
+ }
+ function ry(y){ return role==='guest'?(1-y):y; }
+ function render(){
+ ctx.clearRect(0,0,W,H);
+ ctx.fillStyle='#0a0a12'; ctx.fillRect(0,0,W,H);
+ // mid line
+ ctx.strokeStyle='#1e293b'; ctx.lineWidth=2; ctx.setLineDash([8,10]);
+ ctx.beginPath(); ctx.moveTo(0,H/2); ctx.lineTo(W,H/2); ctx.stroke(); ctx.setLineDash([]);
+ // scores
+ var myScore=role==='guest'?sg:sh, oppScore=role==='guest'?sh:sg;
+ ctx.fillStyle='#1e293b'; ctx.font='700 '+Math.round(H*0.13)+'px system-ui';
+ ctx.textAlign='center'; ctx.textBaseline='middle';
+ ctx.fillText(String(oppScore),W/2,H*0.36);
+ ctx.fillText(String(myScore),W/2,H*0.64);
+ // player names
+ ctx.fillStyle='#64748b'; ctx.font='600 '+Math.round(H*0.03)+'px system-ui';
+ ctx.fillText(oppName,W/2,H*0.21);
+ ctx.fillText(myName||'You',W/2,H*0.79);
+ // paddles
+ var myPY=role==='host'?HPY:GPY, opPY=role==='host'?GPY:HPY;
+ drawPaddle(myX, ry(myPY), '#22c55e');
+ drawPaddle(oppX, ry(opPY), '#f8fafc');
+ // ball
+ ctx.fillStyle='#fbbf24';
+ ctx.beginPath(); ctx.arc(ball.x*W, ry(ball.y)*H, R*W, 0, Math.PI*2); ctx.fill();
+ }
+ function drawPaddle(cx,cy,color){
+ var w=PHW*2*W, h=Math.max(10,H*0.018), x=cx*W-w/2, y=cy*H-h/2;
+ ctx.fillStyle=color;
+ if(ctx.roundRect){ ctx.beginPath(); ctx.roundRect(x,y,w,h,h/2); ctx.fill(); }
+ else ctx.fillRect(x,y,w,h);
+ }
+ function endScreen(title){
+ phase='over';
+ var again=el('button',null,'Find new game'); onTap(again,startQuickMatch);
+ var menuB=el('button','alt','Menu'); onTap(menuB,backToMenu);
+ show(el('h1',null,title), el('p','sub','Final score '+sh+' – '+sg+'.'), again, menuB);
+ }
+ function over(){
+ endScreen('🏆 ' + (winner===role ? (myName||'You') : oppName) + ' wins!');
+ }
+ function oppLeft(){
+ if(phase!=='playing') return;
+ endScreen('Opponent left');
+ }
+
+ RT.onPlayers(function(list){
+ list=list||[];
+ if(phase==='searching'){ lobbyMates=list; queueTick(); return; }
+ players=list;
+ if(phase==='lobby'||phase==='connecting') checkStart();
+ else if(phase==='playing' && players.length<2) oppLeft();
+ });
+ RT.onMessage(function(m){
+ var d=m&&m.data; if(!d) return;
+ if(phase==='searching'){
+ if(d.t==='match' && d.with===lobbyId){
+ queueTick();
+ if(m.from===lobbyCand) beginMatch(m.from);
+ }
+ return;
+ }
+ if(role==='host'){
+ if(m.from===guestId && d.t==='p' && typeof d.x==='number'){
+ oppX=d.x;
+ if(typeof d.nm==='string' && d.nm) oppName=d.nm;
+ }
+ } else {
+ if(d.t==='s'){
+ ball.x=d.bx; ball.y=d.by; ball.vx=d.bvx; ball.vy=d.bvy;
+ oppX=d.hp; sh=d.sh; sg=d.sg;
+ if(typeof d.nm==='string' && d.nm) oppName=d.nm;
+ if(d.w && !winner){ winner=d.w; }
+ }
+ }
+ });
+
+ cv.addEventListener('pointerdown',function(e){ cv.setPointerCapture(e.pointerId); movePaddle(e); e.preventDefault(); });
+ cv.addEventListener('pointermove',function(e){ if(e.buttons||e.pointerType==='touch') movePaddle(e); });
+ function movePaddle(e){
+ if(phase!=='playing') return;
+ var r=cv.getBoundingClientRect();
+ var x=(e.clientX-r.left)/r.width;
+ myX=Math.max(PHW,Math.min(1-PHW,x));
+ }
+
+ menu();
+ loadName();
+})();
+</script>

v1Current

@liveloop · 5/17/2026, 12:33:33 PM

Initial version — all lines are new.

+<style>
+ html,body{height:100%;margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#0a0a12;color:#f8fafc;overflow:hidden;}
+ #app{position:relative;height:100%;}
+ .screen{position:absolute;inset:0;display:none;flex-direction:column;align-items:center;justify-content:center;gap:.85rem;padding:1.5rem;box-sizing:border-box;text-align:center;}
+ h1{margin:0;font-size:1.6rem;}
+ .sub{margin:0;color:#94a3b8;font-size:.85rem;max-width:24ch;line-height:1.45;}
+ button{font:inherit;font-weight:700;border:0;border-radius:9999px;padding:.7rem 1.6rem;cursor:pointer;touch-action:manipulation;background:#22c55e;color:#04120a;font-size:1rem;}
+ button.alt{background:#1e293b;color:#e2e8f0;}
+ input{font:inherit;font-size:1.5rem;text-align:center;text-transform:uppercase;letter-spacing:.35em;width:7rem;padding:.55rem;border-radius:.6rem;border:2px solid #334155;background:#0f172a;color:#fff;}
+ input.name-field{text-transform:none;letter-spacing:normal;font-size:1.05rem;width:11rem;}
+ .code{font-size:2.6rem;font-weight:800;letter-spacing:.35em;color:#fbbf24;}
+ .err{color:#f87171;font-size:.85rem;}
+ #cv{position:absolute;inset:0;width:100%;height:100%;display:none;touch-action:none;}
+</style>
+<div id="app">
+ <div class="screen" id="screen"></div>
+ <canvas id="cv"></canvas>
+</div>
+<script>
+(function(){
+ var L=window.liveloop, RT=L&&L.realtime;
+ var screen=document.getElementById('screen'), cv=document.getElementById('cv'), ctx=cv.getContext('2d');
+ var W=0,H=0;
+ var myId=null, players=[], role='', code='', phase='menu', hostId='', guestId='';
+ var lobbyId=null, lobbyMates=[], lobbyCand=null, queueTimer=null, shownCount=-1, joinStamp=0;
+ var myName='', oppName='Opponent';
+ var R=0.028, HPY=0.93, GPY=0.07, PHW=0.17;
+ var ball={x:0.5,y:0.5,vx:0,vy:0}, myX=0.5, oppX=0.5, sh=0, sg=0, winner=null;
+ var lastBcast=0, lastPad=0, lastT=0;
+
+ function onTap(el,fn){
+ var sx=0,sy=0,armed=false;
+ el.addEventListener('pointerdown',function(e){ armed=true;sx=e.clientX;sy=e.clientY; });
+ el.addEventListener('pointercancel',function(){ armed=false; });
+ el.addEventListener('pointerup',function(e){
+ if(!armed) return; armed=false;
+ if(Math.abs(e.clientX-sx)<16&&Math.abs(e.clientY-sy)<16) fn();
+ });
+ }
+ function fit(){
+ var r=cv.getBoundingClientRect(); if(r.width<=0) return;
+ var dpr=Math.min(window.devicePixelRatio||1,2);
+ W=r.width; H=r.height;
+ cv.width=Math.round(W*dpr); cv.height=Math.round(H*dpr);
+ ctx.setTransform(dpr,0,0,dpr,0,0);
+ }
+ window.addEventListener('resize',fit);
+ function gen4(){
+ var A='ABCDEFGHJKLMNPQRSTUVWXYZ23456789', s='';
+ for(var i=0;i<4;i++) s+=A.charAt(Math.floor(Math.random()*A.length));
+ return s;
+ }
+ function el(tag,cls,txt){ var e=document.createElement(tag); if(cls)e.className=cls; if(txt!=null)e.textContent=txt; return e; }
+ function show(){ cv.style.display='none'; screen.style.display='flex'; screen.innerHTML=''; for(var i=0;i<arguments.length;i++) screen.appendChild(arguments[i]); }
+ function showCanvas(){ screen.style.display='none'; cv.style.display='block'; fit(); }
+
+ if(!RT){ show(el('h1',null,'🏓 Ping Pong'), el('p','err','Realtime is not available here — open this artifact from the Liveloop feed.')); return; }
+
+ function menu(){
+ phase='menu';
+ var nameInp=el('input','name-field');
+ nameInp.maxLength=16; nameInp.placeholder='Your name'; nameInp.value=myName;
+ var quick=el('button',null,'Start game');
+ var friend=el('button','alt','Play a friend');
+ onTap(quick,function(){ commitName(nameInp); startQuickMatch(); });
+ onTap(friend,function(){ commitName(nameInp); friendMenu(); });
+ show(el('h1',null,'🏓 Ping Pong'),
+ el('p','sub','Type a name, tap Start, and we’ll match you with another player — or play a friend with a code.'),
+ nameInp, quick, friend);
+ }
+ function commitName(inp){
+ var n=(((inp&&inp.value)||'').trim()).slice(0,16) || 'Player';
+ myName=n;
+ if(L&&L.storage){ try{ L.storage.set({name:n}); }catch(e){} }
+ }
+ function loadName(){
+ if(!L||!L.storage) return;
+ L.storage.get().then(function(s){
+ if(s&&typeof s.name==='string'&&s.name) myName=s.name;
+ if(phase==='menu') menu();
+ },function(){});
+ }
+ function stopQueue(){ if(queueTimer){ clearInterval(queueTimer); queueTimer=null; } }
+ function backToMenu(){ try{ RT.leave(); }catch(e){} stopQueue(); phase='menu'; players=[]; menu(); }
+ function errScreen(msg){
+ stopQueue(); phase='menu';
+ var back=el('button','alt','Back'); onTap(back,menu);
+ show(el('h1',null,'Something went wrong'), el('p','err',msg), back);
+ }
+
+ // --- Quick match: a shared lobby channel + deterministic pairing ---
+ function startQuickMatch(){
+ try{ RT.leave(); }catch(e){}
+ stopQueue();
+ phase='searching'; lobbyCand=null; lobbyMates=[]; shownCount=-1;
+ searching();
+ RT.join('_lobby').then(function(res){
+ if(phase!=='searching') return;
+ if(!res||!res.ok){ errScreen((res&&res.error)||'Could not reach matchmaking.'); return; }
+ lobbyId=res.playerId; lobbyMates=res.players||[];
+ queueTimer=setInterval(queueTick,1000);
+ queueTick();
+ });
+ }
+ function searching(){
+ var n=lobbyMates.length;
+ var cancel=el('button','alt','Cancel'); onTap(cancel,backToMenu);
+ show(el('h1',null,'Looking for an opponent…'),
+ el('p','sub', n>1 ? (n+' players in the queue — pairing you up.') : 'Waiting for another player to tap Start.'),
+ cancel);
+ }
+ function queueTick(){
+ if(phase!=='searching') return;
+ var sorted=lobbyMates.slice().sort(), cand=null;
+ if(sorted.length>=2){
+ if(lobbyId===sorted[0]) cand=sorted[1];
+ else if(lobbyId===sorted[1]) cand=sorted[0];
+ }
+ lobbyCand=cand;
+ if(cand) RT.send({t:'match',with:cand});
+ if(lobbyMates.length!==shownCount){ shownCount=lobbyMates.length; searching(); }
+ }
+ function roomFor(a,b){
+ var s=[a,b].slice().sort().join('|'), h=5381;
+ for(var i=0;i<s.length;i++){ h=(((h<<5)+h)+s.charCodeAt(i))>>>0; }
+ return 'g'+h.toString(36);
+ }
+ function beginMatch(partner){
+ if(phase!=='searching') return;
+ stopQueue();
+ phase='connecting';
+ show(el('h1',null,'Opponent found!'), el('p','sub','Starting the match…'));
+ RT.join(roomFor(lobbyId,partner)).then(function(res){
+ if(phase!=='connecting') return;
+ if(!res||!res.ok){ errScreen((res&&res.error)||'Could not start the match.'); return; }
+ myId=res.playerId; players=res.players||[];
+ phase='lobby';
+ var stamp=++joinStamp;
+ show(el('h1',null,'Match ready'), el('p','sub','Waiting for your opponent to load…'));
+ checkStart();
+ // If the opponent never loads (rare pairing desync), re-queue.
+ setTimeout(function(){
+ if(phase==='lobby' && joinStamp===stamp) startQuickMatch();
+ }, 9000);
+ });
+ }
+
+ // --- Play a friend: a private room code ---
+ function friendMenu(){
+ phase='menu';
+ var create=el('button',null,'Create game');
+ var join=el('button','alt','Join with code');
+ var back=el('button','alt','Back');
+ onTap(create,doCreate); onTap(join,joinScreen); onTap(back,menu);
+ show(el('h1',null,'Play a friend'),
+ el('p','sub','Create a game and share the code, or join one a friend shared.'),
+ create, join, back);
+ }
+ function joinScreen(){
+ var inp=el('input'); inp.maxLength=4; inp.placeholder='CODE';
+ var go=el('button',null,'Join'); var back=el('button','alt','Back');
+ onTap(go,function(){ doJoin((inp.value||'').toUpperCase().replace(/[^A-Z0-9]/g,'')); });
+ onTap(back,friendMenu);
+ show(el('h1',null,'Join a game'), el('p','sub','Enter the 4-letter code your friend shared.'), inp, go, back);
+ }
+ function doCreate(){ try{ RT.leave(); }catch(e){} code=gen4(); connect(code,true); }
+ function doJoin(c){ if(c.length<3){ joinScreen(); return; } try{ RT.leave(); }catch(e){} code=c; connect(c,false); }
+ function connect(c,created){
+ phase='connecting';
+ show(el('h1',null,'Connecting…'), el('p','sub','Reaching the game room.'));
+ RT.join(c).then(function(res){
+ if(phase!=='connecting') return;
+ if(!res||!res.ok){
+ var back=el('button','alt','Back'); onTap(back,friendMenu);
+ show(el('h1',null,'Could not connect'), el('p','err',(res&&res.error)||'Unknown error.'), back);
+ return;
+ }
+ myId=res.playerId; players=res.players||[];
+ friendLobby(created);
+ checkStart();
+ });
+ }
+ function friendLobby(created){
+ phase='lobby';
+ var leave=el('button','alt','Leave'); onTap(leave,backToMenu);
+ if(created){
+ show(el('h1',null,'Share this code'), el('div','code',code),
+ el('p','sub','Your friend taps “Play a friend” → “Join with code”. Waiting…'),
+ leave);
+ } else {
+ show(el('h1',null,'Joined '+code), el('p','sub','Waiting for the other player…'), leave);
+ }
+ }
+
+ function checkStart(){
+ if(phase!=='lobby'&&phase!=='connecting') return;
+ if(players.length>=2) startGame();
+ }
+ function startGame(){
+ var sorted=players.slice().sort();
+ hostId=sorted[0]; guestId=sorted[1];
+ role = myId===hostId?'host' : myId===guestId?'guest' : 'spectator';
+ sh=0; sg=0; winner=null; myX=0.5; oppX=0.5; oppName='Opponent';
+ if(role==='host') serve(Math.random()<0.5?1:-1);
+ phase='playing';
+ showCanvas();
+ lastT=0;
+ requestAnimationFrame(loop);
+ }
+ function serve(dir){
+ ball={x:0.5,y:0.5,vx:(Math.random()-0.5)*0.007,vy:dir*0.012};
+ }
+ function clampSpeed(){
+ if(ball.vy>0.024) ball.vy=0.024; if(ball.vy<-0.024) ball.vy=-0.024;
+ if(ball.vx>0.016) ball.vx=0.016; if(ball.vx<-0.016) ball.vx=-0.016;
+ }
+ function simulate(dt){
+ if(winner) return;
+ ball.x+=ball.vx*dt; ball.y+=ball.vy*dt;
+ if(ball.x<R){ ball.x=R; ball.vx=Math.abs(ball.vx); }
+ if(ball.x>1-R){ ball.x=1-R; ball.vx=-Math.abs(ball.vx); }
+ if(ball.vy>0 && ball.y+R>=HPY && ball.y<HPY && Math.abs(ball.x-myX)<=PHW+R){
+ ball.y=HPY-R; ball.vy=-Math.abs(ball.vy)*1.04; ball.vx+=(ball.x-myX)*0.05; clampSpeed();
+ }
+ if(ball.vy<0 && ball.y-R<=GPY && ball.y>GPY && Math.abs(ball.x-oppX)<=PHW+R){
+ ball.y=GPY+R; ball.vy=Math.abs(ball.vy)*1.04; ball.vx+=(ball.x-oppX)*0.05; clampSpeed();
+ }
+ if(ball.y>1.12){ sg++; serve(-1); }
+ if(ball.y<-0.12){ sh++; serve(1); }
+ if(sh>=7) winner='host';
+ if(sg>=7) winner='guest';
+ }
+ function loop(t){
+ if(phase!=='playing'){ return; }
+ var dt=lastT?Math.min((t-lastT)/16.67,3):1; lastT=t;
+ if(role==='host'){
+ simulate(dt);
+ if(t-lastBcast>60){
+ lastBcast=t;
+ RT.send({t:'s',bx:ball.x,by:ball.y,bvx:ball.vx,bvy:ball.vy,hp:myX,sh:sh,sg:sg,w:winner,nm:myName});
+ }
+ } else {
+ ball.x+=ball.vx*dt; ball.y+=ball.vy*dt;
+ if(t-lastPad>70){ lastPad=t; RT.send({t:'p',x:myX,nm:myName}); }
+ }
+ render();
+ if(winner){ over(); return; }
+ requestAnimationFrame(loop);
+ }
+ function ry(y){ return role==='guest'?(1-y):y; }
+ function render(){
+ ctx.clearRect(0,0,W,H);
+ ctx.fillStyle='#0a0a12'; ctx.fillRect(0,0,W,H);
+ // mid line
+ ctx.strokeStyle='#1e293b'; ctx.lineWidth=2; ctx.setLineDash([8,10]);
+ ctx.beginPath(); ctx.moveTo(0,H/2); ctx.lineTo(W,H/2); ctx.stroke(); ctx.setLineDash([]);
+ // scores
+ var myScore=role==='guest'?sg:sh, oppScore=role==='guest'?sh:sg;
+ ctx.fillStyle='#1e293b'; ctx.font='700 '+Math.round(H*0.13)+'px system-ui';
+ ctx.textAlign='center'; ctx.textBaseline='middle';
+ ctx.fillText(String(oppScore),W/2,H*0.36);
+ ctx.fillText(String(myScore),W/2,H*0.64);
+ // player names
+ ctx.fillStyle='#64748b'; ctx.font='600 '+Math.round(H*0.03)+'px system-ui';
+ ctx.fillText(oppName,W/2,H*0.21);
+ ctx.fillText(myName||'You',W/2,H*0.79);
+ // paddles
+ var myPY=role==='host'?HPY:GPY, opPY=role==='host'?GPY:HPY;
+ drawPaddle(myX, ry(myPY), '#22c55e');
+ drawPaddle(oppX, ry(opPY), '#f8fafc');
+ // ball
+ ctx.fillStyle='#fbbf24';
+ ctx.beginPath(); ctx.arc(ball.x*W, ry(ball.y)*H, R*W, 0, Math.PI*2); ctx.fill();
+ }
+ function drawPaddle(cx,cy,color){
+ var w=PHW*2*W, h=Math.max(10,H*0.018), x=cx*W-w/2, y=cy*H-h/2;
+ ctx.fillStyle=color;
+ if(ctx.roundRect){ ctx.beginPath(); ctx.roundRect(x,y,w,h,h/2); ctx.fill(); }
+ else ctx.fillRect(x,y,w,h);
+ }
+ function endScreen(title){
+ phase='over';
+ var again=el('button',null,'Find new game'); onTap(again,startQuickMatch);
+ var menuB=el('button','alt','Menu'); onTap(menuB,backToMenu);
+ show(el('h1',null,title), el('p','sub','Final score '+sh+' – '+sg+'.'), again, menuB);
+ }
+ function over(){
+ endScreen('🏆 ' + (winner===role ? (myName||'You') : oppName) + ' wins!');
+ }
+ function oppLeft(){
+ if(phase!=='playing') return;
+ endScreen('Opponent left');
+ }
+
+ RT.onPlayers(function(list){
+ list=list||[];
+ if(phase==='searching'){ lobbyMates=list; queueTick(); return; }
+ players=list;
+ if(phase==='lobby'||phase==='connecting') checkStart();
+ else if(phase==='playing' && players.length<2) oppLeft();
+ });
+ RT.onMessage(function(m){
+ var d=m&&m.data; if(!d) return;
+ if(phase==='searching'){
+ if(d.t==='match' && d.with===lobbyId){
+ queueTick();
+ if(m.from===lobbyCand) beginMatch(m.from);
+ }
+ return;
+ }
+ if(role==='host'){
+ if(m.from===guestId && d.t==='p' && typeof d.x==='number'){
+ oppX=d.x;
+ if(typeof d.nm==='string' && d.nm) oppName=d.nm;
+ }
+ } else {
+ if(d.t==='s'){
+ ball.x=d.bx; ball.y=d.by; ball.vx=d.bvx; ball.vy=d.bvy;
+ oppX=d.hp; sh=d.sh; sg=d.sg;
+ if(typeof d.nm==='string' && d.nm) oppName=d.nm;
+ if(d.w && !winner){ winner=d.w; }
+ }
+ }
+ });
+
+ cv.addEventListener('pointerdown',function(e){ cv.setPointerCapture(e.pointerId); movePaddle(e); e.preventDefault(); });
+ cv.addEventListener('pointermove',function(e){ if(e.buttons||e.pointerType==='touch') movePaddle(e); });
+ function movePaddle(e){
+ if(phase!=='playing') return;
+ var r=cv.getBoundingClientRect();
+ var x=(e.clientX-r.left)/r.width;
+ myX=Math.max(PHW,Math.min(1-PHW,x));
+ }
+
+ menu();
+ loadName();
+})();
+</script>
← Version history