Skip to main content

thrust_rl/env/games/
pong.rs

1//! Single-player Pong environment.
2//!
3//! The agent controls the left paddle; the right paddle is a simple
4//! rule-based tracker. Used as a PPO benchmark — see
5//! `examples/games/pong/train_pong.rs`.
6
7use rand::Rng;
8
9use crate::env::{Environment, SpaceInfo, SpaceType, StepInfo, StepResult};
10
11const LEFT_X: f32 = 0.05;
12const RIGHT_X: f32 = 0.95;
13const BALL_R: f32 = 0.015;
14const PADDLE_H: f32 = 0.1; // half-height (total = 0.2)
15const BALL_SPEED: f32 = 0.018;
16const PADDLE_SPEED: f32 = 0.025;
17const OPPONENT_SPEED: f32 = 0.015;
18const MAX_SCORE: u32 = 7;
19const MAX_STEPS: usize = 2000;
20
21/// Snapshot of a [`Pong`] instance's simulation state.
22///
23/// Captures ball position/velocity, paddle positions, scores, and step
24/// counter. Does **not** capture the thread-local RNG used by `serve` (which
25/// fires on every scoring event). After `restore_state`, a step that does
26/// not trigger a serve is fully reproducible; a step that scores will
27/// produce a serve toward a freshly sampled direction.
28#[derive(Debug, Clone)]
29pub struct PongState {
30    /// Ball x position
31    pub ball_x: f32,
32    /// Ball y position
33    pub ball_y: f32,
34    /// Ball x velocity
35    pub ball_dx: f32,
36    /// Ball y velocity
37    pub ball_dy: f32,
38    /// Left paddle y position
39    pub left_y: f32,
40    /// Right paddle y position
41    pub right_y: f32,
42    /// Left score
43    pub left_score: u32,
44    /// Right score
45    pub right_score: u32,
46    /// Step counter
47    pub steps: usize,
48}
49
50/// Single-player Pong: agent controls left paddle, rule-based opponent on
51/// right.
52///
53/// # Snapshot semantics
54///
55/// `clone_state` / `restore_state` capture ball state, paddles, scores and
56/// step counter, but **not** the thread-local RNG used by `serve` (the
57/// random ball direction/height sampled after every score). Trajectories
58/// that do not score (no ball exiting through either edge) are fully
59/// reproducible after `restore_state`; scoring steps will redraw a new
60/// random serve and so are *not* reproduced bit-for-bit.
61pub struct Pong {
62    ball_x: f32,
63    ball_y: f32,
64    ball_dx: f32,
65    ball_dy: f32,
66    left_y: f32,
67    right_y: f32,
68    left_score: u32,
69    right_score: u32,
70    steps: usize,
71}
72
73impl Pong {
74    /// Construct a new Pong environment with the ball served toward the agent.
75    pub fn new() -> Self {
76        let mut p = Self {
77            ball_x: 0.5,
78            ball_y: 0.5,
79            ball_dx: BALL_SPEED,
80            ball_dy: 0.0,
81            left_y: 0.5,
82            right_y: 0.5,
83            left_score: 0,
84            right_score: 0,
85            steps: 0,
86        };
87        p.serve(true);
88        p
89    }
90
91    fn serve(&mut self, toward_agent: bool) {
92        let mut rng = rand::rng();
93        self.ball_x = 0.5;
94        self.ball_y = 0.3 + rng.random_range(0.0f32..0.4f32);
95        self.ball_dx = if toward_agent {
96            -BALL_SPEED
97        } else {
98            BALL_SPEED
99        };
100        self.ball_dy = rng.random_range(-0.5f32..0.5f32) * BALL_SPEED;
101    }
102
103    fn clamp_paddle(y: f32) -> f32 {
104        y.clamp(PADDLE_H, 1.0 - PADDLE_H)
105    }
106
107    /// Raw rendering state: [ball_x, ball_y, left_y, right_y, left_score,
108    /// right_score]
109    pub fn get_state(&self) -> [f32; 6] {
110        [
111            self.ball_x,
112            self.ball_y,
113            self.left_y,
114            self.right_y,
115            self.left_score as f32,
116            self.right_score as f32,
117        ]
118    }
119
120    /// Step the environment with explicit actions for **both** paddles.
121    ///
122    /// Unlike [`Environment::step`], which drives the right paddle with a
123    /// rule-based tracker (see `OPPONENT_SPEED`), this method takes a direct
124    /// action for the right paddle. Both action arguments use the same
125    /// encoding as `Environment::step`: `0 = up, 1 = stay, 2 = down`.
126    ///
127    /// Used by self-play training where the right paddle is controlled by a
128    /// frozen policy snapshot rather than the heuristic. Ball physics,
129    /// collisions, scoring, and termination are otherwise identical to
130    /// [`Environment::step`].
131    pub fn step_two(&mut self, left_action: i64, right_action: i64) -> StepResult {
132        // Left paddle: 0 = up, 1 = stay, 2 = down
133        self.left_y = match left_action {
134            0 => Self::clamp_paddle(self.left_y - PADDLE_SPEED),
135            2 => Self::clamp_paddle(self.left_y + PADDLE_SPEED),
136            _ => self.left_y,
137        };
138
139        // Right paddle: same encoding, no rule-based override
140        self.right_y = match right_action {
141            0 => Self::clamp_paddle(self.right_y - PADDLE_SPEED),
142            2 => Self::clamp_paddle(self.right_y + PADDLE_SPEED),
143            _ => self.right_y,
144        };
145
146        let old_bx = self.ball_x;
147        self.ball_x += self.ball_dx;
148        self.ball_y += self.ball_dy;
149
150        // Top/bottom wall bounces
151        if self.ball_y - BALL_R < 0.0 {
152            self.ball_y = BALL_R;
153            self.ball_dy = self.ball_dy.abs();
154        } else if self.ball_y + BALL_R > 1.0 {
155            self.ball_y = 1.0 - BALL_R;
156            self.ball_dy = -self.ball_dy.abs();
157        }
158
159        let mut reward = 0.0f32;
160
161        // Left paddle collision: ball's left edge crosses LEFT_X this step and
162        // the paddle is in range. A crossing with the paddle out of range is a
163        // miss — the ball continues to the left wall.
164        if old_bx - BALL_R >= LEFT_X
165            && self.ball_x - BALL_R < LEFT_X
166            && self.ball_dx < 0.0
167            && (self.ball_y - self.left_y).abs() <= PADDLE_H
168        {
169            let hit_pos = (self.ball_y - self.left_y) / PADDLE_H;
170            self.ball_dx = BALL_SPEED;
171            self.ball_dy = (self.ball_dy + hit_pos * BALL_SPEED * 0.6)
172                .clamp(-BALL_SPEED * 1.2, BALL_SPEED * 1.2);
173            self.ball_x = LEFT_X + BALL_R;
174            reward = 0.1;
175        }
176
177        // Right paddle collision: ball's right edge crosses RIGHT_X this step and
178        // the paddle is in range. A crossing with the paddle out of range lets the
179        // agent score when the ball hits the right wall below.
180        if old_bx + BALL_R <= RIGHT_X
181            && self.ball_x + BALL_R > RIGHT_X
182            && self.ball_dx > 0.0
183            && (self.ball_y - self.right_y).abs() <= PADDLE_H
184        {
185            let hit_pos = (self.ball_y - self.right_y) / PADDLE_H;
186            self.ball_dx = -BALL_SPEED;
187            self.ball_dy = (self.ball_dy + hit_pos * BALL_SPEED * 0.6)
188                .clamp(-BALL_SPEED * 1.2, BALL_SPEED * 1.2);
189            self.ball_x = RIGHT_X - BALL_R;
190        }
191
192        // Scoring: ball exits screen
193        if self.ball_x - BALL_R < 0.0 {
194            self.right_score += 1;
195            reward = -1.0;
196            let toward = rand::rng().random_bool(0.5);
197            self.serve(toward);
198        } else if self.ball_x + BALL_R > 1.0 {
199            self.left_score += 1;
200            reward = 1.0;
201            let toward = rand::rng().random_bool(0.5);
202            self.serve(toward);
203        }
204
205        self.steps += 1;
206
207        StepResult {
208            observation: self.get_observation(),
209            reward,
210            terminated: self.left_score >= MAX_SCORE || self.right_score >= MAX_SCORE,
211            truncated: self.steps >= MAX_STEPS,
212            info: StepInfo::default(),
213        }
214    }
215}
216
217/// Mirror a Pong observation to the right paddle's perspective.
218///
219/// The observation layout is
220/// `[ball_x, ball_y, ball_dx, ball_dy, left_y, right_y]` (all normalized to
221/// `[-1, 1]`). To let a single policy network play either side, the right
222/// paddle's view negates the x-axis (`ball_x`, `ball_dx`) and swaps the
223/// paddle positions (`left_y` <-> `right_y`).
224///
225/// Calling `mirror_observation(&mirror_observation(obs))` returns the
226/// original observation (round-trip identity).
227pub fn mirror_observation(obs: &[f32]) -> Vec<f32> {
228    assert_eq!(obs.len(), 6, "Pong observation must be 6-dimensional");
229    vec![-obs[0], obs[1], -obs[2], obs[3], obs[5], obs[4]]
230}
231
232impl Default for Pong {
233    fn default() -> Self {
234        Self::new()
235    }
236}
237
238impl Environment for Pong {
239    type Action = i64;
240    type State = PongState;
241
242    fn reset(&mut self) {
243        self.left_y = 0.5;
244        self.right_y = 0.5;
245        self.left_score = 0;
246        self.right_score = 0;
247        self.steps = 0;
248        let toward = rand::rng().random_bool(0.5);
249        self.serve(toward);
250    }
251
252    fn get_observation(&self) -> Vec<f32> {
253        // All normalized to [-1, 1]
254        vec![
255            self.ball_x * 2.0 - 1.0,
256            self.ball_y * 2.0 - 1.0,
257            self.ball_dx / BALL_SPEED,
258            self.ball_dy / BALL_SPEED,
259            self.left_y * 2.0 - 1.0,
260            self.right_y * 2.0 - 1.0,
261        ]
262    }
263
264    fn step(&mut self, action: i64) -> StepResult {
265        // Agent (left paddle): 0 = up, 1 = stay, 2 = down
266        self.left_y = match action {
267            0 => Self::clamp_paddle(self.left_y - PADDLE_SPEED),
268            2 => Self::clamp_paddle(self.left_y + PADDLE_SPEED),
269            _ => self.left_y,
270        };
271
272        // Opponent (right paddle): tracks ball, speed-limited
273        let diff = (self.ball_y - self.right_y).clamp(-OPPONENT_SPEED, OPPONENT_SPEED);
274        self.right_y = Self::clamp_paddle(self.right_y + diff);
275
276        let old_bx = self.ball_x;
277        self.ball_x += self.ball_dx;
278        self.ball_y += self.ball_dy;
279
280        // Top/bottom wall bounces
281        if self.ball_y - BALL_R < 0.0 {
282            self.ball_y = BALL_R;
283            self.ball_dy = self.ball_dy.abs();
284        } else if self.ball_y + BALL_R > 1.0 {
285            self.ball_y = 1.0 - BALL_R;
286            self.ball_dy = -self.ball_dy.abs();
287        }
288
289        let mut reward = 0.0f32;
290
291        // Left paddle collision: ball's left edge crosses LEFT_X this step and
292        // the paddle is in range. A crossing with the paddle out of range is a
293        // miss — the ball continues to the left wall.
294        if old_bx - BALL_R >= LEFT_X
295            && self.ball_x - BALL_R < LEFT_X
296            && self.ball_dx < 0.0
297            && (self.ball_y - self.left_y).abs() <= PADDLE_H
298        {
299            let hit_pos = (self.ball_y - self.left_y) / PADDLE_H;
300            self.ball_dx = BALL_SPEED;
301            self.ball_dy = (self.ball_dy + hit_pos * BALL_SPEED * 0.6)
302                .clamp(-BALL_SPEED * 1.2, BALL_SPEED * 1.2);
303            self.ball_x = LEFT_X + BALL_R;
304            reward = 0.1;
305        }
306
307        // Right paddle collision: ball's right edge crosses RIGHT_X this step and
308        // the paddle is in range. A crossing with the paddle out of range lets the
309        // agent score when the ball hits the right wall below.
310        if old_bx + BALL_R <= RIGHT_X
311            && self.ball_x + BALL_R > RIGHT_X
312            && self.ball_dx > 0.0
313            && (self.ball_y - self.right_y).abs() <= PADDLE_H
314        {
315            let hit_pos = (self.ball_y - self.right_y) / PADDLE_H;
316            self.ball_dx = -BALL_SPEED;
317            self.ball_dy = (self.ball_dy + hit_pos * BALL_SPEED * 0.6)
318                .clamp(-BALL_SPEED * 1.2, BALL_SPEED * 1.2);
319            self.ball_x = RIGHT_X - BALL_R;
320        }
321
322        // Scoring: ball exits screen
323        if self.ball_x - BALL_R < 0.0 {
324            self.right_score += 1;
325            reward = -1.0;
326            let toward = rand::rng().random_bool(0.5);
327            self.serve(toward);
328        } else if self.ball_x + BALL_R > 1.0 {
329            self.left_score += 1;
330            reward = 1.0;
331            let toward = rand::rng().random_bool(0.5);
332            self.serve(toward);
333        }
334
335        self.steps += 1;
336
337        StepResult {
338            observation: self.get_observation(),
339            reward,
340            terminated: self.left_score >= MAX_SCORE || self.right_score >= MAX_SCORE,
341            truncated: self.steps >= MAX_STEPS,
342            info: StepInfo::default(),
343        }
344    }
345
346    fn observation_space(&self) -> SpaceInfo {
347        SpaceInfo { shape: vec![6], space_type: SpaceType::Box }
348    }
349
350    fn action_space(&self) -> SpaceInfo {
351        SpaceInfo { shape: vec![3], space_type: SpaceType::Discrete(3) }
352    }
353
354    fn render(&self) -> Vec<u8> {
355        vec![]
356    }
357
358    fn close(&mut self) {}
359
360    fn clone_state(&self) -> PongState {
361        PongState {
362            ball_x: self.ball_x,
363            ball_y: self.ball_y,
364            ball_dx: self.ball_dx,
365            ball_dy: self.ball_dy,
366            left_y: self.left_y,
367            right_y: self.right_y,
368            left_score: self.left_score,
369            right_score: self.right_score,
370            steps: self.steps,
371        }
372    }
373
374    fn restore_state(&mut self, state: &PongState) {
375        self.ball_x = state.ball_x;
376        self.ball_y = state.ball_y;
377        self.ball_dx = state.ball_dx;
378        self.ball_dy = state.ball_dy;
379        self.left_y = state.left_y;
380        self.right_y = state.right_y;
381        self.left_score = state.left_score;
382        self.right_score = state.right_score;
383        self.steps = state.steps;
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    /// `step_two` with both actions = "stay" (1) must leave both paddles in
392    /// place. This proves the rule-based right-paddle heuristic is bypassed:
393    /// with `step` the right paddle would chase the ball, but with
394    /// `step_two(1, 1)` it stays put while the ball still moves.
395    #[test]
396    fn step_two_stay_stay_freezes_paddles() {
397        let mut p = Pong::new();
398        let left_y0 = p.left_y;
399        let right_y0 = p.right_y;
400        let ball_x0 = p.ball_x;
401
402        // Run several steps with "stay" actions
403        for _ in 0..10 {
404            let _ = p.step_two(1, 1);
405        }
406
407        // Both paddles must be unchanged (rule-based opponent is bypassed)
408        assert_eq!(p.left_y, left_y0, "Left paddle moved despite stay action");
409        assert_eq!(p.right_y, right_y0, "Right paddle moved — heuristic was not bypassed");
410
411        // The ball must have moved (sanity check that the physics still ran)
412        assert_ne!(p.ball_x, ball_x0, "Ball did not move during step_two");
413    }
414
415    /// `step_two(0, 0)` moves both paddles up. Confirms right-paddle action
416    /// is honored verbatim.
417    #[test]
418    fn step_two_drives_right_paddle_independently() {
419        let mut p = Pong::new();
420        let right_y0 = p.right_y;
421
422        // Drive right paddle UP without touching the heuristic
423        let _ = p.step_two(1, 0);
424        assert!(p.right_y < right_y0, "Right paddle did not move up with action=0");
425
426        // Drive right paddle DOWN
427        let before = p.right_y;
428        let _ = p.step_two(1, 2);
429        assert!(p.right_y > before, "Right paddle did not move down with action=2");
430    }
431
432    /// Round-trip: `mirror(mirror(obs)) == obs`.
433    #[test]
434    fn mirror_observation_round_trip() {
435        let p = Pong::new();
436        let obs = p.get_observation();
437        let mirrored = mirror_observation(&obs);
438        let unmirrored = mirror_observation(&mirrored);
439        assert_eq!(obs.len(), 6);
440        assert_eq!(unmirrored.len(), 6);
441        for (a, b) in obs.iter().zip(unmirrored.iter()) {
442            assert!((a - b).abs() < 1e-7, "round-trip mismatch: {a} vs {b}");
443        }
444    }
445
446    /// Snapshotting Pong on a step that does not score must round-trip
447    /// exactly: replaying `step(action)` after `restore_state` gives the same
448    /// StepResult.
449    #[test]
450    fn clone_restore_round_trips() {
451        let mut env = Pong::new();
452        env.reset();
453
454        // Set ball state explicitly to a configuration that won't score in
455        // one step: the ball is centered with a small velocity, so scoring
456        // cannot happen in a few steps regardless of paddle action.
457        env.ball_x = 0.5;
458        env.ball_y = 0.5;
459        env.ball_dx = BALL_SPEED;
460        env.ball_dy = 0.0;
461        env.left_y = 0.5;
462        env.right_y = 0.5;
463        env.left_score = 0;
464        env.right_score = 0;
465        env.steps = 0;
466
467        // Step a few times to reach a non-initial state, still far from
468        // scoring.
469        for _ in 0..3 {
470            env.step(1);
471        }
472        let snap = env.clone_state();
473
474        // Take an experimental step.
475        let r1 = env.step(2);
476
477        // Restore and take the same step again.
478        env.restore_state(&snap);
479        let r2 = env.step(2);
480
481        // No scoring event => fully deterministic: observation, reward,
482        // termination must all match.
483        assert_eq!(r1.observation, r2.observation);
484        assert_eq!(r1.reward, r2.reward);
485        assert_eq!(r1.terminated, r2.terminated);
486        assert_eq!(r1.truncated, r2.truncated);
487    }
488
489    /// The mirror transformation must negate x components and swap paddle
490    /// positions while leaving y components untouched.
491    #[test]
492    fn mirror_observation_semantics() {
493        let obs = vec![0.3, 0.4, 0.5, 0.6, 0.7, 0.8];
494        let m = mirror_observation(&obs);
495        assert_eq!(m, vec![-0.3, 0.4, -0.5, 0.6, 0.8, 0.7]);
496    }
497}