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 update
and this will update all of your dependencies in thecargo.lock
file. If we go to thelock
file, we can search out the two libraries, then copy the number and replace the asterisk back in thetoml
file.
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
andY
.Our second block is an
`x+1`
.Our third block is
Y
and thenX+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 unwrap
method 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.