Since 2016, Rust has been voted the “most loved programming language” every year in the Stack Overflow Developer Survey by what appears to be a growing margin, and after checking it out for myself, it’s pretty clear why.
Rust offers a plethora of features you’d expect from a modern language and addresses pain points that are present in many others. It competes in the same kind of space occupied by C and C++, offering similar performance, but it is also known for safety, reliability and productivity.
The trade-off with Rust is in its complexity, however; it has a reasonably steep learning curve, particularly with regards to its unique memory management model known as ownership.
In this post, we’ll explore a step by step implementation of Snake, the simple but addictive game found preloaded on old Nokia phones. We’ll use the terminal as the UI and the keyboard for input.
I should preface this post by stating that I am by no means an expert in Rust. I’ve been learning it on and off in my spare time for the past year or so. I’ve also more or less thrashed the game out without much thought for code quality, just as a learning exercise more than anything else.
You can find the code for the game on GitHub, where I would also be happy to address issues or make corrections to this post.
Getting started
Let’s create our project using Rust’s build system and package manager, Cargo.
$ cargo new snake-rs
This command creates a new Rust project called “snake-rs”, in a folder of the same name in the current directory. It creates a ~/Cargo.toml
file and a ~/src/main.rs
file inside that folder. Cargo.toml
is a bit like package.json
for JavaScript/NPM based projects, or pom.xml
in a Java/Maven project and the main.rs
contains a main function which is the entry point to the application.
fn main() {
println!("Hello, world!");
}
Foundation
Let’s start with a few building blocks for the game.
The Direction Enum
The snake will always be travelling in one of four different directions, so we’ll use an enum
to define the Direction
type:
// src/direction.rs
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub enum Direction {
Up,
Right,
Down,
Left
}
Enums in Rust are just like those found in Java; they define a fixed set of possible variants for a value. In the code above, the pub
declares that the enum type is public; in Rust everything is considered private unless explicitly stated otherwise.
#[derive(...)]
is an attribute (annotation) which automatically derives implementations of some traits for the type.
A trait in Rust is similar to an interface with default methods in Java 8+. It is an abstraction which can declare methods that either contain a body, or must be implemented by the concrete type.
The traits named in the #[derive(...)]
attribute come from the Rust standard library and they each have a special meaning. By using this attribute, Rust automatically makes our enum implement those traits and provides their implementations.
The Command Enum
The controls for the game will be pretty simple. The player will use the directional arrows on a keyboard to tell the snake to turn to face a different direction. We’ll also let the player quit the game by pressing ESC
, Q
or CTRL+C
.
Let’s create an enum to represent these commands:
// ~/src/command.rs
use crate::direction::Direction;
pub enum Command {
Quit,
Turn(Direction),
}
Simple enough! The only two variants of the Command
enum are Quit
and Turn
.
Turn
also accepts a value of type Direction
, which happens to be the enum that we created earlier. Unlike enums in languages like Java, individual variants of Rust enums may also contain data such as basic types, structs, or even other enums.
When we receive a Command
we can easily determine if we have a quit or turn instruction, and if the latter, which direction we’ve been asked to turn.
The use
statement is just like the import
statement in Java, in that it allows the use of symbols without the need to specify the full path. The crate
keyword at the beginning of the path simply refers to the root of the current crate, which in this case is the current application.
The Point Struct
Our game will be based on a positive, two-dimensional coordinate system, starting from (0, 0). To represent each point in the system, let’s declare another type:
// ~/src/point.rs
#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq)]
pub struct Point {
pub x: u16,
pub y: u16,
}
This looks a little different to the data structures we’ve seen so far. We’ve used a struct
to declare our custom type. A struct is a little like a Java class, in that it represents a type that can be instantiated and hold several different, named pieces of data with different types.
Our struct stores two fields, x
and y
, both of which have the unsigned 16-bit integer type, u16
(which holds values between 0 and 65535). Rust has many different integer types, both signed and unsigned and of varying sizes.
Lastly, we’ve used pub
on each of the fields to signify that their values are accessible from outside the struct instance.
We could have just as easily used a (u16, u16)
tuple type to represent such a simple value, but for convenience and to allow us to extend this type further, a struct is a better choice.
We’ll now add some methods to this struct to make it a bit more useful:
// ~/src/point.rs
impl Point {
pub fn new(x: u16, y: u16) -> Self {
Self { x, y }
}
pub fn transform(&self, direction: Direction, times: u16) -> Self {
let times = times as i16;
let transformation = match direction {
Direction::Up => (0, -times),
Direction::Right => (times, 0),
Direction::Down => (0, times),
Direction::Left => (-times, 0),
};
Self::new(
Self::transform_value(self.x, transformation.0),
Self::transform_value(self.y, transformation.1),
)
}
fn transform_value(value: u16, by: i16) -> u16 {
if by.is_negative() && by.abs() as u16 > value {
panic!("Transforming value {} by {} would result in a negative number", value, by);
} else {
(value as i16 + by) as u16
}
}
}
In Rust, we separate the implementation of a struct from its definition unlike in Java where we’d have everything in one place. We use an impl
block to add methods and functions to the struct.
The first part inside the impl
block declares a function (fn
) named new. Unlike in Java, Rust does not have constructors; instead, struct instances may be created directly by specifying the values of all fields:
let point = Point { x: 1, y: 2 };
A common convention is to create a constructor function named new which can simplify the creation of structs, especially if manual creation is error prone, verbose or there are commonly used default values (which can be omitted from the constructor function’s parameter list).
The new
function accepts two values, x
and y
of type u16
and returns (indiciated by ->
) a Self
. In Rust, Self
(with the uppercase S) refers to the type we’re implementing, in this case Point
.
The body of the function is a single line which simply creates and returns a Point
instance. The syntax may look a little odd, but it could be rewritten like this:
return Point { x: x, y: y };
When creating a data structure, we can use the shorthand x
in place of x: x
if the name of the variable/symbol is the same as the field. Also, just like in Scala, if the return
keyword is not used, the result of the last expression is assumed to be the return value. This means that the return
keyword can in most cases be omitted entirely, provided that a semicolon is not used (which would turn the expression into a statement).
transform
looks a little different; the first thing in its parameter list is &self
. This means that the function will receive a read-only reference to the instance of the struct which can be accessed using self
, similar to Java’s this
keyword except that we’re not allowed to make any modifications.
The presence of &self
(or &mut self
) as the first item in the parameter list means that the function is a method which operates on an instance of the struct. An omission of this first item means that we’re working with what is like a static method in Java; we don’t receive an instance of the struct to operate upon, we’re just writing a function which happens to be attached to the struct. If you’ve done any Python, this may feel somewhat familiar.
// A "static" method is called with Type::function(...)
let a = Point::new(1, 2);
// but we use dot notation to call an instance method: instance.method(...)
let b = a.transform(Direction::Up, 1);
transform
accepts a Direction
and times
, an unsigned 16-bit integer and returns a new Point
. It first casts times
to a signed integer so that it can support negative values and uses a match
to work out a transformation to apply based on the current direction
.
The match
control-flow operator is an extremely powerful feature of Rust, similar to Scala’s match
or a significantly improved switch
statement from Java. Its syntax is pretty intuitive; we simply check each of the branches on the left side of the =>
arrow and evaluate the expression to the right side for the matching branch.
We then uses the private function, transform_value
to create new x
and y
values based on the transformation and the Point
’s current values. transform_value
uses an if
expression to carry out a bounds check and then applies the transformation by adding by
to value
.
If the bounds check fails (which should ideally never happen), the panic!
macro is used to bail out of the application with a fatal error.
Ownership and Borrowing
Before we go any further with the game it is worth mentioning ownership and borrowing, since we’ve already touched on them a little in the previous sections.
Ownership
One of the unique, core features of the language, the concept of ownership helps Rust deliver on its memory safety guarantees. However, the subject is also one of the biggest stumbling blocks for beginners and is probably the biggest contributor to its steep learning curve.
In Rust, variables are declared with the let
keyword. By default, all variables are immutable; they cannot be re-assigned and their values cannot be changed. It is possible to declare a variable as mutable using the mut
keyword:
fn main() {
let mut a = 1; // a is declared as 1 (mutable).
a = 2; // a is now 2.
let b = 1; // b is declared as 1 (immutable).
b = 2; // Compilation error. Since we tried to re-assign an immutable variable.
}
When execution comes to the end of a scope in which a variable is declared, the value will be dropped and the memory will be reclaimed. This is all managed at compile time:
fn main() {
let a = 1;
if true {
let b = 2;
// b is dropped here
}
// a is dropped here
}
A variable is considered to be the owner of a value and there can only ever be one owner at any given time. By default, re-assigning a value moves ownership, which means that the original variable is no longer valid:
struct Foo {}
fn main() {
let a = Foo {}; // Declare a to be an immutable instance of Foo
let b = a; // a is moved to b, a ceases to be valid
let c = a; // Compilation error. Since the value of a was moved to b, a is no longer valid
}
A move will also occur when passing a value to a function:
struct Foo {}
fn main() {
let a = Foo {}; // Declare a to be an immutable instance of Foo
bar(a); // a is moved to the bar function, a ceases to be valid
let b = a; // Compilation error. Since the value of a was moved to the f parameter of bar,
} // a is no longer valid
fn bar(f: Foo) {
println!("Called bar");
}
An exception to this rule is if the type implements the Copy
trait (remember #[derive(Copy, Clone...)]
from earlier?). This trait marks that the type should be copied, rather than moved.
If Foo
implemented the Copy
trait, the two previous code snippets would compile and each variable (a
, b
and c
in the first, as well as a
, b
and the f
parameter of bar
in the second) would hold distinct, but identical instances of Foo
.
There are some types in Rust that implement
Copy
by default:
- All of the signed and unsigned integer types (like
i32
,u64
etc.)- Floating point types like
f64
- The boolean type
bool
- The character type
char
- Tuples that only contain types that implement
Copy
(e.g.(i32, char)
does but(i32, String)
does not).
References and Borrowing
The move and copy semantics may initially seem pretty tedious and you may be wondering how to work with such seemingly bizarre restrictions. Bear with me, things will soon become clear (hopefully - this is a pretty tricky concept to grasp).
Rust supplements the ownership system with a mechanism called borrowing, which allows access to data without taking ownership.
An immutable reference (or borrow) of a variable can be created using the &
operator. This allows read-only access to a value without transferring ownership. Multiple immutable references may be created from a single variable:
#[derive(Debug)] // Derive an implementation of the Debug trait. This allows us to use instances
struct Foo {} // of Foo in println!() macros, using the {:?} placeholder
fn main() {
let a = Foo {}; // Declare a to be an immutable instance of Foo
let b = &a; // Declare b as an immutable reference of a
let c = &a; // Declare c as a second immutable reference of a
println!("{:?} {:?}", b, c);
}
Functions and methods can also accept immutable references of types to allow them to be called without ownership being moved:
#[derive(Debug)]
struct Foo {}
fn main() {
let a = Foo {}; // Declare a to be an immutable instance of Foo
bar(&a); // Pass an immutable reference of a to bar. Note the signature of the bar function
// At this point, a still owns the instance of Foo
let b = a; // Re-assign a to b (notice we didn't use &), a ceases to be valid and b is now the owner
}
fn bar(f: &Foo) { // The bar function accepts an immutable reference of type Foo
println!("Called bar");
// f goes out of scope here, but since it is a reference (not owned), the value is NOT dropped
}
Remember before when we created some methods on our data structures and &self
appeared as the first item in the parameter list? As you might be able to guess here, self
refers to the current instance and &
means immutable reference; these methods operate upon an immutable reference of an instance of Foo
. They may read properties and call other read-only methods:
#[derive(Debug)]
struct Foo {
value: u32
}
impl Foo {
pub fn bar(&self) { // An immutable reference of an instance of Foo is received
println!("{}", self.value); // Print the value field of the instance of Foo
}
}
fn main() {
let a = Foo { value: 12 }; // Declare a as Foo, with the value field initialised to 12
a.bar(); // Call the bar method on a; a is passed as an immutable
// reference to the bar method
println!("{:?}", a);
}
A mutable reference can also be created using &mut
, but only if the variable is declared as mutable:
struct Foo {}
fn main() {
let mut a = Foo {}; // Declare a as a mutable instance of Foo
let b = &mut a; // Declare b as a mutable reference of a
let c = Foo {}; // Declare c as an immutable instance of Foo
let d = &mut c; // Compilation error. Since a is not mutable, it cannot be mutably borrowed
}
A mutable reference is allowed to modify the value it refers to, but in the above example, Foo
does not have anything that can be changed.
There can only ever be one mutable reference of a value at a time. It is possible to create what appears to be multiple mutable references, but the act of creating another effectively invalidates the previous. Attempting to use the first mutable reference after another mutable reference has been created results in a compilation error:
#[derive(Debug)]
struct Foo {}
fn main() {
let mut a = Foo {}; // Declare a as a mutable instance of Foo
let b = &mut a; // Declare b as a mutable reference of a
let c = &mut a; // Declare c as a mutable reference of a; b is invalidated
println!("{:?}", b); // Compilation error. Since b is invalid. If we'd tried to
// print c instead, compilation would have succeeded.
Functions can also accept mutable references and may modify the value that they refer to:
#[derive(Debug)]
struct Foo {
pub value: u32 // Note that the value field is declared to be public
}
fn main() {
let mut a = Foo { value: 12 }; // Declare a as a mutable instance of Foo with value 12
bar(&mut a); // Pass a mutable reference of a to function bar
println!("{:?}", a.value); // After calling bar, a.value is 13, so this prints "13"
}
fn bar(f: &mut Foo) { // bar accepts a mutable reference of foo
f.value = 13; // bar modifies the internal value field of foo
}
We can also create instance methods on data structures that can mutate state, using &mut self
as the first item in the parameter list:
struct Foo {
value: u32
}
impl Foo {
pub fn add_1(&mut self) { // A mutable reference of an instance of Foo is received
self.value += 1; // The value field is modified, by adding 1 to its current value
println!("{}", self.value); // The current value is printed.
}
}
fn main() {
let mut a = Foo { value: 12 }; // Declare a as a mutable instance of Foo, with initial value 12
a.add_1(); // Call the add_1 method; the value field is changed to 13 and
} // then the new value is printed out.
Mutable and immutable references are mutually exclusive. It is not possible to have both at the same time:
#[derive(Debug)]
struct Foo {}
fn main() {
let mut a = Foo {}; // Declare a as a mutable instance of Foo
let b = &a; // Declare b as an immutable referece of a
let c = &mut a; // Declare c as a mutable reference of a, effectively invalidating b.
println!("{:?}", b); // Compilation error. Since we cannot have simultaneous mutable and
} // immutable references, b is effectively invalidated by c
Don’t worry if you don’t quite get ownership, references and borrowing just yet. It’s a notoriusly difficult concept to grasp, especially for developers coming from garbage collected languages since it feels like everything you know to be true is pulled out from under you.
If you’re interested in learning more, I’d recommend reading the sections on ownership and borrowing in the Rust book.
Back to Snake
Now we’ve at least attempted to address ownership and borrowing, let’s get back to building the game.
The Snake Struct
// ~/src/snake.rs
use crate::direction::Direction;
use crate::point::Point;
#[derive(Debug)]
pub struct Snake {
body: Vec<Point>,
direction: Direction,
digesting: bool,
}
To represent the snake itself, we’ve used a struct
comprised of 3 fields:
body
is aVec
(vector) over thePoint
type, which you can think of as a list that holds values of typePoint
. Each (x, y) item inbody
represents a point in the game’s grid that is occupied by a part of the snake. The very first item in thebody
is always the head of the snake.direction
represents the direction which the snake is currently facing using ourDirection
enum that we declared earlier. The snake will always face up, down, left or right.digesting
represents whether or not the snake has just eaten some food. We use this flag to indicate that the snake should grow when it next moves.
Now for its implementation, step by step:
// ...
impl Snake {
pub fn new(start: Point, length: u16, direction: Direction) -> Self {
let opposite = direction.opposite();
let body: Vec<Point> = (0..length)
.into_iter()
.map(|i| start.transform(opposite, i))
.collect();
Self { body, direction, digesting: false }
}
The new
constructor function creates an instance of the Snake
struct from a starting point, a length and a direction. There’s a lot going on here so we’ll examine it line by line:
- First, the
opposite
method on thedirection
is called. This method does not yet exist; we’ll get to that in just a moment. When implemented, it will return the opposite direction to the value we are operating upon. For example, ifdirection
wasDirection::Right
, then it would returnDirection::Left
and so on. - The next bit is made up of a few different parts:
(0..length)
creates astd::ops::Range
which is a struct that represents the numbers from 0 up to (but not including)length
.into_iter
creates an iterator which allows us to iterate over the range.map
creates astd::iter::Map
over the iterator, which allows us to apply a transformation to eachu16
value.- The code inside the call to
map
is a closure, where|i|
is the parameter wherei
is eachu16
item from the iterator. - The closure uses
start
and thetransform
method to return a newPoint
for everyi
which is effectivelyi
steps in theopposite
direction from thestart
. collect
executes the operation (since everything up until this point is lazy) and brings together a resultingVec
of typePoint
representing the body of the snake.
- Finally, we use the
body
, we’ve just created, thedirection
passed intonew
and the default value offalse
fordigesting
to create and return a new instance ofSelf
/Snake
.
The next four read-only methods above are significantly simpler.
pub fn get_head_point(&self) -> Point {
self.body.first().unwrap().clone()
}
get_head_point
returns a clone of the first Point
of the body
field, which represents the head of the snake.
The first
method returns an Option<&Point>
which is an enum that represents either Some
value or None
, which would be returned if the vector was empty. Rust doesn’t have a concept of null, so it relies on types like Option
to represent the existence or lack of a value.
The unwrap
method simply assumes that the Option
is Some
, containing a value and returns it. If it was None
, unwrap
would panic.
Now we have an immutable reference to a Point
which refers to a value within the vector. Since we’d like our function to just return a Point
instead of &Point
, we use clone
to create a copy of it.
pub fn get_body_points(&self) -> Vec<Point> {
self.body.clone()
}
get_body_points
returns a clone of the body
field. Rust’s Vec
type doesn’t implement Copy
, so if we didn’t have the call to clone
it would try to move the value which is not allowed. Another option would have been to return an immutable reference &Vec<Point>
instead, but I’ve just opted to use clone to make things a little easier to digest.
pub fn get_direction(&self) -> Direction {
self.direction.clone()
}
get_direction
just returns a clone of the direction
field. We could have omitted .clone()
entirely here since Direction
implements Copy
but just to make it clear what’s happening and for consistency with the other methods, I’ve opted to add a call to clone
.
pub fn contains_point(&self, point: &Point) -> bool {
self.body.contains(point)
}
contains_point
is a method that accepts a &Point
(an immutable reference to a point) and returns a boolean value, which tells if the snake’s body contains that point.
The following few methods all mutate the state of the snake, so they have the mutable reference receiver &mut self
as the first item in their parameter lists.
pub fn slither(&mut self) {
self.body.insert(0, self.body.first().unwrap().transform(self.direction, 1));
if !self.digesting {
self.body.remove(self.body.len() - 1);
} else {
self.digesting = false;
}
}
slither
is a method that is called to make the snake move across the grid.
It works by taking the first point of the snake’s body (the head), transforming it 1 step in the snake’s current direction and inserting that new point into the beginning of the body, effectively making the new point the new head of the snake.
It then removes the last point in the snake. This has the effect of shifting all of the points along to simulate a slithering motion on the grid.
If digesting
is true
, we don’t bother removing the last point and we reset digesting
back to false
. This means that we increase the size of the snake by only adding to the head.
pub fn set_direction(&mut self, direction: Direction) {
self.direction = direction;
}
set_direction
updates the direction
field of the snake to match the direction
provided.
pub fn grow(&mut self) {
self.digesting = true;
}
}
Finally, grow
simply sets the digesting
field to true
so that the next slither causes the snake to increase in size.
Missing Method
We’re currently missing a method from the Direction
enum, so let’s add it now:
// ~/src/direction.rs
impl Direction {
pub fn opposite(&self) -> Self {
match self {
Self::Up => Self::Down,
Self::Right => Self::Left,
Self::Down => Self::Up,
Self::Left => Self::Right,
}
}
}
The opposite
method returns the Direction
that is the opposite to the current variant using the match
control-flow operator.
Notice that we have only a single match
expression without a semi-colon, which means we return the result of that expression.
We match on self
, which is the current direction, and compare it to each of the arms until we find a match. The expression is then evaluated to the opposite variant. For example, if the current direction is Direction::Down
, we’d match the third arm of the statement and which would evaluate to Direction::Up
.
The Game Logic
Congratulations for getting this far. This blog post is turning out to be a lot longer than I expected and it’s about to get longer!
We have all of our building blocks so it’s now time to work on fitting it all together.
Some Dependencies
Let’s begin by adding a couple of dependencies to our Cargo.toml
file.
[dependencies]
crossterm = "0.17"
rand = "0.7.3"
Since we’re going to build the UI of the game in the terminal, we’ll use Crossterm to handle the differences between different platforms and generally make interacting with the terminal a bit easier.
We’ll also need to generate some random numbers to position the food on the grid so we’ll use the rand
crate for that.
The Game Struct
// ~/src/game.rs
use crate::snake::Snake;
use crate::point::Point;
use crate::direction::Direction;
use std::io::Stdout;
use std::time::{Duration, Instant};
use crossterm::terminal::size;
use crate::command::Command;
use rand::Rng;
const MAX_INTERVAL: u16 = 700;
const MIN_INTERVAL: u16 = 200;
const MAX_SPEED: u16 = 20;
#[derive(Debug)]
pub struct Game {
stdout: Stdout,
original_terminal_size: (u16, u16),
width: u16,
height: u16,
food: Option<Point>,
snake: Snake,
speed: u16,
score: u16,
}
impl Game {
pub fn new(stdout: Stdout, width: u16, height: u16) -> Self {
let original_terminal_size: (u16, u16) = size().unwrap();
Self {
stdout,
original_terminal_size,
width,
height,
food: None,
snake: Snake::new(
Point::new(width / 2, height / 2),
3,
match rand::thread_rng().gen_range(0, 4) {
0 => Direction::Up,
1 => Direction::Right,
2 => Direction::Down,
_ => Direction::Left
},
),
speed: 0,
score: 0,
}
}
// ...
This struct represents our game state which is the glue that fits everything together. In our constructor function, we capture the standard output stream object, stdout since we’ll be using the terminal to represent our UI and we accept a width
and height
which determines the area of the grid system for our game.
We’re going to resize the terminal window, so we need to get the current size of the window so that we can restore it later. We use Crossterm’s size
function and unwrap
the returned Result<(u16, u16)>
to do this.
The position of the food is set to None
initially, since it will be generated when the game starts. We position the snake’s head in the middle of the grid as determined by the width
and height
provided, give it a fixed size of 3
and we randomly choose its direction by generating a random integer between 0 and 4 (not inclusive) and matching to a Direction
.
The score
and speed
of the game are both initialised to 0
.
Running the Game
Now we get into the primary logic of the game, the run
method. There’s a lot to cover here.
pub fn run(&mut self) {
self.place_food();
self.prepare_ui();
self.render();
let mut done = false;
while !done {
let interval = self.calculate_interval();
let direction = self.snake.get_direction();
let now = Instant::now();
while now.elapsed() < interval {
if let Some(command) = self.get_command(interval - now.elapsed()) {
match command {
Command::Quit => {
done = true;
break;
}
Command::Turn(towards) => {
if direction != towards && direction.opposite() != towards {
self.snake.set_direction(towards);
}
}
}
}
}
if self.has_collided_with_wall() || self.has_bitten_itself() {
done = true;
} else {
self.snake.slither();
if let Some(food_point) = self.food {
if self.snake.get_head_point() == food_point {
self.snake.grow();
self.place_food();
self.score += 1;
if self.score % ((self.width * self.height) / MAX_SPEED) == 0 {
self.speed += 1;
}
}
}
self.render();
}
}
self.restore_ui();
println!("Game Over! Your score is {}", self.score);
}
Placing the Food
We start off by calling an instance method of the Game
struct called place_food
, which looks like this:
fn place_food(&mut self) {
loop {
let random_x = rand::thread_rng().gen_range(0, self.width);
let random_y = rand::thread_rng().gen_range(0, self.height);
let point = Point::new(random_x, random_y);
if !self.snake.contains_point(&point) {
self.food = Some(point);
break;
}
}
}
It uses Rust’s loop
construct to create an infinite loop and creates a Point
from randomly generated x and y coordinates.
We check to make sure that the point is not already part of the snake’s body by calling the contains_point
method of the Snake
struct and finally set the value of the food
field to Some(point)
and break from the loop.
Remember, the type of the food
field is Option<Point>
which can hold either Some(...)
or None
. If the random point that we generated already existed as part of the snake’s body, then we simply try again on the next iteration of the loop.
This method could no doubt be improved, but it’s sufficient for our needs in this demo.
Setting up the UI
The prepare_ui
method uses Crossterm to set everything up to begin rendering the game:
fn prepare_ui(&mut self) {
enable_raw_mode().unwrap();
self.stdout
.execute(SetSize(self.width + 3, self.height + 3)).unwrap()
.execute(Clear(ClearType::All)).unwrap()
.execute(Hide).unwrap();
}
First,we enable raw mode which ensures that user input is passed directly to our application without the terminal driver intercepting and carrying out processing of its own. We then set the size of the terminal and clear the screen as well as hide the cursor.
Rendering
The next part of the run
method is a call to a render
method.
This method and those it calls simply use Crossterm’s API to draw all of the visual aspects of the game including the snake itself, the border around the grid and the food.
We won’t dig into this method any further since this post is already long enough! If you’d like to dig into the rendering side of the game, check out the source code on GitHub.
The Loop
Next, we set up a flag, done
which provides our while
loop with an exit condition. The while
loop begins with a little bit of setup. First, we call a method to calculate the interval
, which is effectively how long each iteration of the while loop should take:
fn calculate_interval(&self) -> Duration {
let speed = MAX_SPEED - self.speed;
Duration::from_millis(
(MIN_INTERVAL + (((MAX_INTERVAL - MIN_INTERVAL) / MAX_SPEED) * speed)) as u64
)
}
Let’s explain this calculation.
- We take the difference between
MAX_INTERVAL
andMIN_INTERVAL
; in this case, we have 700 - 200 which results in 500. - We divide this number by the
MAXIMUM_SPEED
, which is 20 so we end up with 25 milliseconds per unit of speed. - Since the
speed
field starts at 0 and increases toMAX_SPEED
, we subtract its value from theMAX_SPEED
and multiply the 25 milliseconds we calculated earlier to get a total number of milliseconds that we should add onto the minimum to get the real interval. - We cast the value to an unsigned 64-bit integer that
Duration::from_millis
accepts and return it.
For example, if we take the starting speed of 0, we would end up with a 700ms interval since (200 + (((700 - 200) / 20) * (20 - 0))) = 700
. Now let’s say we take a speed of 5
; we would end up with a 575ms interval since (200 + (((700 - 200) / 20) * (20 - 5))) = 575
.
The next line gets the current direction of the snake for this iteration. We store this to be able to compare it to the player’s input to prevent illegal moves. The following line gets the time at the beginning of the iteration.
Responding to player input
The next block of code is all about gathering and responding to the player’s input and waiting for the calculated interval before executing the rest of the loop.
The while
loop ensures that we keep checking for the user’s input until the interval has elapsed. now.interval()
returns a Duration
which represents the amount of time that has elapsed since now
was created. If that duration is less than the interval (which will be between 700ms and 200ms depending on the current speed), then the body of the loop will execute.
The next line involves a call to self.get_command(...)
passing through the difference between the interval and the time that has elapsed since the beginning of the loop.
get_command
is an instance method that uses Crossterm’s API to wait up to a given timeout for the player’s input and returns an Option<Command>
. A valid command will be returned from the method, wrapped in an Option
’s Some
variant and an invalid command (or if the timeout expires) will be returned as None
:
fn get_command(&self, wait_for: Duration) -> Option<Command> {
let key_event = self.wait_for_key_event(wait_for)?;
match key_event.code {
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => Some(Command::Quit),
KeyCode::Char('c') | KeyCode::Char('C') =>
if key_event.modifiers == KeyModifiers::CONTROL {
Some(Command::Quit)
} else {
None
}
KeyCode::Up => Some(Command::Turn(Direction::Up)),
KeyCode::Right => Some(Command::Turn(Direction::Right)),
KeyCode::Down => Some(Command::Turn(Direction::Down)),
KeyCode::Left => Some(Command::Turn(Direction::Left)),
_ => None
}
}
fn wait_for_key_event(&self, wait_for: Duration) -> Option<KeyEvent> {
if poll(wait_for).ok()? {
let event = read().ok()?;
if let Event::Key(key_event) = event {
return Some(key_event);
}
}
None
}
First, a call to wait_for_key_event
is made, which uses Crossterm’s API to wait until the player does something or the wait_for
duration elapses.
Immediately following the call to wait_for_key_event
, there is a ?
, which is an operator to make error handling in Rust easier and less verbose.
A common pattern when using the Result<T, E>
or Option<T>
enum types in Rust is to use a match
or an if let
to check the variant that was returned and immediately return the associated Err
or None
, if found:
let result = get_an_option();
let val = match result {
Some(value) => value,
None => return None,
}
The ?
operator may be used to reduce the verbosity of this common pattern and simply returns the Err
or None
from the method if encountered and causes the value that is wrapped by the Ok
or Some
variants to be returned to the caller. Therefore, the code above could be simplified to:
let val = get_an_option()?;
Inside the wait_for_key_event
method, the poll
function waits up to the given duration and returns a Result<bool>
which tells if an event is available i.e. the player pressed a key, moved their mouse etc.
The ok
method of the Result
is called, which converts it into an Option
. This is then followed by the ?
operator, which will immediately return the None
variant, if one is returned, otherwise the expression will evaluate to the bool
value inside the Option
.
When the if
evaluates to true
, we can guarantee that an event is waiting to be read. Calling the subsequent read
method, using Result::ok
to get an option and the ?
to return None
if necessary, we get a Crossterm Event
enum.
Finally, we use an if let
to assert that the event was a Crossterm KeyEvent
and we return it to the caller. In all other cases, None
is returned.
Back to get_command
, we then match
the keycode of the key event and return a Command
wrapped in a Some
(since the method returns an Option<Command>
) if the key-press corresponds to one we were expecting.
All other key-presses, events or if the wait_for
duration passed to the method elapses, result in a None
being returned.
Back to the game loop, we use if let
again to check that Some(Command)
was returned and we then match
the command to an appropriate action.
If a Command::Quit
variant was received, we simply set done
to true and break from the current loop. Setting done
to true
satisfies the exit condition of the outer loop so this has the effect of effectively ending the game.
On the other hand if the command we receive is a Command::Turn(Direction)
, we first check if the direction that the player has pressed is legal, given the current direction of the snake (the snake may not turn back on itself) and if so we set the snake’s direction using its set_direction
method.
Detecting Collisions
After we’ve waited until the interval has elapsed, it is time to check to see if the snake is about to either collide with a wall or itself. If any of these conditions are met, we set done
to true
which effectively ends the game.
has_collided_with_wall
uses the current direction of the snake and the point that represents its head to determine if the next move would result in the snake going out of bounds / hitting the wall:
fn has_collided_with_wall(&self) -> bool {
let head_point = self.snake.get_head_point();
match self.snake.get_direction() {
Direction::Up => head_point.y == 0,
Direction::Right => head_point.x == self.width - 1,
Direction::Down => head_point.y == self.height - 1,
Direction::Left => head_point.x == 0,
}
}
When the snake is facing up or down, we check if the snake’s head’s y
coordinate is either 0
or one less than the height of the grid, respectively.
Similarly, when the snake is facing left or right, we check if the its head’s x
coordinate is either 0
or one less than the width of the grid, respectively.
If this method returns true
, then the snake is about to go out of bounds.
fn has_bitten_itself(&self) -> bool {
let next_head_point = self.snake.get_head_point().transform(self.snake.get_direction(), 1);
let mut next_body_points = self.snake.get_body_points().clone();
next_body_points.remove(next_body_points.len() - 1);
next_body_points.remove(0);
next_body_points.contains(&next_head_point)
}
has_bitten_itself
works out the next head position of the snake and the points that would be in its body, if it were to proceed.
Since the snake’s body includes the head of the snake at position 0
, we remove the first point and finally we return whether the next_body_points
contains the next_head_point
.
If this method returns true
then the snake is considered to be about to bite itself.
When either has_collided_with_wall
or has_bitten_itself
evaluate to true
, we exit the game loop by setting the done
flag to true
, ending the game.
Slithering on
Now that we can rest assured that the game is in a legal state, we make the snake move by calling its slither
method, which we described earlier.
This is followed by an immediate check to see if the snake has eaten the food that is placed on the grid.
Since the food
field is an Option
, we use an if let
to make sure that there is Some
food for the snake to eat (from the beginning of the game there should always be some food) and we then check to see if the snake’s head is currently (after slithering) at the same position of the food.
If so, we call the snake’s grow
method which will cause the snake’s body to grow on the next slither. We also call the game’s place_food
method again, which chooses another random point that is not currently occupied by the snake’s body to place the next food.
We then increment the score by 1
and decide whether or not to increase the speed of the game.
To make this decision, we take the area of the grid by multiplying the width
and height
and then divide that area by the MAX_SPEED
, which is currently fixed to 20
. Assuming a grid size of 10x10, that would result in an area of 100, which we divide by 20 to get 5
.
This number determines how many points the player must accumulate before the speed increases to the next step.
In this instance, the speed of the game would increase from 0 to 1 when the player achieves a score of 5 and it would increase from 4 to 5 when a player achieves a score of 25.
Finally, we draw the next frame of the game by calling the render
method and the game loop continues.
Ending the Game
If the player sends a quit command to the game, by pressing Q
, ESC
or CTRL+C
or if the player loses by colliding with the wall or by allowing the snake to bite itsself, the game ends.
We call the restore_ui
method to reset all of the adjustments we’ve made to the terminal by restoring the size, clearing the game from the screen, showing the cursor, resetting the colour and disabling raw mode:
fn restore_ui(&mut self) {
let (cols, rows) = self.original_terminal_size;
self.stdout
.execute(SetSize(cols, rows)).unwrap()
.execute(Clear(ClearType::All)).unwrap()
.execute(Show).unwrap()
.execute(ResetColor).unwrap();
disable_raw_mode().unwrap();
}
Once the terminal has been restored, we print out the user’s score and the run
method returns to its caller.
The Entry Point
Back to our ~/src/main.rs
, we just need to initialise and run the game:
mod snake;
mod direction;
mod game;
mod point;
mod command;
use crate::game::Game;
use std::io::stdout;
fn main() {
Game::new(stdout(), 10, 10).run();
}
In Rust, the main
function inside ~/src/main.rs
is the entry point to an application. We instantiate a Game
by calling Game::new(...)
passing through the stdout object which we get from calling the std::io::stdout
function as well as the game grid width of 10
and height of 10
. We then call the run
method on the Game
struct that is returned.
An important thing to note is that Rust’s module system requires the developer to be more explicit when creating modules than in other languages.
The mod
keyword must be used to declare a module. The name specified after the mod
keyword must either refer to a filename, or a folder containing a mod.rs
. If a file is not explicitly declared as part of a module like this, then it is ignored.
Rust also allows modules to be declared directly, without using another file. For example, we could have written the command
module directly in main.rs
like this:
mod snake;
mod direction;
mod game;
mod point;
mod command {
use crate::direction::Direction;
pub enum Command {
Quit,
Turn(Direction),
}
}
// ...
Wrapping up
I hope this has been an insightful and practical introduction to Rust. We’ve come a long way and touched on many different areas of the language, but there’s still so much that we didn’t get into.
If you would like to learn more about Rust, I’d recommend reading the Rust Book or Rust by Example for those who prefer a more practical approach to learning.
The code for Snake can be found on GitHub where you can also raise an issue or submit a correction to this article.