Tutorial: Snake game in Rust (Part 2/2)🐍🦀
Eleftheria Batsou
Posted on May 4, 2024
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
}
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),
}
}
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);
}
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;
}
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};
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];
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;
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,
}
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,
}
}
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);
}
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);
}
}
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);
}
}
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();
}
}
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
}
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;
}
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;
}
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];
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();
.
.
.
}
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);
});
}
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.
Posted on May 4, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.