multimodalart HF Staff commited on
Commit
13eb3cb
·
verified ·
1 Parent(s): 9165118

Mr. Pong interactive demo: browser-side play + server-side match video & benchmark

Browse files
Files changed (5) hide show
  1. README.md +36 -7
  2. app.py +394 -0
  3. game_template.html +418 -0
  4. pong_engine.py +403 -0
  5. requirements.txt +6 -0
README.md CHANGED
@@ -1,13 +1,42 @@
1
  ---
2
- title: Mr Pong Rl Demo
3
- emoji: 🌖
4
- colorFrom: purple
5
- colorTo: blue
6
  sdk: gradio
7
  sdk_version: 6.24.0
8
- python_version: '3.13'
9
  app_file: app.py
10
- pinned: false
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Mr. Pong
3
+ emoji: 🏓
4
+ colorFrom: blue
5
+ colorTo: gray
6
  sdk: gradio
7
  sdk_version: 6.24.0
 
8
  app_file: app.py
9
+ short_description: Play table tennis against a PPO agent
10
+ python_version: "3.12"
11
+ startup_duration_timeout: 30m
12
+ models:
13
+ - fromziro/MrPong
14
+ tags:
15
+ - reinforcement-learning
16
+ - ppo
17
+ - game
18
  ---
19
 
20
+ # 🏓 Mr. Pong
21
+
22
+ An interactive demo for [**fromziro/MrPong**](https://huggingface.co/fromziro/MrPong) —
23
+ a 28,484-parameter actor–critic MLP trained with PPO and self-play for 10M steps to
24
+ play 2D table tennis.
25
+
26
+ Three tabs:
27
+
28
+ - **Play Mr. Pong** — the policy is exported from the checkpoint and evaluated in your
29
+ browser (plain JavaScript, ~26K MACs per decision), so the agent's paddle reacts at
30
+ the same 40 sub-steps/second the physics runs at, with no network latency. The
31
+ critic's value estimate `V(s)` is shown live.
32
+ - **Watch a match** — the real PyTorch checkpoint plays a full match against one of the
33
+ scripted baselines from the model card, rendered to video server-side.
34
+ - **Benchmark** — replays the model card's evaluation protocol over N matches.
35
+
36
+ The physics environment (`pong_engine.py`) and the baseline opponents are a 1:1 port of
37
+ the author's reference `inference.py`, so behaviour matches the reported numbers.
38
+
39
+ Runs entirely on CPU: a policy forward pass is a 12→160→160→3 MLP.
40
+
41
+ Model, environment and baselines by [FromZero](https://huggingface.co/fromziro),
42
+ Apache-2.0.
app.py ADDED
@@ -0,0 +1,394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 🏓 Mr. Pong — an interactive demo for fromziro/MrPong.
3
+
4
+ Two ways to meet the agent:
5
+ 1. Play against it live in the browser. The 28K-parameter policy is exported
6
+ from the checkpoint and evaluated client-side at 40 physics steps/second,
7
+ so the paddle reacts with zero network latency.
8
+ 2. Watch / benchmark the real PyTorch checkpoint playing full matches against
9
+ the baseline opponents from the model card, rendered to video on the server.
10
+ """
11
+
12
+ import base64
13
+ import html as html_lib
14
+ import math
15
+ import os
16
+ import random
17
+ import tempfile
18
+ import time
19
+ from pathlib import Path
20
+
21
+ import gradio as gr
22
+ import numpy as np
23
+ import torch
24
+ from PIL import Image, ImageDraw, ImageFont
25
+ from transformers import AutoConfig, AutoModel
26
+
27
+ from pong_engine import (
28
+ OPPONENTS,
29
+ PhysicsConfig,
30
+ StandalonePongEnv,
31
+ )
32
+
33
+ MODEL_ID = "fromziro/MrPong"
34
+ HERE = Path(__file__).parent
35
+
36
+ # ----------------------------------------------------------------------------
37
+ # Model (28,484 params — pure CPU, a forward pass costs microseconds)
38
+ # ----------------------------------------------------------------------------
39
+ config = AutoConfig.from_pretrained(MODEL_ID, trust_remote_code=True)
40
+ model = AutoModel.from_pretrained(MODEL_ID, trust_remote_code=True)
41
+ model.eval()
42
+ OBS_DIM = int(getattr(config, "obs_dim", 12))
43
+ N_PARAMS = sum(p.numel() for p in model.parameters())
44
+ print(f"[OK] {MODEL_ID} loaded — {N_PARAMS:,} params, obs_dim={OBS_DIM}, "
45
+ f"hidden_dims={config.hidden_dims}")
46
+
47
+
48
+ @torch.no_grad()
49
+ def policy_action(obs: np.ndarray, deterministic: bool = True) -> int:
50
+ """Greedy action from the Mr. Pong policy (0 stay, 1 up, 2 down)."""
51
+ x = obs[:OBS_DIM] if len(obs) >= OBS_DIM else np.pad(obs, (0, OBS_DIM - len(obs)))
52
+ return int(model.act(x, deterministic=deterministic))
53
+
54
+
55
+ # ----------------------------------------------------------------------------
56
+ # Export the checkpoint for the in-browser copy of the policy
57
+ # ----------------------------------------------------------------------------
58
+ def _export_weights_b64() -> str:
59
+ sd = model.state_dict()
60
+ order = ["trunk.0.weight", "trunk.0.bias", "trunk.2.weight", "trunk.2.bias",
61
+ "actor.weight", "actor.bias", "critic.weight", "critic.bias"]
62
+ flat = np.concatenate([sd[k].detach().cpu().numpy().astype("<f4").ravel()
63
+ for k in order])
64
+ assert flat.size == N_PARAMS, (flat.size, N_PARAMS)
65
+ return base64.b64encode(flat.tobytes()).decode("ascii")
66
+
67
+
68
+ GAME_HTML = (HERE / "game_template.html").read_text().replace(
69
+ "__WEIGHTS_B64__", _export_weights_b64()
70
+ )
71
+ GAME_IFRAME = (
72
+ '<iframe title="Play Mr. Pong" srcdoc="{}" '
73
+ 'style="width:100%;height:700px;border:0;border-radius:12px;overflow:hidden" '
74
+ 'scrolling="no"></iframe>'
75
+ ).format(html_lib.escape(GAME_HTML, quote=True))
76
+
77
+
78
+ # ----------------------------------------------------------------------------
79
+ # Server-side match rendering (real PyTorch checkpoint)
80
+ # ----------------------------------------------------------------------------
81
+ SC = 0.8 # render scale: 800x500 table -> 640x400 video
82
+ VW, VH = int(800 * SC), int(500 * SC)
83
+ FPS = 40 # one frame per physics sub-step == real time
84
+ MAX_FRAMES = 1100 # hard cap on video length (~27 s)
85
+ POINT_SUBSTEP_CAP = 420 # a single point is called a draw after this
86
+ MAX_POINTS = 12 # safety net against endless draw sequences
87
+
88
+
89
+ def _font(size: int):
90
+ for p in ("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
91
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"):
92
+ if os.path.exists(p):
93
+ return ImageFont.truetype(p, size)
94
+ try:
95
+ return ImageFont.load_default(size=size)
96
+ except TypeError:
97
+ return ImageFont.load_default()
98
+
99
+
100
+ F_BIG, F_MED, F_SMALL = _font(34), _font(15), _font(13)
101
+
102
+
103
+ def _render_frame(env, score_a, score_b, opp_name, trail, banner=""):
104
+ img = Image.new("RGB", (VW, VH), (15, 22, 38))
105
+ d = ImageDraw.Draw(img)
106
+
107
+ for y in range(0, VH, 26):
108
+ d.line([(VW // 2, y), (VW // 2, min(VH, y + 12))], fill=(38, 48, 70), width=2)
109
+ d.rectangle([0, 0, VW - 1, VH - 1], outline=(28, 36, 54), width=2)
110
+
111
+ for i, (tx, ty) in enumerate(trail):
112
+ a = (i + 1) / (len(trail) + 1)
113
+ c = int(60 + 150 * a)
114
+ r = 8 * SC * (0.35 + 0.6 * a)
115
+ d.ellipse([tx * SC - r, ty * SC - r, tx * SC + r, ty * SC + r], fill=(c, c, c))
116
+
117
+ pw = 14 * SC
118
+ eh = env.ego_paddle_h * SC / 2
119
+ oh = env.opp_paddle_h * SC / 2
120
+ d.rounded_rectangle([2, env.ego_y * SC - eh, 2 + pw, env.ego_y * SC + eh],
121
+ radius=5, fill=(79, 140, 255)) # Mr. Pong (blue)
122
+ d.rounded_rectangle([VW - pw - 2, env.opp_y * SC - oh, VW - 2, env.opp_y * SC + oh],
123
+ radius=5, fill=(249, 115, 22)) # baseline (orange)
124
+
125
+ r = 8 * SC
126
+ d.ellipse([env.ball_x * SC - r, env.ball_y * SC - r,
127
+ env.ball_x * SC + r, env.ball_y * SC + r], fill=(255, 255, 255))
128
+
129
+ d.text((VW // 2 - 46, 16), str(score_a), font=F_BIG, fill=(79, 140, 255), anchor="ma")
130
+ d.text((VW // 2 + 46, 16), str(score_b), font=F_BIG, fill=(249, 115, 22), anchor="ma")
131
+ d.text((20, 20), "MR. PONG", font=F_MED, fill=(120, 150, 200))
132
+ d.text((VW - 20, 20), opp_name.upper(), font=F_MED, fill=(190, 130, 80), anchor="ra")
133
+
134
+ speed = math.hypot(env.ball_vx, env.ball_vy)
135
+ d.text((20, VH - 26),
136
+ f"rally {env.rally_count} hits ball {speed:4.1f} px/f", font=F_SMALL,
137
+ fill=(120, 135, 160))
138
+
139
+ if banner:
140
+ d.rectangle([0, VH // 2 - 34, VW, VH // 2 + 34], fill=(11, 15, 25))
141
+ d.text((VW // 2, VH // 2), banner, font=F_BIG, fill=(249, 115, 22), anchor="mm")
142
+ return np.asarray(img)
143
+
144
+
145
+ def _play_point(env, opponent, frames, opp_name, score_a, score_b, serve_dir, record):
146
+ """Run one point sub-step by sub-step; returns the winner string."""
147
+ env.reset(serve_direction=serve_dir)
148
+ trail = []
149
+ ai_act = 0
150
+ opp_act = 0
151
+ for i in range(POINT_SUBSTEP_CAP):
152
+ # Both policies are re-queried every `frame_skip` sub-steps, exactly as
153
+ # StandalonePongEnv.step() does in the author's simulate() loop.
154
+ if i % env.phys.frame_skip == 0:
155
+ ai_act = policy_action(env.get_ego_observation())
156
+ opp_act = opponent.act(env)
157
+ trail.append((env.ball_x, env.ball_y))
158
+ if len(trail) > 8:
159
+ trail.pop(0)
160
+ done, info = env.physics_substep(ego_action=ai_act, opp_action=opp_act)
161
+ if record and len(frames) < MAX_FRAMES:
162
+ frames.append(_render_frame(env, score_a, score_b, opp_name, trail))
163
+ if done:
164
+ return info.get("winner")
165
+ return "draw"
166
+
167
+
168
+ def watch_match(opponent_name: str, points_to_win: int = 3,
169
+ randomize_seed: bool = True, seed: int = 0):
170
+ """Simulate and render a Mr. Pong match against a baseline opponent.
171
+
172
+ Args:
173
+ opponent_name: which scripted baseline to face (from the model card).
174
+ points_to_win: number of points needed to take the match.
175
+ randomize_seed: draw a fresh random seed for this match.
176
+ seed: RNG seed used when randomize_seed is off.
177
+
178
+ Returns:
179
+ An MP4 of the match, a markdown scoreline, and the seed actually used.
180
+ """
181
+ import imageio.v2 as imageio
182
+
183
+ if randomize_seed:
184
+ seed = random.randint(0, 2**31 - 1)
185
+ seed = int(seed)
186
+ rng = random.Random(seed)
187
+ random.seed(seed)
188
+ np.random.seed(seed % (2**32))
189
+
190
+ points_to_win = int(points_to_win)
191
+ opp_cls = OPPONENTS[opponent_name]
192
+ opponent = opp_cls(rng=rng)
193
+
194
+ env = StandalonePongEnv(PhysicsConfig(), seed=seed)
195
+ frames = []
196
+ score_a = score_b = 0
197
+ serve_dir = 1
198
+ rallies = []
199
+ t0 = time.perf_counter()
200
+
201
+ while (score_a < points_to_win and score_b < points_to_win
202
+ and len(rallies) < MAX_POINTS):
203
+ winner = _play_point(env, opponent, frames, opponent_name,
204
+ score_a, score_b, serve_dir,
205
+ record=len(frames) < MAX_FRAMES)
206
+ rallies.append(env.rally_count)
207
+ if winner == "ego":
208
+ score_a += 1
209
+ serve_dir = 1
210
+ elif winner == "opponent":
211
+ score_b += 1
212
+ serve_dir = -1
213
+ else:
214
+ serve_dir = -serve_dir
215
+ if len(frames) >= MAX_FRAMES:
216
+ break
217
+
218
+ banner = ("MR. PONG WINS %d-%d" % (score_a, score_b) if score_a > score_b
219
+ else ("%s WINS %d-%d" % (opponent_name.upper(), score_b, score_a)
220
+ if score_b > score_a else "TIME LIMIT"))
221
+ for _ in range(FPS * 2):
222
+ if len(frames) < MAX_FRAMES + FPS * 2:
223
+ frames.append(_render_frame(env, score_a, score_b, opponent_name,
224
+ [], banner=banner))
225
+
226
+ sim_s = time.perf_counter() - t0
227
+ out = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name
228
+ with imageio.get_writer(out, fps=FPS, codec="libx264", quality=7,
229
+ macro_block_size=1, pixelformat="yuv420p") as w:
230
+ for f in frames:
231
+ w.append_data(f)
232
+
233
+ stats = (
234
+ f"### Mr. Pong **{score_a} – {score_b}** {opponent_name}\n"
235
+ f"| | |\n|---|---|\n"
236
+ f"| Points played | {len(rallies)} |\n"
237
+ f"| Longest rally | {max(rallies)} hits |\n"
238
+ f"| Average rally | {np.mean(rallies):.1f} hits |\n"
239
+ f"| Video length | {len(frames) / FPS:.1f} s ({len(frames)} frames) |\n"
240
+ f"| Simulation time | {sim_s:.2f} s |\n"
241
+ f"| Seed | `{seed}` |\n"
242
+ )
243
+ return out, stats, seed
244
+
245
+
246
+ def benchmark(opponent_name: str, num_matches: int = 25, seed: int = 0):
247
+ """Replay the model card's evaluation: N single-point matches, no rendering.
248
+
249
+ Args:
250
+ opponent_name: which scripted baseline to face.
251
+ num_matches: how many matches to simulate.
252
+ seed: RNG seed for reproducibility.
253
+
254
+ Returns:
255
+ A markdown summary of the win / draw / loss record.
256
+ """
257
+ seed = int(seed)
258
+ rng = random.Random(seed)
259
+ random.seed(seed)
260
+ num_matches = int(num_matches)
261
+
262
+ opponent = OPPONENTS[opponent_name](rng=rng)
263
+ env = StandalonePongEnv(PhysicsConfig(), seed=seed)
264
+
265
+ wins = draws = losses = 0
266
+ rallies = []
267
+ t0 = time.perf_counter()
268
+
269
+ for i in range(1, num_matches + 1):
270
+ obs = env.reset(serve_direction=1 if i % 2 == 0 else -1)
271
+ done = False
272
+ info = {}
273
+ while not done:
274
+ ego_act = policy_action(obs)
275
+ opp_act = opponent.act(env)
276
+ obs, done, info = env.step(ego_action=ego_act, opp_action=opp_act)
277
+ rallies.append(env.rally_count)
278
+ w = info.get("winner")
279
+ if w == "ego":
280
+ wins += 1
281
+ elif w == "opponent":
282
+ losses += 1
283
+ else:
284
+ draws += 1
285
+
286
+ el = time.perf_counter() - t0
287
+ n = max(1, num_matches)
288
+ return (
289
+ f"### Mr. Pong vs **{opponent_name}** — {num_matches} matches\n\n"
290
+ f"| Metric | Value |\n|---|---|\n"
291
+ f"| Win rate | **{wins / n * 100:.1f}%** |\n"
292
+ f"| Draw rate | {draws / n * 100:.1f}% |\n"
293
+ f"| Loss rate | {losses / n * 100:.1f}% |\n"
294
+ f"| Record (W/D/L) | {wins}W / {draws}D / {losses}L |\n"
295
+ f"| Average rally | {np.mean(rallies):.1f} hits (max {max(rallies)}) |\n"
296
+ f"| Wall time | {el:.2f} s ({num_matches / max(el, 1e-6):.1f} matches/s) |\n\n"
297
+ f"*A draw means the rally hit the {env.phys.max_rally_steps}-step limit "
298
+ f"without either side scoring.*"
299
+ )
300
+
301
+
302
+ # ----------------------------------------------------------------------------
303
+ # UI
304
+ # ----------------------------------------------------------------------------
305
+ CSS = """
306
+ #col-container { max-width: 1000px; margin: 0 auto; }
307
+ .dark .gradio-container { color: var(--body-text-color); }
308
+ """
309
+
310
+ INTRO = f"""# 🏓 Mr. Pong
311
+
312
+ [**fromziro/MrPong**](https://huggingface.co/{MODEL_ID}) is a {N_PARAMS:,}-parameter
313
+ actor–critic MLP trained with PPO and self-play for 10M steps to play 2D table tennis.
314
+ It wins ~87% of its matches against the strongest scripted baseline it was trained on.
315
+
316
+ **Play it yourself below** — the policy runs in your browser, so the paddle answers
317
+ in real time. Or watch the real PyTorch checkpoint take on the model-card opponents.
318
+ """
319
+
320
+ with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="Mr. Pong") as demo:
321
+ with gr.Column(elem_id="col-container"):
322
+ gr.Markdown(INTRO)
323
+
324
+ with gr.Tabs():
325
+ with gr.Tab("🎮 Play Mr. Pong"):
326
+ gr.HTML(GAME_IFRAME)
327
+ gr.Markdown(
328
+ "Click the court to start. Hold **W** / **↑** and **S** / **↓** "
329
+ "(or switch to mouse / touch control). First to 5 points. \n"
330
+ "*V(s) is the critic's value estimate from Mr. Pong's point of "
331
+ "view — it climbs when the agent thinks it is about to win the point.*"
332
+ )
333
+
334
+ with gr.Tab("🎬 Watch a match"):
335
+ with gr.Row():
336
+ opponent = gr.Dropdown(
337
+ list(OPPONENTS.keys()), value="Realistic Hard",
338
+ label="Opponent", scale=3)
339
+ watch_btn = gr.Button("Play match", variant="primary", scale=1)
340
+ video_out = gr.Video(label="Match replay", autoplay=True,
341
+ height=430, format="mp4")
342
+ stats_out = gr.Markdown()
343
+ with gr.Accordion("Advanced settings", open=False):
344
+ points = gr.Slider(1, 5, value=3, step=1, label="Points to win")
345
+ rand_seed = gr.Checkbox(value=True, label="Randomize seed")
346
+ seed_num = gr.Number(value=0, precision=0, label="Seed")
347
+ gr.Examples(
348
+ examples=[["Realistic Hard"], ["Impossible Hard"],
349
+ ["Medium Logic"], ["Random Agent"]],
350
+ inputs=[opponent],
351
+ outputs=[video_out, stats_out, seed_num],
352
+ fn=watch_match,
353
+ cache_examples=True,
354
+ cache_mode="lazy",
355
+ label="Opponents from the model card",
356
+ )
357
+ watch_btn.click(
358
+ watch_match,
359
+ inputs=[opponent, points, rand_seed, seed_num],
360
+ outputs=[video_out, stats_out, seed_num],
361
+ api_name="watch_match",
362
+ )
363
+
364
+ with gr.Tab("📊 Benchmark"):
365
+ with gr.Row():
366
+ bench_opp = gr.Dropdown(
367
+ list(OPPONENTS.keys()), value="Realistic Hard",
368
+ label="Opponent", scale=3)
369
+ bench_btn = gr.Button("Run benchmark", variant="primary", scale=1)
370
+ bench_out = gr.Markdown()
371
+ with gr.Accordion("Advanced settings", open=False):
372
+ n_matches = gr.Slider(5, 200, value=25, step=5, label="Matches")
373
+ bench_seed = gr.Number(value=0, precision=0, label="Seed")
374
+ gr.Markdown(
375
+ "Reference numbers reported by the author over 1000 matches: \n"
376
+ "Easy 99.7% · Medium 99.0% · Realistic Hard 86.6% · "
377
+ "Random 100% · Impossible Hard 0% W / 98.4% D."
378
+ )
379
+ bench_btn.click(
380
+ benchmark,
381
+ inputs=[bench_opp, n_matches, bench_seed],
382
+ outputs=[bench_out],
383
+ api_name="benchmark",
384
+ )
385
+
386
+ gr.Markdown(
387
+ "Model, physics environment and baseline opponents by "
388
+ "[FromZero](https://huggingface.co/fromziro) (Apache-2.0). "
389
+ "The in-browser policy is the exact checkpoint re-evaluated in JavaScript; "
390
+ "the *Watch* and *Benchmark* tabs run the PyTorch model itself."
391
+ )
392
+
393
+ if __name__ == "__main__":
394
+ demo.launch(mcp_server=True)
game_template.html ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <style>
7
+ * { box-sizing: border-box; }
8
+ html, body {
9
+ margin: 0; padding: 0; background: #0b0f19; color: #e6edf3;
10
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
11
+ overflow: hidden;
12
+ }
13
+ #wrap { padding: 8px 10px 10px 10px; max-width: 900px; margin: 0 auto; }
14
+ #bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 8px; }
15
+ .btn {
16
+ background: #1b2438; color: #e6edf3;
17
+ border: 1px solid #2b3550; border-radius: 8px; padding: 6px 12px; cursor: pointer;
18
+ font-size: 13px; font-weight: 600; transition: all .12s;
19
+ }
20
+ .btn:hover { background: #26314a; }
21
+ .btn.active { background: #f97316; border-color: #f97316; color: #0b0f19; }
22
+ .btn.primary { background: #4f8cff; border-color: #4f8cff; color: #08111f; }
23
+ .btn.primary:hover { background: #6ba0ff; }
24
+ .lab { font-size: 12px; color: #8b98ac; text-transform: uppercase; letter-spacing: .06em; margin-right: 2px; }
25
+ #stage { position: relative; width: 100%; }
26
+ canvas { width: 100%; height: auto; display: block; border-radius: 10px; background: #0f1626; }
27
+ #overlay {
28
+ position: absolute; inset: 0; display: flex; flex-direction: column;
29
+ align-items: center; justify-content: center; text-align: center;
30
+ background: rgba(8,12,22,.82); border-radius: 10px; cursor: pointer;
31
+ }
32
+ #overlay h2 { margin: 0 0 6px 0; font-size: 22px; }
33
+ #overlay p { margin: 2px 0; font-size: 13px; color: #b6c2d4; }
34
+ #hud { display: flex; gap: 14px; align-items: center; margin-top: 8px; font-size: 12px; color: #8b98ac; flex-wrap: wrap; }
35
+ #vbar { flex: 1; min-width: 120px; height: 8px; background: #1b2438; border-radius: 4px; overflow: hidden; position: relative; }
36
+ #vfill { position: absolute; top: 0; bottom: 0; left: 50%; width: 0%; background: #4f8cff; transition: width .1s linear, left .1s linear; }
37
+ kbd { background:#1b2438; border:1px solid #2b3550; border-bottom-width:2px; border-radius:5px; padding:1px 6px; font-size:12px; }
38
+ </style>
39
+ </head>
40
+ <body>
41
+ <div id="wrap">
42
+ <div id="bar">
43
+ <span class="lab">Difficulty</span>
44
+ <button class="btn diff" data-d="easy">Easy</button>
45
+ <button class="btn diff active" data-d="normal">Normal</button>
46
+ <button class="btn diff" data-d="hard">Hard</button>
47
+ <span style="flex:1"></span>
48
+ <span class="lab">Control</span>
49
+ <button class="btn ctrl active" data-c="key">Keyboard</button>
50
+ <button class="btn ctrl" data-c="mouse">Mouse / Touch</button>
51
+ <button class="btn primary" id="restart">Restart</button>
52
+ </div>
53
+
54
+ <div id="stage">
55
+ <canvas id="cv" width="800" height="500"></canvas>
56
+ <div id="overlay">
57
+ <h2>🏓 Click here to play Mr. Pong</h2>
58
+ <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>
59
+ <p>First to 5 points. You are the <b style="color:#22d3ee">cyan</b> paddle on the left.</p>
60
+ </div>
61
+ </div>
62
+
63
+ <div id="hud">
64
+ <span>Mr.&nbsp;Pong action: <b id="act" style="color:#e6edf3">STAY</b></span>
65
+ <span>V(s):&nbsp;<b id="vtxt" style="color:#e6edf3">0.00</b></span>
66
+ <div id="vbar"><div id="vfill"></div></div>
67
+ </div>
68
+ </div>
69
+
70
+ <script>
71
+ // ---------------------------------------------------------------------------
72
+ // Mr. Pong policy network (fromziro/MrPong) — 28,484 params, exported from the
73
+ // checkpoint shipped on the Hub and evaluated here in the browser.
74
+ // trunk: Linear(12,160) -> Tanh -> Linear(160,160) -> Tanh
75
+ // actor: Linear(160,3) (argmax => stay / up / down) critic: Linear(160,1)
76
+ // ---------------------------------------------------------------------------
77
+ const B64 = "__WEIGHTS_B64__";
78
+ const WB = (function () {
79
+ const bin = atob(B64);
80
+ const buf = new ArrayBuffer(bin.length);
81
+ const u8 = new Uint8Array(buf);
82
+ for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
83
+ return new Float32Array(buf);
84
+ })();
85
+ const OBS = 12, HID = 160, ACT = 3;
86
+ const O_L0W = 0, O_L0B = O_L0W + HID * OBS;
87
+ const O_L2W = O_L0B + HID, O_L2B = O_L2W + HID * HID;
88
+ const O_AW = O_L2B + HID, O_AB = O_AW + ACT * HID;
89
+ const O_CW = O_AB + ACT, O_CB = O_CW + HID;
90
+
91
+ const h1 = new Float32Array(HID), h2 = new Float32Array(HID);
92
+ function policy(x) {
93
+ for (let i = 0; i < HID; i++) {
94
+ let s = WB[O_L0B + i], b = O_L0W + i * OBS;
95
+ for (let j = 0; j < OBS; j++) s += WB[b + j] * x[j];
96
+ h1[i] = Math.tanh(s);
97
+ }
98
+ for (let i = 0; i < HID; i++) {
99
+ let s = WB[O_L2B + i], b = O_L2W + i * HID;
100
+ for (let j = 0; j < HID; j++) s += WB[b + j] * h1[j];
101
+ h2[i] = Math.tanh(s);
102
+ }
103
+ let best = 0, bv = -1e30;
104
+ for (let i = 0; i < ACT; i++) {
105
+ let s = WB[O_AB + i], b = O_AW + i * HID;
106
+ for (let j = 0; j < HID; j++) s += WB[b + j] * h2[j];
107
+ if (s > bv) { bv = s; best = i; }
108
+ }
109
+ let v = WB[O_CB];
110
+ for (let j = 0; j < HID; j++) v += WB[O_CW + j] * h2[j];
111
+ return [best, v];
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Physics — 1:1 port of StandalonePongEnv from the author's inference.py
116
+ // ---------------------------------------------------------------------------
117
+ const W = 800, H = 500, PW = 14, R = 8, SMOOTH = 0.70, VMAX = 16, ACCEL = 1.035, SKIP = 3;
118
+ const PRESETS = {
119
+ easy: { pspeed: 9.0, serve: 3.5, egoH: 110, dt: 28 },
120
+ normal: { pspeed: 8.5, serve: 4.2, egoH: 95, dt: 25 },
121
+ hard: { pspeed: 8.0, serve: 6.0, egoH: 80, dt: 22 }
122
+ };
123
+
124
+ let P = PRESETS.normal;
125
+ let egoY, oppY, egoVy, oppVy, ballX, ballY, ballVx, ballVy, rally, egoH = 95, oppH = 80;
126
+ let scoreH = 0, scoreA = 0, maxRally = 0, aiAct = 0, aiVal = 0;
127
+ let running = false, phase = "idle", phaseT = 0, banner = "", serveDir = 1, sub = 0;
128
+ let trail = [];
129
+
130
+ function resetPoint(dir) {
131
+ egoY = H / 2; oppY = H / 2; egoVy = 0; oppVy = 0;
132
+ ballX = W / 2; ballY = H / 2;
133
+ const ang = (Math.random() * 2 - 1) * (Math.PI / 7);
134
+ ballVx = dir * P.serve * Math.cos(ang);
135
+ ballVy = P.serve * Math.sin(ang);
136
+ rally = 0; sub = 0; trail = [];
137
+ }
138
+
139
+ function interceptY(targetX, bx, by, bvx, bvy) {
140
+ if ((targetX > bx && bvx <= 0) || (targetX < bx && bvx >= 0)) return H / 2;
141
+ for (let k = 0; k < 10; k++) {
142
+ const dtx = bvx !== 0 ? (targetX - bx) / bvx : Infinity;
143
+ if (dtx <= 0) break;
144
+ let dty;
145
+ if (bvy > 0) dty = (H - R - by) / bvy;
146
+ else if (bvy < 0) dty = (R - by) / bvy;
147
+ else dty = Infinity;
148
+ if (dtx <= dty) { by += bvy * dtx; break; }
149
+ bx += bvx * dty; by += bvy * dty; bvy = -bvy;
150
+ }
151
+ return Math.min(H - R, Math.max(R, by));
152
+ }
153
+
154
+ const obsBuf = new Float32Array(OBS);
155
+ function oppObservation() {
156
+ const oppX = W - PW;
157
+ const pred = interceptY(oppX, ballX, ballY, ballVx, ballVy);
158
+ obsBuf[0] = (ballY - oppY) / H;
159
+ obsBuf[1] = (oppX - ballX) / W;
160
+ obsBuf[2] = -ballVx / VMAX;
161
+ obsBuf[3] = ballVy / VMAX;
162
+ obsBuf[4] = oppY / H;
163
+ obsBuf[5] = oppVy / P.pspeed;
164
+ obsBuf[6] = (egoY - oppY) / H;
165
+ obsBuf[7] = egoVy / P.pspeed;
166
+ obsBuf[8] = ballY / H;
167
+ obsBuf[9] = (W - ballX) / W;
168
+ obsBuf[10] = (pred - oppY) / H;
169
+ obsBuf[11] = pred / H;
170
+ return obsBuf;
171
+ }
172
+
173
+ function actionVel(a) { return a === 1 ? -P.pspeed : (a === 2 ? P.pspeed : 0); }
174
+
175
+ function substep(egoAct, oppAct) {
176
+ const pEgoY = egoY, pOppY = oppY;
177
+ egoVy = SMOOTH * egoVy + (1 - SMOOTH) * actionVel(egoAct);
178
+ oppVy = SMOOTH * oppVy + (1 - SMOOTH) * actionVel(oppAct);
179
+ const eh = egoH / 2, oh = oppH / 2;
180
+ egoY = Math.min(H - eh, Math.max(eh, egoY + egoVy));
181
+ oppY = Math.min(H - oh, Math.max(oh, oppY + oppVy));
182
+
183
+ const pbx = ballX, pby = ballY;
184
+ const egoPlane = PW + R, oppPlane = (W - PW) - R;
185
+ const nbx = pbx + ballVx, nby = pby + ballVy;
186
+ let hit = false;
187
+
188
+ if (ballVx < 0 && pbx >= egoPlane && nbx <= egoPlane) {
189
+ const t = Math.min(1, Math.max(0, (pbx - egoPlane) / Math.max(1e-6, -ballVx)));
190
+ const yb = pby + t * ballVy, yp = pEgoY + t * (egoY - pEgoY);
191
+ if (Math.abs(yb - yp) <= eh + R * 0.6) {
192
+ hit = true; rally++;
193
+ const off = Math.min(1, Math.max(-1, (yb - yp) / eh));
194
+ const ang = off * (Math.PI / 3);
195
+ const sp = Math.min(Math.hypot(ballVx, ballVy) * ACCEL, VMAX);
196
+ const nvx = sp * Math.cos(ang), nvy = sp * Math.sin(ang) + 0.25 * egoVy;
197
+ const rem = 1 - t;
198
+ ballX = egoPlane + rem * nvx; ballY = yb + rem * nvy; ballVx = nvx; ballVy = nvy;
199
+ }
200
+ } else if (ballVx > 0 && pbx <= oppPlane && nbx >= oppPlane) {
201
+ const t = Math.min(1, Math.max(0, (oppPlane - pbx) / Math.max(1e-6, ballVx)));
202
+ const yb = pby + t * ballVy, yp = pOppY + t * (oppY - pOppY);
203
+ if (Math.abs(yb - yp) <= oh + R * 0.6) {
204
+ hit = true; rally++;
205
+ const off = Math.min(1, Math.max(-1, (yb - yp) / oh));
206
+ const ang = off * (Math.PI / 3);
207
+ const sp = Math.min(Math.hypot(ballVx, ballVy) * ACCEL, VMAX);
208
+ const nvx = -sp * Math.cos(ang), nvy = sp * Math.sin(ang) + 0.25 * oppVy;
209
+ const rem = 1 - t;
210
+ ballX = oppPlane + rem * nvx; ballY = yb + rem * nvy; ballVx = nvx; ballVy = nvy;
211
+ }
212
+ }
213
+ if (!hit) { ballX = nbx; ballY = nby; }
214
+
215
+ if (ballY - R <= 0) { ballY = R + Math.abs(R - ballY); ballVy = Math.abs(ballVy); }
216
+ else if (ballY + R >= H) { ballY = (H - R) - Math.abs(ballY + R - H); ballVy = -Math.abs(ballVy); }
217
+
218
+ if (ballX - R < 0) return "opponent";
219
+ if (ballX + R > W) return "ego";
220
+ return null;
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Input
225
+ // ---------------------------------------------------------------------------
226
+ let keyUp = false, keyDown = false, ctrlMode = "key", pointerY = null;
227
+ const KUP = ["w", "W", "ArrowUp"], KDN = ["s", "S", "ArrowDown"];
228
+ document.addEventListener("keydown", e => {
229
+ if (KUP.includes(e.key)) { keyUp = true; e.preventDefault(); }
230
+ else if (KDN.includes(e.key)) { keyDown = true; e.preventDefault(); }
231
+ });
232
+ document.addEventListener("keyup", e => {
233
+ if (KUP.includes(e.key)) { keyUp = false; e.preventDefault(); }
234
+ else if (KDN.includes(e.key)) { keyDown = false; e.preventDefault(); }
235
+ });
236
+ window.addEventListener("blur", () => { keyUp = false; keyDown = false; });
237
+
238
+ const cv = document.getElementById("cv");
239
+ function pointerFromEvent(e) {
240
+ const r = cv.getBoundingClientRect();
241
+ const cy = (e.touches ? e.touches[0].clientY : e.clientY) - r.top;
242
+ pointerY = Math.min(H, Math.max(0, cy * (H / r.height)));
243
+ }
244
+ cv.addEventListener("mousemove", pointerFromEvent);
245
+ cv.addEventListener("touchmove", e => { pointerFromEvent(e); e.preventDefault(); }, { passive: false });
246
+ cv.addEventListener("touchstart", pointerFromEvent, { passive: true });
247
+
248
+ function humanAction() {
249
+ if (ctrlMode === "mouse" && pointerY !== null) {
250
+ const d = pointerY - egoY;
251
+ if (Math.abs(d) < 6) return 0;
252
+ return d > 0 ? 2 : 1;
253
+ }
254
+ if (keyUp && !keyDown) return 1;
255
+ if (keyDown && !keyUp) return 2;
256
+ return 0;
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Render
261
+ // ---------------------------------------------------------------------------
262
+ const ctx = cv.getContext("2d");
263
+ const ACTNAMES = ["STAY", "UP", "DOWN"];
264
+ function draw() {
265
+ ctx.fillStyle = "#0f1626";
266
+ ctx.fillRect(0, 0, W, H);
267
+
268
+ ctx.strokeStyle = "rgba(255,255,255,.10)";
269
+ ctx.lineWidth = 3; ctx.setLineDash([12, 14]);
270
+ ctx.beginPath(); ctx.moveTo(W / 2, 0); ctx.lineTo(W / 2, H); ctx.stroke();
271
+ ctx.setLineDash([]);
272
+ ctx.lineWidth = 2; ctx.strokeStyle = "rgba(255,255,255,.07)";
273
+ ctx.strokeRect(1, 1, W - 2, H - 2);
274
+
275
+ for (let i = 0; i < trail.length; i++) {
276
+ const p = trail[i], a = (i + 1) / (trail.length + 1);
277
+ ctx.globalAlpha = a * 0.35;
278
+ ctx.fillStyle = "#e6edf3";
279
+ ctx.beginPath(); ctx.arc(p[0], p[1], R * (0.35 + 0.6 * a), 0, 6.2832); ctx.fill();
280
+ }
281
+ ctx.globalAlpha = 1;
282
+
283
+ ctx.fillStyle = "#22d3ee";
284
+ roundRect(2, egoY - egoH / 2, PW, egoH, 6);
285
+ ctx.fillStyle = "#4f8cff";
286
+ roundRect(W - PW - 2, oppY - oppH / 2, PW, oppH, 6);
287
+
288
+ ctx.fillStyle = "#ffffff";
289
+ ctx.beginPath(); ctx.arc(ballX, ballY, R, 0, 6.2832); ctx.fill();
290
+
291
+ ctx.font = "700 46px ui-sans-serif, system-ui, sans-serif";
292
+ ctx.textAlign = "center";
293
+ ctx.fillStyle = "rgba(34,211,238,.55)";
294
+ ctx.fillText(String(scoreH), W / 2 - 60, 58);
295
+ ctx.fillStyle = "rgba(79,140,255,.55)";
296
+ ctx.fillText(String(scoreA), W / 2 + 60, 58);
297
+
298
+ ctx.font = "600 13px ui-sans-serif, system-ui, sans-serif";
299
+ ctx.fillStyle = "rgba(230,237,243,.45)";
300
+ ctx.textAlign = "left";
301
+ ctx.fillText("YOU", 26, 34);
302
+ ctx.textAlign = "right";
303
+ ctx.fillText("MR. PONG", W - 26, 34);
304
+ ctx.textAlign = "left";
305
+ ctx.fillText("Rally " + rally + " hits · Ball " + Math.hypot(ballVx, ballVy).toFixed(1) + " px/f · Longest " + maxRally, 26, H - 20);
306
+
307
+ if (banner) {
308
+ ctx.textAlign = "center";
309
+ ctx.font = "800 40px ui-sans-serif, system-ui, sans-serif";
310
+ ctx.fillStyle = "rgba(11,15,25,.72)";
311
+ ctx.fillRect(0, H / 2 - 46, W, 92);
312
+ ctx.fillStyle = "#f97316";
313
+ ctx.fillText(banner, W / 2, H / 2 + 14);
314
+ }
315
+ }
316
+ function roundRect(x, y, w, h, r) {
317
+ ctx.beginPath();
318
+ ctx.moveTo(x + r, y);
319
+ ctx.arcTo(x + w, y, x + w, y + h, r);
320
+ ctx.arcTo(x + w, y + h, x, y + h, r);
321
+ ctx.arcTo(x, y + h, x, y, r);
322
+ ctx.arcTo(x, y, x + w, y, r);
323
+ ctx.closePath(); ctx.fill();
324
+ }
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // Loop
328
+ // ---------------------------------------------------------------------------
329
+ const actEl = document.getElementById("act");
330
+ const vtxtEl = document.getElementById("vtxt");
331
+ const vfillEl = document.getElementById("vfill");
332
+ let acc = 0, last = 0;
333
+
334
+ function tick(ts) {
335
+ if (!last) last = ts;
336
+ let dt = ts - last; last = ts;
337
+ if (dt > 200) dt = 200;
338
+
339
+ if (running) {
340
+ if (phase === "count") {
341
+ phaseT -= dt;
342
+ const n = Math.ceil(phaseT / 600);
343
+ banner = n > 0 ? "SERVING IN " + n : "GO!";
344
+ if (phaseT <= 0) { banner = ""; phase = "rally"; acc = 0; }
345
+ } else if (phase === "rally") {
346
+ acc += dt;
347
+ let guard = 0;
348
+ while (acc >= P.dt && guard < 12) {
349
+ acc -= P.dt; guard++;
350
+ if (sub % SKIP === 0) {
351
+ const out = policy(oppObservation());
352
+ aiAct = out[0]; aiVal = out[1];
353
+ }
354
+ sub++;
355
+ trail.push([ballX, ballY]);
356
+ if (trail.length > 9) trail.shift();
357
+ const winner = substep(humanAction(), aiAct);
358
+ if (rally > maxRally) maxRally = rally;
359
+ if (winner) {
360
+ if (winner === "ego") { scoreH++; serveDir = 1; banner = "POINT — YOU!"; }
361
+ else { scoreA++; serveDir = -1; banner = "POINT — MR. PONG"; }
362
+ if (scoreH >= 5 || scoreA >= 5) {
363
+ banner = scoreH >= 5 ? "🏆 YOU WIN " + scoreH + "–" + scoreA : "🤖 MR. PONG WINS " + scoreA + "–" + scoreH;
364
+ phase = "over"; running = false;
365
+ document.getElementById("overlay").style.display = "flex";
366
+ document.getElementById("overlay").innerHTML =
367
+ "<h2>" + banner + "</h2><p>Longest rally: " + maxRally + " hits</p><p>Click to play again</p>";
368
+ } else {
369
+ phase = "pause"; phaseT = 1100;
370
+ }
371
+ break;
372
+ }
373
+ }
374
+ } else if (phase === "pause") {
375
+ phaseT -= dt;
376
+ if (phaseT <= 0) { resetPoint(serveDir); phase = "count"; phaseT = 1900; }
377
+ }
378
+ }
379
+
380
+ actEl.textContent = ACTNAMES[aiAct];
381
+ vtxtEl.textContent = aiVal.toFixed(2);
382
+ const v = Math.max(-1, Math.min(1, aiVal / 3));
383
+ vfillEl.style.left = (v >= 0 ? 50 : 50 + v * 50) + "%";
384
+ vfillEl.style.width = Math.abs(v) * 50 + "%";
385
+ vfillEl.style.background = v >= 0 ? "#4f8cff" : "#f97316";
386
+
387
+ draw();
388
+ requestAnimationFrame(tick);
389
+ }
390
+
391
+ function startMatch() {
392
+ scoreH = 0; scoreA = 0; maxRally = 0; serveDir = 1;
393
+ resetPoint(serveDir);
394
+ banner = ""; phase = "count"; phaseT = 1900; running = true; last = 0; acc = 0;
395
+ document.getElementById("overlay").style.display = "none";
396
+ window.focus();
397
+ }
398
+
399
+ document.getElementById("overlay").addEventListener("click", startMatch);
400
+ document.getElementById("restart").addEventListener("click", startMatch);
401
+ document.querySelectorAll(".diff").forEach(b => b.addEventListener("click", () => {
402
+ document.querySelectorAll(".diff").forEach(x => x.classList.remove("active"));
403
+ b.classList.add("active");
404
+ P = PRESETS[b.dataset.d]; egoH = P.egoH;
405
+ startMatch();
406
+ }));
407
+ document.querySelectorAll(".ctrl").forEach(b => b.addEventListener("click", () => {
408
+ document.querySelectorAll(".ctrl").forEach(x => x.classList.remove("active"));
409
+ b.classList.add("active");
410
+ ctrlMode = b.dataset.c;
411
+ }));
412
+
413
+ egoH = P.egoH;
414
+ resetPoint(1);
415
+ requestAnimationFrame(tick);
416
+ </script>
417
+ </body>
418
+ </html>
pong_engine.py ADDED
@@ -0,0 +1,403 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Standalone table-tennis physics environment + baseline opponents for Mr. Pong.
3
+
4
+ This is a faithful port of the reference implementation shipped by the model
5
+ author in `inference.py` at https://huggingface.co/fromziro/MrPong (Apache-2.0).
6
+ Physics constants, collision handling, observation layout and the baseline
7
+ opponent policies are kept 1:1 with the original so that the behaviour shown in
8
+ this Space matches the numbers reported on the model card.
9
+ """
10
+
11
+ import math
12
+ import random
13
+ from dataclasses import dataclass
14
+ from typing import Any, Dict, Optional, Tuple
15
+
16
+ import numpy as np
17
+
18
+
19
+ @dataclass
20
+ class PhysicsConfig:
21
+ table_width: float = 800.0
22
+ table_height: float = 500.0
23
+ paddle_width: float = 14.0
24
+ paddle_height: float = 80.0
25
+ paddle_speed: float = 8.0
26
+ paddle_smoothing: float = 0.70
27
+ ball_radius: float = 8.0
28
+ ball_speed_initial: float = 8.0
29
+ ball_speed_max: float = 16.0
30
+ ball_acceleration: float = 1.035
31
+ frame_skip: int = 3
32
+ max_rally_steps: int = 1500
33
+
34
+
35
+ class StandalonePongEnv:
36
+ """Self-contained table tennis physics environment (port of the author's env)."""
37
+
38
+ def __init__(self, phys: Optional[PhysicsConfig] = None, seed: Optional[int] = None):
39
+ self.phys = phys or PhysicsConfig()
40
+ self.rng = random.Random(seed)
41
+ self.ego_paddle_h = self.phys.paddle_height
42
+ self.opp_paddle_h = self.phys.paddle_height
43
+ self.reset()
44
+
45
+ def reset(self, serve_direction: Optional[int] = None,
46
+ initial_speed: Optional[float] = None) -> np.ndarray:
47
+ self.ego_y = self.phys.table_height / 2.0
48
+ self.opp_y = self.phys.table_height / 2.0
49
+ self.ego_vy = 0.0
50
+ self.opp_vy = 0.0
51
+ self.prev_ego_action = 0
52
+
53
+ self.ball_x = self.phys.table_width / 2.0
54
+ self.ball_y = self.phys.table_height / 2.0
55
+
56
+ if serve_direction is None:
57
+ serve_direction = 1 if self.rng.random() < 0.5 else -1
58
+
59
+ serve_angle = self.rng.uniform(-math.pi / 7.0, math.pi / 7.0)
60
+ speed = initial_speed or self.phys.ball_speed_initial
61
+ self.ball_vx = serve_direction * speed * math.cos(serve_angle)
62
+ self.ball_vy = speed * math.sin(serve_angle)
63
+ self.rally_count = 0
64
+ self.step_count = 0
65
+
66
+ return self.get_ego_observation()
67
+
68
+ def _get_action_velocity(self, action: int) -> float:
69
+ if action == 1:
70
+ return -self.phys.paddle_speed
71
+ elif action == 2:
72
+ return self.phys.paddle_speed
73
+ return 0.0
74
+
75
+ def physics_substep(self, ego_action: int, opp_action: int) -> Tuple[bool, Dict[str, Any]]:
76
+ info = {"winner": None}
77
+ done = False
78
+
79
+ prev_ego_y = self.ego_y
80
+ prev_opp_y = self.opp_y
81
+
82
+ ego_target_v = self._get_action_velocity(ego_action)
83
+ opp_target_v = self._get_action_velocity(opp_action)
84
+
85
+ alpha = self.phys.paddle_smoothing
86
+ self.ego_vy = alpha * self.ego_vy + (1.0 - alpha) * ego_target_v
87
+ self.opp_vy = alpha * self.opp_vy + (1.0 - alpha) * opp_target_v
88
+
89
+ ego_half_h = self.ego_paddle_h / 2.0
90
+ opp_half_h = self.opp_paddle_h / 2.0
91
+
92
+ self.ego_y = float(np.clip(self.ego_y + self.ego_vy, ego_half_h,
93
+ self.phys.table_height - ego_half_h))
94
+ self.opp_y = float(np.clip(self.opp_y + self.opp_vy, opp_half_h,
95
+ self.phys.table_height - opp_half_h))
96
+
97
+ prev_ball_x = self.ball_x
98
+ prev_ball_y = self.ball_y
99
+ r = self.phys.ball_radius
100
+
101
+ ego_paddle_x = self.phys.paddle_width
102
+ opp_paddle_x = self.phys.table_width - self.phys.paddle_width
103
+ ego_impact_plane = ego_paddle_x + r
104
+ opp_impact_plane = opp_paddle_x - r
105
+
106
+ next_ball_x = prev_ball_x + self.ball_vx
107
+ next_ball_y = prev_ball_y + self.ball_vy
108
+
109
+ hit_occurred = False
110
+
111
+ # Left (ego) paddle — continuous collision detection
112
+ if self.ball_vx < 0 and prev_ball_x >= ego_impact_plane and next_ball_x <= ego_impact_plane:
113
+ t = float(np.clip((prev_ball_x - ego_impact_plane) / max(1e-6, -self.ball_vx), 0.0, 1.0))
114
+ y_ball_at_impact = prev_ball_y + t * self.ball_vy
115
+ y_ego_at_impact = prev_ego_y + t * (self.ego_y - prev_ego_y)
116
+
117
+ if abs(y_ball_at_impact - y_ego_at_impact) <= (ego_half_h + r * 0.6):
118
+ hit_occurred = True
119
+ self.rally_count += 1
120
+ offset = float(np.clip((y_ball_at_impact - y_ego_at_impact) / ego_half_h, -1.0, 1.0))
121
+ bounce_angle = offset * (math.pi / 3.0)
122
+ current_speed = math.hypot(self.ball_vx, self.ball_vy)
123
+ new_speed = min(current_speed * self.phys.ball_acceleration, self.phys.ball_speed_max)
124
+ new_vx = new_speed * math.cos(bounce_angle)
125
+ new_vy = new_speed * math.sin(bounce_angle) + 0.25 * self.ego_vy
126
+
127
+ rem_dt = 1.0 - t
128
+ self.ball_x = ego_impact_plane + rem_dt * new_vx
129
+ self.ball_y = y_ball_at_impact + rem_dt * new_vy
130
+ self.ball_vx = new_vx
131
+ self.ball_vy = new_vy
132
+
133
+ # Right (opponent) paddle
134
+ elif self.ball_vx > 0 and prev_ball_x <= opp_impact_plane and next_ball_x >= opp_impact_plane:
135
+ t = float(np.clip((opp_impact_plane - prev_ball_x) / max(1e-6, self.ball_vx), 0.0, 1.0))
136
+ y_ball_at_impact = prev_ball_y + t * self.ball_vy
137
+ y_opp_at_impact = prev_opp_y + t * (self.opp_y - prev_opp_y)
138
+
139
+ if abs(y_ball_at_impact - y_opp_at_impact) <= (opp_half_h + r * 0.6):
140
+ hit_occurred = True
141
+ self.rally_count += 1
142
+ offset = float(np.clip((y_ball_at_impact - y_opp_at_impact) / opp_half_h, -1.0, 1.0))
143
+ bounce_angle = offset * (math.pi / 3.0)
144
+ current_speed = math.hypot(self.ball_vx, self.ball_vy)
145
+ new_speed = min(current_speed * self.phys.ball_acceleration, self.phys.ball_speed_max)
146
+ new_vx = -new_speed * math.cos(bounce_angle)
147
+ new_vy = new_speed * math.sin(bounce_angle) + 0.25 * self.opp_vy
148
+
149
+ rem_dt = 1.0 - t
150
+ self.ball_x = opp_impact_plane + rem_dt * new_vx
151
+ self.ball_y = y_ball_at_impact + rem_dt * new_vy
152
+ self.ball_vx = new_vx
153
+ self.ball_vy = new_vy
154
+
155
+ if not hit_occurred:
156
+ self.ball_x = next_ball_x
157
+ self.ball_y = next_ball_y
158
+
159
+ # Wall collisions
160
+ if self.ball_y - r <= 0:
161
+ self.ball_y = r + abs(r - self.ball_y)
162
+ self.ball_vy = abs(self.ball_vy)
163
+ elif self.ball_y + r >= self.phys.table_height:
164
+ self.ball_y = (self.phys.table_height - r) - abs(self.ball_y + r - self.phys.table_height)
165
+ self.ball_vy = -abs(self.ball_vy)
166
+
167
+ # Goal boundaries
168
+ if self.ball_x - r < 0:
169
+ done = True
170
+ info["winner"] = "opponent"
171
+ elif self.ball_x + r > self.phys.table_width:
172
+ done = True
173
+ info["winner"] = "ego"
174
+
175
+ self.prev_ego_action = ego_action
176
+ return done, info
177
+
178
+ def step(self, ego_action: int, opp_action: int) -> Tuple[np.ndarray, bool, Dict[str, Any]]:
179
+ self.step_count += 1
180
+ done = False
181
+ info = {"winner": None}
182
+
183
+ for _ in range(self.phys.frame_skip):
184
+ d, sub_info = self.physics_substep(ego_action, opp_action)
185
+ if d:
186
+ done = True
187
+ info = sub_info
188
+ break
189
+
190
+ if not done and self.step_count >= self.phys.max_rally_steps:
191
+ done = True
192
+ info["winner"] = "draw"
193
+
194
+ return self.get_ego_observation(), done, info
195
+
196
+ def calculate_intercept_y(self, target_x: float, ball_x: float, ball_y: float,
197
+ ball_vx: float, ball_vy: float) -> float:
198
+ if (target_x > ball_x and ball_vx <= 0) or (target_x < ball_x and ball_vx >= 0):
199
+ return self.phys.table_height / 2.0
200
+
201
+ bx, by = float(ball_x), float(ball_y)
202
+ bvx, bvy = float(ball_vx), float(ball_vy)
203
+ h = self.phys.table_height
204
+ r = self.phys.ball_radius
205
+
206
+ for _ in range(10):
207
+ dt_x = (target_x - bx) / bvx if bvx != 0 else float("inf")
208
+ if dt_x <= 0:
209
+ break
210
+ if bvy > 0:
211
+ dt_y = (h - r - by) / bvy
212
+ elif bvy < 0:
213
+ dt_y = (r - by) / bvy
214
+ else:
215
+ dt_y = float("inf")
216
+
217
+ if dt_x <= dt_y:
218
+ by += bvy * dt_x
219
+ break
220
+ else:
221
+ bx += bvx * dt_y
222
+ by += bvy * dt_y
223
+ bvy = -bvy
224
+
225
+ return float(np.clip(by, r, h - r))
226
+
227
+ def get_ego_observation(self) -> np.ndarray:
228
+ w, h = self.phys.table_width, self.phys.table_height
229
+ v_max = self.phys.ball_speed_max
230
+ pv_max = self.phys.paddle_speed
231
+ half_h = self.ego_paddle_h / 2.0
232
+ ego_x = self.phys.paddle_width
233
+
234
+ pred_intercept_y = self.calculate_intercept_y(ego_x, self.ball_x, self.ball_y,
235
+ self.ball_vx, self.ball_vy)
236
+ rel_pred_y = (pred_intercept_y - self.ego_y) / h
237
+ pred_norm_y = pred_intercept_y / h
238
+
239
+ opp_y_norm = self.opp_y / h
240
+ opp_open_top = (self.opp_y - half_h) / h
241
+ opp_open_bottom = (h - (self.opp_y + half_h)) / h
242
+ speed_norm = math.hypot(self.ball_vx, self.ball_vy) / v_max
243
+
244
+ return np.array([
245
+ (self.ball_y - self.ego_y) / h,
246
+ (self.ball_x - ego_x) / w,
247
+ self.ball_vx / v_max,
248
+ self.ball_vy / v_max,
249
+ self.ego_y / h,
250
+ self.ego_vy / pv_max,
251
+ (self.opp_y - self.ego_y) / h,
252
+ self.opp_vy / pv_max,
253
+ self.ball_y / h,
254
+ self.ball_x / w,
255
+ rel_pred_y,
256
+ pred_norm_y,
257
+ opp_y_norm,
258
+ opp_open_top,
259
+ opp_open_bottom,
260
+ speed_norm,
261
+ ], dtype=np.float32)
262
+
263
+ def get_opp_observation(self) -> np.ndarray:
264
+ w, h = self.phys.table_width, self.phys.table_height
265
+ v_max = self.phys.ball_speed_max
266
+ pv_max = self.phys.paddle_speed
267
+ half_h = self.opp_paddle_h / 2.0
268
+ opp_x = self.phys.table_width - self.phys.paddle_width
269
+
270
+ pred_intercept_y = self.calculate_intercept_y(opp_x, self.ball_x, self.ball_y,
271
+ self.ball_vx, self.ball_vy)
272
+ rel_pred_y = (pred_intercept_y - self.opp_y) / h
273
+ pred_norm_y = pred_intercept_y / h
274
+
275
+ ego_y_norm = self.ego_y / h
276
+ ego_open_top = (self.ego_y - half_h) / h
277
+ ego_open_bottom = (h - (self.ego_y + half_h)) / h
278
+ speed_norm = math.hypot(self.ball_vx, self.ball_vy) / v_max
279
+
280
+ return np.array([
281
+ (self.ball_y - self.opp_y) / h,
282
+ (opp_x - self.ball_x) / w,
283
+ -self.ball_vx / v_max,
284
+ self.ball_vy / v_max,
285
+ self.opp_y / h,
286
+ self.opp_vy / pv_max,
287
+ (self.ego_y - self.opp_y) / h,
288
+ self.ego_vy / pv_max,
289
+ self.ball_y / h,
290
+ (w - self.ball_x) / w,
291
+ rel_pred_y,
292
+ pred_norm_y,
293
+ ego_y_norm,
294
+ ego_open_top,
295
+ ego_open_bottom,
296
+ speed_norm,
297
+ ], dtype=np.float32)
298
+
299
+
300
+ # ==================================================================================
301
+ # Baseline opponents (ported from the author's inference.py)
302
+ # ==================================================================================
303
+
304
+ def smooth_aim_action(current_y: float, target_y: float, prev_action: int,
305
+ deadzone: float = 6.0) -> int:
306
+ diff = target_y - current_y
307
+ if abs(diff) < deadzone:
308
+ return 0
309
+ return 2 if diff > 0 else 1
310
+
311
+
312
+ class RealisticHardOpponent:
313
+ def __init__(self, commit_x_ratio: float = 0.60, rng: Optional[random.Random] = None):
314
+ self.commit_x_ratio = commit_x_ratio
315
+ self.prev_action = 0
316
+ self.perceptual_noise = 0.0
317
+ self.rng = rng or random.Random()
318
+
319
+ def act(self, env: StandalonePongEnv) -> int:
320
+ if env.ball_vx <= 0:
321
+ target_y = env.phys.table_height / 2.0
322
+ self.perceptual_noise = self.rng.uniform(-12.0, 12.0)
323
+ elif env.ball_x < env.phys.table_width * self.commit_x_ratio:
324
+ target_y = env.phys.table_height / 2.0 + (env.ball_y - env.phys.table_height / 2.0) * 0.40
325
+ else:
326
+ target_x = env.phys.table_width - env.phys.paddle_width
327
+ exact_y = env.calculate_intercept_y(target_x, env.ball_x, env.ball_y,
328
+ env.ball_vx, env.ball_vy)
329
+ target_y = float(np.clip(exact_y + self.perceptual_noise, 8.0,
330
+ env.phys.table_height - 8.0))
331
+
332
+ action = smooth_aim_action(env.opp_y, target_y, self.prev_action, deadzone=7.0)
333
+ self.prev_action = action
334
+ return action
335
+
336
+
337
+ class MediumOpponent:
338
+ def __init__(self, rng: Optional[random.Random] = None):
339
+ self.prev_action = 0
340
+ self.rng = rng or random.Random()
341
+
342
+ def act(self, env: StandalonePongEnv) -> int:
343
+ if env.ball_vx <= 0:
344
+ target_y = env.phys.table_height / 2.0
345
+ else:
346
+ dt = (env.phys.table_width - env.phys.paddle_width - env.ball_x) / max(1.0, env.ball_vx)
347
+ target_y = env.ball_y + env.ball_vy * dt
348
+ target_y = float(np.clip(target_y, 0, env.phys.table_height))
349
+
350
+ action = smooth_aim_action(env.opp_y, target_y, self.prev_action, deadzone=14.0)
351
+ self.prev_action = action
352
+ return action
353
+
354
+
355
+ class EasyOpponent:
356
+ def __init__(self, rng: Optional[random.Random] = None):
357
+ self.prev_action = 0
358
+ self.rng = rng or random.Random()
359
+
360
+ def act(self, env: StandalonePongEnv) -> int:
361
+ if env.ball_vx <= 0 or env.ball_x < env.phys.table_width * 0.45:
362
+ target_y = env.phys.table_height / 2.0
363
+ else:
364
+ target_y = env.ball_y + self.rng.uniform(-30.0, 30.0)
365
+
366
+ action = smooth_aim_action(env.opp_y, target_y, self.prev_action, deadzone=25.0)
367
+ self.prev_action = action
368
+ return action
369
+
370
+
371
+ class ImpossibleHardOpponent:
372
+ def __init__(self, rng: Optional[random.Random] = None):
373
+ self.prev_action = 0
374
+ self.rng = rng or random.Random()
375
+
376
+ def act(self, env: StandalonePongEnv) -> int:
377
+ if env.ball_vx <= 0:
378
+ target_y = env.phys.table_height / 2.0
379
+ else:
380
+ target_x = env.phys.table_width - env.phys.paddle_width
381
+ target_y = env.calculate_intercept_y(target_x, env.ball_x, env.ball_y,
382
+ env.ball_vx, env.ball_vy)
383
+
384
+ action = smooth_aim_action(env.opp_y, target_y, self.prev_action, deadzone=2.0)
385
+ self.prev_action = action
386
+ return action
387
+
388
+
389
+ class RandomOpponent:
390
+ def __init__(self, rng: Optional[random.Random] = None):
391
+ self.rng = rng or random.Random()
392
+
393
+ def act(self, env: StandalonePongEnv) -> int:
394
+ return self.rng.randint(0, 2)
395
+
396
+
397
+ OPPONENTS = {
398
+ "Realistic Hard": RealisticHardOpponent,
399
+ "Medium Logic": MediumOpponent,
400
+ "Easy Logic": EasyOpponent,
401
+ "Impossible Hard": ImpossibleHardOpponent,
402
+ "Random Agent": RandomOpponent,
403
+ }
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ torch
2
+ transformers
3
+ numpy
4
+ pillow
5
+ imageio
6
+ imageio-ffmpeg