IBM Developer

Article

Beginner's guide to Rust

Put your Rust skills to use by building a simple Tic-Tac-Toe game

By Dylan Hicks

I really love Rust. This statically compiled language is memory safe and operating system agnostic, so it can run on any computer. Rust gives you the speed and low-level benefits of a systems language without the pesky garbage collection of languages like C# and Java. Learn more of the key concepts of Rust that make it easy to love in this article.

There's no better way to learn a language than to actually start using it. This article helps you put Rust to use by showing you how to build a simple Tic-Tac-Toe game using the language. Follow along to build your own fun game.

Prerequisites

Most beginners' guides dive right into the code, but I think it's better to know first how to compile and run the software before you start development. The Rust installation notes describe the steps necessary for installation, regardless of your operating system.

Next, I set up a project in which my examples will reside. I could make the directory structure of the project myself, but I would much rather use the powerful package manager that comes with Rust, called Cargo. Reading through the Cargo documentation, it's easy to use Cargo to make a new executable binary program. Simply issue this command in the terminal:

$ cd ~/Documents
$ cargo new rusty_start –bin

The command cargo new rusty_start tells Cargo to create a new program directory structure named rusty_start. The --bin option tells Cargo that this program will be an executable binary. If you leave this option off, Cargo will create a library, instead. Essentially, replacing the file main.rs with lib.rs is simple. Looking at the new rusty_start directory in the tree program, I see the following:

$ cd rusty_start
$ tree .
.
??? Cargo.toml
??? src
    ??? main.rs

Perfect! What is the Cargo.toml file, though? It's what Cargo uses to build the program. Right now, it just describes the authors of the program and the program's dependencies. For this example, I am the only author, and no outside packages are needed.

Note in main.rs that Cargo also generated some code:

fn main() {
    println!("Hello, world!");
}

This is how Rust defines its main function. For now, just know that this function prints to the terminal. The question now is, How do I run this code? It's actually simple - see the following code.

$ cargo build
    Compiling...
     Finished...
$ cargo run
     Finished...
      Running...
Hello, world!

That was certainly painless compared with ; java or setting up CMake. To make things even easier, I could have executed cargo run, a command that both compiles and executes.

With Rust installed and running, I can move on to the good stuff.

Start the project

First, you need to set up your project. Once again we'll use Cargo to create the new executable binary program from the terminal:

cd ~/Documents
cargo new tic_tac_toe –bin

In the tree program, your new tic_tac_toe directory looks like this:

cd tic_tac_toe
tree .
.
??? Cargo.toml
??? src
    ??? main.rs

The main.rs file should consist of the following lines:

fn main() {
    println!("Hello, world!");
}

Running the program is just as easy as creating it, as the following code shows.

  cargo build
    Compiling...
     Finished...
  cargo run
     Finished...
      Running...
Hello, world!

Now, you also need a file for the game module. Create this file by executing the following command line:

touch ./src/game.rs

With the project and directories setup, you can dive into outlining the game.

Plan out the game with types and structs

The classic Tic-Tac-Toe game consists of two main components: a board and turns for each player. The board is essentially an empty 3x3 array, and the turns indicate which player must make a move. To translate this functionality, you must edit the game.rs file you made in the last section (see the following code).

type Board = Vec<Vec<String>>;

enum Turn {
    Player,
    Bot,
}

pub struct Game {
    board: Board,
    current_turn: Turn,
}

You may have noticed the odd syntax here, but don't worry: I describe that as we go.

The board

To translate the game board, you use the type keyword to alias the name Board to be synonymous with the type Vec<Vec<String>>. Now, Board is a simple type for a two-dimensional vector of strings. I would use a char here because the only values in the array will be x, o, or a number indicating an open position.

The turns

A turn simply indicates which player must choose a spot, so an enum structure works perfectly. On each turn, simply match the Turn variant to make the appropriate method calls.

The game

Finally, you must create a Game object that holds the board and the current turn being played. But wait! Where are the methods for the Game struct? Never fear: That's next.

Implement the game

What methods make up a Tic-Tac-Toe game? Well, there are turns. On each turn, the board is displayed, a player makes a move, the board is displayed again, and the win condition is checked. If the game was won, the game announces which player won and asks him or her to play again. If no one won the game, then the game switches the current player and plays the next turn. Obviously, there are finer issues inside each move, depending on the player, but you can just dive in from here.

First, you create a construction that is nested in an impl block, as shown in the following code.

impl Game {
    pub fn new() -> Game {
        let first_row = vec![
            String::from("1"), String::from("2"),
            String::from("3")];

        let second_row = vec![
            String::from("4"), String::from("5"),
            String::from("6")];

        let third_row = vec![
            String::from("7"), String::from("8"),
            String::from("9")];

        Game {
            board: vec![first_row, second_row, third_row],
            current_turn: Turn::Player,
        }
    }
}

The static method new creates and returns a Game struct. This is a standard name for an object constructor in Rust.

You must bind the board member variable with a 2d vector of String objects. Instead of leaving each location blank, notice that I filled them with a number indicating the available positions for each move. Next, bind the current_turn member variable to the value of Turn::Player. This line means that every game has the player move first.

How do you play the game?

The first method serves as a map for the program. You add this method inside the impl Game block (along with the rest of the methods in this section). The following code shows the method.

pub fn play_game( self) {
    let mut finished = false;

    while !finished {
        self.play_turn();

        if self.game_is_won() {
            self.print_board();

            match self.current_turn {
                Turn::Player => println!("You won!"),
                Turn::Bot => println!("You lost!"),
            };

            finished = Self::player_is_finished();

            self.reset();
        }

        self.current_turn = self.get_next_turn();
    }
}

It's easy to see the flow of the game. Using an infinite loop, you move from one turn to the next, alternating the current_turn. For this reason, you use a mutable borrow on self, because the game's internal state changes with each turn.

That enum is already paying off because if the game is won, the information about who won the game is embedded. You then let the player know that he or she either won or lost. In addition, you reset the board to its original state, which is helpful if the user wants to play again.

Notice that this will be the only pub method other than new. This means that play_game and new are the only methods that another library has access to when using Game objects. All other methods, static or otherwise, are private.

Turning the tides

The first helper method used in the play_game method is play_turn. The following code shows this nifty little function.

fn play_turn( self) {
    self.print_board();

    let (token, valid_move) = match self.current_turn {
        Turn::Player => (
            String::from("X"), self.get_player_move()),
        Turn::Bot => (
            String::from("O"), self.get_bot_move()),
    };

    let (row, col) = Self::to_board_location(valid_move);

    self.board[row][col] = token;
}

This one is tricky. First, you print the board so that the user knows which positions are available (useful even when it's the bot's turn). Next, depending on the variant of current_turn, you assign the variables token and valid_move by using tuple deconstruction and match.

token is either the String X or O for the player or bot, respectively. valid_move is the integer 1 through 9, who's spot on the board isn't occupied. This variable is then converted to the respective row and column for the board, using the to_board_location static method. (Self, with a capital "S," returns a type of self — in this case, Game.)

Let's see that board

Now that you have set up the play_turn, you need a method for printing. The following code shows that method for printing the game board.

fn print_board() {
    let separator = "+---+---+---+";

    println!("\n{}", separator);

    for row in  {
        println!("| {} |\n{}", row.join(" | "), separator);
    }

    print!("\n");
}

In this method, you use a for loop to print an ASCII representation of the rows on the board. The temporary variable row is a reference to each vector in the board. Using the join method, you can turn row into a String and print that new value with an appended separator String.

With printing functionality now working, you can finally move on to getting the valid moves for the player and the bot.

Player, it's your turn

So far, this program is a series of hard-coded returns with no input from the player. The following code shows how to set up turn taking.

fn get_player_move() -> u32 {
    loop {
        let mut player_input = String::new();

        println!(
            "\nPlease enter your move (an integer between \
            1 and 9): ");

        match io::stdin().read_line( player_input) {
            Err(_) => println!(
                "Error reading input, try again!"),
            Ok(_) => match self.validate() {
                Err(err) => println!("{}", err),
                Ok(num) => return num,
            },
        }
    }
}

The heart of this method boils down to this: It loops infinitely unless the player provides a valid move for the game.

The first match expression after the user prompt attempts to read a user's input into a Stringplayer_input — and checks if an error occurs in doing so. The io module provides this functionality; you must import this module at the top of the game.rs file. Its stdin().read_line method (stdin() returns a handle object to the current standard input). Here's my import of the io module:

use std::io;

It is also important to note that the read_line method, while mutating a given String, also returns an enum called Result. I didn't talk about Result in my introductory article, so I touch on it next.

The Result enum

Result is what's known as an algebraic type. It's an enum with two variants: Ok and Err. Each variant can hold data, like String or i32.

In the read_line case, the Result returned is a special version from the io module, which means that Err is a special io::Error variant. In contrast, Ok is the same as the original Result variant and, in this case, holds an integer that represents the number of bytes read. Result is a useful enum that helps ensure that you're handling all possible errors at compile time instead of runtime.

Another sibling enum that's pervasive in Rust is Option. Instead of Ok and Err, its variants are None (which holds no data) and Some (which does). Option is useful in the way that nullptr in C++ or None in Python is useful.

What's the difference between Option and Result and when should you use them? Here are my go-to answers. First, if you expect that a function can return nothing, then use Option. Use Result for functions that you expect to succeed at all times but that can fail, meaning that the error must be caught. Got it? Great. Back to the get_player_move method.

Back to the game

I left off at reading the input from the player. If an error reading the user's input occurs, the program notifies the user and asks him or her for another input. If no error occurs, then the program reaches the second match expression. Notice the use of the underscores (_): They tell Rust that you're not binding the data inside the Result's Ok or Err variants, which you do in the second match expression.

This match expression checks if the player_input variable is valid. If it isn't, the code returns an error (which the game alerts the player to), and asks the player for a valid input. If player_input is valid, then that input, converted into an integer using the validate method, is returned.

Validate your code

With the core of the game written, it's a good to write a validate function. The following code shows this function.

fn validate(, input: ) -> Resultu32, String {
    match input.trim().parse::u32() {
        Err(_) => Err(
            String::from(
                "Please input a valid unsigned integer!")),
        Ok(number) => {
            if self.is_valid_move(number) {
                Ok(number)
            } else {
                Err(
                    String::from(
                        "Please input a number, between \
                        1 and 9, not already chosen!"))
            }
        }
    }
}

Running through this output line by line, here's the gist of the method.

First, the program is returning a Result enum. I haven't covered type templates, but basically, you're stating that the Ok variant of the Result must hold a u32 integer and the Err variant must hold a String. Why a Result return here? Well, the method is expected to pass and throws an error only if the given input is:

  • Not an integer;
  • Not a valid location because of occupancy; or
  • Not a valid location because the integer isn't 1–9.

Next, the program attempts to transform the input into a u32 by using input's parse method. The turbofish, ::type is a special aspect of some functions that tells them what type to return. In this case, it's simultaneously telling parse to try to convert input to a u32 and setting the Result's Ok variant to hold a u32. If input cannot be converted, the code returns an error indicating that the input was not an unsigned integer. However, if it's successfully converted, then the code passes the input through another helper function: is_valid_move.

Why is there another helper function for validating? From the earlier list of possible errors, number 1 is specific to the user. The bot will always give an integer. That's why you use validate only to validate the player's response. is_valid_move checks the other two possible errors.

The following code shows the last piece of the validation code.

fn is_valid_move(, unchecked_move: u32) -> bool {
    match unchecked_move {
        1...9 => {
            let (row, col) = Self::to_board_location(
                unchecked_move);

            match self.board[row][col].as_str() {
                "X" | "O" => false,
                 _ => true,
            }
        }
        _ => false,
    }
}

Simple enough. If the given unchecked_move is not between 1 and 9 (inclusive), then it's not a valid move. Otherwise, the code is forced to check whether the move has already been made. Like before in play_turn, you transform unchecked_move into the respective row and column on the board. You then can check if that location is on the board. If the location is X or O, then the move is invalid.

On to the bot

Before moving on to writing the method to get the bot's move, create the to_board_location static method that the following code shows.

fn to_board_location(game_move: u32) -> (usize, usize) {
    let row = (game_move - 1) / 3;
    let col = (game_move - 1) % 3;

    (row as usize, col as usize)
}

This method is a bit of a cheat because you know that when to_board_location is called in validate and play_turn, the argument game_move is an integer between 1 and 9 (inclusive). You set this method as static because the math has no ties to a Game object. A Tic-Tac-Toe board is always 3x3.

Chatter bot

Your code can get a move from a player, but consider the bot. First, the bot's move should be a random number, which means that you need to import the third-party crate rand. Second, you keep generating this random move until it reaches a valid location by using the is_valid_move method. Then, the game must notify the player what move the bot made and return the move.

You import and install that rand crate in a file called Cargo.toml, with rand as a dependency. The following code shows the Cargo.toml file.

[package]
name = "tic_tac_toe"
version = "0.1.0"
authors = ["Dylan Hicks dirtgrub.dylanhicks@gmail.com"]

[dependencies]
rand = "0.4"

The main.js file tells Cargo that you want to use this dependency. I put this command at the top of the file:

extern crate rand;

Then, put this command at the top of the game.rs file, above the io import:

use rand;

With the rand crate to generate a random number, you need a method to get a move from the bot. The following code shows that bot_move method.

fn get_bot_move() -> u32 {
    let mut bot_move: u32 = rand::random::u32() % 9 + 1;

    while !self.is_valid_move(bot_move) {
        bot_move = rand::random::u32() % 9 + 1;
    }

    println!("Bot played moved at: {}", bot_move);

    bot_move
}

That was painless, right?

That method finishes off the play_turn method dependencies. Now, you need to make a method to check if the game was won.

We are the champions

Now, you're going to play a little fast and loose with the Boolean algebra with the following code.

fn game_is_won() -> bool {
    let mut all_same_row = false;
    let mut all_same_col = false;

    for index in 0..3 {
        all_same_row |=
            self.board[index][0] == self.board[index][1]
             self.board[index][1] == self.board[index][2];
        all_same_col |=
            self.board[0][index] == self.board[1][index]
             self.board[1][index] == self.board[2][index];
    }

    let all_same_diag_1 =
        self.board[0][0] == self.board[1][1]
         self.board[1][1] == self.board[2][2];
    let all_same_diag_2 =
        self.board[0][2] == self.board[1][1]
         self.board[1][1] == self.board[2][0];

        (all_same_row || all_same_col || all_same_diag_1 ||
         all_same_diag_2)
}

During the for loop, you simultaneously check the rows and columns to see if the win condition for Tic-Tac-Toe has been met (that is, three Xs or Os in a row). You do this with |=, which is like +=, but instead of the addition operator it uses the or operator. Then, you check if the two diagonals are all the same character. Finally, you return whether any of the win conditions have been met by using some Boolean algebra. Three more methods and you're done.

Would you like to play again?

If you go back and look at the play_game method in A map of the game program, you see that the code keeps looping until finished is true. This happens only if the method player_is_finished is true. This method should be based on the player's response, either yes or no, as shown in the following code.

fn player_is_finished() -> bool {
    let mut player_input = String::new();

    println!("Are you finished playing (y/n)?:");

    match io::stdin().read_line( player_input) {
        Ok(_) => {
            let temp = player_input.to_lowercase();

            temp.trim() == "y" || temp.trim() == "yes"
        }
            Err(_) => false,
    }
}

When I originally wrote this method, I decided that it was best if I just handled the "yes" case of the player's input, meaning that all other input returns false. Again, this is a static method because it has no use for any of the data that self carries.

A hard reset fixes all

One of the last methods used in play_game is reset, shown in the following code.

fn reset( self) {
    self.current_turn = Turn::Player;
    self.board = vec![
        vec![
            String::from("1"), String::from("2"),
            String::from("3")],
        vec![
            String::from("4"), String::from("5"),
            String::from("6")],
        vec![
            String::from("7"), String::from("8"),
            String::from("9")],
    ];
}

All this method does is set the game's member variables back to their defaults.

The final method that you need to complete the game is get_next_turn, shown in the following code.

fn get_next_turn() -> Turn {
    match self.current_turn {
        Turn::Player => Turn::Bot,
        Turn::Bot => Turn::Player,
    }
}

This method simply checks which turn self is on and returns the opposite.

Run and compile the game

With the game.rs module finished, main.rs is now at the point that you can compile and play the game.

extern crate rand;

mod game;

use game::Game;

fn main() {
    println!("Welcome to Tic-Tac-Toe!");

    let mut game = Game::new();

    game.play_game();
}

That's it. You just declared that the game module exists in this project with mod and brought the Game object into scope with use. Then, you created a game object with Game::new() and told the object to play the game. Now, run it with Cargo using the following code.

  cargo run
   Compiling tic_tac_toe v0.1.0...
    Finished dev [unoptimized + debuginfo]...
     Running...
Welcome to Tic-Tac-Toe!

+---+---+---+
| 1 | 2 | 3 |
+---+---+---+
| 4 | 5 | 6 |
+---+---+---+
| 7 | 8 | 9 |
+---+---+---+

Please enter your move (an integer between 1 and 9):
...

Final thoughts

As you learned throughout this tutorial, Rust is a versatile language that has the ease of use of Java, C#, or Python but the speed and power of C or C++. Not only is this code compiled and fast, but all memory and error concerns are handled at compile time instead of runtime, cutting down the human errors possible in the code.

To see the code I created for this article, please visit my GitHub repo