Beginner’s Guide to Rust
Put your Rust skills to use by building a simple Tic Tac Toe
game
Content series:
This content is part # of # in the series: Beginner’s Guide to Rust
https://www.ibm.com/developerworks/library/?series_title_by=**auto**
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 3×3
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 String
—player_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 3×3.
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
Credit: Source link