Learning Rust by Coding a Game of Snake
snake.rustbridge.com
In this tutorial you will write a very simple game engine for a snake game, while learning the basics of Rust. You can also use and adapt the game engine to write any other old school pixely 2D game.
This tutorial is still in beta. If you find typos or error or have ideas for improvement, please file an issue or a pull request at Rusty-Snake-Book
Installation
Standard Installation of the Rust Compiler and Tools
Linux and MacOS Users
For these platforms, it is recommended to use the Official Rust Installation Process via rustup.rs
. When asked, the default settings (including the stable
compiler) are suitable for this course.
This will process will install a number of tools required for this course, including rustc
, cargo
, and rustup
.
Windows Users
Windows Users may need to install additional tools. Please refer to the following guide for installation instructions for Windows.
Simple DirectMedia Layer
Simple DirectMedia Layer is a cross-platform development library designed to provide low level access to audio, keyboard, mouse, joystick, and graphics hardware via OpenGL and Direct3D. It is written in C, and we will only use Rust bindings later on, you need to install the C library separately.
MacOS
brew install sdl2
Ubuntu
sudo apt-get install libsdl2-dev
Windows
Follow the instructions here, following the steps "For Rustup users".
Other Operating Systems
Download and install the development library according to your system.
Cargo and Dependencies
To initialize a new project, run cargo new
:
$ cargo new rusty_snake
Cargo generates a new folder called rusty_snake
. cd
into the directory to see what cargo generated.
Open the file cargo.toml
in the editor of your choice. This is called a manifest, and it contains all of the metadata that Cargo needs to compile your project.
It should look like this:
cargo.toml
#![allow(unused_variables)] fn main() { [package] name = "rusty-snake" version = "0.1.0" authors = ["Your Name"] edition = "2018" See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] }
To use SDL2 in rust, we need to add the sdl2 crate as a dependency.
Add the following line in the [dependencies] section:
sdl2 = "0.30.0"
open src/main.rs
Substitute the content of the file with the following code:
// Dependencies go here // this function initializes the canvas fn init(width: u32, height: u32) -> (Canvas<Window>, EventPump) { let sdl_context = sdl2::init().unwrap(); let video_subsystem = sdl_context.video().unwrap(); let window = video_subsystem .window("Game", width + 1, height + 1) .position_centered() .build() .unwrap(); let mut canvas = window.into_canvas().present_vsync().build().unwrap(); canvas.set_draw_color(Color::RGB(0, 0, 0)); canvas.clear(); canvas.present(); let event_pump = sdl_context.event_pump().unwrap(); (canvas, event_pump) } // this is main fn main() { println!("Hello, world!"); }
Right in the beginning of the file, in the section // Dependencies go here
, add the following lines:
#![allow(unused_variables)] fn main() { use sdl2::video::Window; use sdl2::pixels::Color; use sdl2::render::Canvas; use sdl2::EventPump; }
In your terminal, go into the folder /rusty-snake
, and run the command cargo run
.
What do you see? To get rid of this warning, we need to call fn init
in main()
.
Initializing the Canvas
Our game needs a canvas on which all the games' graphics will be rendered. Let's initialize it.
Functions and variables
Let's take a closer look at fn init
.
main.rs
#![allow(unused_variables)] fn main() { // this function initializes the canvas fn init(width: u32, height: u32) -> (Canvas<Window>, EventPump) { let sdl_context = sdl2::init().unwrap(); let video_subsystem = sdl_context.video().unwrap(); let window = video_subsystem .window("Rusty Snake", width as u32 + 1, height as u32 + 1) .position_centered() .build() .unwrap(); let mut canvas = window.into_canvas().present_vsync().build().unwrap(); canvas.set_draw_color(Color::RGB(0, 0, 0)); canvas.clear(); canvas.present(); let event_pump = sdl_context.event_pump().unwrap(); (canvas, event_pump) } }
fn
declares the function, init
is its name. The function takes two parameters, x and y. Rust is a type safe language, so the types of the parameters need to be explicitly indicated. In this example, the type of both parameters is u32
, a 32bit unsigned integer. The return types also need to be specifically named. fn init
returns two values in a bracket, separated by a comma. The types of these values are defined in the sdl2
-crate. We'll ignore the body of the function for now.
-
In
main()
, delete theprintln!
statement. -
Declare the variables
canvas_width
andcanvas_height
, each with the value720_u32
._u32
makes this number explicitly an unsigned 32bit integer. -
Call the function by adding the following line:
#![allow(unused_variables)] fn main() { let (canvas, events) = init(canvas_width, canvas_height); }
-
In the terminal enter
cargo run
. What happens?
The Game Loop
Loops
When running the program, we shouldn't see anything. If you don't see any error messages, the program compiles. We have initialized the canvas, but in order for the program to run continuously, we need a game loop (aka event loop).
💡 Unlike a command-line program, which performs a single pre-specified task and once done terminates, a program with a GUI (such as a game) is commonly expected to stay open until the user decides to close the program (e.g. by clicking on "Quit" in the main menu). For this to work one commonly makes use of an infinite loop (here:
loop { … }
), within which on each iteration one asks an event source for any events that have occurred since the last iteration and passes them to the program for handling. Over and over again.
-
After the last line inside
fn main()
add a#![allow(unused_variables)] fn main() { loop { } }
-
Inside this
loop
, iterate overevent
inevents.poll_iter()
with afor
loop. -
Run the program. In your group, read and discuss the output on the screen. What information does it provide?
Debugging
Iterators
One of Rusts features are the error messages that actually help. From the error message we learn, that the type of events
is called EventPump
, and where in sdl2
it is defined. In order to be able to iterate over a type, it has to implement the Iterator
trait, and apparently, EventPump
does not. To fix this, we consult the documentation. Besides looking it up on the internet, using the command cargo doc --open
is an elegant way of getting the documentation for all the crates in your project locally and open in a browser window.
In the sdl2
crate documentation, go to EventPump
. Look at the implementation section for this type. Can you find the Iterator
trait? On the same page, look for a function, that returns an Iterator. On the page of this return type, check, if the Iterator
trait is implemented.
- Call the function on events.
- To make things easier for later, we will give this loop the name
'game
.
It should look like this:
#![allow(unused_variables)] fn main() { 'game: loop { for event in events.poll_iter() { // handle user input here } } }
- Run the program. From the output on your screen, do you know what to do?
Mutability
In Rust, variables are immutable by default. You have to make them mutable by declaring them as such with let mut
instead of just let
. Iterators need to be mutable, because in each step, an item from the iterator is consumed, to keep track of where it currently is in the iteration sequence. So the internal state of the iterator is changed, which is only be possible, if the variable is declared as mutable.
- Run the program. Close the window.
Handling User Input
enums and match
Yeah, it's kind of hard to close the window. This is because we have not told our program how to deal with events, so it continues to run and we have to force it to quit. That's uncomfortable, let's change that.
Go to the sdl2
documentation. Go to the event
module, find the Event
enum
. An enum
in Rust is a type that represents data that is one of several possible variants. Each variant in the enum
can optionally have data associated with it. Look at the possible event
variants in the declaration of Event
. We want to end the program by pushing esc
. What variant are we looking for?
In our game loop, we iterate over events
. Depending on what kind of event
is happening, we want the program to react in different ways. Pushing space
means something different than pushing esc
. We will match
the event
to the variants of the enum Event
. match
is a way to control the flow of the program when there are several possible options, similar to if
else
statements.
- To be able to use the enum and work with keyboard input, we need to add the following dependencies:
#![allow(unused_variables)] fn main() { use sdl2::event::Event; use sdl2::keyboard::Keycode; }
- Enter the following lines into the
for
loop.match
compares the value of theevent
-variable to the variants of the enumEvent
. If the value of theevent
-variable is a pressedesc
-key, the'game
-loop breaks. If the value is something else, the loop continues. The last part of amatch
always needs to be_ =>
, the case that covers all cases that are not explicitly defined.
#![allow(unused_variables)] fn main() { match event { Event::KeyDown { keycode: Some(Keycode::Escape), .. } => break 'game, _ => continue 'game, } }
- Run the program, try if the user input you implemented works!
- To be able to close the window with a mouse click, change the first line of the
match
to includeEvent::Quit { .. }
. The'game
-loop breaks now if either theesc
-key is pressed, or the quit-button is clicked with the cursor.
#![allow(unused_variables)] fn main() { match event { Event::Quit { .. } | Event::KeyDown { keycode: Some(Keycode::Escape), .. } => break 'game, _ => continue 'game, } }
Multithreading
Rust is famous for its safe and easy way to write multithreaded programs. We don't need this program to be multithreaded yet, but it comes with a handy sleep mode, that we need.
For the following code examples, we need to bring the thread
and time
modules in scope at the top of our file:
#![allow(unused_variables)] fn main() { use std::thread; use std::time; }
To spawn a thread, add these lines before 'game
:
#![allow(unused_variables)] fn main() { thread::spawn(move || { // some work here }); }
Inside, at the end of 'game
, add this line:
#![allow(unused_variables)] fn main() { thread::sleep(time::Duration::from_millis(800)); }
'game
will now sleep 800 milliseconds, before starting over.
Frames.
Each animation step is displayed in a new frame. We will now draw a rectangle of the size of the canvas, with a different random color.
sdl
offers methods to draw basic shapes, like rectangles, this needs to be imported. It also offers methods that convert our u8 values into RGB colors, that can be displayed.
#![allow(unused_variables)] fn main() { use sdl2::rect::Rect; use rand; }
rand
is another library we are using. To activate it, add it to your Cargo.toml
as a dependency. It should now look like this:
[dependencies]
sdl2 = "0.30.0"
rand = "0.7"
Add the following function to your program.
#![allow(unused_variables)] fn main() { fn display_rectangle ( renderer: &mut Canvas<Window>, canvas_width: &u32, canvas_height: &u32, ) { let red: u8 = rand::random(); let green: u8 = rand::random(); let blue: u8 = rand::random(); renderer.clear(); let drawing_color = Color::RGB(red, green, blue); renderer.set_draw_color(drawing_color); let square_definition = Rect::new(0, 0, *canvas_width, *canvas_height); renderer.fill_rect(square_definition); renderer.present(); } }
The function takes the canvas, as well as the canvas width and height as arguments. It does not return a value. In the body of the function, the variables red, green and blue are assigned random u8
numbers.
The clear()
method is called on the renderer, this clears the canvas. When, like in our case, the entire canvas is drawn over, this does not really matter, but it's a good habit to think of this.
Then, the drawing color is defined. We use a function that is provided by sdl2
. The function takes in three u8
values and returns a color.
The method set_draw_color()
is called on the renderer, with drawing_color
as argument.
We create a new rectangle with its minimum and maximum x and y values as arguments and bind it to the variable square_definition
. This variable is then passed to the method fill_rect()
. Our square is rendered and put into the back buffer.
The last line updates the screen with all the rendering done since the last update.
Do you notice anything peculiar about some of the type declarations in this function?
Try calling this function for every 'game
loop iteration in your main program.
Run the program.
Error Handling with the Result Type
Check the warnings in the terminal output. What does it say about the Result Type in fn display_rectangle()
?
-
Check the
sdl2
documentation for the return value of thefill_rect()
method. Research in thestd
documentation how to use this value. -
Change the code.
Find the solution on the next page.
Solution for Using the ResultType
#![allow(unused_variables)] fn main() { let square = renderer.fill_rect(square_definition); match square { Ok(()) => {} Err(error) => println!("{}", error), } }
What happened so far?
This setup is the base for any other program with graphic output.
So far you have learned:
- how to install Rust and initialize a new Rust project with cargo.
- what a cargo manifest is, and how you can add dependencies.
- how to declare mutable and immutable variables and how to write functions.
- how to use two different types of loops and iterate over something.
- You got to know Rust's awesome error messages.
- You know what an enum is and can use
match
for control flow. - You can look things up in the documentation.
- You made your first experience in handling errors and took the first step to write a multithreaded program.
Awesome!!!!
Ownership and Borrowing
We're finally getting to something that is very exclusively Rusty: Ownership, referencing and borrowing. Ownership guarantees the memory safety of Rust. Each value has a variable, that is called its owner. There can only be one owner at a time. When the value goes out of scope, the value will be dropped.
- Uncomment one
println!
statement at a time, to find out, wheres
is in scope und where it is not. Run the code in this window. - Why is
s
not in scope, after it moved into the function?
fn main() { let s = String::from("hello"); //println!("{}", s); takes_ownership(s); //println!("{}", s); } //println!("{}", s); fn takes_ownership(some_string: String) { println!("{}", some_string); }
So yeah, if you don't want to hand over the ownership of your value to the function, the function can borrow it. A borrowed value is indicated by the &
operator. This is also called referencing a value.
fn main() { let s = String::from("hello"); borrows_value(&s); println!("{}", s); } fn borrows_value(some_string: &String) { println!("{}", some_string); }
- What happens, if the borrowed value is changed?
fn main() { let s = String::from("hello"); change(&s); } fn change(some_string: &String) { some_string.push_str(", world"); }
References are immutable by default. To make the reference mutable, the variable has to be declared mutable, and &s
changes to &mut s
, same goes for &String
. Try it!
But there are two restrictions:
- There can only be one mutable reference to a value!
- A value can not be borrowed mutable and immutable!
Take another look at fn display_frame
. Where are borrowed values?
#![allow(unused_variables)] fn main() { fn display_frame ( renderer: &mut Canvas<Window>, canvas_width: &i32, canvas_height: &i32, ) { let red: u8 = rand::random(); let green: u8 = rand::random(); let blue: u8 = rand::random(); renderer.clear(); let drawing_color = Color::RGB(red, green, blue); renderer.set_draw_color(drawing_color); let square_definition = Rect::new(0, 0, *canvas_width as u32, *canvas_height as u32); let square = renderer.fill_rect(square_definition); match square { Ok(()) => {} Err(error) => println!("{}", error), } renderer.present(); } }
Dereferencing
Looking at the function from our game, we can now explain almost all the peculiar signs. One is missing... try running your program without the *
s.
#![allow(unused_variables)] fn main() { fn display_rectangle ( renderer: &mut Canvas<Window>, canvas_width: &u32, canvas_height: &u32, ) { let red: u8 = rand::random(); let green: u8 = rand::random(); let blue: u8 = rand::random(); renderer.clear(); let drawing_color = Color::RGB(red, green, blue); renderer.set_draw_color(drawing_color); // let square_definition = Rect::new(0, 0, *canvas_width, *canvas_height); let square_definition = Rect::new(0, 0, canvas_width, canvas_height); let square = renderer.fill_rect(square_definition); match square { Ok(()) => {} Err(error) => println!("{}", error), renderer.present(); } }
A reference is a type of pointer. You can imagine it as an arrow to a value stored somewhere else.
#![allow(unused_variables)] fn main() { let x = 5; let y = &x; assert_eq!(5, x); assert_eq!(5, y); }
Execute this code.
x
and y
don't have the same value. x
is an integer, and y
is &x
, an arrow that points to that integer behind x
. In order to get to the value &x
is pointing to, y
needs to be dereferenced: *y
.
Change the code above and execute!
Modularization
Creating Modules
As this project will be bigger than just a couple of lines of code, things will be more organized if we split it up into modules. To do this, make a new folder inside the src
folder, and call it lib
. Inside lib
, create mod.rs
and types.rs
.
.
├── rusty snake
│ ├── src
│ │ ├── lib
│ │ │ ├── mod.rs
│ │ │ └── types.rs
│ │ │
│ │ └── main.rs
│ │
│ ├── Cargo.lock
│ └── Cargo.toml
└── ...
Move all functions you wrote to mod.rs
. In order for this to work, all dependencies that these functions need, also need to be declared in mod.rs
. If they are not used in main.rs
, you can delete them there.
Dependencies
Dependencies for the moved functions have to be moved to mod.rs
too.
Making Functions and structs Public
In order for a function to be accessible in main.rs
, it needs to be marked with pub
.
Accessing the Modules in main.rs
Before fn main()
add the following line:
#![allow(unused_variables)] fn main() { pub mod lib; }
Functions now have to be called in the namespace of the module:
#![allow(unused_variables)] fn main() { let (canvas, mut events) = lib::init(canvas_width, canvas_height); }
The Grid
Now that we have set up the very basic framework for graphic output, we want to add some actual game elements.
In the very basic version, the snake will be a square block, moving around in a grid. The food for the snake is a static square block of a different color. As the snake moves and eats the food by moving over it, the snake gets longer by one block. If the snake moves over its own parts, the game is over and restarts. If the snake moves out of the canvas, it reenters it on the other side.
Structs and Vectors
Structs are a datatype, that can contain several fields, the fields do not have to be of the same datatype.
Using structs, we will define our own data types for the grid and the cells. Both struct definition go into types.rs
-
Let's define the
Grid
-struct. It has one field namedgrid
. The datatypes are vectors ofCell
s inside a vector. The items in the inner vector are rows, the items in the outer vector are columns.#![allow(unused_variables)] fn main() { pub struct Grid { pub grid: Vec<Vec<Cell>>, } }
-
Write your own struct for the
Cell
type. This datatype is used to define the color of a cell, so it needs fields for each RGB-value. The data type of the RGB-values is an unsigned 8-bit integer:u8
. -
Add the following lines to
mod.rs
to be able to use the types there.#![allow(unused_variables)] fn main() { pub mod types; use types::{Grid, Cell}; }
Next, we need to initialize the grid.
-
Add this function to
mod.rs
#![allow(unused_variables)] fn main() { pub fn grid_init(nx_cells: u32, ny_cells: u32) -> Grid { let mut grid_vector = Vec::new(); for row in 0..ny_cells { grid_vector.push(Vec::new()); for _column in 0..nx_cells { grid_vector[row as usize].push(Cell { red: 35_u8, green: 15_u8, blue: 13_u8, }); } } let grid = Grid { grid: grid_vector }; grid } }
-
Discuss in your group, what this function does, line by line.
-
Add the following line in
fn main()
before you spawn the thread.#![allow(unused_variables)] fn main() { let mut grid = lib::grid_init(columns, rows); }
Displaying the grid in Frames
For each frame, the grid is drawn by displaying each single cell as a rectangle.
In fn main()
, right after we defined the variables for rows and columns, define a variable for cell_width
and calculate its value.
fn display_cell
converts row column values of a single Cell into x and y pixels and draws a rectangle in the specified color at the specified coordinate.
#![allow(unused_variables)] fn main() { pub fn display_cell( renderer: &mut Canvas<Window>, row: u32, col: u32, grid_data: &Grid, cell_width: &u32, ) { let cell_height = cell_width; let grid = &grid_data.grid; let x = cell_width * col; let y = cell_width * row; // For now, we want random colors, to see what happens. // let cell_color = &grid[row as usize][col as usize]; // let drawing_color = Color::RGB(cell_color.red, cell_color.green, cell_color.blue); let red: u8 = rand::random(); let green: u8 = rand::random(); let blue: u8 = rand::random(); let drawing_color = Color::RGB(red, green, blue); renderer.set_draw_color(drawing_color); let square = renderer.fill_rect(Rect::new(x, y, *cell_width, *cell_height)); match square { Ok(()) => {} Err(error) => println!("{}", error), } } }
fn display_frame
displays the whole grid by repeatedly calling fn display_cell
on every cell in the grid.
#![allow(unused_variables)] fn main() { pub fn display_frame( renderer: &mut Canvas<Window>, grid: &Grid, nx_cells: &u32, ny_cells: &u32, cell_width: &u32, ) { renderer.set_draw_color(Color::RGB(0, 0, 0)); renderer.clear(); for row in 0..*ny_cells { for column in 0..*nx_cells { display_cell(renderer, row, column, &grid, &cell_width) } } renderer.present(); } }
Add both of these functions to your program. Discuss how they compare to fn display_rectangle
. Then delete fn display_rectangle
, as it is not needed anymore.
Substitute the line that calls fn display_rectangle
with the following line:
#![allow(unused_variables)] fn main() { lib::display_frame(&mut canvas, &grid, &columns, &rows, &cell_width); }
Run the program! Enjoy!
Adding the snake
Well, maybe not a snake yet, but a square that moves according to your wishes! In this section, you can try to apply what you have learned so far. If you don't know how to continue, you can either ask for help, or look at one way of solving the problems on the next page.
-
In the
lib
folder, make a new file calledsnake.rs
. -
Add the following line to the top of
snake.rs
#![allow(unused_variables)] fn main() { use crate::lib::types::{Cell, SnakeHead, Grid}; }
-
Add
pub mod snake;
right below the existingpub mod types;
. -
Write a function that initializes the snake as a
SnakeHead
.SnakeHead
is a struct that contains fields for a row, a column and aCell
value. The row and column values need to bei32
instead ofu32
. Why?
Defining a tuple
In fn main()
, after the grid is initialized, we define a tuple for direction. direction.0
is the row value, direction.1
is the column value.
#![allow(unused_variables)] fn main() { let mut direction = (1, 0); }
Movement
-
In
snake.rs
, write a function that takes a mutable reference of theSnakeHead
. It calculates a new position withdirection
values and the coordinates fromSnakeHead
and then returns a newSnakeHead
with an updated position. -
Write a function that takes ownership of the grid and changes the color of the square, where the current
SnakeHead
is located. The grid is then the return value.
Adding User Input
-
Add Events for
up
,down
,left
andright
key. -
How does the
direction.0
and thedirection.1
value change, when each of these buttons is pushed? Implement it!
Changing the Game Loop
-
Add this line to the top of
main.rs
:#![allow(unused_variables)] fn main() { use crate::lib::snake; }
-
Call the functions in the following order:
- update position of snake
- update grid with position of snake
- display frame
Run the game! Play the game!
Solution for the Snake
snake.rs
#![allow(unused_variables)] fn main() { use crate::lib::types::{Cell, SnakeHead, Grid}; pub fn snake_init() -> SnakeHead { let snake = SnakeHead { row: 6, column: 6, color: Cell { red: 200_u8, green: 0_u8, blue: 100_u8, } }; snake } pub fn snake_moves(snake: &mut SnakeHead, direction: (i32, i32)) -> SnakeHead { snake.row = snake.row + direction.0 ; snake.column = snake.column + direction.1; let new_snake = SnakeHead { row: snake.row, column: snake.column, color: Cell { red: 200_u8, green: 0_u8, blue: 100_u8, } }; new_snake } pub fn draw_grid_with_snake(mut grid: Grid, snake: &SnakeHead) -> Grid { let color = Cell { red: snake.color.red, green: snake.color.green, blue: snake.color.green, }; grid.grid[snake.row as usize][snake.column as usize] = color; grid } }
types.rs
#![allow(unused_variables)] fn main() { pub struct SnakeHead { pub row: i32, pub column: i32, pub color: Cell, } }
Content of 'game
in main.rs
#![allow(unused_variables)] fn main() { //outside the loop initialize snake let mut snake = snake::snake_init(); for event in events.poll_iter() { match event { Event::KeyDown { keycode: Some(Keycode::Up), .. } => { direction.0 = -1; direction.1 = 0; } Event::KeyDown { keycode: Some(Keycode::Down), .. } => { direction.0 = 1; direction.1 = 0; } Event::KeyDown { keycode: Some(Keycode::Left), .. } => { direction.1 = -1; direction.0 = 0; } Event::KeyDown { keycode: Some(Keycode::Right), .. } => { direction.1 = 1; direction.0 = 0; } Event::Quit { .. } | Event::KeyDown { keycode: Some(Keycode::Escape), .. } => break 'game, _ => continue 'game, } } snake = snake::snake_moves(&mut snake, direction); grid = snake::draw_grid_with_snake(grid, &snake); lib::display_frame(&mut canvas, &grid, &columns, &rows, &cell_width); }
How to Continue from Here
Congratulations, if you made it this far!
You don't have a running snake game yet, but you learned almost everything you need to know to continue on your own.
Things you can do to continue with the game:
Implement
-
a function that clears the grid, so the moving square does not leave a trace.
-
a function that deals with the square moving out of the canvas by either
-
wrapping (the square moves out of the canvas on one side then enters it on the other side again) or
-
restarting the game.
-
-
the snack for the snake.
-
the snake growing when eating the snack.
-
the snake biting itself leading to a restart of the game.
Things you can do to continue in the future:
-
Form a regular study group!
-
Go to a local Rust MeetUp to get help or learn new things.
-
Check out the book The Rust Programming Language to learn more and get a deeper and systematic understanding of the language.
-
Check out Rust by Example that illustrates various Rust concepts with running code examples.
-
Do Rustlings exercises.
-
Become a RustBridge Teaching Assistant the next time! You don't need to understand everything and you can learn so much when you're explaining things to others.