DEV Community

Cover image for Tutorial: Snake game in Rust (Part 2/2)πŸπŸ¦€
Eleftheria Batsou
Eleftheria Batsou

Posted on • Originally published at eleftheriabatsou.hashnode.dev on

Tutorial: Snake game in Rust (Part 2/2)πŸπŸ¦€

Introduction

Hello, amazing people, and welcome to the 2nd part of this tutorial, building the Snake game in Rust. If you missed the 1st part you can find it here.

In this article, we'll finish the snake.rs file, and also continue with the rest of the files (main.rs, draw.rs, game.rs).

snake.rs

As a reminder from the [1st part], we had finished working with the functions draw, head_position and move_forward in the snake.rs file.

Functions: head_direction, next_head, restore_tail and overlap_tail

Time to create a new function that will allow us to take in our snake or a reference to our snake and then get a direction.

pub fn head_direction(&self) -> Direction { 
      self.direction
}
Enter fullscreen mode Exit fullscreen mode

Alright, so we want another method, I'm going to call it next_head. This will take in a reference to &self and an Option<Direction>, and then it will output a tuple of i32. So we'll say let (head_x, head_y): (i32, i 32) and then we'll get the head_position using our head_position method.

pub fn next_head(&self, dir: Option<Direction>) -> (i32, i32) {
        let (head_x, head_y): (i32, i32) = self.head_position();

        let mut moving_dir = self.direction;
        match dir {
            Some(d) => moving_dir = d,
            None => {}
        }

        match moving_dir {
            Direction::Up => (head_x, head_y - 1),
            Direction::Down => (head_x, head_y + 1),
            Direction::Left => (head_x - 1, head_y),
            Direction::Right => (head_x + 1, head_y),
        }
    }
Enter fullscreen mode Exit fullscreen mode

We'll get the snake direction with the mutable moving direction let mut moving_dir = self.direction; and then we're going to match on the direction that we're passing into the method.

Then we're going to match again on this new moving_dir, this will help with accuracy.

Finally, we have two more methods we want to create. Create another public function called restore_tail. It will take in a reference to our mutable Snake. We'll also create a block which will be based on our tail. Then we're going to push_back our cloned tail into the back of our body.

Basically, as you know the tail doesn't get rendered unless we eat an apple. So if we eat an apple this method will be run and the tail will be pushed into our linked list body. This is how our snake is growing in size.

   pub fn restore_tail(&mut self) {
        let blk = self.tail.clone().unwrap();
        self.body.push_back(blk);
    }

Enter fullscreen mode Exit fullscreen mode

Last but not least, we have our last method for this file. Let's call this method overlap_tail. It will take in our Snake an x and a y, then we will pass back a boolean.Let's also create a mutable value and set it to equal to zero. We'll iterate through our snake body and we'll check to see if x equals block.x and if y equals block.x. So in other words:

  • If our snake is overlapping with any other part of its actual body then we'll return true.

  • Otherwise, we're going to increment ch.

Then we're going to check if ch equals == self.body.len() - 1, what we're doing with this part of our method is checking to see if our snake is actually overpassing the tail. If the tail and the head overlap in the same block there is actually a moment where the head will be in that block and so will the tail and we don't want this to cause a failure state so we break.

     pub fn overlap_tail(&self, x: i32, y: i32) -> bool {
        let mut ch = 0;
        for block in &self.body {
            if x == block.x && y == block.y {
                return true;
            }

            ch += 1;
            if ch == self.body.len() - 1 {
                break;
            }
        }
        return false;
    }

Enter fullscreen mode Exit fullscreen mode

That's it for our snake file! Woohoo! Take a moment to reflect on the code we wrote so far, cause quite honestly we have a few more functions to write in the other files! 😊

game.rs

Let's go to the game.rs file. Same as with our other files we want to come into our main file and type mod game; to link it up with our project.

Then, back on the game.rs files, we want to import all of the piston_window (that's why we'll use the asterisk).

We also want the random library and we want to get out thread_rng as it allows us to create a thread local random number generator (this way we're using our operating system to create a random number). We're also bringing in the Rng .

use piston_window::types::Color;
use piston_window::*;

use rand::{thread_rng, Rng};

Enter fullscreen mode Exit fullscreen mode

Then we also want to bring in our Snake direction and then the Snake itself.

use crate::snake::{Direction, Snake};

And we also want to bring in our Draw block and our Draw rectangle functions.

use crate::draw::{draw_block, draw_rectangle};

We want to create 3 constants:

  • FOOD_COLOR: This will be red, so 0.8 and it will have an opacity of 1.

  • BORDER_COLOR: This will be completely black.

  • GAMEOVER_COLOR: This will be 0.9 so it will be red again, but it will have an opacity of 0.5.

const FOOD_COLOR: Color = [0.80, 0.00, 0.00, 1.0];
const BORDER_COLOR: Color = [0.00, 0.00, 0.00, 1.0];
const GAMEOVER_COLOR: Color = [0.90, 0.00, 0.00, 0.5];

Enter fullscreen mode Exit fullscreen mode

Then we also want to create 2 other constants.

  • MOVING_PERIOD: This is essentially the frames per second that our snake will move at.

  • RESTART_TIME: The restart time is 1 second. When we hit a failure state with our snake this will pause the game for one second before resetting it. If you find this to be too fast you can fiddle around with it.

const MOVING_PERIOD: f64 = 0.1;
const RESTART_TIME: f64 = 1.0;
Enter fullscreen mode Exit fullscreen mode

Alright, now we're going to create a new struct called Game. This will have a snake in it but also the food which will be a boolean. If food_exists on the board then we don't need to spawn more. We'll have the food_x and food_y coordinates, and then we'll have the width and the height of the actual game board. Finally, we'll have the game state (game_over) as a boolean and the waiting_time which is the restart time up.

pub struct Game {
    snake: Snake,

    food_exists: bool,
    food_x: i32,
    food_y: i32,

    width: i32,
    height: i32,

    game_over: bool,
    waiting_time: f64,
}

Enter fullscreen mode Exit fullscreen mode

Implementation block Game

We want to make an implementation block for our game so we can create some methods. We're going to create a new method so that we can instantiate a new game. This will take in the width and the height of the actual game board itself and then we'll output a Game which will then run the Snake::new(2,2) function (2,2 is 2 units out and 2 units down). Then our waiting_time will be 0 so the snake will automatically start moving. food_exists will be true so the food will spawn and it will spawn at this food_x and food_y. Then we have our width and height, these are the size of the board and then our game_over will be false. When the game is running this will be false and then once we hit a wall or we hit ourselves it will turn to true.

impl Game {  
    pub fn new(width: i32, height: i32) -> Game {
        Game {
            snake: Snake::new(2, 2),
            waiting_time: 0.0,
            food_exists: true,
            food_x: 6,
            food_y: 4,
            width,
            height,
            game_over: false,
        }
    }

Enter fullscreen mode Exit fullscreen mode

Now we want to create another method called key_pressed, this will allow us to figure out whether or not the user has pressed the key and then react accordingly. So key_pressed takes in a mutable game self and then it takes in a key type. If game_over then we want to just quit but if it's not then we want to match on key and:

  • If Key::Up => Some(Direction::Up) then we're going to go up.

  • If Key::Down => Some(Direction::Down) then we're going to go down.

  • Etc...

Then we're going to check dir, if dir == self.snake.head_direction().opposite() then we're going to quit out of this function. So for example, if the snake is moving up and we try to hit down then nothing will happen.

    pub fn key_pressed(&mut self, key: Key) {
        if self.game_over {
            return;
        }

        let dir = match key {
            Key::Up => Some(Direction::Up),
            Key::Down => Some(Direction::Down),
            Key::Left => Some(Direction::Left),
            Key::Right => Some(Direction::Right),
            _ => Some(self.snake.head_direction()),
        };

        if let Some(dir) = dir {
            if dir == self.snake.head_direction().opposite() {
                return;
            }
        }

        self.update_snake(dir);
    }

Enter fullscreen mode Exit fullscreen mode

Alright, as you can see above, in the last line, I have the self.update_snake(dir);, but we haven't written it yet. We'll do that pretty soon... Keep reading and coding with me.

Let's create a public draw function. It will take in a reference to our game board, the context and our graphics buffer. First, we're going to call self.snake.draw and what this will do is to iterate through our linked list and then draw_block based on those linked lists. Then we're going to check and see if food_exists. If this comes back as true then we're going to draw_block with the FOOD_COLOR , self.food.x and self.food.y.

pub fn draw(&self, con: &Context, g: &mut G2d) {
        self.snake.draw(con, g);

        if self.food_exists {
            draw_block(FOOD_COLOR, self.food_x, self.food_y, con, g);
        }

        draw_rectangle(BORDER_COLOR, 0, 0, self.width, 1, con, g);
        draw_rectangle(BORDER_COLOR, 0, self.height - 1, self.width, 1, con, g);
        draw_rectangle(BORDER_COLOR, 0, 0, 1, self.height, con, g);
        draw_rectangle(BORDER_COLOR, self.width - 1, 0, 1, self.height, con, g);

        if self.game_over {
            draw_rectangle(GAMEOVER_COLOR, 0, 0, self.width, self.height, con, g);
        }
    }

Enter fullscreen mode Exit fullscreen mode

Then we're going to draw the borders and finally, we will run another check: if self.game_over then we want to draw the entire screen.

All right, now we're going to make an update function. We'll pass our game state as a mutable and then a time (delta_time: f64). Then we're going to iterate our waiting_time and if the game is over and if self.waiting_time > RESTART_TIME then restart the game. We'll use this function restart , we haven't written it yet, but keep it up and you'll write it soon with me! Otherwise, we're just going to return .

If the food does not exist then we're going to call the add_food method (we'll write it soon). Then we're going to update the snake (update_snake~ see the function below).

    pub fn update(&mut self, delta_time: f64) {
        self.waiting_time += delta_time;

        if self.game_over {
            if self.waiting_time > RESTART_TIME {
                self.restart();
            }
            return;
        }

        if !self.food_exists {
            self.add_food();
        }

        if self.waiting_time > MOVING_PERIOD {
            self.update_snake(None);
        }
    }

Enter fullscreen mode Exit fullscreen mode

Now let's check and see if the snake has eaten. We have a new function check_eating which takes the mutable game state. We're going to find the head_x and head_y of the head using our head_position method. Then we're going to check if the food_exists and if self.food_x == head_x && self.food_y == head_y. If the head overlaps with our food then we're going to say that food doesn't exist anymore (false) and call our restore_tail function. In other words our snake is going to grow one block!

fn check_eating(&mut self) {
        let (head_x, head_y): (i32, i32) = self.snake.head_position();
        if self.food_exists && self.food_x == head_x && self.food_y == head_y {
            self.food_exists = false;
            self.snake.restore_tail();
        }
    }

Enter fullscreen mode Exit fullscreen mode

Now we want to check if the snake is alive! We have a new function check_if_snake_alive and we pass in our reference to self and then an Option of Direction , we're also going to pass back a boolean. We're going to check if the snake head overlaps with the tail self.snake.overlap_tail(next_x, next_y) , in this case, we'll return false. If we go out of bounds of the window then the game will end and it will restart after a second.

    fn check_if_snake_alive(&self, dir: Option<Direction>) -> bool {
        let (next_x, next_y) = self.snake.next_head(dir);

        if self.snake.overlap_tail(next_x, next_y) {
            return false;
        }

        next_x > 0 && next_y > 0 && next_x < self.width - 1 && next_y < self.height - 1
    }

Enter fullscreen mode Exit fullscreen mode

Now let's actually add the food! The add_food is the method that we were calling in the update function. It takes a mutable game state and then we create an rng element and call our thread_rng . We'll check if the snake is overlapping with the tail (we don't want the snake to overall with the apple), and then we'll set the food_x and food_y and also the food_exists to true.

    fn add_food(&mut self) {
        let mut rng = thread_rng();

        let mut new_x = rng.gen_range(1..self.width - 1);
        let mut new_y = rng.gen_range(1..self.height - 1);
        while self.snake.overlap_tail(new_x, new_y) {
            new_x = rng.gen_range(1..self.width - 1);
            new_y = rng.gen_range(1..self.height - 1);
        }

        self.food_x = new_x;
        self.food_y = new_y;
        self.food_exists = true;
    }

Enter fullscreen mode Exit fullscreen mode

Perfect, we're getting closer! We just need a few more functions.

Let's create the update_snake function which was mentioned above, in the update and key_pressed functions. We pass in our reference to self and then an Option of Direction. We'll check if the snake is alive, and if it is then we'll move_forward and check for eating, if it's not the the game_over becomes true and we set the waiting_time to 0.0.

  fn update_snake(&mut self, dir: Option<Direction>) {
        if self.check_if_snake_alive(dir) {
            self.snake.move_forward(dir);
            self.check_eating();
        } else {
            self.game_over = true;
        }
        self.waiting_time = 0.0;
    }

Enter fullscreen mode Exit fullscreen mode

Let's also write the restart method that we saw in the restart function. We pass in our reference to self and then we create a new Snake game, and set all the other parameters as well (like wating_time, food_exists, etc). This is very similar to the new function. The reason we don't call it it's because we don't want to render a new window everytime the game resets!

main.rs

Alright! Time to move on to main.rs.

Make sure you have imported the piston_window and the crates game and draw. We also want a CONST for BACK_COLOR (the color looks like gray):

use piston_window::*;
use piston_window::types::Color;

use crate::game::Game;
use crate::draw::to_coord_u32;

const BACK_COLOR: Color = [0.5, 0.5, 0.5, 1.0];

Enter fullscreen mode Exit fullscreen mode

Note the to_coord_u32 function. This is very similar to to_coord from draw.rs except here we don't want to return an f64 but a u32.

In the fn main() we'll get the width and the height and set it to (20, 20) (you can obviously set it to whatever you prefer), then we're going to create a mutable window which will be a PistonWindow and we'll create: a Snake game, a game window ([to_coord_u32(width), to_coord_u32(height)] ), we want to build the actual window and finally we have the unwrap to deal with any errors.

fn main() {
    let (width, height) = (30, 30);

    let mut window: PistonWindow =
        WindowSettings::new("Snake", [to_coord_u32(width), to_coord_u32(height)])
            .exit_on_esc(true)
            .build()
            .unwrap();

    .
    .
    .
}

Enter fullscreen mode Exit fullscreen mode

Then we'll create a new Game with width and height. If the player presses a button, we're going to call the press_args and then pass a key in key_pressed , otherwise, we're going to draw_2d and pass in the event, clear the window and then draw the game.

Lastly, we're going to update the game with arg.dt.

let mut game = Game::new(width, height);
    while let Some(event) = window.next() {
        if let Some(Button::Keyboard(key)) = event.press_args() {
            game.key_pressed(key);
        }
        window.draw_2d(&event, |c, g, _| {
            clear(BACK_COLOR, g);
            game.draw(&c, g);
        });

        event.update(|arg| {
            game.update(arg.dt);
        });
    }

Enter fullscreen mode Exit fullscreen mode

That's it, our game is finished! πŸ‘πŸ‘

Run the Game

You can run in your terminal cargo check to check if there are any errors and then cargo run to play the game! Enjoy and congrats on building it.

Thank you for staying with me in this long, 2-parts, tutorial.

I hope you liked it and learned something new. If you have any comments or need more details don't hesitate to type your thoughts.

Find the code here.

Happy Rust Coding! πŸ€žπŸ¦€


πŸ‘‹ Hello, I'm Eleftheria, Community Manager, developer, public speaker, and content creator.

πŸ₯° If you liked this article, consider sharing it.

πŸ”— All links | X | LinkedIn

Top comments (0)