Two-player Pong
HTML for this example
Select the code and use your copy shortcut. Changes in this preview are temporary.
<!doctype html><html lang="en-US"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Two-player Pong</title><style>*{box-sizing:border-box}html{color-scheme:light}body{margin:0;background:var(--bg,#eef1f4);color:var(--ink,#233449);font:16px/1.5 system-ui,-apple-system,Segoe UI,sans-serif}main{max-width:1040px;margin:auto;padding:26px 28px 44px}header{margin-bottom:20px}h1{font-size:clamp(26px,4vw,36px);letter-spacing:-.035em;line-height:1.15;margin:5px 0 10px}h2{font-size:21px;line-height:1.3;margin:0 0 15px}h3{font-size:17px;margin:0 0 9px}p{margin:8px 0 16px}.kicker{font-size:12px;text-transform:uppercase;letter-spacing:.12em;font-weight:750;color:var(--accent,#315b87)}.muted,small{color:#556473}.panel{margin:20px 0;padding:24px;border:1px solid var(--line,#ccd5df);border-radius:10px;background:#fff}.hero{padding:24px;background:var(--hero,#183b5c);color:#fff;border-radius:12px;margin:18px 0}.hero .kicker,.hero small{color:#d9e5ef}.big{font-size:clamp(32px,5vw,52px);line-height:1.15;font-weight:750;letter-spacing:-.035em;overflow-wrap:anywhere;margin:10px 0 15px}.row{display:flex;flex-wrap:wrap;gap:15px;align-items:end}.row>label{flex:1;min-width:140px}.grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}.thirds{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:15px}label{display:block;font-size:14px;font-weight:650}input,select,textarea{font:inherit;font-size:16px;color:inherit;display:block;width:100%;margin-top:6px;padding:11px 12px;border:1px solid #a9b4c1;border-radius:6px;background:white;min-width:0}input[type=checkbox]{display:inline;width:auto;margin:0 8px 0 0;accent-color:var(--accent,#315b87)}textarea{min-height:100px;resize:vertical}button{font:inherit;font-weight:650;min-height:44px;border:1px solid var(--accent,#315b87);border-radius:6px;background:var(--accent,#315b87);color:white;padding:10px 16px;cursor:pointer}button.secondary{background:white;color:var(--accent,#315b87)}button:disabled{opacity:.5;cursor:default}button[aria-pressed=true]{box-shadow:0 0 0 3px #eab655}button:hover:not(:disabled){filter:brightness(.94)}:focus-visible{outline:3px solid #e5a72f;outline-offset:3px}.actions{display:flex;flex-wrap:wrap;gap:10px;margin:18px 0 0}[hidden]{display:none!important}.error{color:#a3232a;font-weight:650}.error:not(:empty){padding:12px;background:#fff0ed;border-left:4px solid #ab3439;margin:14px 0}.note{padding:14px;background:#edf2f6;border-radius:6px;margin:14px 0}.mono,pre{font-family:ui-monospace,Consolas,monospace;font-variant-numeric:tabular-nums}pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:14px;line-height:1.7;margin:0}.mono{overflow-wrap:anywhere}table{width:100%;border-collapse:collapse;table-layout:fixed;font-size:14px}th,td{text-align:left;vertical-align:top;overflow-wrap:anywhere;padding:11px 8px;border-bottom:1px solid #d4dce4}th{font-size:12px;text-transform:uppercase;letter-spacing:.035em;color:#536372}th:first-child,td:first-child{padding-left:0}.tag{display:inline-block;padding:4px 8px;border:1px solid currentColor;border-radius:4px;font-size:12px;font-weight:750}.rule{border-top:1px solid #cbd4dd;padding-top:18px}.line{height:30px;border-bottom:1px solid #aab5bf}.stat{font-size:34px;line-height:1.15;font-weight:750}.stat small{font:14px system-ui;display:block;margin-top:8px}details{margin:20px 0;border-top:1px solid #cbd4dd;padding:18px 0}summary{font-weight:700;cursor:pointer}details p{margin-top:15px}a{color:var(--accent,#315b87)}footer{font-size:13px;color:#556473;margin-top:25px}fieldset{min-width:0;border:1px solid #c8d2db;border-radius:7px;padding:18px;margin:15px 0}legend{font-weight:750;padding:0 6px}@media(max-width:720px){main{padding:20px}.panel,.hero{padding:20px}.thirds{gap:10px}}@media(max-width:540px){main{padding:16px}.grid,.thirds{grid-template-columns:1fr}.panel,.hero{padding:18px}.row{gap:12px}.row>label{min-width:100px}h1{font-size:27px}th,td{padding:10px 5px;font-size:13px}.big{font-size:37px}}.surface{background:white;border:1px solid #ccd5df;border-radius:10px;padding:24px;margin:18px 0}.metrics{display:flex;gap:34px;flex-wrap:wrap}.metric b{font-size:clamp(32px,5vw,52px);line-height:1.1;display:block;letter-spacing:-.04em}.metric span{display:block;margin-top:8px;font-size:14px}.strip{display:grid;grid-template-columns:repeat(10,minmax(0,1fr));gap:7px;margin:22px 0}.unit{padding:10px 3px;text-align:center;background:#e5f0eb;border-bottom:4px solid #327660;font-size:13px;font-weight:700}.unit.rework{background:#fff0d5;border-color:#aa701d}.unit.fail{background:#f9e2e0;border-color:#ab3d35}.ledger-row{display:grid;grid-template-columns:100px 1fr 140px;gap:18px;padding:18px 0;border-bottom:1px solid #d2dbe3;align-items:start}.ledger-row p{margin:5px 0}.amount{text-align:right;font-size:22px;font-weight:750}.pill{display:inline-block;border-radius:20px;background:#e7edf2;padding:5px 10px;font-size:12px;font-weight:700}.timeline{border-left:3px solid #c7d4de;padding:0 0 0 22px;margin:16px 0}.timeline article{padding:10px 0 18px}.timeline h3{margin-bottom:5px}.timeline p{margin:5px 0}.artifact{font-family:Georgia,serif}.warning{background:#fff3db;border-left:4px solid #a66d18;padding:15px}.empty{color:#5d6771;font-style:italic}.check{color:#22694f}.bad{color:#9a332e}button.linklike{padding:8px 12px;min-height:40px;background:white;color:var(--accent,#315b87)}@media(max-width:540px){.surface{padding:18px}.ledger-row{grid-template-columns:65px 1fr;gap:12px}.ledger-row .amount{grid-column:2;text-align:left}.strip{gap:3px}.unit{font-size:11px;padding:8px 0}.metrics{gap:22px}.metric b{font-size:38px}}.arena{max-width:820px;margin:auto;border-top:5px solid #476b7e}.scoreboard{display:grid;grid-template-columns:1fr 1.6fr 1fr;align-items:center;text-align:center;gap:15px;margin-bottom:15px}.scoreboard b{display:block;font:48px/1.1 ui-monospace,monospace;margin-top:8px}.scoreboard small{font-size:12px;letter-spacing:.06em}.scoreboard p{margin:10px 0 0;font-weight:700}#field{display:block;width:100%;height:auto;max-height:420px;border:2px solid #496574;border-radius:7px}.hold-controls{display:grid;grid-template-columns:1fr 1fr;gap:25px;margin-top:20px}.hold-controls h3{font-size:14px;margin:0 0 8px}.hold-controls button{touch-action:none;user-select:none;margin:0 5px 7px 0;padding:10px}.hold-controls button[aria-pressed=true]{background:#183d50}.arena:focus-visible{outline:3px solid #d7a735}@media(max-width:540px){.scoreboard{gap:8px}.scoreboard b{font-size:36px}.scoreboard small{font-size:10px}.hold-controls{gap:12px}.hold-controls button{width:100%;margin-right:0;font-size:14px}.scoreboard p{font-size:13px}}</style></head><body style="--bg:#eaf0f4;--accent:#315f77"><main><header><div class="kicker">Original 400 × 240 logical field</div><h1>Two-player Pong</h1><p class="muted">Defend opposite sides; the first player to five points wins.</p></header><section id="gameRegion" class="surface arena" tabindex="0" aria-label="Pong game region. Left W and S; right arrow up and down."><div class="scoreboard"><div><small>LEFT · W / S</small><b id="leftScore">0</b></div><div><span class="pill">First to 5</span><p id="gameState" role="status" aria-live="polite"></p></div><div><small>RIGHT · ↑ / ↓</small><b id="rightScore">0</b></div></div><svg id="field" viewBox="0 0 400 240" role="img" aria-label="Pong field, left and right paddles and ball"><rect width="400" height="240" fill="#152f40"/><path d="M200 0V240" stroke="#597282" stroke-dasharray="6 8"/><rect id="leftPaddle" x="16" y="90" width="10" height="60" rx="2" fill="#a8d8dd"/><rect id="rightPaddle" x="374" y="90" width="10" height="60" rx="2" fill="#ffd893"/><circle id="ball" cx="200" cy="120" r="6" fill="white"/></svg><div class="actions"><button type="button" id="serve">Serve</button><button type="button" id="pause" class="secondary">Pause</button><button type="button" id="reset" class="secondary">Reset match</button></div><div class="hold-controls"><div><h3>Left player</h3><button type="button" id="leftUp">Hold left ↑</button><button type="button" id="leftDown">Hold left ↓</button></div><div><h3>Right player</h3><button type="button" id="rightUp">Hold right ↑</button><button type="button" id="rightDown">Hold right ↓</button></div></div><p id="rallyNote" class="muted"></p></section><details><summary>Controls, pause, and physics</summary><p>Focus this region for W/S and Up/Down. Hold buttons support pointer press/release and Space/Enter press/release. Opposing directions on the same paddle cancel. Release/cancel/lost capture clears that input; leaving the region or hiding the tab clears inputs and pauses. Returning requires Resume.</p><p>Physics uses swept contact times within substeps whose ball and paddle displacement is at most one logical unit per axis. Vertical overlap is evaluated at contact time, including moving paddles. Frame intervals are capped at 50 ms; paused time is discarded. A point stops remaining updates, centers the ball, clears held inputs, and waits for Serve. The next legal serve alternates horizontal direction; a rejected Serve does not advance it.</p><p>The native source and local tests are available for review. Actual rendering, focus, pointer-capture and device behavior need the parent browser review. No AI opponent, sounds, network match, leaderboard, or saved scores.</p></details><footer>Local session only · No network or saved progress. Keep maintained source records authoritative.</footer></main><script>const $=id=>document.getElementById(id);const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const clamp=y=>Math.max(0,Math.min(180,y));const actions=['leftUp','leftDown','rightUp','rightDown'];let held=Object.fromEntries(actions.map(k=>[k,new Set()]));let match,raf=null,epoch=0,lastStamp=null;
function fresh(){return {left:0,right:0,x:200,y:120,vx:0,vy:0,leftY:90,rightY:90,state:'wait',paused:false,serves:0,winner:null}}function clearHeld(){actions.forEach(k=>held[k].clear())}function velocity(player){return ((held[player+'Down'].size?1:0)-(held[player+'Up'].size?1:0))*180}function press(action,token,on){if(!actions.includes(action))return;if(on&&match.state==='running'&&!match.paused)held[action].add(token);else held[action].delete(token);render()}
function stopLoop(){epoch++;if(raf!==null)cancelAnimationFrame(raf);raf=null;lastStamp=null}function loop(){if(raf!==null||match.paused||match.state!=='running')return;const token=epoch;raf=requestAnimationFrame(stamp=>{if(token!==epoch)return;raf=null;if(lastStamp!==null)advance(Math.min(.05,Math.max(0,(stamp-lastStamp)/1000)));lastStamp=stamp;render();loop()})}
function render(){$('leftScore').textContent=match.left;$('rightScore').textContent=match.right;$('gameState').textContent=match.state==='terminal'?match.winner+' wins':match.paused?'Paused · explicit Resume required':match.state==='wait'?'Waiting for Serve':'Rally in progress';$('leftPaddle').setAttribute('y',match.leftY);$('rightPaddle').setAttribute('y',match.rightY);$('ball').setAttribute('cx',match.x);$('ball').setAttribute('cy',match.y);$('serve').disabled=match.paused||match.state!=='wait';$('pause').disabled=match.state==='terminal';$('pause').textContent=match.paused?'Resume':'Pause';$('rallyNote').textContent='Legal serves: '+match.serves+'. '+(match.state==='terminal'?'Reset begins a new match.':'Next serve horizontal direction: '+(match.serves%2?'left':'right')+'.');actions.forEach(k=>{$(k).disabled=match.paused||match.state!=='running';$(k).setAttribute('aria-pressed',String(held[k].size>0))})}
function award(side){if(match.state!=='running')return false;match[side]++;match.x=200;match.y=120;match.vx=0;match.vy=0;clearHeld();if(match[side]>=5){match.state='terminal';match.winner=side==='left'?'Left':'Right'}else match.state='wait';stopLoop();return true}
function advance(dt){if(match.paused||match.state!=='running'||!Number.isFinite(dt)||dt<0)return;const count=Math.max(1,Math.ceil(Math.max(Math.abs(match.vx),Math.abs(match.vy),180)*dt)),h=dt/count;for(let step=0;step<count;step++){if(match.state!=='running')return;const ly=velocity('left'),ry=velocity('right');let remaining=h,guard=0;while(remaining>1e-12&&guard++<6){if(match.y<6){match.y=6;if(match.vy<0)match.vy=-match.vy}if(match.y>234){match.y=234;if(match.vy>0)match.vy=-match.vy}let t=remaining,wall=false,paddle=false;const candidates=[];if(match.vy<0)candidates.push({t:(6-match.y)/match.vy,kind:'wall'});if(match.vy>0)candidates.push({t:(234-match.y)/match.vy,kind:'wall'});if(match.vx<0&&match.x>=32){const at=(32-match.x)/match.vx,top=clamp(match.leftY+ly*at),y=match.y+match.vy*at;if(y+6>=top&&y-6<=top+60)candidates.push({t:at,kind:'paddle'})}if(match.vx>0&&match.x<=368){const at=(368-match.x)/match.vx,top=clamp(match.rightY+ry*at),y=match.y+match.vy*at;if(y+6>=top&&y-6<=top+60)candidates.push({t:at,kind:'paddle'})}const valid=candidates.filter(c=>c.t>=-1e-12&&c.t<=remaining+1e-12);if(valid.length){t=Math.max(0,Math.min(remaining,...valid.map(c=>c.t)));wall=valid.some(c=>Math.abs(c.t-t)<1e-10&&c.kind==='wall');paddle=valid.some(c=>Math.abs(c.t-t)<1e-10&&c.kind==='paddle')}match.x+=match.vx*t;match.y+=match.vy*t;match.leftY=clamp(match.leftY+ly*t);match.rightY=clamp(match.rightY+ry*t);remaining-=t;if(wall)match.vy=-match.vy;if(paddle)match.vx=-match.vx;if(!wall&&!paddle)remaining=0;}if(match.x+6<0){award('right');return}if(match.x-6>400){award('left');return}}}
function serve(){if(match.paused||match.state!=='wait')return false;match.x=200;match.y=120;match.vx=match.serves%2?-120:120;match.vy=80;match.serves++;match.state='running';lastStamp=null;render();loop();return true}function pause(){if(match.state==='terminal')return;match.paused=!match.paused;clearHeld();stopLoop();render();if(!match.paused)loop()}function interrupt(){clearHeld();if(match.state!=='terminal')match.paused=true;stopLoop();render()}function reset(){stopLoop();clearHeld();match=fresh();render()}
const region=$('gameRegion'),keyActions={w:'leftUp',s:'leftDown',ArrowUp:'rightUp',ArrowDown:'rightDown'};region.addEventListener('keydown',e=>{const key=e.key.length===1?e.key.toLowerCase():e.key;if(keyActions[key]){e.preventDefault();press(keyActions[key],'key:'+key,true)}});region.addEventListener('keyup',e=>{const key=e.key.length===1?e.key.toLowerCase():e.key;if(keyActions[key]){e.preventDefault();press(keyActions[key],'key:'+key,false)}});region.addEventListener('focusout',e=>{if(!region.contains(e.relatedTarget))interrupt()});actions.forEach(k=>{const b=$(k);b.addEventListener('pointerdown',e=>{e.preventDefault();b.focus();b.setPointerCapture(e.pointerId);press(k,'p:'+e.pointerId,true)});['pointerup','pointercancel','lostpointercapture'].forEach(ev=>b.addEventListener(ev,e=>press(k,'p:'+e.pointerId,false)));b.addEventListener('keydown',e=>{if([' ','Enter'].includes(e.key)){e.preventDefault();press(k,'button:'+e.key,true)}});b.addEventListener('keyup',e=>{if([' ','Enter'].includes(e.key)){e.preventDefault();press(k,'button:'+e.key,false)}});b.addEventListener('blur',()=>{held[k].clear();render()})});$('serve').onclick=serve;$('pause').onclick=pause;$('reset').onclick=reset;window.addEventListener('blur',interrupt);document.addEventListener('visibilitychange',()=>{if(document.hidden)interrupt()});reset();</script></body></html>