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

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

Introduction

Hello, amazing people. Today I'm excited to show you how to code the "Snake Game" in Rust! I feel that Snake is the perfect program to showcase your skills. This Snake game has multiple different components.

Basic information about the Components of the Game

🐍 The Snake can move in all directions. If our Snake eats an apple, it will grow by one "point". When we push an arrow button, the head will move up or down or left or right. The Snake cannot touch itself. For example, if it's going to the left and I push the right key, it won't be able to go backward, this would cause the game to enter a fail state because the Snake cannot actually touch itself. If the Snake touches itself, it dies and we have to restart the game. Also, if the Snake hits the wall, it dies. Those are the two main failure states for the game.

🍎 The second component is the Apple. In most Snake games, the Apple appears at random places. With this game, you'll notice that the Apple appears at the same place every time we start the game. The same with the Snake. The Snake will appear at a fixed place every time we restart it.

🧱 The third component is the walls. The walls will be represented as a rectangle that goes around the entire border of our game board. The Snake cannot pass through the walls.

πŸ•ΉοΈ Finally, we have the game board itself. This will be a 2D plane that the Snake moves along and that the Apple spawns in.


You'll need to create these 4 files:

Dependencies

Cargo.toml

In the Cargo file, we want to add two dependencies.

rand = "0.8.5"
piston_window = "0.131.0"

The first dependency is rand (for random), this is a library that will allow us to deal with the random numbers for our Apple.

The second dependency is piston_window. This will allow us to render our elements with a UI as well as deal with some of the game logic.

Tip: When you write the dependencies you can use inside the quotes an asterisk for the version numbers. Then go to the terminal, typecargo updateand this will update all of your dependencies in thecargo.lockfile. If we go to thelockfile, we can search out the two libraries, then copy the number and replace the asterisk back in thetomlfile.

rand = "*"
piston_window = "*"

The reason it's important to use static versions is just in case the library actually changes. If the syntax changes, then the game will not work properly anymore because we will be behind the API. Normally, libraries try to keep a consistent API, but sometimes it does change.

main.rs

In the main we'll have: rand and piston_window crate

extern crate piston_window;
extern crate rand;

draw.rs

Add draw.rs to the main.rs -> mod draw;

Now let's start working on some helper functions!

The imports that we want to make inside our draw file are about piston_window.

use piston_window::{rectangle, Context, G2d};
use piston_window::types::Color;

Now, the first thing we want to do is to create a BLOCK_SIZE constant. Constants in Rust, like many other programming languages, require that we use uppercase letters and we need to specify the type annotation and what the value is equal to. In this case, we want it to be equal to 25. This means our blocks will scale up 25 pixels.

const BLOCK_SIZE: f64 = 25.0;

Functions: to_coord , draw_block and draw_rectangle

Now we want to create a function to_coord, this will take in a game coordinate which will be an i32 and then we want to return an f64. So what we're just doing with this helper function is taking in a coordinate.

pub fn to_coord(game_coord: i32) -> f64 {
    (game_coord as f64) * BLOCK_SIZE
}

We're going to cast it to an f64 and then multiply it by our block size. Also we're using the pub keyword, which allows us to export this function and make it public to our entire program.

Alright so now let's look at our first major public helper function (we want to draw a block):

pub fn draw_block(color: Color, x: i32, y: i32, con: &Context, g: &mut G2d) {
    let gui_x = to_coord(x);
    let gui_y = to_coord(y);

    rectangle(
        color,
        [gui_x, gui_y, BLOCK_SIZE, BLOCK_SIZE],
        con.transform,
        g,
    );
}

We're passing a color, and an X and a Y, both are i32. We also need to pass in the context and a G2d . Then we call our rectangle and pass in a color and the actual parameters for the rectangle and then the width and the height. Finally, we need to pass in the context transform and our g.

Next we want to create a public function called draw_rectangle. This will be a slight modification on the draw_block function. We're still passing in a color and the X and the Y, but next we're also passing in the width and the height (this will allow us to draw rectangles). The only real difference is that we take the block size and we multiply it by the width, cast it as an f64 and the height casts it as an f64. This way we can control the size of our rectangle. (We're going to mainly use this for the size of our board).

pub fn draw_rectangle(
    color: Color,
    x: i32,
    y: i32,
    width: i32,
    height: i32,
    con: &Context,
    g: &mut G2d,
) {
    let x = to_coord(x);
    let y = to_coord(y);

    rectangle(
        color,
        [
            x,
            y,
            BLOCK_SIZE * (width as f64),
            BLOCK_SIZE * (height as f64),
        ],
        con.transform,
        g,
    );
}

These are our helper functions in draw.js. We'll need one more later but for now we are OK!

snake.rs

Let's move to the snake.rs file. In this file we're going to tie most of the logic that we need to actually create our snake.

Imports: First we're importing from the standard library collections a type called LinkedList. A linked list allows pushing and popping elements from either end. Next, we're bringing in our context and our graphical buffer again. We're also bringing in the color type.

use std::collections::LinkedList;
use piston_window::{Context, G2d};
use piston_window::types::Color;

Let's also bring the draw_block function from the draw.rs file.

use crate::draw::draw_block;

At this point, don't forget to add the snake.rs to the main.rs file.

mod snake;

Next, we want to create a constant for our snake color. It's an array of four elements. Each element corresponds with a part of the color spectrum. The first item is our red element. The second item is our green element. The third item is our blue element. And then the fourth element is our opacity. We want to have a green snake hence I'll have this as 0.80 and we want it to have 1.0 opacity.

const SNAKE_COLOR: Color = [0.00, 0.80, 0.00, 1.0];

The next thing we want to do is create an enum for the direction. The enum will handle the direction of the snake as well as how our keyboard inputs interact with the snake. We want the snake to be able to go up, down, left, and right on our screen.

pub enum Direction {
    Up,
    Down,
    Left,
    Right,
}

I want to do one more thing: If the snake is going up and I try to hit down, the snake shouldn't be able to go down. Let's see how to implement this:

impl Direction {
    pub fn opposite(&self) -> Direction {
        match *self {
            Direction::Up => Direction::Down,
            Direction::Down => Direction::Up,
            Direction::Left => Direction::Right,
            Direction::Right => Direction::Left,
        }
    }
}

As you can see above, we have a new public function opposite that takes in a reference to &self and outputs a Direction. Then we match with *self.

  • If the direction is up, then pass back direction down.

  • If the direction is down, pass back direction up.

  • Etc...

Next, we want to create a struct for our block type. We want to have an X and a Y, both of i32.

struct Block {
    x: i32,
    y: i32,
}

And inside of Snake, we want to have the following states:

pub struct Snake {
    direction: Direction,
    body: LinkedList<Block>,
    tail: Option<Block>,
}
  • The direction that the snake is currently traveling in.

  • The body of the snake, which will be a LinkedList of blocks.

  • The tail, which will be an Option<Block>. (This is important because we want to have our tail be an actual value when we eat an apple.)

Now we want to create an implementation block for our Snake so that we can create methods. We're going to create a function called new , it will take in an X and a Y value and output our Snake.

impl Snake {
    pub fn new(x: i32, y: i32) -> Snake {
        let mut body: LinkedList<Block> = LinkedList::new();
        body.push_back(Block {
            x: x + 2,
            y,
        });
        body.push_back(Block {
            x: x + 1,
            y,
        });
        body.push_back(Block {
            x,
            y,
        });

        Snake {
            direction: Direction::Right,
            body,
            tail: None,
        }
    }
.
.
}

We will create a mutable body, which will be a linked list of blocks. Then we'll use the push_back method (it appends an element to the back of a list). Essentially, what we're doing here is we're setting up the default Snake.

  • Our first block is X and Y.

  • Our second block is an `x+1` .

  • Our third block is Y and then X+2.

So our Snake will be horizontal with the X and Y coordinate. It will also start out moving in the direction of right and the tail will be none. It will be exactly three blocks long.

Functions: draw , head_position , move_forward

We want to create a function called draw. It will take in a reference to &self, the context, and our graphical buffer. Then we will iterate through our list.

pub fn draw(&self, con: &Context, g: &mut G2d) {
   for block in &self.body {
        draw_block(SNAKE_COLOR, block.x, block.y, con, g);
   }
}

We'll call our draw_block function on each of the blocks of the Snake with our SNAKE_COLOR inside of it. (This will render out a green snake.)

Now we want to create a head_position function. It will take a mutable &self variable and then it will output a tuple of i32. We'll find the head of our Snake by using the self.body.front() method. Our return will be head_block.x and head_block.y.

pub fn head_position(&self) -> (i32, i32) {
        let head_block = self.body.front().unwrap();
        (head_block.x, head_block.y)
    }

Then, we're going to create a move_forward function. It will take in a mutable Snake reference and a dir which will be an Option with a Direction inside of it. First, we'll match on dir to get the option away from it.

pub fn move_forward(&mut self, dir: Option<Direction>) {
   match dir {
       Some(d) => self.direction = d,
       None => (),
   }

    let (last_x, last_y): (i32, i32) = self.head_position();
.
.
}

Now let's work on the direction.

If we're going in Direction::Up then we're going to create a new Block (this is going to end up on the head of our snake).

let new_block = match self.direction {
            Direction::Up => Block {
                x: last_x,
                y: last_y - 1,
            },
            Direction::Down => Block {
                x: last_x,
                y: last_y + 1,
            },
            Direction::Left => Block {
                x: last_x - 1,
                y: last_y,
            },
            Direction::Right => Block {
                x: last_x + 1,
                y: last_y,
            },
};

Note: 🎢Hello Math my old friend...!

As we go down this is actually the positive Y axis. For actually moving downwards we're moving up the Y axis. Now left and right are as you would actually imagine them. For left we're subtracting 1 and then for right we're adding 1.

To recap, we're removing the last block and adding a new one in front.

Let's push this into the front of our list:

self.body.push_front(new_block);
let removed_block = self.body.pop_back().unwrap();
self.tail = Some(removed_block);

We call self.body.pop_back(), this will pop off the back part of our linked list. And then we use that unwrapmethod again. Finally, we set self.tail equal to Some(removed_block).

Read part 2/2 here:


Alright folks, since this article is getting too long and a bit hard to manage, and quite frankly we have some more steps and concepts to cover, I'll create a part 2 where we'll finish the snake.rs file, and also continue with the rest of the files we created at the beginning of this tutorial.

In this tutorial, we explored how to create a Snake game in Rust, focusing on setting up the game environment and initializing key components such as the snake, apple, walls, and game board. We discussed adding necessary dependencies via Cargo.toml, and started coding the main.rs, draw.rs, and snake.rs, where we defined functions and structures essential for the game’s functionality. We also introduced drawing functions and snake movement logic, setting the stage for further development in a subsequent part of the tutorial.

You can already find all the code here and part 2 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

Did you find this article valuable?

Support Eleftheria Batsou by becoming a sponsor. Any amount is appreciated!

Β