mr-pong-rl-demo / game_template.html
multimodalart's picture
multimodalart HF Staff
Mr. Pong interactive demo: browser-side play + server-side match video & benchmark
13eb3cb verified
Raw
History Blame Contribute Delete
16.6 kB
<!DOCTYPE html>
<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>&uarr;</kbd> to move up, <kbd>S</kbd>/<kbd>&darr;</kbd> to move down &mdash; 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.&nbsp;Pong action: <b id="act" style="color:#e6edf3">STAY</b></span>
<span>V(s):&nbsp;<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>