Learn Rust by coding a command line Connect 4 game

In this article, we’ll explore how Rust can help us write command line applications by creating a simple version of Connect 4.

Published on
Mar 4, 2024

Read time
14 min read

Introduction

In this article, we’ll explore how Rust can help us write command line applications by creating a simple version of Connect 4.

I recently took on the challenge of learning Rust and, in a bid to take my own advice, I am trying to consolidate my learning by get my hands dirty and using it in personal projects. Connect 4 is a great challenge — simple enough to be coded in one afternoon, but complex enough that it takes some thought to do well.

Here’s a Rust playground featuring my final result. Below, we’ll go the exact steps I took to get there. If you’d like to follow along, ensure you have Rust installed, then run cargo new rust_connect_4_tutorial in your terminal, open the project up in your favourite editor, go into src/main.rs, and let’s get started!

Or, if you prefer to learn by watching videos, check out my video version of this tutorial:

Defining our types

In Rust, the best way to get started is often by defining our types, so we’ll do exactly that!

First, let’s define the board we’ll be using for our game. The classic version of Connect 4 is seven slots wide and six slots tall, so we can store these integers as constants, and use the constants to define the lengths of our two-dimensional array in a Board type:

const BOARD_WIDTH: usize = 7;
const BOARD_HEIGHT: usize = 6;

type Board = [[u8; BOARD_WIDTH]; BOARD_HEIGHT];

We’ll need a way to keep track of players, which we can do with an enum. We’re only supporting two players, but it’ll be useful to have a None variant to mark slots which neither player has got a piece in and also to handle if the game ends with a draw.

#[derive(Clone, Copy, Debug, PartialEq)]
#[repr(u8)]
enum Player {
    One = 1,
    Two = 2,
    None = 0,
}

Above, we’re using derive to implement a handful of traits which will be useful to us later, as well as repr to control the memory layout of our enum; since we know all the possible values, we can restrict ourselves to u8.

Next, we’ll create a struct for our game, containing all the state we’ll need to keep track of.

struct Game {
    current_move: u8,
    current_player: Player,
    board: Board,
    is_finished: bool,
    winner: Player,
}

The current_move is an integer representing which column a player chooses. Even though we have a winner field, it will be useful to have a separate is_finished boolean for when the game ends in a draw!

Finally, we’ll implement a default function on Game which contains the initial state:

impl Game {
    fn default() -> Game {
        Game {
            current_move: 0,
            current_player: Player::One,
            board: [
                [0, 0, 0, 0, 0, 0, 0],
                [0, 0, 0, 0, 0, 0, 0],
                [0, 0, 0, 0, 0, 0, 0],
                [0, 0, 0, 0, 0, 0, 0],
                [0, 0, 0, 0, 0, 0, 0],
                [0, 0, 0, 0, 0, 0, 0],
            ],
            is_finished: false,
            winner: Player::None,
        }
    }
}

Let’s talk a bit about the board. Here, I’m using 0 to represent an empty slot, 1 for a slot taken by Player One, and 2 for a slot taken by Player Two.

I could’ve created this dynamically, using BOARD_WIDTH and BOARD_HEIGHT, but while coding I found seeing the full array to be a helpful visual aid. For the same reason, I chose not to use the Player enum instead of integers, as more text makes the code harder to understand at a glance.

The (small) downside to this approach is that, later on, we’ll need to convert from an integer to a Player. But this can be solved easily by implementing a from_int function on Player, like so:

impl Player {
    fn from_int(int: u8) -> Player {
        match int {
            1 => Player::One,
            2 => Player::Two,
            _ => Player::None,
        }
    }
}

Displaying the board

For debugging purposes, it’ll be helpful to code a nice way to visualise the board earlier rather than later, so let’s do that next. Rather than using a crate to print to the command line, I’ll use hexadecimal escape strings for some simple colours.

const RESET: &str = "\x1b[0m";
const ORANGE: &str = "\x1b[93m";
const RED: &str = "\x1b[0;31m";

In the code below, we’ll use map to convert our slots to emojis that look more similar to actual Connect 4 tokens. This also seems like a good place to announce the winner, if is_finished is true.

impl Game {
    // other functions

    fn display_board(&self) {
        println!("{}--------------------{}", ORANGE, RESET);
        println!("{}CONNECT 4 (Move {}){}", ORANGE, self.current_move, RESET);
        println!("{}--------------------{}", ORANGE, RESET);

        for row in self.board {
            let row_str: String = row
                .iter()
                .map(|&cell| match cell {
                    1 => "🔴",
                    2 => "🟡",
                    _ => "⚫",
                })
                .collect::Vec<&str()
                .join(" ");

            println!("{}", row_str);
        }

        println!("{}--------------------{}", ORANGE, RESET);

        if self.is_finished {
            match self.winner {
                Player::One => println!("{}🔴 Player 1 has won!{}", ORANGE, RESET),
                Player::Two => println!("{}🟡 Player 2 has won!{}", ORANGE, RESET),
                Player::None => println!("{}It's a draw!{}", ORANGE, RESET),
            }

            println!("{}--------------------{}", ORANGE, RESET);
        }
    }
}

We can then call this inside our main function at any time, to see the state of the board.

fn main() {
    let mut game = Game::default();
    game.display_board();
}

It’s a bit boring, though, without any moves being made — so let’s program a way to make moves now!

Playing moves

A first pass of our play_move function needs to change the correct item in our two-dimensional array, increment the move count, and then change the current player.

impl Game {
    // other functions

    fn play_move(&mut self, column: usize) {
        if let Some(row) = (0..BOARD_HEIGHT)
            .rev()
            .find(|&row| self.board[row][column] == 0)
        {
            self.board[row][column] = self.current_player as u8;

            self.current_move += 1;

            self.current_player = match self.current_player {
                Player::One => Player::Two,
                _ => Player::One,
            };
        }
    }
}

In the if let code above, we’re starting at the bottom of the column, and then going up the column one-by-one until we find a slot without a token in it. If we find an empty slot, we’ll change it to the integer representing the current_player, increment the current_move, and use a match expression to change the player.

At this point, we can start playing moves via code and seeing the results in the terminal:

fn main() {
    let mut game = Game::default();

    game.play_move(3);
    game.play_move(3);
    game.play_move(4);
    game.play_move(3);

    game.display_board();
}

Error handling

So far, we’re not handling any potential errors, such as an attempt to play a move outside of the bounds of our board. So let’s handle three possible errors:

  • The column provided is outside of the allowed range.
  • The column provided is already full.
  • The game is finished.

(Once we start accepting user inputs, we’ll need additional to handle some additional possible errors. We’ll do that later.)

Let’s make an enum containing our three error types.

#[derive(Debug)]
enum MoveError {
    GameFinished,
    InvalidColumn,
    ColumnFull,
}

We’ll want to print our errors to the command line, so we’ll need to implement a custom Display trait containing our error messages. For example:

impl std::fmt::Display for MoveError {
    fn fmt(&self, f: &mut std::fmt::Formatter'_) -> std::fmt::Result {
        match self {
            MoveError::ColumnFull => write!(f, "column is full"),
            MoveError::InvalidColumn => write!(f, "column must be between 1 and 7"),
            MoveError::GameFinished => write!(f, "game is already finished"),
        }
    }
}

With these, we can make some edits to our play_move function. Now, we’ll return a Result enum, where nothing is returned if the move is sucessful, but an error is returned if it is not.

impl Game {
    // other functions

    fn play_move(&mut self, column: usize) -> Result(), MoveError {
        if self.is_finished {
            return Err(MoveError::GameFinished);
        }

        if column >= BOARD_WIDTH {
            return Err(MoveError::InvalidColumn);
        }

        if let Some(row) = (0..BOARD_HEIGHT)
            .rev()
            .find(|&row| self.board[row][column] == 0)
        {
            self.board[row][column] = self.current_player as u8;
        } else {
            return Err(MoveError::ColumnFull);
        }

        self.current_move += 1;

        self.current_player = match self.current_player {
            Player::One => Player::Two,
            _ => Player::One,
        };

        Ok(())
    }
}

For debugging, we can now print the result of play_move and, if there’s an unexpected input, we’ll now see an error from our enum.

While we’re here, let’s add a helper function to print our error messages to the command line.

impl Game {
    // other functions

    fn display_error(&self, error: String) {
        self.display_board();
        println!("{}Error: {}{}", RED, error, RESET);
    }
}

Calculating if the game is won

We need to traverse our two-dimensional array in several directions to determine whether somebody has won. This Leetcode-style problem was a fun challenge, and it took me a few tries to get to a result I was happy with.

There are four possible directions that someone can get four-in-a-row:

  • horizontal,
  • vertical,
  • backward slash diagonal (top-left to bottom-right),
  • forward slash diagonal (bottom-left to top-right).

We can iterate through every slot on the board and test in each of these four directions. (Since the board is small, it’s not costly to use a brute force approach. But I’m curious if anyone knows of a superior approach!)

We start at a given slot in the board. If it contains a player’s token, then we move one step in the first direction. If this new slot contains the same token, we move another step in this direction. We continue this until we find four slots with the same token. Otherwise, we move onto another direction.

Each direction can be represented as a tuple of integers — one for the distance per row and one the distance per column — and we can store these in an array.

let directions = [
    (0, 1),  // horizontal
    (1, 0),  // vertical
    (1, 1),  // diagonal (top-left to bottom-right)
    (-1, 1), // diagonal (bottom-left to top-right)
];

This is what the tuples represent:

  • To take a horizontal step, the row does not change (0) but the column does (1).
  • To take a vertical step, the row must change (1) but the column does not (0).
  • To take a step along the “backward slash diagonal”, both the row and column must increment —so (1, 1).
  • Finally, to take a step along the “forward slash diagonal”, the column must increment but the row must decrement — so (-1, 1).

To test every slot in every direction, we’ll need three loops, as below.

impl Game {
    // other functions

    fn calculate_winner(&mut self) -> Player {
        for row in 0..BOARD_HEIGHT {
            for col in 0..BOARD_WIDTH {
                let cell = self.board[row][col];

                if cell != 0 {
                    let directions = [
                        (0, 1),  // horizontal
                        (1, 0),  // vertical
                        (1, 1),  // diagonal (top-left to bottom-right)
                        (-1, 1), // diagonal (bottom-left to top-right)
                    ];

                    for (row_step, col_step) in directions {
                        // TODO - traverse the board in the given direction
                        // and, if a winner is found, return that Player
                    }
                }
            }
        }
        Player::None
    }
}

We’ll keep a consecutive_count and — if it reaches 4 — we’ll know we have a winner.

Then, in our final nested loop, we’ll try going from slot to slot until we…

  • Hit a different token from our starting token (in which case, break the loop);
  • Hit the edge of the board (in which case, break the loop);
  • Hit four-in-a-row of the same token (in which case, end the game and assign the winner).
impl Game {
    // other functions

    fn calculate_winner(&mut self) -> Player {
        for row in 0..BOARD_HEIGHT {
            for col in 0..BOARD_WIDTH {
                let cell = self.board[row][col];

                if cell != 0 {
                    let directions = [
                        (0, 1),  // horizontal
                        (1, 0),  // vertical
                        (1, 1),  // diagonal (top-left to bottom-right)
                        (-1, 1), // diagonal (bottom-left to top-right)
                    ];

                    for (row_step, col_step) in directions {
                        let mut consecutive_count = 1;
                        let mut r = row as isize + row_step;
                        let mut c = col as isize + col_step;

                        while r >= 0
                            && r  BOARD_HEIGHT as isize
                            && c = 0
                            && c  BOARD_WIDTH as isize
                        {
                            if self.board[r as usize][c as usize] == cell {
                                consecutive_count += 1;

                                if consecutive_count == 4 {
                                    self.is_finished = true;
                                    return Player::from_int(cell);
                                }
                            } else {
                                break;
                            }
                            r += row_step;
                            c += col_step;
                        }
                    }
                }
            }
        }

        Player::None
    }
}

We can add a little optimisation by not performing these checks if the move count is below seven, since it’s impossible to win the game before then. So let’s put this at the top of our function:

if self.current_move < 7 {
    return Player::None;
}

And we also need to mark the game as finished if the board is full but there is no winner (i.e. it’s a draw). This can go just above the return statement at the bottom:

if self.current_move = BOARD_HEIGHT as u8 * BOARD_WIDTH as u8 {
    self.is_finished = true;
}

Finally, we can now add our new calculate_winner function into play_move. This time, instead of returning nothing, we’ll return a Result enum which is either successful (and returns nothing) or it fails (and returns an error).

impl Game {
    // other functions

    fn play_move(&mut self, column: usize) -> Result(), MoveError {
        if self.is_finished {
            return Err(MoveError::GameFinished);
        }

        if column >= BOARD_WIDTH {
            return Err(MoveError::InvalidColumn);
        }

        if let Some(row) = (0..BOARD_HEIGHT)
            .rev()
            .find(|&row| self.board[row][column] == 0)
        {
            self.board[row][column] = self.current_player as u8;
            self.current_move += 1;
        } else {
            return Err(MoveError::ColumnFull);
        }

        let calculated_winner = self.calculate_winner();

        if calculated_winner != Player::None {
            self.winner = calculated_winner;
        } else {
            self.current_player = match self.current_player {
                Player::One => Player::Two,
                _ => Player::One,
            };
        }

        Ok(())
    }
}

Our Connect 4 game engine is complete! All that’s left is a way to get inputs from a user…

Reading user inputs

For the final part of our command line game, we’ll return to the main function so we can read our users’ inputs.

First, let’s import Rust’s standard I/O library.

use std::io;

The logic to read a user input could look something like this. Note that, here, we’ll start counting from one (rather than counting from zero, like a computer)!

let mut user_move = String::new();

io::stdin()
    .read_line(&mut user_move)
    .expect("Failed to read line");

let user_move: usize = match user_move.trim().parse() {
    Ok(num) => {
        if num  1 || num  BOARD_WIDTH as u8 {
            game.display_error(MoveError::InvalidColumn.to_string());
            continue;
        } else {
            num
        }
    }
    Err(err) => {
        game.display_error(err.to_string());
        continue;
    }
};

match game.play_move(user_move - 1) {
    Ok(_) => {
        game.display_board();
    }
    Err(err) => {
        game.display_error(err.to_string());
    }
}

We begin by defining an empty string, then use the stdin method to receive text from the user.

Next, we attempt to parse this input. We’ll display an error if, either:

  • The parse fails (for example, if the input is not numerical).
  • The parse succeeds but the number is outside of the desired range.

If no errors are displayed, we’ll play the selected move. This could also return an error so, in that scenario, we’ll display the error.

But the code above only runs once. We need to loop until the game is finished. Here’s what main looks like with that loop, plus a few helpful messages for our users:

fn main() {
    let mut game = Game::default();
    game.display_board();

    while !game.is_finished {
        println!("\n");

        match game.current_player {
            Player::One => println!("PLAYER 1"),
            Player::Two => println!("PLAYER 2"),
            _ => (),
        };

        println!("Enter a column between 1 and 7:");

        let mut user_move = String::new();
        io::stdin()
            .read_line(&mut user_move)
            .expect("Failed to read line");

        let user_move: usize = match user_move.trim().parse() {
            Ok(num) => {
                if num  1 || num  7 {
                    game.display_error(MoveError::InvalidColumn.to_string());
                    continue;
                } else {
                    num
                }
            }
            Err(err) => {
                game.display_error(err.to_string());
                continue;
            }
        };

        match game.play_move(user_move - 1) {
            Ok(_) => {
                game.display_board();
            }
            Err(err) => {
                game.display_error(err.to_string());
            }
        }
    }
}

Playing multiple games

Finally, I’d like to add some extra code that allows users to restart a game when they get to the end. We don’t want them having to restart the executable every time!

To do this, we’ll create a new loop. We can use the same methods as above to read a user’s inputs. If they press “R”, we’ll start a new game. Or if they press “Q”, we’ll break the loop let the code run to the end.

fn main() {
    let mut game = Game::default();
    game.display_board();

    loop {
        while !game.is_finished {
            // code unchanged
        }

        println!("Press 'R' to restart or 'Q' to quit the game.");

        let mut user_input = String::new();

        io::stdin()
            .read_line(&mut user_input)
            .expect("Failed to read line");

        match user_input.trim() {
            "R" | "r" => {
                game = Game::default();
                game.display_board();
            }
            "Q" | "q" => {
                println!("Quitting...");
                break;
            }
            _ => game.display_error("invalid input".to_string()),
        }
    }
}

As a final touch, we can use an ASCII escape code to clear the terminal between turns.

fn clear_screen(&self) {
    print!("{}[2J", 27 as char);
}

We can do this every time we display the game board.

fn display_board(&self) {
    self.clear_screen();

    // code unchanged
}

Bringing it all together

And that’s everything you need for a simple command line game of Connect 4! To try a working version of this, check out this Rust playground.

Here’s the entire file we’ve been building:

use std::io;

const RESET: &str = "\x1b[0m";
const ORANGE: &str = "\x1b[93m";
const RED: &str = "\x1b[0;31m";

const BOARD_WIDTH: usize = 7;
const BOARD_HEIGHT: usize = 6;

type Board = [[u8; BOARD_WIDTH]; BOARD_HEIGHT];

#[derive(Clone, Copy, Debug, PartialEq)]
#[repr(u8)]
enum Player {
    One = 1,
    Two = 2,
    None = 0,
}

impl Player {
    fn from_int(int: u8) -> Player {
        match int {
            1 => Player::One,
            2 => Player::Two,
            _ => Player::None,
        }
    }
}

#[derive(Debug)]
enum MoveError {
    GameFinished,
    InvalidColumn,
    ColumnFull,
}

impl std::fmt::Display for MoveError {
    fn fmt(&self, f: &mut std::fmt::Formatter'_) -> std::fmt::Result {
        match self {
            MoveError::ColumnFull => write!(f, "column is full"),
            MoveError::InvalidColumn => write!(f, "column must be between 1 and 7"),
            MoveError::GameFinished => write!(f, "game is already finished"),
        }
    }
}

struct Game {
    current_move: u8,
    current_player: Player,
    board: Board,
    is_finished: bool,
    winner: Player,
}

impl Game {
    fn default() -> Game {
        Game {
            current_move: 0,
            current_player: Player::One,
            board: [
                [0, 0, 0, 0, 0, 0, 0],
                [0, 0, 0, 0, 0, 0, 0],
                [0, 0, 0, 0, 0, 0, 0],
                [0, 0, 0, 0, 0, 0, 0],
                [0, 0, 0, 0, 0, 0, 0],
                [0, 0, 0, 0, 0, 0, 0],
            ],
            is_finished: false,
            winner: Player::None,
        }
    }

    fn clear_screen(&self) {
        print!("{}[2J", 27 as char);
    }

    fn display_board(&self) {
        self.clear_screen();

        println!("{}--------------------{}", ORANGE, RESET);
        println!("{}CONNECT 4 (Move {}){}", ORANGE, self.current_move, RESET);
        println!("{}--------------------{}", ORANGE, RESET);

        for row in self.board {
            let row_str: String = row
                .iter()
                .map(|&cell| match cell {
                    1 => "🔴",
                    2 => "🟡",
                    _ => "⚫",
                })
                .collect::Vec<&str()
                .join(" ");

            println!("{}", row_str);
        }

        println!("{}--------------------{}", ORANGE, RESET);

        if self.is_finished {
            match self.winner {
                Player::One => println!("{}🔴 Player 1 has won!{}", ORANGE, RESET),
                Player::Two => println!("{}🟡 Player 2 has won!{}", ORANGE, RESET),
                Player::None => println!("{}It's a draw!{}", ORANGE, RESET),
            }

            println!("{}--------------------{}", ORANGE, RESET);
        }
    }

    fn display_error(&self, error: String) {
        self.display_board();
        println!("{}Error: {}{}", RED, error, RESET);
    }

    fn calculate_winner(&mut self) -> Player {
        if self.current_move  BOARD_WIDTH as u8 {
            return Player::None;
        }

        for row in 0..BOARD_HEIGHT {
            for col in 0..BOARD_WIDTH {
                let cell = self.board[row][col];

                if cell != 0 {
                    let directions = [
                        (0, 1),  // horizontal
                        (1, 0),  // vertical
                        (1, 1),  // diagonal (top-left to bottom-right)
                        (-1, 1), // diagonal (bottom-left to top-right)
                    ];

                    for (row_step, col_step) in directions {
                        let mut consecutive_count = 1;
                        let mut r = row as isize + row_step;
                        let mut c = col as isize + col_step;

                        while r = 0
                            && r  BOARD_HEIGHT as isize
                            && c = 0
                            && c  BOARD_WIDTH as isize
                        {
                            if self.board[r as usize][c as usize] == cell {
                                consecutive_count += 1;

                                if consecutive_count == 4 {
                                    self.is_finished = true;
                                    return Player::from_int(cell);
                                }
                            } else {
                                break;
                            }
                            r += row_step;
                            c += col_step;
                        }
                    }
                }
            }
        }

        if self.current_move = BOARD_HEIGHT as u8 * BOARD_WIDTH as u8 {
            self.is_finished = true;
        }

        Player::None
    }

    fn play_move(&mut self, column: usize) -> Result(), MoveError {
        if self.is_finished {
            return Err(MoveError::GameFinished);
        }

        if column >= BOARD_WIDTH {
            return Err(MoveError::InvalidColumn);
        }

        if let Some(row) = (0..BOARD_HEIGHT)
            .rev()
            .find(|&row| self.board[row][column] == 0)
        {
            self.board[row][column] = self.current_player as u8;
            self.current_move += 1;
        } else {
            return Err(MoveError::ColumnFull);
        }

        let calculated_winner = self.calculate_winner();

        if calculated_winner != Player::None {
            self.winner = calculated_winner;
        } else {
            self.current_player = match self.current_player {
                Player::One => Player::Two,
                _ => Player::One,
            };
        }

        Ok(())
    }
}

fn main() {
    let mut game = Game::default();
    game.display_board();

    loop {
        while !game.is_finished {
            println!("\n");

            match game.current_player {
                Player::One => println!("PLAYER 1"),
                Player::Two => println!("PLAYER 2"),
                _ => (),
            };

            println!("Enter a column between 1 and 7:");

            let mut user_move = String::new();
            io::stdin()
                .read_line(&mut user_move)
                .expect("Failed to read line");

            let user_move: usize = match user_move.trim().parse() {
                Ok(num) => {
                    if num  1 || num  7 {
                        game.display_error(MoveError::InvalidColumn.to_string());
                        continue;
                    } else {
                        num
                    }
                }
                Err(err) => {
                    game.display_error(err.to_string());
                    continue;
                }
            };

            match game.play_move(user_move - 1) {
                Ok(_) => {
                    game.display_board();
                }
                Err(err) => {
                    game.display_error(err.to_string());
                }
            }
        }

        println!("Press 'R' to restart or 'Q' to quit the game.");

        let mut user_input = String::new();

        io::stdin()
            .read_line(&mut user_input)
            .expect("Failed to read line");

        match user_input.trim() {
            "R" | "r" => {
                game = Game::default();
                game.display_board();
            }
            "Q" | "q" => {
                println!("Quitting...");
                break;
            }
            _ => game.display_error("invalid input".to_string()),
        }
    }
}

Conclusion

If you’ve been following along and you’d like to take the project further, you could try keeping a track of victories across multiple games. Or how about turning the application into a web server, allowing a website to play Connect 4 via HTTP?

I hope you found this a useful and fun insight into how to build a command line application using Rust. I am still fairly early in my Rust journey, so if you have any critiques or ideas about how to write the application using more idiomatic Rust, I’d love to read your thoughts.

© 2024 Bret Cameron