Starting Game Development with Rust

A tutorial for beginners.

Featured on Hashnode
Starting Game Development with Rust

TL;DR;

Game development(Snake) using rust and piston available at my [Github Repo](github.com/WishAllVA/rust-snake

  • There is no denying that Game Development is very lucrative(until you find out that it involves Math :P). In this article, we'll see how to start with 2-D game development with Rust.

We'll start with the Hello World of game development - Snake Game *

Why rust?

  • It has better performance than almost all other languages
  • It has memory safety features like borrowing and ownership which makes it better than the alternatives like C++

About the game

I don't think anyone needs an introduction to the rules of the game but for the one who do-

  • You will be a player as a snake which will continuously moving
  • If you touch the food(green block), the size of snake will grow
  • If you touch the borders of the window, you will die
  • If you touch the poison(red block), you will die
  • If you touch the portal(blue block), you will come out of the other blue block

Which packages are we going to use?

There are a lot of packages that can allow you to program a 2-D game in Rust. Some of them are -

  • Macroquad
  • Bevy
  • Piston
  • Amethyst

and many more.

But for this tutorial, I am going to use Piston, because why not.

Initializing the project

To initialize the project run the command

cargo init snake_game

This will initialize a Cargo.toml file and main.rs file with a main function.

Cargo is a package manager for Rust(same as npm, yarn for typescript and pip for python)

Go to your Cargo.toml file and add the these packages to the dependencies section rand = "0.8.5" piston_window = "0.124.0"

rand will be used to generate random numbers and piston_window for game objects.

Creating Game Objects

There will be 3 main game objects - snake, food and the game scene itself.

A snake will have 3 properties -

  • Direction: The direction in which the snake is moving
  • Body: The length of the snake
  • tail: The co ordinates of the tail (needed to increase the length of the snake in case it eats food)

So we'll create a struct of the the Snake like this:

pub struct Snake {
    direction: Direction,
    body: LinkedList<Block>,
    tail: Option<Block>
}
#[derive(Debug, Clone)]
struct Block {
    x: i32,
    y: i32
}

The block is the single minimum block that creates a snake. Notice the #derive annotation - the Debug helps if you want to print something from this Block and the Clone is for copying this block.

For the implementation of the Snake struct we'll need the following functions

  • head_position (to get the current position of the head of snake)
  • move_through_portal (to transport the head from one of the portals to another)
  • move_forward (to move the snake forward with each frame)
  • head_direction (to get the direction in which the snake is moving)
  • next_head (the position of the head after the current frame)
  • restore_tail (function to increase the length of snake from tail's side)
  • overlap_tail (function to check if the snake has collided)

The implementation of these functions are as follows -

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

    pub fn move_through_portal(&mut self, portal: &Food) {
        let (last_x, last_y) = self.head_position();
        self.body.push_front(Block { x: portal.x, y: portal.y });
        self.tail = Some(Block { x: last_x, y: last_y });
    }

    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();

        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 },
        };
        self.body.push_front(new_block);
        let removed_block = self.body.pop_back().unwrap();
        self.tail = Some(removed_block);
    }

    pub fn head_direction(&self) -> Direction {
        self.direction
    }

    pub fn next_head(&self, dir: Option<Direction>) -> (i32, i32) {
        let (head_x, head_y): (i32, i32) = self.head_position();
        let mut moving_dir = self.direction;
        match dir {
            Some(d) => moving_dir = d,
            None => ()
        }
        match moving_dir {
            Direction::Up => (head_x, head_y - 1),
            Direction::Down => (head_x, head_y + 1),
            Direction::Left => (head_x - 1, head_y),
            Direction::Right => (head_x + 1, head_y),
        }
    }

    pub fn restore_tail(&mut self) {
        let blk = self.tail.clone().unwrap();
        self.body.push_back(blk);
    }

    pub fn overlap_tail(&self, x: i32, y: i32) -> bool {
        let mut ch = false;
        for block in &self.body {
            if block.x == x && block.y == y {
                ch = true;
            }
        }
        ch
    }

Now let's move to the food part

A food will have 3 properties - x co-ordinate, y co-ordinate, and a food type

pub struct Food {
    pub x: i32,
    pub y: i32,
    pub food_type: FoodType,
}

and the food can be of 3 types

pub enum FoodType {
    NORMAL,
    PORTAL,
    POISON,
}

The only function we need for the food is to draw it, as the collision mechanism is written in the snake class.

Next up is the game itself

The following are the properties need to create the game object

pub struct Game {
    snake: Snake,
    food_exists: bool,
    food: Food,
    width: i32,
    height: i32,
    game_over: bool,
    waiting_time: f64,
    portal: Food,
    portal2: Food,
    poison: Food,
}

and we need the following function to check the game state at each frame -

  • draw (to draw the objects on the screen)
  • update (to update the game state at each frame)
  • check_eating (check if the snake has eaten the food)
  • check_if_snake_alive (check at each frame if snake has not collided)
  • check_if_portal_enabled (check if snake has entered any of the portals)
  • add_food (add a food to the screen in case the snake has eaten the previous one)
  • update_snake (update the snake position at each frame)
  • restart (restart the game in case the snake died)

The implementation for these functions are as follows -

    pub fn draw(&self, con: &Context, g: &mut G2d) {
        self.snake.draw(con, g);
        self.portal.draw(con, g);
        self.portal2.draw(con, g);
        self.poison.draw(con, g);


        if self.food_exists {
            self.food.draw(con, g);
        }

        draw_rectangle(BORDER_COLOR, 0, 0, self.width, 1, con, g); // TOP BORDER
        draw_rectangle(BORDER_COLOR, 0, self.height - 1, self.width, 1, con, g); // BOTTOM BORDER
        draw_rectangle(BORDER_COLOR, 0, 0, 1, self.height, con, g); // LEFT BORDER
        draw_rectangle(BORDER_COLOR, self.width - 1, 0, 1, self.height, con, g); // RIGHT BORDER

        if self.game_over {
            draw_rectangle(GAMEOVER_COLOR, 0, 0, self.width, self.height, con, g);
        }
    }

    pub fn update(&mut self, delta_time: f64) {
        self.waiting_time += delta_time;

        if self.game_over {
            if self.waiting_time > RESTART_TIME {
                self.restart();
            }
            return;
        }
        if !self.food_exists {
            self.add_food();
        }
        if self.waiting_time > MOVING_PERIOD {
            self.update_snake(None);
        }
    }

    fn check_eating(&mut self) {
        let (head_x, head_y) = self.snake.head_position();
        if self.food_exists && self.food.x == head_x && self.food.y == head_y {
            self.food_exists = false;
            self.snake.restore_tail();
        }
    }

    fn check_if_snake_alive(&mut self, dir: Option<Direction>) -> bool {
        let (next_x, next_y) = self.snake.next_head(dir);

        if self.snake.overlap_tail(next_x, next_y) || (next_x == self.poison.x && next_y == self.poison.y) {
            return false;
        }
        next_x > 0 && next_x < self.width - 1 && next_y > 0 && next_y < self.height - 1 
    }

    fn check_if_portal_enabled(&mut self) -> Food {
        let (head_x, head_y) = self.snake.head_position();
        if head_x == self.portal.x && head_y == self.portal.y {
            return self.portal2;
        }
        if head_x == self.portal2.x && head_y == self.portal2.y {
            return self.portal;
        }
        Food::new(-1, -1, FoodType::NORMAL)
    }

    fn add_food(&mut self) {
        let mut rng = thread_rng();
        let mut new_x = rng.gen_range(1..self.width - 1);
        let mut new_y = rng.gen_range(1..self.height - 1);
        while self.snake.overlap_tail(new_x, new_y) {
            new_x = rng.gen_range(1..self.width - 1);
            new_y = rng.gen_range(1..self.height - 1);
        }
        self.food.x = new_x;
        self.food.y = new_y;
        self.food_exists = true;
    }

    fn update_snake(&mut self, dir: Option<Direction>) {
        if self.check_if_snake_alive(dir) {
            self.snake.move_forward(dir);
            self.check_eating();
        } else {
            self.game_over = true
        }
        let is_portal_enabled = self.check_if_portal_enabled();
        if is_portal_enabled.x != -1 && is_portal_enabled.y != -1 {
            self.snake.move_through_portal(&is_portal_enabled);
        }
        self.waiting_time = 0.0;
    }

    fn restart(&mut self) {
        self.snake = Snake::new(2, 2);
        self.food_exists = true;
        self.game_over = false;
        self.waiting_time = 0.0;
        self.food = Food::new(6, 4, FoodType::NORMAL);
    }

These are the major components that you needed to create the mechanics of the game

Now let's head over to the main.rs file where we initialize game screen configuration like width and height and colors.

fn main() {
    let (width, height) = (51, 27);

    let mut window : PistonWindow = WindowSettings::new(
        "Snake",
        [to_coord_u32(width), to_coord_u32(height)],
    )
        .exit_on_esc(true)
        .build()
        .unwrap();

    let mut game = Game::new(width, height);
    while let Some(event) = window.next() {
        if let Some(Button::Keyboard(key)) = event.press_args() {
            game.key_pressed(key);
        }
        window.draw_2d(&event, |c, g, _| {
            clear(BACK_COLOR, g);
            game.draw(&c, g);
        });

        event.update(|arg| {
            game.update(arg.dt);
        });
    }
}

That's it! That was all you needed to create your first game. Head over to the terminal and run the cargo run command to run the game.

To take it further try adding the score and high score feature to the game.

You can find the full source code of the game at my Github Repo

Let me know in comments if you have any doubts or you want to see more of this.

Did you find this article valuable?

Support Vishal's blog by becoming a sponsor. Any amount is appreciated!