Contents


Beginner's Guide to Rust

Start coding with the Rust language

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

Comments

Content series:

This content is part # of # in the series: Beginner's Guide to Rust

Stay tuned for additional content in this series.

This content is part of the series:Beginner's Guide to Rust

Stay tuned for additional content in this series.

As I mentioned in the first part of this series, 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.

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

Start by reading the first part of this series, Beginner's Guide to Rust. I show you how to install and run Rust, describe its core functionality, and introduce you to the concepts you need to get started. In this article, I won't be describing every facet of the language, so you'll need to get a handle on the language's basics.

Start the project

First, you need to set up your project. You can 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 Listing 1 shows.

Listing 1. Running "Hello, World!"
$ 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 Listing 2).

Listing 2. Game.rs modified for a board and player turns
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 Listing 3.

Listing 3. The game construction
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 Grid block (along with the rest of the methods in this section). Listing 4 shows the method.

Listing 4. A map of the game program
pub fn play_game(&mut 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. Listing 5 shows this nifty little function.

Listing 5. The play_turn function
fn play_turn(&mut 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. Listing 6 shows that method.

Listing 6. Printing the game board
fn print_board(&self) {
    let separator = "+---+---+---+";

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

    for row in &self.board {
        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. Listing 7 changes that.

Listing 7. Setting up turn taking
fn get_player_move(&self) -> u32 {
    loop {
        let mut player_input = String::new();

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

        match io::stdin().read_line(&mut player_input) {
            Err(_) => println!(
                "Error reading input, try again!"),
            Ok(_) => match self.validate(&player_input) {
                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. Listing 8 shows the code.

Listing 8. The validate function
fn validate(&self, input: &str) -> Result<u32, 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.

Listing 9 shows the last piece of the validation code.

Listing 9. A bit more validation
fn is_valid_move(&self, 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 Listing 10 shows.

Listing 10. The to_board_location method
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. Listing 11 shows the file.

Listing 11. Cargo.toml
[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. Listing 12 shows that method.

Listing 12. The bot_move method
fn get_bot_move(&self) -> 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 (Listing 13).

Listing 13. A bit of Boolean algebra
fn game_is_won(&self) -> 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 Listing 4, 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 (Listing 14).

Listing 14. The player_is_finished method
fn player_is_finished() -> bool {
    let mut player_input = String::new();

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

    match io::stdin().read_line(&mut 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 Listing 15.

Listing 15. The reset method
fn reset(&mut 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 Listing 16.

Listing 16. The get_next_turn method
fn get_next_turn(&self) -> 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 (Listing 17).

Listing 17. Compile 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 (Listing 18).

Listing 18. Run the game
$ 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.

Next steps

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

Downloadable resources


Comments

Sign in or register to add and subscribe to comments.

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=1
Zone=Open source
ArticleID=1060266
ArticleTitle=Beginner's Guide to Rust: Start coding with the Rust language
publish-date=05072018