How to make a Rock, Paper, Scissors Game in Rust - II

Photo by timlewisnm
This is the second post in a series that shows how to make a Rock, Paper, Scissors game using the Rust programming language. The post assumes you've read the earlier post in the series. Click here to jump to the beginning and start reading.

In our first post, we developed a Rock, Paper, Scissors game from scratch. The game was functional, but wasn't really complete. First, as written in the earlier post, the game will panic if a player submits an unexpected input (i.e not 'r', 'rock', 'p', 'paper', 's' or 'scissors'). Second, the game only played one round. Just about everyone who plays Rock, Paper, Scissors plays multiple rounds. In this post we're going to fix those issues by adding more robust error handling and additional rounds.

If you'd like to follow along with this project, a git repository can be found here. We've moved beyond the Rust book now, so commits will follow the headings.

Better Error Handling

The first thing to address is the error handling. Even the simplest mistake will cause the program to panic. Even a capital 'R' instead of a lower case 'r' will cause a panic. To see this behavior, run the program again, choose 'R', and watch what happens:
$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.01s
     Running `/home/rskerr/dev/rock-paper-scissors/target/debug/rock-paper-scissors`
Hello, Lets play Rock, Paper, Scissors!
Please select (r)ock, (p)aper, or (s)cissors:
R
thread 'main' panicked at 'This is not a valid guess.: Unknown("R")', src/main.rs:146:36
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
We see the panic with thread 'main' panicked at 'This is not a valid guess.: Unknown("R")', src/main.rs:146:36. Once again, though, Rust's error handling helps us out. Rust tells us the panic message ("This is not a valid guess."), the faulty input ("R") and the exact file, line and character position ("src/main.rs:146:36") where the error occurred. We can go there and we see this line:
let player_move: RockPaperScissorsGuess =
    player_move.trim().parse().expect("This is not a valid guess.");
The error is on the call to expect(). expect() is a function implemented for Result values that works like unwrap(), but with a message if a panic occurs. expect() checks its self value and if the value is Ok() returns the passed value. If the value is an Err(), expect() panics with the given panic message. The default panic behavior is to print the message to stderr and then exit.

To improve the situation, we need to handle the Err(). Before we do though, it is helpful to take a moment to talk about expect() and its close cousin unwrap(). expect() and unwrap() are, to put it the way an early version of the Rust book does, "the bull in the china shop." They will help you deal with errors, but they will trample a lot along the way and generally make a mess of things. When looking to make a rust program more robust, searching for unwap() and expect() calls and finding better, more ergonomic ways to handle them is a good place to start. The Rust book has some further discussion on unwrap, expect and when to panic! in the error handling chapter.

In our case, the way we'll make error handling better is to remove the expect() call and handle the error more deliberately. The first thing to do is to remove the expect call and then process the Result in player_move with a match.
let player_move: Result<RockPaperScissorsGuess, ParseRockPaperScissorsGuessError>
    = player_move.trim().parse();
                
match player_move {
    Ok(_) => {
        //
        // Do something on Ok
        //
    }
    Err(ParseRockPaperScissorsGuessError::Unknown(s)) => {
        //
        // Do something on Err
        //
    },
}
The match above has two arms, one for each of the possible values of Result. In the first arm we'll process the Ok() result. Note the use of '_' in the Ok() arm. This tells Rust to ignore the value contained in the Ok() body.

In the second arm we unpack the Err() case. The match value shows the use of destructuring to unwrap the Result into a variable 's' that we can then use inside the body.

The pattern we see here of getting a Result back from a function call and then using a match to handle the result values is extremely common in Rust and we'll use it often to process Result values. Note also that we can use destructuring to do the unwrap for us to extract the actual value from the Result.

Now that we've reviewed the pattern for error handling, we can look at the implementations for the match bodies. Lets look at the Ok() arm first:
let player_move: Result<RockPaperScissorsGuess, ParseRockPaperScissorsGuessError>
    = player_move.trim().parse();

let player_move = match player_move {
    Ok(player_move_val) => {
        println!("");
        println!("You chose {}", player_move_val);
        println!("I chose {}", comp_move);
        player_move_val
    },
    Err(ParseRockPaperScissorsGuessError::Unknown(s)) => {
        //
        // Do something on Err
        //
    },
};
Observe that we've added a return value ('player_move') to the match so we can extract a value from the match statement for later use. In the Ok() arm, we've also added a destructured value which is the returned result from the Ok() body. Before returning we print out computer and player's choices.

Next up is the Err() arm:
loop {
    // Read player move
    // Elided for clarity
        
    let player_move: Result<RockPaperScissorsGuess, ParseRockPaperScissorsGuessError>
        = player_move.trim().parse();

    let player_move = match player_move {
        Ok(player_move_val) => {
            println!("");
            println!("You chose {}", player_move_val);
            println!("I chose {}", comp_move);
            player_move_val
        },
        Err(ParseRockPaperScissorsGuessError::Unknown(s)) => {
            println!("\"{}\" is not a valid guess, try again.\n",s);
            continue
        }
    }
}
We've added a loop around the input and parsing. The idea is that if the player enters an incorrect value then the system should catch that and ask for a new input. println!() in the Err() arm displays the incorrect value and then continues the loop. Later, we'll use this same match statement to gracefully allow players to quit the game early.

When we putting all of that together main() looks like this:
fn main() {
    println!("Hello, Lets play Rock, Paper, Scissors!");

    let comp_move: RockPaperScissorsGuess = rand::thread_rng().gen();

    println!("Please select (r)ock, (p)aper, or (s)cissors:");
 
    loop {
        let mut player_move = String::new();

        io::stdin()
            .read_line(&mut player_move)
            .expect("Failed to read move");

        let player_move: Result<RockPaperScissorsGuess, ParseRockPaperScissorsGuessError>
            = player_move.trim().parse();

        let player_move = match player_move {
            Ok(player_move_val) => {
                println!("");
                println!("You chose {}", player_move_val);
                println!("I chose {}", comp_move);
                player_move_val
            },
            Err(ParseRockPaperScissorsGuessError::Unknown(s)) => {
                println!("\"{}\" is not a valid guess, try again.\n",s);
                continue
            },
        };

        let result: RockPaperScissorsResult = player_move.compare(&comp_move);
        println!("{}", result);
        break;
    }
}
We can run now and see that the system handles errors much more gracefully:
$ cargo run
   Compiling rock-paper-scissors v0.1.0 (/home/rskerr/dev/rock-paper-scissors)
    Finished dev [unoptimized + debuginfo] target(s) in 0.54s
     Running `/home/rskerr/dev/rock-paper-scissors/target/debug/rock-paper-scissors`
Hello, Lets play Rock, Paper, Scissors!
Please select (r)ock, (p)aper, or (s)cissors:
foo
"foo" is not a valid guess, try again.

bar
"bar" is not a valid guess, try again.

r

You chose Rock
I chose Rock
We Tied...Rock
That is our error handling! Next up is to add multiple rounds to the game.

Adding Multiple Rounds

Rock, Paper, Scissors is almost always played as a series of rounds where the first player to win some number of rounds wins the game. For example, games will play to 'best 3 out of 5.' To win you need to be the first player to win 3 games. The 'out of 5' part comes because that is the lowest number of games that would force a win by one player or another.

To add multiple rounds to the game we'll add some extra variables to track the game state, and a loop to process each round. We'll also need to add some additional println! messages to communicate with the player how they're doing in the overall game. Lets add the outer loop first:
fn main() {
    println!("Hello, Lets play Rock, Paper, Scissors!");

    println!("Let's play best 3 out of 5 rounds.");

    let mut player_wins = 0;
    let mut comp_wins = 0;

    loop { // game

        let comp_move: RockPaperScissorsGuess = rand::thread_rng().gen();

        loop { // round
    
            // Rock, Paper, Scissors Game Code
            // Elided for clarity

            let result: RockPaperScissorsResult = player_move.compare(&comp_move);
            match result {
                RockPaperScissorsResult::Win(_) => {
                    player_wins += 1;
                    println!("{}", result);
                    println!("You won this round.");
                },
                RockPaperScissorsResult::Tie => println!("Tie..."),
                RockPaperScissorsResult::Loss(_) => {
                    comp_wins += 1;
                    println!("{}", result);
                    println!("You lost this round.");
                },
            }
            break;
        }
        break;
    }
}
With this loop, we've added some mutable state to hold our game values. In a more complicated game we would probably put these into a structure to keep them together and more easily move them around, but for our purpose here having three separate values keeps things simple.

Note also that we gave the loops comment labels 'game', and 'round' for the outer and inner loops respectively. This is just some sugar that will make it easier to refer to these loops in our discussion

The other big change here is to transform the single println! the used to be at the end of the 'round loop into a match statement does some housekeeping at the end of each round. Depending on the value of result (either a RockPaperScissorsResult::Win, RockPaperScissorsResult::Loss, or a RockPaperScissorsResult::Tie) we add a win to the computer or player's wins variable and print out who won the round. (Note we also removed the 'You Won....' and 'You Lost....' statements from the Display implementation for RockPaperScissorsResult. Not shown here for brevity.)

As is, the game still plays only one round though. In the above listing, we show a break; at the end of the 'round' loop to break out of the 'game' loop and end the round. We can remove that break if we add additional checks at the end of the 'game' loop to see if anyone has won the game.
println!("");
if player_wins == 3 {
    println!("Congratulations, You won the game!\n");
    break;
} else if comp_wins == 3 {
    println!("Too bad...You lost the game! Better luck next time.\n");
    break;
} else {
    println!("You have {} wins, and I have {} wins.\n", player_wins, comp_wins);           
}
The interesting bits here are the break statements inside the top two if branches. If a player has won the game, the test will succeed, the game prints a message that you've won or lost and then calls break to exit the 'game loop. If no one has won, then the game prints the score and loops back in the 'game loop to perform another round.

With these changes we now have a game that will play multiple rounds and the first player to win 3 rounds total will win the whole game.
...

Congratulations, You won the game!

One more thing...


When adding error checking, I mentioned that we would use the error handling match to allow for a graceful early exit. Lets add that now.

What we want is a way for players to quit the game early if they choose to. In the Err() arm of the match on player_move, we already have detected invalid input and we handle it with a simple print statement like so:
let player_move = match player_move {
    Ok(player_move_val) => {
        println!("");
        println!("You chose {}", player_move_val);
        println!("I chose {}", comp_move);
        player_move_val
    },
    Err(ParseRockPaperScissorsGuessError::Unknown(s)) => {
        println!("\"{}\" is not a valid guess, try again.\n",s);
        continue
    },
};
What we're going to do instead is to add a match on the destructured input value and catch if the user has entered a 'q' character to quit. When we detect that we'll break out of the loops and exit the game with a 'Thank you and good bye' message. To get started we'll add a variable to track if the player has requested an early exit and then at the end of the main body check to see if this is an early exit. The changes look like this:
fn main() {
    //
    // Game Intro
    //
    
    let mut quit = false;

    'game: loop { // game
    
    	//
        // 'game' code
        //
        
        loop { // round
        
	    //
            // 'round' code
            //
            
            let player_move = match player_move {
                Ok(player_move_val) => {
                    println!("");
                    println!("You chose {}", player_move_val);
                    println!("I chose {}", comp_move);
                    player_move_val
                },
                Err(ParseRockPaperScissorsGuessError::Unknown(s)) => {
                    match &s[..] {
                        "q" | "quit" => {
                            println!("Quit? Okay.");
                            quit = true;
                            break 'game;
                        },
                        _            => {
                            println!("\"{}\" is not a valid guess, try again.\n",s);
                            continue
                        },
                    }
                },
            };

            //
            // Report Round Results
            //

        } // end round loop
        
        //
        // Win Check
        //
        
    } // end game loop

    if quit == true {
        println!("Well... thanks for playing. Sorry you had to leave so soon.");
    }
}
All the parts here are pretty self explanatory. The 'quit' variable starts 'false' and is set to 'true' if the user enters 'q' as their move. When quitting, the game prints a message and breaks out of the the loops using a named break. Note that we can name loops ('game in this case) and refer to them with the break statement as a way to break out of nested loops. After breaking, we have a final println! to add a goodbye message for quitting players.

Questions, Reflections, and Learning
This was my first game project in Rust. Not only did I have fun, but I learned a lot. Taking a project like this on while reading the Rust book provided a valuable tool for helping to cement some of the topics addressed in the rust book. As I'm wrapping up this series, I thought it would be useful to consider some questions that you as a reader might have and also to reflect on what I've learned.

1. This seems like a really simple project, why not do something more ambitious?

As a first project, I wanted to make sure that it was simple enough that I'd not get too far away from my (limited) knowledge of Rust. Rock, Paper, Scissors is so close to the guessing game example in the Rust book that I knew I'd have a good structure to fall back on when I got stuck or had questions. I also didn't want to select a project that would require a load of 3rd party crates. I had enough to learn with the language that adding in different crates and their APIs and perhaps not idiomatic interactions was something I wanted to avoid.

What I didn't realize when I started was just how much of an impact switching to Rock, Paper, Scissors would have. This game doesn't rely on numbers and so I had to develop and implement multiple user defined types to represent the domain values of 'Rock,' 'Paper,' and 'Scissors.' That requirement was just enough of a step away from the core types that made this project very interesting.

2. What worked well?

Rust Tooling - Rust really delivered. I had heard before I started just how nice the Rust tooling is, but I was quite frankly blown away by just how very helpful it is. Cargo is a great tool and makes project setup, dependency management and builds very easy right from the start. Compiler error messages are such a breath of fresh air that they'll get their own bullet (see below). Even the editor integration is great. I'm a vim user and integrating cargo check, code highlighting, and completion just worked.

Rust Error Messages - I've generally not had issues with error messages in languages. My earliest work was in C and then C++, and while sometimes messages can appear cryptic at first, after spending the time to really read them, their message is almost always clear and unambiguous. (The one exception is Clojure IMO, where error messages are just plain atrocious and don't seem to be getting any better.) But Rust is by far, hands down the best experience I've ever had. Not only were the messages unusually clear, but they point directly at the place in code (line and character) where the issue was discovered. They also include additional explanations built into the compiler invokable on the command line. That alone would be a huge win, but on top of that, 9 times out of 10, the compiler will have a helpful suggestion for how to fix. And the corrections almost always worked as suggested. Rust error messages are a game changer and I never want to go back to something else. Not only does the compiler actually help me get my project done, but along the way it helps me to become a better Rust programmer. Simply amazing.

Traits - I think there is genius in Rust traits. I'm still just scratching the surface, but when working with user defined types, the compiler would tell me so many times that I needed to implement some trait or another to be able to make some call to println! or generate a random number. And the thing is, they 'just worked.' Implementation was as simple as looking up the trait and dashing off a quick couple line function. Easy peasy. But along the way, the language and compiler guided me to a correct solution that I'm confident is fairly idiomatic for the language.

3. What did you struggle with?

Mostly things I still have to learn. I ran into the borrow checker a few times while wandering down a dark corridor. For this project, I mostly didn't try to resolve borrow issues and either went back and took a different approach, or just followed the compiler's recommendations for corrections. That said, there were more than a few places where Rust mentioned that a move had occurred in a place where I wasn't expecting it and caused me to refactor to work around. It is clear that I have more to learn here.

The other area that I know I'll have work to do on is lifetimes. I stumbled into lifetime errors a few times as well. You'll note that there are no lifetime declarations in this project. When I ran into issues with lifetimes I just went back and refactored a different solution.

Both of these were fairly conscious decisions. My goal with this project was to just rip off a simple project that I could get through without working too hard. I'll spend time learning about lifetimes and better understand when and how moves occur as I spend more time with the language.

4. What is next?

There are a number of places I could go with this project.

Other Rock, Paper, Scissors variants - Wikipedia lists a number of variations for Rock, Paper, Scissors and it might be fun to implement them.

Adding command line parameters - Adding parameters to indicate game variations, or number of rounds would be a good learning exercise.

Polishing the game - It might be nice to add some ASCII art on login or to add color to the output. This would really polish up the UX and make the game a bit more fun.

Something completely different - For the next project, I've thought about something a bit bigger (not too much). Perhaps a text adventure. This would get into multiple files and some more interesting data structures.

What do you think? Leave a comment below, I'd love to hear from you.
Enjoyed this post? Help me create more with a coffee. Never miss out on future posts by following me.

Comments

Popular posts from this blog

How to make a Text Adventure game in Rust - Introduction