Spaces:
Running
Running
multimodalart HF Staff
Mr. Pong interactive demo: browser-side play + server-side match video & benchmark
13eb3cb verified | """ | |
| Standalone table-tennis physics environment + baseline opponents for Mr. Pong. | |
| This is a faithful port of the reference implementation shipped by the model | |
| author in `inference.py` at https://huggingface.co/fromziro/MrPong (Apache-2.0). | |
| Physics constants, collision handling, observation layout and the baseline | |
| opponent policies are kept 1:1 with the original so that the behaviour shown in | |
| this Space matches the numbers reported on the model card. | |
| """ | |
| import math | |
| import random | |
| from dataclasses import dataclass | |
| from typing import Any, Dict, Optional, Tuple | |
| import numpy as np | |
| class PhysicsConfig: | |
| table_width: float = 800.0 | |
| table_height: float = 500.0 | |
| paddle_width: float = 14.0 | |
| paddle_height: float = 80.0 | |
| paddle_speed: float = 8.0 | |
| paddle_smoothing: float = 0.70 | |
| ball_radius: float = 8.0 | |
| ball_speed_initial: float = 8.0 | |
| ball_speed_max: float = 16.0 | |
| ball_acceleration: float = 1.035 | |
| frame_skip: int = 3 | |
| max_rally_steps: int = 1500 | |
| class StandalonePongEnv: | |
| """Self-contained table tennis physics environment (port of the author's env).""" | |
| def __init__(self, phys: Optional[PhysicsConfig] = None, seed: Optional[int] = None): | |
| self.phys = phys or PhysicsConfig() | |
| self.rng = random.Random(seed) | |
| self.ego_paddle_h = self.phys.paddle_height | |
| self.opp_paddle_h = self.phys.paddle_height | |
| self.reset() | |
| def reset(self, serve_direction: Optional[int] = None, | |
| initial_speed: Optional[float] = None) -> np.ndarray: | |
| self.ego_y = self.phys.table_height / 2.0 | |
| self.opp_y = self.phys.table_height / 2.0 | |
| self.ego_vy = 0.0 | |
| self.opp_vy = 0.0 | |
| self.prev_ego_action = 0 | |
| self.ball_x = self.phys.table_width / 2.0 | |
| self.ball_y = self.phys.table_height / 2.0 | |
| if serve_direction is None: | |
| serve_direction = 1 if self.rng.random() < 0.5 else -1 | |
| serve_angle = self.rng.uniform(-math.pi / 7.0, math.pi / 7.0) | |
| speed = initial_speed or self.phys.ball_speed_initial | |
| self.ball_vx = serve_direction * speed * math.cos(serve_angle) | |
| self.ball_vy = speed * math.sin(serve_angle) | |
| self.rally_count = 0 | |
| self.step_count = 0 | |
| return self.get_ego_observation() | |
| def _get_action_velocity(self, action: int) -> float: | |
| if action == 1: | |
| return -self.phys.paddle_speed | |
| elif action == 2: | |
| return self.phys.paddle_speed | |
| return 0.0 | |
| def physics_substep(self, ego_action: int, opp_action: int) -> Tuple[bool, Dict[str, Any]]: | |
| info = {"winner": None} | |
| done = False | |
| prev_ego_y = self.ego_y | |
| prev_opp_y = self.opp_y | |
| ego_target_v = self._get_action_velocity(ego_action) | |
| opp_target_v = self._get_action_velocity(opp_action) | |
| alpha = self.phys.paddle_smoothing | |
| self.ego_vy = alpha * self.ego_vy + (1.0 - alpha) * ego_target_v | |
| self.opp_vy = alpha * self.opp_vy + (1.0 - alpha) * opp_target_v | |
| ego_half_h = self.ego_paddle_h / 2.0 | |
| opp_half_h = self.opp_paddle_h / 2.0 | |
| self.ego_y = float(np.clip(self.ego_y + self.ego_vy, ego_half_h, | |
| self.phys.table_height - ego_half_h)) | |
| self.opp_y = float(np.clip(self.opp_y + self.opp_vy, opp_half_h, | |
| self.phys.table_height - opp_half_h)) | |
| prev_ball_x = self.ball_x | |
| prev_ball_y = self.ball_y | |
| r = self.phys.ball_radius | |
| ego_paddle_x = self.phys.paddle_width | |
| opp_paddle_x = self.phys.table_width - self.phys.paddle_width | |
| ego_impact_plane = ego_paddle_x + r | |
| opp_impact_plane = opp_paddle_x - r | |
| next_ball_x = prev_ball_x + self.ball_vx | |
| next_ball_y = prev_ball_y + self.ball_vy | |
| hit_occurred = False | |
| # Left (ego) paddle — continuous collision detection | |
| if self.ball_vx < 0 and prev_ball_x >= ego_impact_plane and next_ball_x <= ego_impact_plane: | |
| t = float(np.clip((prev_ball_x - ego_impact_plane) / max(1e-6, -self.ball_vx), 0.0, 1.0)) | |
| y_ball_at_impact = prev_ball_y + t * self.ball_vy | |
| y_ego_at_impact = prev_ego_y + t * (self.ego_y - prev_ego_y) | |
| if abs(y_ball_at_impact - y_ego_at_impact) <= (ego_half_h + r * 0.6): | |
| hit_occurred = True | |
| self.rally_count += 1 | |
| offset = float(np.clip((y_ball_at_impact - y_ego_at_impact) / ego_half_h, -1.0, 1.0)) | |
| bounce_angle = offset * (math.pi / 3.0) | |
| current_speed = math.hypot(self.ball_vx, self.ball_vy) | |
| new_speed = min(current_speed * self.phys.ball_acceleration, self.phys.ball_speed_max) | |
| new_vx = new_speed * math.cos(bounce_angle) | |
| new_vy = new_speed * math.sin(bounce_angle) + 0.25 * self.ego_vy | |
| rem_dt = 1.0 - t | |
| self.ball_x = ego_impact_plane + rem_dt * new_vx | |
| self.ball_y = y_ball_at_impact + rem_dt * new_vy | |
| self.ball_vx = new_vx | |
| self.ball_vy = new_vy | |
| # Right (opponent) paddle | |
| elif self.ball_vx > 0 and prev_ball_x <= opp_impact_plane and next_ball_x >= opp_impact_plane: | |
| t = float(np.clip((opp_impact_plane - prev_ball_x) / max(1e-6, self.ball_vx), 0.0, 1.0)) | |
| y_ball_at_impact = prev_ball_y + t * self.ball_vy | |
| y_opp_at_impact = prev_opp_y + t * (self.opp_y - prev_opp_y) | |
| if abs(y_ball_at_impact - y_opp_at_impact) <= (opp_half_h + r * 0.6): | |
| hit_occurred = True | |
| self.rally_count += 1 | |
| offset = float(np.clip((y_ball_at_impact - y_opp_at_impact) / opp_half_h, -1.0, 1.0)) | |
| bounce_angle = offset * (math.pi / 3.0) | |
| current_speed = math.hypot(self.ball_vx, self.ball_vy) | |
| new_speed = min(current_speed * self.phys.ball_acceleration, self.phys.ball_speed_max) | |
| new_vx = -new_speed * math.cos(bounce_angle) | |
| new_vy = new_speed * math.sin(bounce_angle) + 0.25 * self.opp_vy | |
| rem_dt = 1.0 - t | |
| self.ball_x = opp_impact_plane + rem_dt * new_vx | |
| self.ball_y = y_ball_at_impact + rem_dt * new_vy | |
| self.ball_vx = new_vx | |
| self.ball_vy = new_vy | |
| if not hit_occurred: | |
| self.ball_x = next_ball_x | |
| self.ball_y = next_ball_y | |
| # Wall collisions | |
| if self.ball_y - r <= 0: | |
| self.ball_y = r + abs(r - self.ball_y) | |
| self.ball_vy = abs(self.ball_vy) | |
| elif self.ball_y + r >= self.phys.table_height: | |
| self.ball_y = (self.phys.table_height - r) - abs(self.ball_y + r - self.phys.table_height) | |
| self.ball_vy = -abs(self.ball_vy) | |
| # Goal boundaries | |
| if self.ball_x - r < 0: | |
| done = True | |
| info["winner"] = "opponent" | |
| elif self.ball_x + r > self.phys.table_width: | |
| done = True | |
| info["winner"] = "ego" | |
| self.prev_ego_action = ego_action | |
| return done, info | |
| def step(self, ego_action: int, opp_action: int) -> Tuple[np.ndarray, bool, Dict[str, Any]]: | |
| self.step_count += 1 | |
| done = False | |
| info = {"winner": None} | |
| for _ in range(self.phys.frame_skip): | |
| d, sub_info = self.physics_substep(ego_action, opp_action) | |
| if d: | |
| done = True | |
| info = sub_info | |
| break | |
| if not done and self.step_count >= self.phys.max_rally_steps: | |
| done = True | |
| info["winner"] = "draw" | |
| return self.get_ego_observation(), done, info | |
| def calculate_intercept_y(self, target_x: float, ball_x: float, ball_y: float, | |
| ball_vx: float, ball_vy: float) -> float: | |
| if (target_x > ball_x and ball_vx <= 0) or (target_x < ball_x and ball_vx >= 0): | |
| return self.phys.table_height / 2.0 | |
| bx, by = float(ball_x), float(ball_y) | |
| bvx, bvy = float(ball_vx), float(ball_vy) | |
| h = self.phys.table_height | |
| r = self.phys.ball_radius | |
| for _ in range(10): | |
| dt_x = (target_x - bx) / bvx if bvx != 0 else float("inf") | |
| if dt_x <= 0: | |
| break | |
| if bvy > 0: | |
| dt_y = (h - r - by) / bvy | |
| elif bvy < 0: | |
| dt_y = (r - by) / bvy | |
| else: | |
| dt_y = float("inf") | |
| if dt_x <= dt_y: | |
| by += bvy * dt_x | |
| break | |
| else: | |
| bx += bvx * dt_y | |
| by += bvy * dt_y | |
| bvy = -bvy | |
| return float(np.clip(by, r, h - r)) | |
| def get_ego_observation(self) -> np.ndarray: | |
| w, h = self.phys.table_width, self.phys.table_height | |
| v_max = self.phys.ball_speed_max | |
| pv_max = self.phys.paddle_speed | |
| half_h = self.ego_paddle_h / 2.0 | |
| ego_x = self.phys.paddle_width | |
| pred_intercept_y = self.calculate_intercept_y(ego_x, self.ball_x, self.ball_y, | |
| self.ball_vx, self.ball_vy) | |
| rel_pred_y = (pred_intercept_y - self.ego_y) / h | |
| pred_norm_y = pred_intercept_y / h | |
| opp_y_norm = self.opp_y / h | |
| opp_open_top = (self.opp_y - half_h) / h | |
| opp_open_bottom = (h - (self.opp_y + half_h)) / h | |
| speed_norm = math.hypot(self.ball_vx, self.ball_vy) / v_max | |
| return np.array([ | |
| (self.ball_y - self.ego_y) / h, | |
| (self.ball_x - ego_x) / w, | |
| self.ball_vx / v_max, | |
| self.ball_vy / v_max, | |
| self.ego_y / h, | |
| self.ego_vy / pv_max, | |
| (self.opp_y - self.ego_y) / h, | |
| self.opp_vy / pv_max, | |
| self.ball_y / h, | |
| self.ball_x / w, | |
| rel_pred_y, | |
| pred_norm_y, | |
| opp_y_norm, | |
| opp_open_top, | |
| opp_open_bottom, | |
| speed_norm, | |
| ], dtype=np.float32) | |
| def get_opp_observation(self) -> np.ndarray: | |
| w, h = self.phys.table_width, self.phys.table_height | |
| v_max = self.phys.ball_speed_max | |
| pv_max = self.phys.paddle_speed | |
| half_h = self.opp_paddle_h / 2.0 | |
| opp_x = self.phys.table_width - self.phys.paddle_width | |
| pred_intercept_y = self.calculate_intercept_y(opp_x, self.ball_x, self.ball_y, | |
| self.ball_vx, self.ball_vy) | |
| rel_pred_y = (pred_intercept_y - self.opp_y) / h | |
| pred_norm_y = pred_intercept_y / h | |
| ego_y_norm = self.ego_y / h | |
| ego_open_top = (self.ego_y - half_h) / h | |
| ego_open_bottom = (h - (self.ego_y + half_h)) / h | |
| speed_norm = math.hypot(self.ball_vx, self.ball_vy) / v_max | |
| return np.array([ | |
| (self.ball_y - self.opp_y) / h, | |
| (opp_x - self.ball_x) / w, | |
| -self.ball_vx / v_max, | |
| self.ball_vy / v_max, | |
| self.opp_y / h, | |
| self.opp_vy / pv_max, | |
| (self.ego_y - self.opp_y) / h, | |
| self.ego_vy / pv_max, | |
| self.ball_y / h, | |
| (w - self.ball_x) / w, | |
| rel_pred_y, | |
| pred_norm_y, | |
| ego_y_norm, | |
| ego_open_top, | |
| ego_open_bottom, | |
| speed_norm, | |
| ], dtype=np.float32) | |
| # ================================================================================== | |
| # Baseline opponents (ported from the author's inference.py) | |
| # ================================================================================== | |
| def smooth_aim_action(current_y: float, target_y: float, prev_action: int, | |
| deadzone: float = 6.0) -> int: | |
| diff = target_y - current_y | |
| if abs(diff) < deadzone: | |
| return 0 | |
| return 2 if diff > 0 else 1 | |
| class RealisticHardOpponent: | |
| def __init__(self, commit_x_ratio: float = 0.60, rng: Optional[random.Random] = None): | |
| self.commit_x_ratio = commit_x_ratio | |
| self.prev_action = 0 | |
| self.perceptual_noise = 0.0 | |
| self.rng = rng or random.Random() | |
| def act(self, env: StandalonePongEnv) -> int: | |
| if env.ball_vx <= 0: | |
| target_y = env.phys.table_height / 2.0 | |
| self.perceptual_noise = self.rng.uniform(-12.0, 12.0) | |
| elif env.ball_x < env.phys.table_width * self.commit_x_ratio: | |
| target_y = env.phys.table_height / 2.0 + (env.ball_y - env.phys.table_height / 2.0) * 0.40 | |
| else: | |
| target_x = env.phys.table_width - env.phys.paddle_width | |
| exact_y = env.calculate_intercept_y(target_x, env.ball_x, env.ball_y, | |
| env.ball_vx, env.ball_vy) | |
| target_y = float(np.clip(exact_y + self.perceptual_noise, 8.0, | |
| env.phys.table_height - 8.0)) | |
| action = smooth_aim_action(env.opp_y, target_y, self.prev_action, deadzone=7.0) | |
| self.prev_action = action | |
| return action | |
| class MediumOpponent: | |
| def __init__(self, rng: Optional[random.Random] = None): | |
| self.prev_action = 0 | |
| self.rng = rng or random.Random() | |
| def act(self, env: StandalonePongEnv) -> int: | |
| if env.ball_vx <= 0: | |
| target_y = env.phys.table_height / 2.0 | |
| else: | |
| dt = (env.phys.table_width - env.phys.paddle_width - env.ball_x) / max(1.0, env.ball_vx) | |
| target_y = env.ball_y + env.ball_vy * dt | |
| target_y = float(np.clip(target_y, 0, env.phys.table_height)) | |
| action = smooth_aim_action(env.opp_y, target_y, self.prev_action, deadzone=14.0) | |
| self.prev_action = action | |
| return action | |
| class EasyOpponent: | |
| def __init__(self, rng: Optional[random.Random] = None): | |
| self.prev_action = 0 | |
| self.rng = rng or random.Random() | |
| def act(self, env: StandalonePongEnv) -> int: | |
| if env.ball_vx <= 0 or env.ball_x < env.phys.table_width * 0.45: | |
| target_y = env.phys.table_height / 2.0 | |
| else: | |
| target_y = env.ball_y + self.rng.uniform(-30.0, 30.0) | |
| action = smooth_aim_action(env.opp_y, target_y, self.prev_action, deadzone=25.0) | |
| self.prev_action = action | |
| return action | |
| class ImpossibleHardOpponent: | |
| def __init__(self, rng: Optional[random.Random] = None): | |
| self.prev_action = 0 | |
| self.rng = rng or random.Random() | |
| def act(self, env: StandalonePongEnv) -> int: | |
| if env.ball_vx <= 0: | |
| target_y = env.phys.table_height / 2.0 | |
| else: | |
| target_x = env.phys.table_width - env.phys.paddle_width | |
| target_y = env.calculate_intercept_y(target_x, env.ball_x, env.ball_y, | |
| env.ball_vx, env.ball_vy) | |
| action = smooth_aim_action(env.opp_y, target_y, self.prev_action, deadzone=2.0) | |
| self.prev_action = action | |
| return action | |
| class RandomOpponent: | |
| def __init__(self, rng: Optional[random.Random] = None): | |
| self.rng = rng or random.Random() | |
| def act(self, env: StandalonePongEnv) -> int: | |
| return self.rng.randint(0, 2) | |
| OPPONENTS = { | |
| "Realistic Hard": RealisticHardOpponent, | |
| "Medium Logic": MediumOpponent, | |
| "Easy Logic": EasyOpponent, | |
| "Impossible Hard": ImpossibleHardOpponent, | |
| "Random Agent": RandomOpponent, | |
| } | |