DEV Community

loading...
Cover image for Rust CLI Game of Life tutorial - PART 1

Rust CLI Game of Life tutorial - PART 1

jbarszczewski profile image Jakub Barszczewski Updated on ・7 min read

Intro

Hi! If you're here that means you are curious about Rust and/or want to learn it. I've written my first Rust tutorial, Rust + Actix + CosmosDB (MongoDB) tutorial api, back in June 2020. This time I've decided I will try to cover another use case for Rust which is CLI. To make it more interesting it will be implementation of Game of Life based on Official Rust WebAssembly tutorial enhanced with some user interaction logic.

This is beginner tutorial, yet I still highly recommend going through official rustlings tutorial.

"Final" code can be found on my github repo

Let's start!

Create The Universe

After creating new project something like cargo new cli-game-of-life (or cargo init if you're already in correct directory) open your favourite editor and... ignore main.rs for now. We're gonna create game logic module first, so go ahead and create a new file src/game.rs. As mentioned in before, I will base the logic on official wasm tutorial so if you've done it before it will be very familiar. Let's start with defining an enum that will represent single cell in our game universe:

#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Cell {
    Dead = 0,
    Alive = 1,
}
Enter fullscreen mode Exit fullscreen mode

derive attribute will tell the compiler to provide basic implementation of passed traits so that we can assign cells with enum values and compare them.

Note: We could use simple bool value as well but enum will give us better readability while having the same memory footprint.

Our game universe is defined as follows:

pub struct Universe {
    width: u32,
    height: u32,
    cells: Vec<Cell>,
}
Enter fullscreen mode Exit fullscreen mode

Now we can start implementing functions for our game. Let's start with a handy constructor that will initialize Universe with given size and assign Cells starting values and set_cells function that will accept an array of cells coordinates and set them to Alive state.

impl Universe {
    pub fn new(width: u32, height: u32) -> Universe {
        Universe {
            width: width,
            height: height,
            cells: vec![Cell::Dead; (width * height) as usize],
        }
    }

    pub fn set_cells(&mut self, cells: &[(u32, u32)]) {
        for (row, col) in cells.iter().cloned() {
            let idx = self.get_index(row, col);
            self.cells[idx] = Cell::Alive;
        }
    }

    fn get_index(&self, row: u32, column: u32) -> usize {
        (row * self.width + column) as usize
    }
}
Enter fullscreen mode Exit fullscreen mode

The get_index is a helper fumction that will translate Universe coordinates into index of coresponding cell in cells array.

Next, we will implement Display trait for easy printing out current state of our game:

use std::fmt;

impl fmt::Display for Universe {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        for line in self.cells.as_slice().chunks(self.width as usize) {
            for &cell in line {
                let symbol = if cell == Cell::Dead { '◻' } else { '◼' };
                write!(f, "{}", symbol)?;
            }
            write!(f, "\n")?;
        }

        Ok(())
    }
}
Enter fullscreen mode Exit fullscreen mode

Perfect! Now we have something to run. Head over to your main.rs and replace all with the following content:

mod game;

fn main() {
    let mut game = game::Universe::new(5, 5);
    game.set_cells(&[(2, 1), (2, 2), (2, 3)]);
    print!("{}", game);
}
Enter fullscreen mode Exit fullscreen mode

And run your prohect with cargo run. Ok it works! Of course, nothing is really happening here so let's do another step and add the tick function to our code:

pub fn tick(&mut self) {
    let mut next = self.cells.clone();
    for row in 0..self.height {
        for col in 0..self.width {
            let idx = self.get_index(row, col);
            let cell = self.cells[idx];
            let live_neighbours = self.live_neighbour_count(row, col);
            next[idx] = match (cell, live_neighbours) {
                (Cell::Alive, x) if x < 2 => Cell::Dead,
                (Cell::Alive, 2) | (Cell::Alive, 3) => Cell::Alive,
                (Cell::Alive, x) if x > 3 => Cell::Dead,
                (Cell::Dead, 3) => Cell::Alive,
                (otherwise, _) => otherwise,
            };
        }
    }
    self.cells = next;
}

fn live_neighbour_count(&self, row: u32, column: u32) -> u8 {
    let mut count = 0;
    for delta_row in [self.height - 1, 0, 1].iter().cloned() {
        for delta_col in [self.width - 1, 0, 1].iter().cloned() {
            if delta_row == 0 && delta_col == 0 {
                continue;
            }

            let neighbour_row = (row + delta_row) % self.height;
            let neighbour_col = (column + delta_col) % self.width;
            let idx = self.get_index(neighbour_row, neighbour_col);
            count += self.cells[idx] as u8;
        }
    }

    count
}
Enter fullscreen mode Exit fullscreen mode

This code comes straight from the WASM rust book and it applies Conway's Game Of Life rules to our universe while also taking care of edge wrapping so that our universe seems looped (See flavour 3).
Before we can use tick, we need to prepare our terminal to display animated game Universe. Let's hop into that right now!

P.S. - You can find source code for this chapter on my GitHub

Animate The Universe

To work with terminal input/output we will use Crossterm crate, so let's add it to our Cargo.toml:

[dependencies]
crossterm = "0.19.0"
Enter fullscreen mode Exit fullscreen mode

This crate has some really handy functions to manipulate terminal and it's cross platform so we don't need to worry about any differences. Most of the crossterm commands are self-explanatory as they are grouped into relevan modules, like cursor::Hide does exactly what it says: it hides the cursor.

Because our game Universe will be updated and displayed in a loop, we want to clear the screen before each tick. We will move into the alternate screen for the game time and go back to original terminal screen once we are done. First let's make sure we have all the necessery imports:

use crossterm::{
    cursor::{Hide, MoveTo, Show},
    event::{poll, read, Event},
    execute,
    style::{Color, Print, ResetColor, SetForegroundColor},
    terminal::{Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
    Result,
};
use std::io::stdout;
use std::time::Duration;
Enter fullscreen mode Exit fullscreen mode

Also, our main function need complete overhaul and now will look like this:

fn main() -> Result<()> {
    let mut game = game::Universe::new(5, 5);
    game.set_cells(&[(2, 1), (2, 2), (2, 3)]);
    execute!(
        stdout(),
        EnterAlternateScreen,
        SetForegroundColor(Color::Magenta),
        Hide
    )?;

    loop {
        if poll(Duration::from_millis(500))? {
            match read()? {
                Event::Key(_) => break,
                _ => {}
            }
        } else {
            execute!(
                stdout(),
                Clear(ClearType::All),
                MoveTo(0, 0),
                Print(&game),
                Print("Press enter to exit...")
            )?;
            game.tick();
        }
    }
    execute!(stdout(), ResetColor, Show, LeaveAlternateScreen)?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Ok let's break down what we did here:

  1. main now returns Result type. This will allow us to provide feedback to users and set appropriate exit codes where needed.
  2. We set up our terminal in execute! macro, which accepts std::io::Writer type (stdout in our case) as first argument followed by one or more commands.
  3. In a loop we try to read the user input wrapped in a poll which ensure that we don't block the execution. We break the loop when user press the Enter key. If no user input is available in 500ms then we draw current state of the Universe and compute next state with tick()
  4. Once the loop is over, we leave the alternate screen of the terminal.

Now run the app with cargo run and you should see simple pattern alternating between horizontal and vertical lines.
Ok but pressing Enter is not what user expect when trying to exit the app. Let's modify our code so that it could respond to different keys.

Interact with The Universe

Reason we could only process Enter is that by default input is being processed on enter press. Which makes sense as usually you first want to type in the command and execute when it's all ready. But in our case, we want user to be able to interact with single key presses. That means we need to enable raw mode. New code changes are as follow:

// add required imports:
use terminal::{disable_raw_mode, enable_raw_mode};

// add this line at the very begining of the main() function:
enable_raw_mode()?;

// replace code block when poll returns true, the match statement, with following:

if let Event::Key(KeyEvent { code, .. }) = read()? {
    match code {
        KeyCode::Esc => {
            break;
        }
        _ => {}
    }
}

// finaly disable raw mode at the end of the function before returning Ok(()):
disable_raw_mode()?;
Enter fullscreen mode Exit fullscreen mode

It's very important to add ability to exit from the loop as raw mode disables ctrl+c funcionality.

When you will try to run it now you will notice that formatting is all messed up. That's because raw mode doesn't process new line character. We need now explicitly set the cursor to the correct positions. That also means we cannot use the Display trait anymore. Instead, we will iterate through each row of the game Universe and print it out separately.

Add new method to the Universe:

pub fn row_as_string(&self, row: u32) -> Option<String> {
    if row < self.height {
        let mut row_string = String::new();
        let start = self.get_index(row, 0);
        let end = self.get_index(row, self.width);
        let line = &self.cells[start..end];
        for &cell in line {
            let symbol = if cell == Cell::Dead { '◻' } else { '◼' };
            row_string.push(symbol);
        }
        Some(row_string)
    } else {
        None
    }
}
Enter fullscreen mode Exit fullscreen mode

If the row is withing Universe size we will return all its cells as a String, otherwise None is returned.
In our main.rs add new import from crossterm queue, queue! macro is similar to execute but require manual flush. This makes it really handy if you want to conditionaly build your output. Let's see how it goes. First at the beginning of our main() function initialize a new variable:

let mut stdout = stdout();
Enter fullscreen mode Exit fullscreen mode

Now you can replace stdout() with our new variable name for consistency. Then replace the whole loop with following code:

loop {
    if poll(Duration::from_millis(500))? {
        if let Event::Key(KeyEvent { code, .. }) = read()? {
            match code {
                KeyCode::Esc => {
                    break;
                }
                _ => {}
            }
        }
    } else {
        queue!(stdout, Clear(ClearType::All))?;
        let mut i = 0;
        while let Some(line) = game.row_as_string(i) {
            queue!(stdout, MoveTo(0, i as u16), Print(line))?;
            i += 1;
        }

        queue!(
            stdout,
            MoveTo(0, (i + 1) as u16),
            Print("Press Esc to exit...")
        )?;
        stdout.flush()?;
        game.tick();
    }
}
Enter fullscreen mode Exit fullscreen mode

The key press handling remains unchanged. All the changes are in else block:

  1. We've replaced single execute! with queue! macros.
  2. We iterate through game Universe rows while row_as_string(i) returns results and queue printing them on separate lines. You can see here how handy is to return Option<T>! We don't need any null handling and the code looks super clean.
  3. After all text is ready, we flush() stdout.

Conclusion

And that's it for part 1! A good exercise would be to enhance the app with some more user interactions, like controlling speed or colours. In the next part I will cover how we can process command line arguments to set up our game.
I hope you've enjoyed this tutorial and as always if any suggestions/questions don't hesitate to leave a comment below.

Thanks for reading and till the next time!

Edit: Part 2 now available here!

Discussion (0)

pic
Editor guide