Skip to main content

thrust_rl/env/games/snake/
environment.rs

1//! Snake environment implementation
2//!
3//! This module implements the SnakeEnv struct and the Environment trait
4//! for both single-agent and multi-agent snake games.
5
6use rand::{Rng, SeedableRng, rngs::StdRng};
7
8use super::{
9    snake::Snake,
10    types::{Cell, Direction, GameState, Position},
11};
12use crate::env::{Environment, SpaceInfo, SpaceType, StepInfo, StepResult};
13
14/// Default seed used when constructing a [`SnakeEnv`] without an explicit seed.
15const DEFAULT_SEED: u64 = 0;
16
17/// Snapshot of a [`SnakeEnv`]'s simulation state.
18///
19/// Captures every visible field of the env (grid, snake positions, food
20/// position, score, step count, done flag) **and** the env's RNG. Because the
21/// RNG is part of the snapshot, `restore_state` followed by `step` replays the
22/// exact same random stream — so a food respawn after restore spawns food at
23/// the same location as the original trajectory, making round-trips
24/// bit-identical.
25#[derive(Debug, Clone)]
26pub struct SnakeEnvState {
27    /// Grid width
28    pub width: i32,
29    /// Grid height
30    pub height: i32,
31    /// All snakes
32    pub snakes: Vec<super::snake::Snake>,
33    /// Number of agents
34    pub num_agents: usize,
35    /// Food position
36    pub food: Position,
37    /// Episode counter
38    pub episode: usize,
39    /// Step counter
40    pub steps: usize,
41    /// Maximum steps per episode
42    pub max_steps: usize,
43    /// Done flag
44    pub done: bool,
45    /// RNG state used for food respawn (captured so restores are deterministic)
46    pub rng: StdRng,
47}
48
49/// Multi-agent Snake environment.
50///
51/// # Snapshot semantics
52///
53/// `clone_state` / `restore_state` capture every visible env field (grid,
54/// snake bodies, food location, score, step count, done flag) **and** the
55/// env's owned RNG. Because the RNG is part of the snapshot, restoring and
56/// re-stepping replays the identical random stream: a food respawn after
57/// `restore_state` lands in the same cell as the original trajectory, so
58/// round-trips are fully reproducible (including trajectories that eat food).
59#[derive(Debug, Clone)]
60pub struct SnakeEnv {
61    /// Grid width
62    pub width: i32,
63    /// Grid height
64    pub height: i32,
65    /// All snakes
66    pub snakes: Vec<Snake>,
67    /// Number of agents
68    pub num_agents: usize,
69    /// Food position
70    pub food: Position,
71    /// Episode counter
72    pub episode: usize,
73    /// Step counter
74    pub steps: usize,
75    /// Maximum steps per episode
76    pub max_steps: usize,
77    /// Done flag
78    pub done: bool,
79    /// Owned RNG for food spawning. Captured by `clone_state` so that
80    /// `restore_state` + `step` reproduces the exact same trajectory.
81    pub rng: StdRng,
82}
83
84impl SnakeEnv {
85    /// Create new snake environment with specified number of agents
86    pub fn new_multi(width: i32, height: i32, num_agents: usize) -> Self {
87        Self::new_multi_with_seed(width, height, num_agents, DEFAULT_SEED)
88    }
89
90    /// Create new snake environment with a specific RNG seed.
91    ///
92    /// The seed controls food placement. Two envs constructed with the same
93    /// seed and stepped with the same actions produce identical trajectories.
94    pub fn new_multi_with_seed(width: i32, height: i32, num_agents: usize, seed: u64) -> Self {
95        let mut rng = StdRng::seed_from_u64(seed);
96
97        // Create snakes in different corners
98        let mut snakes = Vec::new();
99        let positions = [
100            (width / 4, height / 4, Direction::Right),     // Top-left
101            (3 * width / 4, height / 4, Direction::Left),  // Top-right
102            (width / 4, 3 * height / 4, Direction::Right), // Bottom-left
103            (3 * width / 4, 3 * height / 4, Direction::Left), // Bottom-right
104        ];
105
106        for (i, &(x, y, dir)) in positions.iter().enumerate().take(num_agents.min(4)) {
107            let start_pos = Position::new(x, y);
108            snakes.push(Snake::new(i, start_pos, dir));
109        }
110
111        // Generate initial food
112        let food_pos = Position::new(rng.random_range(0..width), rng.random_range(0..height));
113
114        Self {
115            width,
116            height,
117            snakes,
118            num_agents,
119            food: food_pos,
120            episode: 0,
121            steps: 0,
122            max_steps: 400,
123            done: false,
124            rng,
125        }
126    }
127
128    /// Create new single-agent snake environment (for backward compatibility)
129    pub fn new(width: i32, height: i32) -> Self {
130        Self::new_multi(width, height, 1)
131    }
132
133    /// Toroidal (wraparound) distance between two positions
134    fn toroidal_distance(&self, a: &Position, b: &Position) -> f32 {
135        let dx = (a.x - b.x).unsigned_abs() as f32;
136        let dy = (a.y - b.y).unsigned_abs() as f32;
137        let dx = dx.min(self.width as f32 - dx);
138        let dy = dy.min(self.height as f32 - dy);
139        (dx * dx + dy * dy).sqrt()
140    }
141
142    /// Reset environment to initial state
143    pub fn reset(&mut self) {
144        // Reset all snakes
145        self.snakes.clear();
146        let positions = [
147            (self.width / 4, self.height / 4, Direction::Right),
148            (3 * self.width / 4, self.height / 4, Direction::Left),
149            (self.width / 4, 3 * self.height / 4, Direction::Right),
150            (3 * self.width / 4, 3 * self.height / 4, Direction::Left),
151        ];
152
153        for (i, &(x, y, dir)) in positions.iter().enumerate().take(self.num_agents.min(4)) {
154            let start_pos = Position::new(x, y);
155            self.snakes.push(Snake::new(i, start_pos, dir));
156        }
157
158        // Generate new food
159        self.food = Position::new(
160            self.rng.random_range(0..self.width),
161            self.rng.random_range(0..self.height),
162        );
163
164        self.episode += 1;
165        self.steps = 0;
166        self.done = false;
167    }
168
169    /// Execute multi-agent step with actions for all snakes
170    /// Returns a single StepResult with summed rewards (for backward
171    /// compatibility) Use step_multi_agents for per-agent rewards
172    pub fn step_multi(&mut self, actions: &[i64]) -> StepResult {
173        if self.done {
174            return StepResult {
175                observation: self.get_observation(),
176                reward: 0.0,
177                terminated: true,
178                truncated: false,
179                info: StepInfo::default(),
180            };
181        }
182
183        // Store previous distances to food for reward shaping (toroidal)
184        let mut prev_distances: Vec<f32> = Vec::new();
185        for snake in &self.snakes {
186            if snake.is_alive() {
187                prev_distances.push(self.toroidal_distance(&snake.head, &self.food));
188            } else {
189                prev_distances.push(f32::MAX); // Dead snakes don't get distance rewards
190            }
191        }
192
193        // Apply actions and move all snakes (with wraparound boundaries)
194        for (i, &action) in actions.iter().enumerate() {
195            if i < self.snakes.len() && self.snakes[i].is_alive() {
196                let new_direction = Direction::from_action(action);
197                self.snakes[i].change_direction(new_direction);
198                self.snakes[i].move_forward_wrap(self.width, self.height);
199            }
200        }
201
202        self.steps += 1;
203
204        let mut total_reward = 0.0;
205        let mut any_alive = false;
206
207        // Check collisions for each snake
208        for i in 0..self.snakes.len() {
209            if !self.snakes[i].is_alive() {
210                continue;
211            }
212
213            // Note: no wall collision — game uses toroidal wraparound boundaries
214
215            // Check self collision
216            if self.snakes[i].collides_with_self() {
217                self.snakes[i].alive = false;
218                total_reward -= 0.5; // Reduced death penalty
219                continue;
220            }
221
222            // Check collision with other snakes
223            for j in 0..self.snakes.len() {
224                if i == j || !self.snakes[j].is_alive() {
225                    continue;
226                }
227                // Check if snake i's head collides with snake j's body
228                if self.snakes[j].get_all_positions().contains(&self.snakes[i].head) {
229                    self.snakes[i].alive = false;
230                    total_reward -= 0.5; // Reduced death penalty
231                    break;
232                }
233            }
234
235            if !self.snakes[i].is_alive() {
236                continue;
237            }
238
239            // Check food collection
240            if self.snakes[i].eats_food(&self.food) {
241                total_reward += 1.0;
242                self.snakes[i].grow();
243
244                // Generate new food
245                loop {
246                    let x = self.rng.random_range(0..self.width);
247                    let y = self.rng.random_range(0..self.height);
248                    let new_food = Position::new(x, y);
249
250                    // Make sure food doesn't spawn on any snake
251                    let mut on_snake = false;
252                    for snake in &self.snakes {
253                        if snake.get_all_positions().contains(&new_food) {
254                            on_snake = true;
255                            break;
256                        }
257                    }
258
259                    if !on_snake {
260                        self.food = new_food;
261                        break;
262                    }
263                }
264            }
265
266            if self.snakes[i].is_alive() {
267                any_alive = true;
268                // Survival reward per step (0.01 per snake per step)
269                // This encourages staying alive and exploring
270                total_reward += 0.01;
271
272                // Additional reward for longer snakes
273                if self.snakes[i].body.len() > 3 {
274                    let length_bonus = 0.1 * ((self.snakes[i].body.len() - 3) as f32);
275                    total_reward += length_bonus;
276                }
277
278                // Distance-based reward shaping: reward getting closer to food (toroidal)
279                if i < prev_distances.len() {
280                    let current_distance = self.toroidal_distance(&self.snakes[i].head, &self.food);
281                    let distance_delta = prev_distances[i] - current_distance;
282                    let distance_reward =
283                        distance_delta / (self.width.max(self.height) as f32) * 2.0;
284                    total_reward += distance_reward;
285                }
286            }
287        }
288
289        // Check if all snakes are dead
290        let terminated = !any_alive;
291        if terminated {
292            self.done = true;
293        }
294
295        // Check step limit
296        let truncated = self.steps >= self.max_steps;
297        if truncated {
298            self.done = true;
299        }
300
301        StepResult {
302            observation: self.get_observation(),
303            reward: total_reward,
304            terminated,
305            truncated,
306            info: StepInfo::default(),
307        }
308    }
309
310    /// Execute multi-agent step with per-agent rewards
311    /// Returns individual rewards for each snake
312    pub fn step_multi_agents(&mut self, actions: &[i64]) -> (Vec<f32>, bool, bool) {
313        if self.done {
314            return (vec![0.0; self.snakes.len()], true, false);
315        }
316
317        // Store previous distances to food for reward shaping (toroidal)
318        let mut prev_distances: Vec<f32> = Vec::new();
319        for snake in &self.snakes {
320            if snake.is_alive() {
321                prev_distances.push(self.toroidal_distance(&snake.head, &self.food));
322            } else {
323                prev_distances.push(f32::MAX); // Dead snakes don't get distance rewards
324            }
325        }
326
327        // Apply actions and move all snakes (with wraparound boundaries)
328        for (i, &action) in actions.iter().enumerate() {
329            if i < self.snakes.len() && self.snakes[i].is_alive() {
330                let new_direction = Direction::from_action(action);
331                self.snakes[i].change_direction(new_direction);
332                self.snakes[i].move_forward_wrap(self.width, self.height);
333            }
334        }
335
336        self.steps += 1;
337
338        let mut agent_rewards = vec![0.0; self.snakes.len()];
339        let mut any_alive = false;
340
341        // Check collisions for each snake
342        for i in 0..self.snakes.len() {
343            if !self.snakes[i].is_alive() {
344                continue;
345            }
346
347            // Wall collision removed - using torus/wraparound boundaries
348            // Snakes now wrap around the edges instead of dying
349
350            // Check self collision
351            if self.snakes[i].collides_with_self() {
352                self.snakes[i].alive = false;
353                agent_rewards[i] -= 0.1; // Minimal death penalty to encourage risk-taking
354                continue;
355            }
356
357            // Check collision with other snakes
358            for j in 0..self.snakes.len() {
359                if i == j || !self.snakes[j].is_alive() {
360                    continue;
361                }
362                // Check if snake i's head collides with snake j's body
363                if self.snakes[j].get_all_positions().contains(&self.snakes[i].head) {
364                    self.snakes[i].alive = false;
365                    agent_rewards[i] -= 0.1; // Minimal death penalty to encourage risk-taking
366                    break;
367                }
368            }
369
370            if !self.snakes[i].is_alive() {
371                continue;
372            }
373
374            // Check food collection
375            if self.snakes[i].eats_food(&self.food) {
376                agent_rewards[i] += 1.0;
377                self.snakes[i].grow();
378
379                // Generate new food
380                loop {
381                    let x = self.rng.random_range(0..self.width);
382                    let y = self.rng.random_range(0..self.height);
383                    let new_food = Position::new(x, y);
384
385                    // Make sure food doesn't spawn on any snake
386                    let mut on_snake = false;
387                    for snake in &self.snakes {
388                        if snake.get_all_positions().contains(&new_food) {
389                            on_snake = true;
390                            break;
391                        }
392                    }
393
394                    if !on_snake {
395                        self.food = new_food;
396                        break;
397                    }
398                }
399            }
400
401            if self.snakes[i].is_alive() {
402                any_alive = true;
403                // Survival reward per step
404                agent_rewards[i] += 0.01;
405
406                // Strong reward for longer snakes (encourages aggressive eating)
407                if self.snakes[i].body.len() > 3 {
408                    let length_bonus = 1.0 * ((self.snakes[i].body.len() - 3) as f32);
409                    agent_rewards[i] += length_bonus;
410                }
411
412                // Distance-based reward shaping: reward getting closer to food (toroidal)
413                if i < prev_distances.len() {
414                    let current_distance = self.toroidal_distance(&self.snakes[i].head, &self.food);
415                    let distance_delta = prev_distances[i] - current_distance;
416                    let distance_reward =
417                        distance_delta / (self.width.max(self.height) as f32) * 2.0;
418                    agent_rewards[i] += distance_reward;
419                }
420            }
421        }
422
423        // Check if all snakes are dead
424        let terminated = !any_alive;
425        if terminated {
426            self.done = true;
427        }
428
429        // Check step limit
430        let truncated = self.steps >= self.max_steps;
431        if truncated {
432            self.done = true;
433        }
434
435        (agent_rewards, terminated, truncated)
436    }
437
438    /// Execute single-agent step (for backward compatibility)
439    pub fn step(&mut self, action: i64) -> StepResult {
440        self.step_multi(&[action])
441    }
442
443    /// Get grid-based observation for a specific snake
444    ///
445    /// Returns a multi-channel grid representation:
446    /// - Channel 0: Own snake body (1.0 where body is)
447    /// - Channel 1: Own snake head (1.0 at head position)
448    /// - Channel 2: Other snakes (1.0 where other snakes are)
449    /// - Channel 3: Food (1.0 at food position)
450    /// - Channel 4: Walls (1.0 at boundaries)
451    ///
452    /// Flattened as [C0_pixels..., C1_pixels..., C2_pixels..., ...]
453    pub fn get_grid_observation(&self, snake_id: usize) -> Vec<f32> {
454        if snake_id >= self.snakes.len() {
455            // Return empty grid if invalid snake_id
456            return vec![0.0; 5 * (self.width as usize) * (self.height as usize)];
457        }
458
459        let grid_size = (self.width as usize) * (self.height as usize);
460        let mut obs = vec![0.0; 5 * grid_size];
461
462        let own_snake = &self.snakes[snake_id];
463
464        // Channel 0: Own snake body
465        for pos in &own_snake.body {
466            if pos.x >= 0 && pos.x < self.width && pos.y >= 0 && pos.y < self.height {
467                let idx = (pos.y as usize) * (self.width as usize) + (pos.x as usize);
468                obs[idx] = 1.0;
469            }
470        }
471
472        // Channel 1: Own snake head
473        if own_snake.head.x >= 0
474            && own_snake.head.x < self.width
475            && own_snake.head.y >= 0
476            && own_snake.head.y < self.height
477        {
478            let head_idx =
479                (own_snake.head.y as usize) * (self.width as usize) + (own_snake.head.x as usize);
480            obs[grid_size + head_idx] = 1.0;
481        }
482
483        // Channel 2: Other snakes
484        for (id, snake) in self.snakes.iter().enumerate() {
485            if id != snake_id {
486                for pos in &snake.body {
487                    if pos.x >= 0 && pos.x < self.width && pos.y >= 0 && pos.y < self.height {
488                        let idx = 2 * grid_size
489                            + (pos.y as usize) * (self.width as usize)
490                            + (pos.x as usize);
491                        obs[idx] = 1.0;
492                    }
493                }
494            }
495        }
496
497        // Channel 3: Food
498        if self.food.x >= 0
499            && self.food.x < self.width
500            && self.food.y >= 0
501            && self.food.y < self.height
502        {
503            let food_idx = 3 * grid_size
504                + (self.food.y as usize) * (self.width as usize)
505                + (self.food.x as usize);
506            obs[food_idx] = 1.0;
507        }
508
509        // Channel 4: Walls (boundaries)
510        // Top and bottom walls
511        for x in 0..self.width as usize {
512            obs[4 * grid_size + x] = 1.0; // Top
513            obs[4 * grid_size + ((self.height as usize - 1) * (self.width as usize)) + x] = 1.0; // Bottom
514        }
515        // Left and right walls
516        for y in 0..self.height as usize {
517            obs[4 * grid_size + y * (self.width as usize)] = 1.0; // Left
518            obs[4 * grid_size + y * (self.width as usize) + (self.width as usize - 1)] = 1.0; // Right
519        }
520
521        obs
522    }
523
524    /// Get current observation (for first snake, backward compatibility)
525    pub fn get_observation(&self) -> Vec<f32> {
526        if self.snakes.is_empty() {
527            return vec![0.0; 6];
528        }
529
530        let snake = &self.snakes[0];
531        let dx = (self.food.x - snake.head.x) as f32 / self.width as f32;
532        let dy = (self.food.y - snake.head.y) as f32 / self.height as f32;
533
534        let direction_onehot = match snake.direction {
535            Direction::Up => [1.0, 0.0, 0.0, 0.0],
536            Direction::Down => [0.0, 1.0, 0.0, 0.0],
537            Direction::Left => [0.0, 0.0, 1.0, 0.0],
538            Direction::Right => [0.0, 0.0, 0.0, 1.0],
539        };
540
541        vec![
542            dx,
543            dy,
544            direction_onehot[0],
545            direction_onehot[1],
546            direction_onehot[2],
547            direction_onehot[3],
548        ]
549    }
550
551    /// Render current game state
552    pub fn render(&self) -> GameState {
553        let mut grid = vec![vec![Cell::Empty; self.width as usize]; self.height as usize];
554
555        // Place food
556        grid[self.food.y as usize][self.food.x as usize] = Cell::Food;
557
558        // Place all snakes
559        for snake in &self.snakes {
560            for (i, &pos) in snake.body.iter().enumerate() {
561                let cell = if i == 0 {
562                    Cell::SnakeHead(snake.id)
563                } else {
564                    Cell::SnakeBody(snake.id)
565                };
566                grid[pos.y as usize][pos.x as usize] = cell;
567            }
568        }
569
570        GameState {
571            grid,
572            scores: self.snakes.iter().map(|s| s.length as i32).collect(),
573            active_agents: self.snakes.iter().map(|s| s.is_alive()).collect(),
574            episode: self.episode,
575            steps: self.steps,
576        }
577    }
578}
579
580impl Environment for SnakeEnv {
581    type Action = i64;
582    type State = SnakeEnvState;
583
584    fn reset(&mut self) {
585        self.reset();
586    }
587
588    fn get_observation(&self) -> Vec<f32> {
589        self.get_observation()
590    }
591
592    fn step(&mut self, action: i64) -> StepResult {
593        self.step(action)
594    }
595
596    fn observation_space(&self) -> SpaceInfo {
597        SpaceInfo {
598            shape: vec![6], // [food_dx, food_dy, dir_up, dir_down, dir_left, dir_right]
599            space_type: SpaceType::Box,
600        }
601    }
602
603    fn action_space(&self) -> SpaceInfo {
604        SpaceInfo {
605            shape: vec![4], // 4 directions
606            space_type: SpaceType::Discrete(4),
607        }
608    }
609
610    fn render(&self) -> Vec<u8> {
611        vec![] // Rendering handled by GameState
612    }
613
614    fn close(&mut self) {
615        // Nothing to clean up
616    }
617
618    fn clone_state(&self) -> SnakeEnvState {
619        SnakeEnvState {
620            width: self.width,
621            height: self.height,
622            snakes: self.snakes.clone(),
623            num_agents: self.num_agents,
624            food: self.food,
625            episode: self.episode,
626            steps: self.steps,
627            max_steps: self.max_steps,
628            done: self.done,
629            rng: self.rng.clone(),
630        }
631    }
632
633    fn restore_state(&mut self, state: &SnakeEnvState) {
634        self.width = state.width;
635        self.height = state.height;
636        self.snakes = state.snakes.clone();
637        self.num_agents = state.num_agents;
638        self.food = state.food;
639        self.episode = state.episode;
640        self.steps = state.steps;
641        self.max_steps = state.max_steps;
642        self.done = state.done;
643        self.rng = state.rng.clone();
644    }
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650
651    #[test]
652    fn clone_restore_round_trips() {
653        let mut env = SnakeEnv::new(10, 10);
654        env.reset();
655
656        // Step a few times to a non-initial state.
657        for i in 0..5 {
658            env.step((i % 4) as i64);
659        }
660        let snap = env.clone_state();
661
662        // Snapshot must capture every visible field.
663        assert_eq!(env.steps, snap.steps);
664        assert_eq!(env.food, snap.food);
665        assert_eq!(env.snakes.len(), snap.snakes.len());
666
667        // Take an experimental step.
668        let r1 = env.step(0);
669        let post_food_1 = env.food;
670        let post_steps_1 = env.steps;
671
672        // Restore and take the same step again.
673        env.restore_state(&snap);
674        assert_eq!(env.steps, snap.steps);
675        assert_eq!(env.food, snap.food);
676
677        let r2 = env.step(0);
678
679        // Snake's `step` is deterministic except for food respawn, which only
680        // fires when food is eaten. With identical action and snapshot the
681        // observation/reward must match.
682        assert_eq!(r1.observation, r2.observation);
683        assert_eq!(r1.reward, r2.reward);
684        assert_eq!(r1.terminated, r2.terminated);
685        assert_eq!(r1.truncated, r2.truncated);
686        assert_eq!(env.food, post_food_1);
687        assert_eq!(env.steps, post_steps_1);
688    }
689
690    /// Forces the food-respawn branch (which draws from the env RNG) to run
691    /// across a `clone_state` / `restore_state` round-trip and asserts the
692    /// respawned food lands in the same cell both times. This is the path that
693    /// previously diverged because food respawn used the thread-local
694    /// `rand::rng()` instead of the captured snapshot RNG.
695    #[test]
696    fn clone_restore_round_trips_when_food_is_eaten() {
697        let mut env = SnakeEnv::new(10, 10);
698        env.reset();
699
700        // Action 0 maps to Direction::Up. The snake starts facing Right (Up is
701        // not a reversal), so after the step the head moves up one cell. Place
702        // the food there so the next step eats it and triggers an RNG respawn.
703        let head = env.snakes[0].head;
704        let food_before = Position::new(head.x, head.y - 1);
705        env.food = food_before;
706
707        let snap = env.clone_state();
708
709        let r1 = env.step(0);
710        let food_after_1 = env.food;
711
712        env.restore_state(&snap);
713        let r2 = env.step(0);
714        let food_after_2 = env.food;
715
716        // The step must have eaten the food and respawned it elsewhere via the
717        // env RNG (confirming the respawn branch actually fired).
718        assert_ne!(food_after_1, food_before, "expected food to be eaten and respawned");
719
720        // The respawn must be reproducible across the restore: same new food
721        // cell, same observation, same reward. Before the fix, respawn used the
722        // thread-local RNG and these diverged intermittently.
723        assert_eq!(food_after_1, food_after_2, "respawned food must match across restore");
724        assert_eq!(r1.observation, r2.observation);
725        assert_eq!(r1.reward, r2.reward);
726    }
727}