@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>