Spaces:
Running
Running
multimodalart HF Staff
Mr. Pong interactive demo: browser-side play + server-side match video & benchmark
13eb3cb verified | <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <style> | |
| * { box-sizing: border-box; } | |
| html, body { | |
| margin: 0; padding: 0; background: #0b0f19; color: #e6edf3; | |
| font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; | |
| overflow: hidden; | |
| } | |
| #wrap { padding: 8px 10px 10px 10px; max-width: 900px; margin: 0 auto; } | |
| #bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 8px; } | |
| .btn { | |
| background: #1b2438; color: #e6edf3; | |
| border: 1px solid #2b3550; border-radius: 8px; padding: 6px 12px; cursor: pointer; | |
| font-size: 13px; font-weight: 600; transition: all .12s; | |
| } | |
| .btn:hover { background: #26314a; } | |
| .btn.active { background: #f97316; border-color: #f97316; color: #0b0f19; } | |
| .btn.primary { background: #4f8cff; border-color: #4f8cff; color: #08111f; } | |
| .btn.primary:hover { background: #6ba0ff; } | |
| .lab { font-size: 12px; color: #8b98ac; text-transform: uppercase; letter-spacing: .06em; margin-right: 2px; } | |
| #stage { position: relative; width: 100%; } | |
| canvas { width: 100%; height: auto; display: block; border-radius: 10px; background: #0f1626; } | |
| #overlay { | |
| position: absolute; inset: 0; display: flex; flex-direction: column; | |
| align-items: center; justify-content: center; text-align: center; | |
| background: rgba(8,12,22,.82); border-radius: 10px; cursor: pointer; | |
| } | |
| #overlay h2 { margin: 0 0 6px 0; font-size: 22px; } | |
| #overlay p { margin: 2px 0; font-size: 13px; color: #b6c2d4; } | |
| #hud { display: flex; gap: 14px; align-items: center; margin-top: 8px; font-size: 12px; color: #8b98ac; flex-wrap: wrap; } | |
| #vbar { flex: 1; min-width: 120px; height: 8px; background: #1b2438; border-radius: 4px; overflow: hidden; position: relative; } | |
| #vfill { position: absolute; top: 0; bottom: 0; left: 50%; width: 0%; background: #4f8cff; transition: width .1s linear, left .1s linear; } | |
| kbd { background:#1b2438; border:1px solid #2b3550; border-bottom-width:2px; border-radius:5px; padding:1px 6px; font-size:12px; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="wrap"> | |
| <div id="bar"> | |
| <span class="lab">Difficulty</span> | |
| <button class="btn diff" data-d="easy">Easy</button> | |
| <button class="btn diff active" data-d="normal">Normal</button> | |
| <button class="btn diff" data-d="hard">Hard</button> | |
| <span style="flex:1"></span> | |
| <span class="lab">Control</span> | |
| <button class="btn ctrl active" data-c="key">Keyboard</button> | |
| <button class="btn ctrl" data-c="mouse">Mouse / Touch</button> | |
| <button class="btn primary" id="restart">Restart</button> | |
| </div> | |
| <div id="stage"> | |
| <canvas id="cv" width="800" height="500"></canvas> | |
| <div id="overlay"> | |
| <h2>π Click here to play Mr. Pong</h2> | |
| <p>Hold <kbd>W</kbd>/<kbd>↑</kbd> to move up, <kbd>S</kbd>/<kbd>↓</kbd> to move down — release to stop.</p> | |
| <p>First to 5 points. You are the <b style="color:#22d3ee">cyan</b> paddle on the left.</p> | |
| </div> | |
| </div> | |
| <div id="hud"> | |
| <span>Mr. Pong action: <b id="act" style="color:#e6edf3">STAY</b></span> | |
| <span>V(s): <b id="vtxt" style="color:#e6edf3">0.00</b></span> | |
| <div id="vbar"><div id="vfill"></div></div> | |
| </div> | |
| </div> | |
| <script> | |
| // --------------------------------------------------------------------------- | |
| // Mr. Pong policy network (fromziro/MrPong) β 28,484 params, exported from the | |
| // checkpoint shipped on the Hub and evaluated here in the browser. | |
| // trunk: Linear(12,160) -> Tanh -> Linear(160,160) -> Tanh | |
| // actor: Linear(160,3) (argmax => stay / up / down) critic: Linear(160,1) | |
| // --------------------------------------------------------------------------- | |
| const B64 = "__WEIGHTS_B64__"; | |
| const WB = (function () { | |
| const bin = atob(B64); | |
| const buf = new ArrayBuffer(bin.length); | |
| const u8 = new Uint8Array(buf); | |
| for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); | |
| return new Float32Array(buf); | |
| })(); | |
| const OBS = 12, HID = 160, ACT = 3; | |
| const O_L0W = 0, O_L0B = O_L0W + HID * OBS; | |
| const O_L2W = O_L0B + HID, O_L2B = O_L2W + HID * HID; | |
| const O_AW = O_L2B + HID, O_AB = O_AW + ACT * HID; | |
| const O_CW = O_AB + ACT, O_CB = O_CW + HID; | |
| const h1 = new Float32Array(HID), h2 = new Float32Array(HID); | |
| function policy(x) { | |
| for (let i = 0; i < HID; i++) { | |
| let s = WB[O_L0B + i], b = O_L0W + i * OBS; | |
| for (let j = 0; j < OBS; j++) s += WB[b + j] * x[j]; | |
| h1[i] = Math.tanh(s); | |
| } | |
| for (let i = 0; i < HID; i++) { | |
| let s = WB[O_L2B + i], b = O_L2W + i * HID; | |
| for (let j = 0; j < HID; j++) s += WB[b + j] * h1[j]; | |
| h2[i] = Math.tanh(s); | |
| } | |
| let best = 0, bv = -1e30; | |
| for (let i = 0; i < ACT; i++) { | |
| let s = WB[O_AB + i], b = O_AW + i * HID; | |
| for (let j = 0; j < HID; j++) s += WB[b + j] * h2[j]; | |
| if (s > bv) { bv = s; best = i; } | |
| } | |
| let v = WB[O_CB]; | |
| for (let j = 0; j < HID; j++) v += WB[O_CW + j] * h2[j]; | |
| return [best, v]; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Physics β 1:1 port of StandalonePongEnv from the author's inference.py | |
| // --------------------------------------------------------------------------- | |
| const W = 800, H = 500, PW = 14, R = 8, SMOOTH = 0.70, VMAX = 16, ACCEL = 1.035, SKIP = 3; | |
| const PRESETS = { | |
| easy: { pspeed: 9.0, serve: 3.5, egoH: 110, dt: 28 }, | |
| normal: { pspeed: 8.5, serve: 4.2, egoH: 95, dt: 25 }, | |
| hard: { pspeed: 8.0, serve: 6.0, egoH: 80, dt: 22 } | |
| }; | |
| let P = PRESETS.normal; | |
| let egoY, oppY, egoVy, oppVy, ballX, ballY, ballVx, ballVy, rally, egoH = 95, oppH = 80; | |
| let scoreH = 0, scoreA = 0, maxRally = 0, aiAct = 0, aiVal = 0; | |
| let running = false, phase = "idle", phaseT = 0, banner = "", serveDir = 1, sub = 0; | |
| let trail = []; | |
| function resetPoint(dir) { | |
| egoY = H / 2; oppY = H / 2; egoVy = 0; oppVy = 0; | |
| ballX = W / 2; ballY = H / 2; | |
| const ang = (Math.random() * 2 - 1) * (Math.PI / 7); | |
| ballVx = dir * P.serve * Math.cos(ang); | |
| ballVy = P.serve * Math.sin(ang); | |
| rally = 0; sub = 0; trail = []; | |
| } | |
| function interceptY(targetX, bx, by, bvx, bvy) { | |
| if ((targetX > bx && bvx <= 0) || (targetX < bx && bvx >= 0)) return H / 2; | |
| for (let k = 0; k < 10; k++) { | |
| const dtx = bvx !== 0 ? (targetX - bx) / bvx : Infinity; | |
| if (dtx <= 0) break; | |
| let dty; | |
| if (bvy > 0) dty = (H - R - by) / bvy; | |
| else if (bvy < 0) dty = (R - by) / bvy; | |
| else dty = Infinity; | |
| if (dtx <= dty) { by += bvy * dtx; break; } | |
| bx += bvx * dty; by += bvy * dty; bvy = -bvy; | |
| } | |
| return Math.min(H - R, Math.max(R, by)); | |
| } | |
| const obsBuf = new Float32Array(OBS); | |
| function oppObservation() { | |
| const oppX = W - PW; | |
| const pred = interceptY(oppX, ballX, ballY, ballVx, ballVy); | |
| obsBuf[0] = (ballY - oppY) / H; | |
| obsBuf[1] = (oppX - ballX) / W; | |
| obsBuf[2] = -ballVx / VMAX; | |
| obsBuf[3] = ballVy / VMAX; | |
| obsBuf[4] = oppY / H; | |
| obsBuf[5] = oppVy / P.pspeed; | |
| obsBuf[6] = (egoY - oppY) / H; | |
| obsBuf[7] = egoVy / P.pspeed; | |
| obsBuf[8] = ballY / H; | |
| obsBuf[9] = (W - ballX) / W; | |
| obsBuf[10] = (pred - oppY) / H; | |
| obsBuf[11] = pred / H; | |
| return obsBuf; | |
| } | |
| function actionVel(a) { return a === 1 ? -P.pspeed : (a === 2 ? P.pspeed : 0); } | |
| function substep(egoAct, oppAct) { | |
| const pEgoY = egoY, pOppY = oppY; | |
| egoVy = SMOOTH * egoVy + (1 - SMOOTH) * actionVel(egoAct); | |
| oppVy = SMOOTH * oppVy + (1 - SMOOTH) * actionVel(oppAct); | |
| const eh = egoH / 2, oh = oppH / 2; | |
| egoY = Math.min(H - eh, Math.max(eh, egoY + egoVy)); | |
| oppY = Math.min(H - oh, Math.max(oh, oppY + oppVy)); | |
| const pbx = ballX, pby = ballY; | |
| const egoPlane = PW + R, oppPlane = (W - PW) - R; | |
| const nbx = pbx + ballVx, nby = pby + ballVy; | |
| let hit = false; | |
| if (ballVx < 0 && pbx >= egoPlane && nbx <= egoPlane) { | |
| const t = Math.min(1, Math.max(0, (pbx - egoPlane) / Math.max(1e-6, -ballVx))); | |
| const yb = pby + t * ballVy, yp = pEgoY + t * (egoY - pEgoY); | |
| if (Math.abs(yb - yp) <= eh + R * 0.6) { | |
| hit = true; rally++; | |
| const off = Math.min(1, Math.max(-1, (yb - yp) / eh)); | |
| const ang = off * (Math.PI / 3); | |
| const sp = Math.min(Math.hypot(ballVx, ballVy) * ACCEL, VMAX); | |
| const nvx = sp * Math.cos(ang), nvy = sp * Math.sin(ang) + 0.25 * egoVy; | |
| const rem = 1 - t; | |
| ballX = egoPlane + rem * nvx; ballY = yb + rem * nvy; ballVx = nvx; ballVy = nvy; | |
| } | |
| } else if (ballVx > 0 && pbx <= oppPlane && nbx >= oppPlane) { | |
| const t = Math.min(1, Math.max(0, (oppPlane - pbx) / Math.max(1e-6, ballVx))); | |
| const yb = pby + t * ballVy, yp = pOppY + t * (oppY - pOppY); | |
| if (Math.abs(yb - yp) <= oh + R * 0.6) { | |
| hit = true; rally++; | |
| const off = Math.min(1, Math.max(-1, (yb - yp) / oh)); | |
| const ang = off * (Math.PI / 3); | |
| const sp = Math.min(Math.hypot(ballVx, ballVy) * ACCEL, VMAX); | |
| const nvx = -sp * Math.cos(ang), nvy = sp * Math.sin(ang) + 0.25 * oppVy; | |
| const rem = 1 - t; | |
| ballX = oppPlane + rem * nvx; ballY = yb + rem * nvy; ballVx = nvx; ballVy = nvy; | |
| } | |
| } | |
| if (!hit) { ballX = nbx; ballY = nby; } | |
| if (ballY - R <= 0) { ballY = R + Math.abs(R - ballY); ballVy = Math.abs(ballVy); } | |
| else if (ballY + R >= H) { ballY = (H - R) - Math.abs(ballY + R - H); ballVy = -Math.abs(ballVy); } | |
| if (ballX - R < 0) return "opponent"; | |
| if (ballX + R > W) return "ego"; | |
| return null; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Input | |
| // --------------------------------------------------------------------------- | |
| let keyUp = false, keyDown = false, ctrlMode = "key", pointerY = null; | |
| const KUP = ["w", "W", "ArrowUp"], KDN = ["s", "S", "ArrowDown"]; | |
| document.addEventListener("keydown", e => { | |
| if (KUP.includes(e.key)) { keyUp = true; e.preventDefault(); } | |
| else if (KDN.includes(e.key)) { keyDown = true; e.preventDefault(); } | |
| }); | |
| document.addEventListener("keyup", e => { | |
| if (KUP.includes(e.key)) { keyUp = false; e.preventDefault(); } | |
| else if (KDN.includes(e.key)) { keyDown = false; e.preventDefault(); } | |
| }); | |
| window.addEventListener("blur", () => { keyUp = false; keyDown = false; }); | |
| const cv = document.getElementById("cv"); | |
| function pointerFromEvent(e) { | |
| const r = cv.getBoundingClientRect(); | |
| const cy = (e.touches ? e.touches[0].clientY : e.clientY) - r.top; | |
| pointerY = Math.min(H, Math.max(0, cy * (H / r.height))); | |
| } | |
| cv.addEventListener("mousemove", pointerFromEvent); | |
| cv.addEventListener("touchmove", e => { pointerFromEvent(e); e.preventDefault(); }, { passive: false }); | |
| cv.addEventListener("touchstart", pointerFromEvent, { passive: true }); | |
| function humanAction() { | |
| if (ctrlMode === "mouse" && pointerY !== null) { | |
| const d = pointerY - egoY; | |
| if (Math.abs(d) < 6) return 0; | |
| return d > 0 ? 2 : 1; | |
| } | |
| if (keyUp && !keyDown) return 1; | |
| if (keyDown && !keyUp) return 2; | |
| return 0; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Render | |
| // --------------------------------------------------------------------------- | |
| const ctx = cv.getContext("2d"); | |
| const ACTNAMES = ["STAY", "UP", "DOWN"]; | |
| function draw() { | |
| ctx.fillStyle = "#0f1626"; | |
| ctx.fillRect(0, 0, W, H); | |
| ctx.strokeStyle = "rgba(255,255,255,.10)"; | |
| ctx.lineWidth = 3; ctx.setLineDash([12, 14]); | |
| ctx.beginPath(); ctx.moveTo(W / 2, 0); ctx.lineTo(W / 2, H); ctx.stroke(); | |
| ctx.setLineDash([]); | |
| ctx.lineWidth = 2; ctx.strokeStyle = "rgba(255,255,255,.07)"; | |
| ctx.strokeRect(1, 1, W - 2, H - 2); | |
| for (let i = 0; i < trail.length; i++) { | |
| const p = trail[i], a = (i + 1) / (trail.length + 1); | |
| ctx.globalAlpha = a * 0.35; | |
| ctx.fillStyle = "#e6edf3"; | |
| ctx.beginPath(); ctx.arc(p[0], p[1], R * (0.35 + 0.6 * a), 0, 6.2832); ctx.fill(); | |
| } | |
| ctx.globalAlpha = 1; | |
| ctx.fillStyle = "#22d3ee"; | |
| roundRect(2, egoY - egoH / 2, PW, egoH, 6); | |
| ctx.fillStyle = "#4f8cff"; | |
| roundRect(W - PW - 2, oppY - oppH / 2, PW, oppH, 6); | |
| ctx.fillStyle = "#ffffff"; | |
| ctx.beginPath(); ctx.arc(ballX, ballY, R, 0, 6.2832); ctx.fill(); | |
| ctx.font = "700 46px ui-sans-serif, system-ui, sans-serif"; | |
| ctx.textAlign = "center"; | |
| ctx.fillStyle = "rgba(34,211,238,.55)"; | |
| ctx.fillText(String(scoreH), W / 2 - 60, 58); | |
| ctx.fillStyle = "rgba(79,140,255,.55)"; | |
| ctx.fillText(String(scoreA), W / 2 + 60, 58); | |
| ctx.font = "600 13px ui-sans-serif, system-ui, sans-serif"; | |
| ctx.fillStyle = "rgba(230,237,243,.45)"; | |
| ctx.textAlign = "left"; | |
| ctx.fillText("YOU", 26, 34); | |
| ctx.textAlign = "right"; | |
| ctx.fillText("MR. PONG", W - 26, 34); | |
| ctx.textAlign = "left"; | |
| ctx.fillText("Rally " + rally + " hits Β· Ball " + Math.hypot(ballVx, ballVy).toFixed(1) + " px/f Β· Longest " + maxRally, 26, H - 20); | |
| if (banner) { | |
| ctx.textAlign = "center"; | |
| ctx.font = "800 40px ui-sans-serif, system-ui, sans-serif"; | |
| ctx.fillStyle = "rgba(11,15,25,.72)"; | |
| ctx.fillRect(0, H / 2 - 46, W, 92); | |
| ctx.fillStyle = "#f97316"; | |
| ctx.fillText(banner, W / 2, H / 2 + 14); | |
| } | |
| } | |
| function roundRect(x, y, w, h, r) { | |
| ctx.beginPath(); | |
| ctx.moveTo(x + r, y); | |
| ctx.arcTo(x + w, y, x + w, y + h, r); | |
| ctx.arcTo(x + w, y + h, x, y + h, r); | |
| ctx.arcTo(x, y + h, x, y, r); | |
| ctx.arcTo(x, y, x + w, y, r); | |
| ctx.closePath(); ctx.fill(); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Loop | |
| // --------------------------------------------------------------------------- | |
| const actEl = document.getElementById("act"); | |
| const vtxtEl = document.getElementById("vtxt"); | |
| const vfillEl = document.getElementById("vfill"); | |
| let acc = 0, last = 0; | |
| function tick(ts) { | |
| if (!last) last = ts; | |
| let dt = ts - last; last = ts; | |
| if (dt > 200) dt = 200; | |
| if (running) { | |
| if (phase === "count") { | |
| phaseT -= dt; | |
| const n = Math.ceil(phaseT / 600); | |
| banner = n > 0 ? "SERVING IN " + n : "GO!"; | |
| if (phaseT <= 0) { banner = ""; phase = "rally"; acc = 0; } | |
| } else if (phase === "rally") { | |
| acc += dt; | |
| let guard = 0; | |
| while (acc >= P.dt && guard < 12) { | |
| acc -= P.dt; guard++; | |
| if (sub % SKIP === 0) { | |
| const out = policy(oppObservation()); | |
| aiAct = out[0]; aiVal = out[1]; | |
| } | |
| sub++; | |
| trail.push([ballX, ballY]); | |
| if (trail.length > 9) trail.shift(); | |
| const winner = substep(humanAction(), aiAct); | |
| if (rally > maxRally) maxRally = rally; | |
| if (winner) { | |
| if (winner === "ego") { scoreH++; serveDir = 1; banner = "POINT β YOU!"; } | |
| else { scoreA++; serveDir = -1; banner = "POINT β MR. PONG"; } | |
| if (scoreH >= 5 || scoreA >= 5) { | |
| banner = scoreH >= 5 ? "π YOU WIN " + scoreH + "β" + scoreA : "π€ MR. PONG WINS " + scoreA + "β" + scoreH; | |
| phase = "over"; running = false; | |
| document.getElementById("overlay").style.display = "flex"; | |
| document.getElementById("overlay").innerHTML = | |
| "<h2>" + banner + "</h2><p>Longest rally: " + maxRally + " hits</p><p>Click to play again</p>"; | |
| } else { | |
| phase = "pause"; phaseT = 1100; | |
| } | |
| break; | |
| } | |
| } | |
| } else if (phase === "pause") { | |
| phaseT -= dt; | |
| if (phaseT <= 0) { resetPoint(serveDir); phase = "count"; phaseT = 1900; } | |
| } | |
| } | |
| actEl.textContent = ACTNAMES[aiAct]; | |
| vtxtEl.textContent = aiVal.toFixed(2); | |
| const v = Math.max(-1, Math.min(1, aiVal / 3)); | |
| vfillEl.style.left = (v >= 0 ? 50 : 50 + v * 50) + "%"; | |
| vfillEl.style.width = Math.abs(v) * 50 + "%"; | |
| vfillEl.style.background = v >= 0 ? "#4f8cff" : "#f97316"; | |
| draw(); | |
| requestAnimationFrame(tick); | |
| } | |
| function startMatch() { | |
| scoreH = 0; scoreA = 0; maxRally = 0; serveDir = 1; | |
| resetPoint(serveDir); | |
| banner = ""; phase = "count"; phaseT = 1900; running = true; last = 0; acc = 0; | |
| document.getElementById("overlay").style.display = "none"; | |
| window.focus(); | |
| } | |
| document.getElementById("overlay").addEventListener("click", startMatch); | |
| document.getElementById("restart").addEventListener("click", startMatch); | |
| document.querySelectorAll(".diff").forEach(b => b.addEventListener("click", () => { | |
| document.querySelectorAll(".diff").forEach(x => x.classList.remove("active")); | |
| b.classList.add("active"); | |
| P = PRESETS[b.dataset.d]; egoH = P.egoH; | |
| startMatch(); | |
| })); | |
| document.querySelectorAll(".ctrl").forEach(b => b.addEventListener("click", () => { | |
| document.querySelectorAll(".ctrl").forEach(x => x.classList.remove("active")); | |
| b.classList.add("active"); | |
| ctrlMode = b.dataset.c; | |
| })); | |
| egoH = P.egoH; | |
| resetPoint(1); | |
| requestAnimationFrame(tick); | |
| </script> | |
| </body> | |
| </html> | |