"Life is all about sharing. If we are good at something, pass it on." - Mary Berry

Learning Rust by building Tetris: my favorite childhood game

2023-10-03

Categories: Programming

Play tetris 2-player mode in the terminal

After completing the Rust book and working through rustlings, I found myself standing at the crossroads, wondering where to go next. It was then that I had an idea - a project that would allow me to apply my newfound knowledge and create something meaningful. My favorite childhood game, Tetris, became the inspiration for my next coding adventure.

Since I want it to be playable on Windows, I chose the TUI library crossterm.

  1. Drawing borders and the playfield
  2. Drawing tetrominoes
  3. Automatic and soft drop
  4. Lock tetromino and move to the next
  5. Handling key events
  6. Clearing the filled lines
  7. Game over
  8. Pause and Quit
  9. Reset
  10. Multiplayer mode

1. Drawing borders and playfield

The initial step is to draw the borders. The standard Tetris playing field has 20 rows and 10 columns. I’ve designated the pipe operator (|) for the left and right borders and a dash (-) for the top and bottom borders:

The function takes input in the form of coordinates, width and height parameters representing the playing field:

 1fn render_frame(
 2    stdout: &mut io::Stdout,
 3    title: &str,
 4    start_x: usize,
 5    start_y: usize,
 6    width: usize,
 7    height: usize,
 8) -> Result<()> {
 9    execute!(
10        stdout,
11        SetForegroundColor(Color::White),
12        SetBackgroundColor(Color::Black),
13    )?;
14
15    // Print the top border
16    let left = (width - title.len() - 2) / 2;
17    execute!(
18        stdout,
19        MoveTo(start_x as u16, start_y as u16),
20        Print(format!(
21            "|{} {} {}|",
22            "-".repeat(left as usize),
23            title,
24            "-".repeat(width as usize - left as usize - title.len() - 2)
25        )),
26    )?;
27
28    // Print the left and right borders
29    for index in 1..height {
30        execute!(
31            stdout,
32            MoveTo(start_x as u16, start_y as u16 + index as u16),
33            Print("|"),
34            MoveTo(
35                start_x as u16 + width as u16 + 1,
36                start_y as u16 + index as u16
37            ),
38            Print("|"),
39        )?;
40    }
41
42    // Print the bottom border
43    execute!(
44        stdout,
45        MoveTo(start_x as u16, start_y as u16 + height as u16),
46        Print(format!("|{}|", ("-").repeat(width as usize))),
47    )?;
48
49    stdout.flush()?;
50
51    Ok(())
52}

the borders can be rendered as:

1const PLAY_WIDTH: usize = 10;
2const PLAY_HEIGHT: usize = 20;
3const SQUARE_BRACKETS: &str = "[ ]";
1render_frame(
2    stdout,
3    "Tetris",
4    self.start_x,
5    self.start_y,
6    PLAY_WIDTH * 3,
7    PLAY_HEIGHT + 1,
8)?;

As I use square brackets to make up the tetromino, we need to multiply PLAY_WIDTH by 3:

1fn create_grid(
2    width: usize,
3    height: usize,
4) -> Vec<Vec<Cell>> {
5    let mut grid = vec![vec![EMPTY_CELL; width]; height];
6
7    grid
8}
1let play_grid = create_grid(PLAY_WIDTH, PLAY_HEIGHT);

2. Drawing Tetrominoes

A tetromino can be represented as:

1struct Tetromino {
2    states: Vec<Vec<Vec<Cell>>>,
3    current_state: usize,
4    position: Position,
5}
1const I_CELL: Cell = Cell {
2    symbols: SQUARE_BRACKETS,
3    color: Color::Cyan,
4};
 1let i_tetromino_states: Vec<Vec<Vec<Cell>>> = vec![
 2    vec![
 3        vec![EMPTY_CELL, EMPTY_CELL, EMPTY_CELL, EMPTY_CELL],
 4        vec![I_CELL, I_CELL, I_CELL, I_CELL],
 5        vec![EMPTY_CELL, EMPTY_CELL, EMPTY_CELL, EMPTY_CELL],
 6        vec![EMPTY_CELL, EMPTY_CELL, EMPTY_CELL, EMPTY_CELL],
 7    ],
 8    vec![
 9        vec![EMPTY_CELL, EMPTY_CELL, I_CELL, EMPTY_CELL],
10        vec![EMPTY_CELL, EMPTY_CELL, I_CELL, EMPTY_CELL],
11        vec![EMPTY_CELL, EMPTY_CELL, I_CELL, EMPTY_CELL],
12        vec![EMPTY_CELL, EMPTY_CELL, I_CELL, EMPTY_CELL],
13    ],
14    vec![
15        vec![EMPTY_CELL, EMPTY_CELL, EMPTY_CELL, EMPTY_CELL],
16        vec![EMPTY_CELL, EMPTY_CELL, EMPTY_CELL, EMPTY_CELL],
17        vec![I_CELL, I_CELL, I_CELL, I_CELL],
18        vec![EMPTY_CELL, EMPTY_CELL, EMPTY_CELL, EMPTY_CELL],
19    ],
20    vec![
21        vec![EMPTY_CELL, I_CELL, EMPTY_CELL, EMPTY_CELL],
22        vec![EMPTY_CELL, I_CELL, EMPTY_CELL, EMPTY_CELL],
23        vec![EMPTY_CELL, I_CELL, EMPTY_CELL, EMPTY_CELL],
24        vec![EMPTY_CELL, I_CELL, EMPTY_CELL, EMPTY_CELL],
25    ],
26];

To draw a tetromino, we just need to loop through the its current state, and draw each cell with corresponding color:

 1fn render_current_tetromino(&self, stdout: &mut std::io::Stdout) -> Result<()> {
 2    let current_tetromino = &self.current_tetromino;
 3    for (row_index, row) in current_tetromino.states[current_tetromino.current_state]
 4        .iter()
 5        .enumerate()
 6    {
 7        for (col_index, &ref cell) in row.iter().enumerate() {
 8            let grid_x = current_tetromino.position.col + col_index as isize;
 9            let grid_y = current_tetromino.position.row + row_index as isize;
10
11            if cell.symbols != SPACE {
12                if grid_x < PLAY_WIDTH as isize && grid_y < PLAY_HEIGHT as isize {
13                    execute!(
14                        stdout,
15                        SavePosition,
16                        MoveTo(
17                            self.start_x as u16 + 1 + grid_x as u16 * CELL_WIDTH as u16,
18                            self.start_y as u16 + 1 + grid_y as u16
19                        ),
20                        SetForegroundColor(cell.color),
21                        SetBackgroundColor(Color::Black),
22                        Print(cell.symbols),
23                        ResetColor,
24                        RestorePosition,
25                    )?;
26                }
27            }
28        }
29    }
30
31    Ok(())
32}

3. Automatic and soft drop

3.1 Automatic drop

First, we need to write a function to detect the collision. To do this, we need to check if the new column / row (after moving) goes outside of the playfield, or if that cell is already occupied:

 1fn can_move(&mut self, tetromino: &Tetromino, new_row: i16, new_col: i16) -> bool {
 2    for (t_row, row) in tetromino.get_cells().iter().enumerate() {
 3        for (t_col, &ref cell) in row.iter().enumerate() {
 4            if cell.symbols == SQUARE_BRACKETS {
 5                let grid_x = new_col + t_col as i16;
 6                let grid_y = new_row + t_row as i16;
 7
 8                if grid_x < 0
 9                    || grid_x >= PLAY_WIDTH as i16
10                    || grid_y >= PLAY_HEIGHT as i16
11                    || self.play_grid[grid_y as usize][grid_x as usize].symbols
12                        == SQUARE_BRACKETS
13                {
14                    return false;
15                }
16            }
17        }
18    }
19
20    true
21}

At a regular interval, we will check if a tetromino can be moved down, and if so, we will increase the row by 1, clear the old tetromino and draw a new one:

 1let mut drop_timer = Instant::now();
 2if drop_timer.elapsed() >= Duration::from_millis(self.drop_interval) {
 3    let mut tetromino = self.current_tetromino.clone();
 4    let can_move_down = self.can_move(
 5        &tetromino,
 6        tetromino.position.row as i16 + 1,
 7        tetromino.position.col as i16,
 8    );
 9
10    if can_move_down {
11        tetromino.move_down(self, stdout)?;
12        self.current_tetromino = tetromino;
13    } else {
14        self.lock_and_move_to_next(&tetromino, stdout)?;
15    }
16
17    self.render_current_tetromino(stdout)?;
18
19    drop_timer = Instant::now();
20}
1fn move_down(&mut self, game: &mut Game, stdout: &mut std::io::Stdout) -> Result<()> {
2    if game.can_move(self, self.position.row as i16 + 1, self.position.col as i16) {
3        game.clear_tetromino(stdout)?;
4        self.position.row += 1;
5    }
6
7    Ok(())
8}

To clear the old tetromino, we just need to draw an empty cell:

 1fn clear_tetromino(&mut self, stdout: &mut std::io::Stdout) -> Result<()> {
 2    let tetromino = &self.current_tetromino;
 3    for (row_index, row) in tetromino.states[tetromino.current_state].iter().enumerate() {
 4        for (col_index, &ref cell) in row.iter().enumerate() {
 5            let grid_x = tetromino.position.col + col_index as isize;
 6            let grid_y = tetromino.position.row + row_index as isize;
 7
 8            if cell.symbols != SPACE {
 9                execute!(
10                    stdout,
11                    SetBackgroundColor(Color::Black),
12                    SavePosition,
13                    MoveTo(
14                        self.start_x as u16 + 1 + grid_x as u16 * CELL_WIDTH as u16,
15                        self.start_y as u16 + 1 + grid_y as u16,
16                    ),
17                    Print(SPACE),
18                    ResetColor,
19                    RestorePosition
20                )?;
21            }
22        }
23    }
24
25    Ok(())
26}
3.2. Soft drop

At the starting levels, we may need a way to make the tetromino drop faster than the regular interval. That’s when the soft drop comes into play.

 1if kind == KeyEventKind::Press {
 2    let mut tetromino = self.current_tetromino.clone();
 3    match code {
 4        KeyCode::Char('s') | KeyCode::Up => {
 5            if soft_drop_timer.elapsed()
 6                >= (Duration::from_millis(self.drop_interval / 8))
 7            {
 8                let mut tetromino = self.current_tetromino.clone();
 9                if self.can_move(
10                    &tetromino,
11                    tetromino.position.row as i16 + 1,
12                    tetromino.position.col as i16,
13                ) {
14                    tetromino.move_down(self, stdout)?;
15                    self.current_tetromino = tetromino;
16                } else {
17                    self.lock_and_move_to_next(&tetromino, stdout)?;
18                }
19
20                soft_drop_timer = Instant::now();
21            }
22        }

4. Lock tetromino and move the next

When a tetromino reaches the bottom, we need to lock it at that position and move on to the next one:

 1fn lock_and_move_to_next(
 2    &mut self,
 3    tetromino: &Tetromino,
 4    stdout: &mut io::Stdout,
 5) -> Result<()> {
 6    self.lock_tetromino(tetromino, stdout)?;
 7    self.move_to_next(stdout)?;
 8
 9    Ok(())
10}
 1fn lock_tetromino(&mut self, tetromino: &Tetromino, stdout: &mut io::Stdout) -> Result<()> {
 2    for (ty, row) in tetromino.get_cells().iter().enumerate() {
 3        for (tx, &ref cell) in row.iter().enumerate() {
 4            if cell.symbols == SQUARE_BRACKETS {
 5                let grid_x = (tetromino.position.col as usize).wrapping_add(tx);
 6                let grid_y = (tetromino.position.row as usize).wrapping_add(ty);
 7
 8                self.play_grid[grid_y][grid_x] = cell.clone();
 9            }
10        }
11    }
12
13    self.clear_filled_rows(stdout)?;
14
15    Ok(())
16}
17
18fn move_to_next(&mut self, stdout: &mut io::Stdout) -> Result<()> {
19    self.current_tetromino = self.next_tetromino.clone();
20    self.current_tetromino.position.row = 0;
21    self.current_tetromino.position.col =
22        (PLAY_WIDTH - tetromino_width(&self.current_tetromino.states[0])) as isize / 2;
23    self.render_current_tetromino(stdout)?;
24
25    self.next_tetromino = Tetromino::new(true);
26    self.render_next_tetromino(stdout)?;
27
28    Ok(())
29}

The next tetromino can be rendered the same as the current one; we just need different coordinates.

5. Handling key events

Similar to the automatic drop, we need to handle the key events to move left, right, rotate and hard drop:

 1if poll(Duration::from_millis(10))? {
 2    let event = read()?;
 3    match event {
 4        Event::Key(KeyEvent {
 5            code,
 6            state: _,
 7            kind,
 8            modifiers: _,
 9        }) => {
10            if kind == KeyEventKind::Press {
11                let mut tetromino = self.current_tetromino.clone();
12                match code {
13                    KeyCode::Char('h') | KeyCode::Left => {
14                        tetromino.move_left(self, stdout)?;
15                        self.current_tetromino = tetromino;
16                    }
17                    KeyCode::Char('l') | KeyCode::Right => {
18                        tetromino.move_right(self, stdout)?;
19                        self.current_tetromino = tetromino;
20                    }
21                    KeyCode::Char(' ') => {
22                        tetromino.rotate(self, stdout)?;
23                        self.current_tetromino = tetromino;
24                    }
25                    KeyCode::Char('j') | KeyCode::Down => {
26                        tetromino.hard_drop(self, stdout)?;
27                        self.lock_and_move_to_next(&tetromino, stdout)?;
28                    }
29                    _ => {}
30                }
31            }
32        }
33        _ => {}
34    }
35    self.render_current_tetromino(stdout)?;
36}

Moving left, right is straightforward, but what about the rotate? When considering the algorithm for rotating a tetromino, I discovered that we can simplify it by storing all states of a tetromino:

 1let t_tetromino_states: Vec<Vec<Vec<Cell>>> = vec![
 2    vec![
 3        vec![EMPTY_CELL, T_CELL, EMPTY_CELL],
 4        vec![T_CELL, T_CELL, T_CELL],
 5        vec![EMPTY_CELL, EMPTY_CELL, EMPTY_CELL],
 6    ],
 7    vec![
 8        vec![EMPTY_CELL, T_CELL, EMPTY_CELL],
 9        vec![EMPTY_CELL, T_CELL, T_CELL],
10        vec![EMPTY_CELL, T_CELL, EMPTY_CELL],
11    ],
12    vec![
13        vec![EMPTY_CELL, EMPTY_CELL, EMPTY_CELL],
14        vec![T_CELL, T_CELL, T_CELL],
15        vec![EMPTY_CELL, T_CELL, EMPTY_CELL],
16    ],
17    vec![
18        vec![EMPTY_CELL, T_CELL, EMPTY_CELL],
19        vec![T_CELL, T_CELL, EMPTY_CELL],
20        vec![EMPTY_CELL, T_CELL, EMPTY_CELL],
21    ],
22];

and switch to the next state when rotating:

 1fn rotate(&mut self, game: &mut Game, stdout: &mut std::io::Stdout) -> Result<()> {
 2    let next_state = (self.current_state + 1) % (self.states.len());
 3
 4    let mut temp_tetromino = self.clone();
 5    temp_tetromino.current_state = next_state;
 6
 7    if game.can_move(
 8        &temp_tetromino,
 9        self.position.row as i16,
10        self.position.col as i16,
11    ) {
12        game.clear_tetromino(stdout)?;
13        self.current_state = next_state;
14    }
15
16    Ok(())
17}

The hard_drop function can be implemented by moving down infinitely until it reaches the bottom:

1fn hard_drop(&mut self, game: &mut Game, stdout: &mut std::io::Stdout) -> Result<()> {
2    while game.can_move(self, self.position.row as i16 + 1, self.position.col as i16) {
3        game.clear_tetromino(stdout)?;
4        self.position.row += 1;
5    }
6
7    Ok(())
8}

6. Clearing the filled lines

After a tetromino reaches the bottom, we need to check if there are any filled lines. If so, we need to clear them from the playfield and update the score/lines:

 1fn clear_filled_rows(&mut self, stdout: &mut io::Stdout) -> Result<()> {
 2    let mut filled_rows: Vec<usize> = Vec::new();
 3
 4    for row_index in (0..PLAY_HEIGHT).rev() {
 5        if self.play_grid[row_index][0..PLAY_WIDTH]
 6            .iter()
 7            .all(|cell| cell.symbols == SQUARE_BRACKETS)
 8        {
 9            filled_rows.push(row_index);
10        }
11    }
12
13    let new_row = vec![EMPTY_CELL; PLAY_WIDTH];
14    for &row_index in filled_rows.iter().rev() {
15        self.play_grid.remove(row_index);
16        self.play_grid.insert(0, new_row.clone());
17
18        self.lines += 1;
19    }
20
21    let num_filled_rows = filled_rows.len();
22    match num_filled_rows {
23        1 => {
24            self.score += 100 * (self.level + 1);
25        }
26        2 => {
27            self.score += 300 * (self.level + 1);
28        }
29        3 => {
30            self.score += 500 * (self.level + 1);
31        }
32        4 => {
33            self.score += 800 * (self.level + 1);
34        }
35        _ => (),
36    }
37
38    self.render_changed_portions(stdout)?;
39
40    Ok(())
41}

To minimize flickering, we only need to re-render the portions that have been changed:

 1fn render_changed_portions(&self, stdout: &mut std::io::Stdout) -> Result<()> {
 2    self.render_play_grid(stdout)?;
 3
 4    let stats_start_x = self.start_x - STATS_WIDTH - DISTANCE - 1;
 5    execute!(
 6        stdout,
 7        SetForegroundColor(Color::White),
 8        SetBackgroundColor(Color::Black),
 9        SavePosition,
10        MoveTo(
11            stats_start_x as u16 + 2 + "Score: ".len() as u16,
12            self.start_y as u16 + 2
13        ),
14        Print(self.score),
15        MoveTo(
16            stats_start_x as u16 + 2 + "Lines: ".len() as u16,
17            self.start_y as u16 + 3
18        ),
19        Print(self.lines),
20        MoveTo(
21            stats_start_x as u16 + 2 + "Level: ".len() as u16,
22            self.start_y as u16 + 4
23        ),
24        Print(self.level),
25        ResetColor,
26        RestorePosition,
27    )?;
28
29    Ok(())
30}

7. Game over

Whenever a new tetromino is spawned, if it cannot be placed in the playfield, it means it’s game over:

 1fn lock_and_move_to_next(
 2    &mut self,
 3    tetromino: &Tetromino,
 4    stdout: &mut io::Stdout,
 5) -> Result<()> {
 6    self.lock_tetromino(tetromino, stdout)?;
 7    self.move_to_next(stdout)?;
 8
 9    if self.is_game_over() {
10        self.handle_game_over(stdout)?;
11    }
12
13    Ok(())
14}
15
16fn is_game_over(&mut self) -> bool {
17    let tetromino = self.current_tetromino.clone();
18
19    let next_state = (tetromino.current_state + 1) % (tetromino.states.len());
20    let mut temp_tetromino = tetromino.clone();
21    temp_tetromino.current_state = next_state;
22
23    if !self.can_move(
24        &tetromino,
25        tetromino.position.row as i16,
26        tetromino.position.col as i16 - 1,
27    ) && !self.can_move(
28        &tetromino,
29        tetromino.position.row as i16,
30        tetromino.position.col as i16 + 1,
31    ) && !self.can_move(
32        &tetromino,
33        tetromino.position.row as i16 + 1,
34        tetromino.position.col as i16,
35    ) && !self.can_move(
36        &temp_tetromino,
37        tetromino.position.row as i16,
38        tetromino.position.col as i16,
39    ) {
40        return true;
41    }
42
43    false
44}

We can check if the player has achieved a new high score and allow them to enter their name:

 1fn handle_game_over(&mut self, stdout: &mut io::Stdout) -> Result<()> {
 2    if self.score == 0 {
 3        self.show_high_scores(stdout)?;
 4    } else {
 5        let count: i64 =
 6            self.conn
 7                .query_row("SELECT COUNT(*) FROM high_scores", params![], |row| {
 8                    row.get(0)
 9                })?;
10
11        if count < 5 {
12            self.new_high_score(stdout)?;
13        } else {
14            let player: Player = self.conn.query_row(
15                "SELECT player_name, score FROM high_scores ORDER BY score DESC LIMIT 4,1",
16                params![],
17                |row| Ok(Player { score: row.get(1)? }),
18            )?;
19
20            if (self.score as u64) <= player.score {
21                self.show_high_scores(stdout)?;
22            } else {
23                self.new_high_score(stdout)?;
24            }
25        }
26    }
27
28    Ok(())
29}
 1fn new_high_score(&mut self, stdout: &mut std::io::Stdout) -> Result<()> {
 2    print_centered_messages(
 3        stdout,
 4        None,
 5        vec![
 6            "NEW HIGH SCORE!",
 7            &self.score.to_string(),
 8            "",
 9            &format!("{}{}", ENTER_YOUR_NAME_MESSAGE, " ".repeat(MAX_NAME_LENGTH)),
10        ],
11    )?;
12
13    let mut name = String::new();
14    let mut cursor_position: usize = 0;
15
16    let (term_width, term_height) = terminal::size()?;
17    stdout.execute(MoveTo(
18        (term_width - ENTER_YOUR_NAME_MESSAGE.len() as u16 - MAX_NAME_LENGTH as u16) / 2
19            + ENTER_YOUR_NAME_MESSAGE.len() as u16,
20        term_height / 2 - 3 / 2 + 2,
21    ))?;
22    stdout.write(format!("{}", name).as_bytes())?;
23    stdout.execute(cursor::Show)?;
24    stdout.flush()?;
25
26    loop {
27        if poll(Duration::from_millis(10))? {
28            let event = read()?;
29            match event {
30                Event::Key(KeyEvent {
31                    code,
32                    state: _,
33                    kind,
34                    modifiers: _,
35                }) => {
36                    if kind == KeyEventKind::Press {
37                        match code {
38                            KeyCode::Backspace => {
39                                // Handle Backspace key to remove characters.
40                                if !name.is_empty() && cursor_position > 0 {
41                                    name.remove(cursor_position - 1);
42                                    cursor_position -= 1;
43
44                                    stdout.execute(MoveLeft(1))?;
45                                    stdout.write(b" ")?;
46                                    stdout.flush()?;
47                                    print!("{}", &name[cursor_position..]);
48                                    stdout.execute(MoveLeft(
49                                        name.len() as u16 - cursor_position as u16 + 1,
50                                    ))?;
51                                    stdout.flush()?;
52                                }
53                            }
54                            KeyCode::Enter => {
55                                self.conn.execute(
56                                    "INSERT INTO high_scores (player_name, score) VALUES (?1, ?2)",
57                                    params![name, self.score],
58                                )?;
59
60                                execute!(stdout.lock(), cursor::Hide)?;
61                                self.show_high_scores(stdout)?;
62                            }
63                            KeyCode::Left => {
64                                // Move the cursor left.
65                                if cursor_position > 0 {
66                                    stdout.execute(MoveLeft(1))?;
67                                    cursor_position -= 1;
68                                }
69                            }
70                            KeyCode::Right => {
71                                // Move the cursor right.
72                                if cursor_position < name.len() {
73                                    stdout.execute(MoveRight(1))?;
74                                    cursor_position += 1;
75                                }
76                            }
77                            KeyCode::Char(c) => {
78                                if name.len() < MAX_NAME_LENGTH {
79                                    name.insert(cursor_position, c);
80                                    cursor_position += 1;
81                                    print!("{}", &name[cursor_position - 1..]);
82                                    stdout.flush()?;
83                                    for _ in cursor_position..name.len() {
84                                        stdout.execute(MoveLeft(1))?;
85                                    }
86                                    stdout.flush()?;
87                                }
88                            }
89                            _ => {}
90                        }
91                    }
92                }
93                _ => {}
94            }
95        }
96    }
97}

8. Pause and Quit

 1if poll(Duration::from_millis(10))? {
 2    let event = read()?;
 3    match event {
 4        Event::Key(KeyEvent {
 5            code,
 6            state: _,
 7            kind,
 8            modifiers: _,
 9        }) => {
10            if kind == KeyEventKind::Press {
11                let mut tetromino = self.current_tetromino.clone();
12                match code {
13                    KeyCode::Char('p') => {
14                        self.paused = !self.paused;
15                    }
16                    KeyCode::Char('q') => {
17                        self.handle_quit_event(stdout)?;
18                    }
19                    _ => {}
20                }
21            }
22        }
23        _ => {}
24    }
25    self.render_current_tetromino(stdout)?;
26}
 1fn handle_pause_event(&mut self, stdout: &mut io::Stdout) -> Result<()> {
 2    print_centered_messages(stdout, None, vec!["PAUSED", "", "(C)ontinue | (Q)uit"])?;
 3
 4    loop {
 5        if poll(Duration::from_millis(10))? {
 6            let event = read()?;
 7            match event {
 8                Event::Key(KeyEvent {
 9                    code,
10                    modifiers: _,
11                    kind,
12                    state: _,
13                }) => {
14                    if kind == KeyEventKind::Press {
15                        match code {
16                            KeyCode::Enter | KeyCode::Char('c') => {
17                                self.render_changed_portions(stdout)?;
18                                self.paused = false;
19                                break;
20                            }
21                            KeyCode::Char('q') => {
22                                quit(stdout)?;
23                            }
24                            _ => {}
25                        }
26                    }
27                }
28                _ => {}
29            }
30        }
31    }
32
33    Ok(())
34}
 1fn handle_quit_event(&mut self, stdout: &mut io::Stdout) -> Result<()> {
 2    print_centered_messages(stdout, None, vec!["QUIT?", "", "(Y)es | (N)o"])?;
 3
 4    loop {
 5        if poll(Duration::from_millis(10))? {
 6            let event = read()?;
 7            match event {
 8                Event::Key(KeyEvent {
 9                    code,
10                    modifiers: _,
11                    kind,
12                    state: _,
13                }) => {
14                    if kind == KeyEventKind::Press {
15                        match code {
16                            KeyCode::Enter | KeyCode::Char('y') => {
17                                quit(stdout)?;
18                            }
19                            KeyCode::Esc | KeyCode::Char('n') => {
20                                self.render_changed_portions(stdout)?;
21                                self.paused = false;
22                                break;
23                            }
24                            _ => {}
25                        }
26                    }
27                }
28                _ => {}
29            }
30        }
31    }
32
33    Ok(())
34}

9. Reset

After the game is over, we can display the highscores table, and allow the player press r to restart:

1fn reset_game(game: &mut Game, stdout: &mut io::Stdout) -> Result<()> {
2    game.reset();
3    game.render(stdout)?;
4
5    game.handle_event(stdout)?;
6
7    Ok(())
8}
 1fn reset(&mut self) {
 2    // Reset play grid
 3    self.play_grid = create_grid(
 4        PLAY_WIDTH,
 5        PLAY_HEIGHT,
 6        self.start_with_number_of_filled_lines,
 7    );
 8
 9    // Reset tetrominos
10    self.current_tetromino = Tetromino::new(false);
11    self.next_tetromino = Tetromino::new(true);
12
13    // Reset game statistics
14    self.lines = 0;
15    self.level = self.start_at_level;
16    self.score = 0;
17
18    let mut drop_interval: u64 = DEFAULT_INTERVAL;
19    for _i in 1..=self.start_at_level {
20        drop_interval -= drop_interval / 10;
21    }
22    self.drop_interval = drop_interval;
23
24    // Clear any existing messages in the receiver
25    if let Some(ref mut receiver) = self.receiver {
26        while let Ok(_) = receiver.try_recv() {}
27    }
28
29    // Resume the game
30    self.paused = false;
31}

10. Multiplayer mode

To make the game more interesting, I want to add the 2-player mode (so I can play with my son). Whenever a player clears some lines, the number of cleared lines will be sent to the competitor.

To do that, first, we need to open a TCP connection between 2 players and then spawn a new thread to receive messages from the competitor:

1player 1 <--- TCP stream ---> [receive_message thread <--- mpsc::channel --> main thread] player 2
 1if args.multiplayer {
 2    if args.server_address == None {
 3        let listener = TcpListener::bind("0.0.0.0:8080")?;
 4        let my_local_ip = local_ip()?;
 5        println!(
 6            "Server started. Please invite your competitor to connect to {}.",
 7            format!("{}:8080", my_local_ip)
 8        );
 9
10        let (stream, _) = listener.accept()?;
11        println!("Player 2 connected.");
12
13        let mut stream_clone = stream.try_clone()?;
14        let (sender, receiver): (Sender<MessageType>, Receiver<MessageType>) = channel();
15        let mut game = Game::new(
16            conn,
17            Some(stream),
18            Some(receiver),
19            args.number_of_lines_already_filled,
20            args.level,
21        )?;
22
23        thread::spawn(move || {
24            receive_message(&mut stream_clone, sender);
25        });
26
27        game.start()?;
28    } else {
29        if let Some(server_address) = args.server_address {
30            let stream = TcpStream::connect(server_address)?;
31
32            let mut stream_clone = stream.try_clone()?;
33            let (sender, receiver): (Sender<MessageType>, Receiver<MessageType>) = channel();
34            let mut game = Game::new(
35                conn,
36                Some(stream),
37                Some(receiver),
38                number_of_lines_already_filled,
39                start_at_level,
40            )?;
41
42            thread::spawn(move || {
43                receive_message(&mut stream_clone, sender);
44            });
45
46            game.start()?;
47        }
48    }
49}

When a player receives a message, the sender will forward it to the receiver via the channel:

 1fn receive_message(stream: &mut TcpStream, sender: Sender<MessageType>) {
 2    let mut buffer = [0u8; 256];
 3    loop {
 4        match stream.read(&mut buffer) {
 5            Ok(n) if n > 0 => {
 6                let msg = String::from_utf8_lossy(&buffer[0..n]);
 7                if msg.starts_with(PREFIX_CLEARED_ROWS) {
 8                    if let Ok(rows) = msg.trim_start_matches(PREFIX_CLEARED_ROWS).parse() {
 9                        if let Err(err) = sender.send(MessageType::ClearedRows(rows)) {
10                            eprintln!("Error sending number of cleared rows: {}", err)
11                        }
12                    }
13                } else if msg.starts_with(PREFIX_NOTIFICATION) {
14                    let msg = msg.trim_start_matches(PREFIX_NOTIFICATION).to_string();
15                    if let Err(err) = sender.send(MessageType::Notification(msg)) {
16                        eprintln!("Error sending notification message: {}", err)
17                    }
18                }
19            }
20            Ok(_) | Err(_) => {
21                break;
22            }
23        }
24    }
25}

On the receiver side, when a player receive a number of cleared lines, it will add the received number of lines (each with an empty cell in a random column) to the bottom of playfield:

 1if let Some(receiver) = &self.receiver {
 2    for message in receiver.try_iter() {
 3        match message {
 4            MessageType::ClearedRows(rows) => {
 5                let cells =
 6                    vec![I_CELL, O_CELL, T_CELL, S_CELL, Z_CELL, T_CELL, L_CELL];
 7                let mut rng = rand::thread_rng();
 8                let random_cell_index = rng.gen_range(0..cells.len());
 9                let random_cell = cells[random_cell_index].clone();
10
11                let mut new_row = vec![random_cell; PLAY_WIDTH];
12                let random_column = rng.gen_range(0..PLAY_WIDTH);
13                new_row[random_column] = EMPTY_CELL;
14
15                for _ in 0..rows {
16                    self.play_grid.remove(0);
17                    self.play_grid.insert(PLAY_HEIGHT - 1, new_row.clone());
18                }
19
20                self.render_play_grid(stdout)?;
21            }

We also need another component to store the score when playing in 2-player mode:

 1struct MultiplayerScore {
 2    my_score: u8,
 3    competitor_score: u8,
 4}
 5
 6fn handle_game_over(&mut self, stdout: &mut io::Stdout) -> Result<()> {
 7    if let Some(stream) = &mut self.stream {
 8        send_message(stream, MessageType::Notification("YOU WIN!".to_string()));
 9        self.multiplayer_score.competitor_score += 1;
10
11        let stats_start_x = self.start_x - STATS_WIDTH - DISTANCE - 1;
12        if let Some(_) = &self.stream {
13            execute!(
14                stdout,
15                SetForegroundColor(Color::White),
16                SetBackgroundColor(Color::Black),
17                SavePosition,
18                MoveTo(
19                    stats_start_x as u16 + 2 + "Score: ".len() as u16,
20                    self.start_y as u16 + 10
21                ),
22                Print(format!(
23                    "{} - {}",
24                    self.multiplayer_score.my_score, self.multiplayer_score.competitor_score
25                )),
26                ResetColor,
27                RestorePosition,
28            )?;
29        }
30    }
 1if let Some(receiver) = &self.receiver {
 2    for message in receiver.try_iter() {
 3        match message {
 4            MessageType::Notification(msg) => {
 5                self.paused = !self.paused;
 6
 7                print_centered_messages(
 8                    stdout,
 9                    None,
10                    vec![&msg, "", "(R)estart | (C)ontinue | (Q)uit"],
11                )?;
12
13                self.multiplayer_score.my_score += 1;
14
15                let stats_start_x = self.start_x - STATS_WIDTH - DISTANCE - 1;
16                if let Some(_) = &self.stream {
17                    execute!(
18                        stdout,
19                        SetForegroundColor(Color::White),
20                        SetBackgroundColor(Color::Black),
21                        SavePosition,
22                        MoveTo(
23                            stats_start_x as u16 + 2 + "Score: ".len() as u16,
24                            self.start_y as u16 + 10
25                        ),
26                        Print(format!(
27                            "{} - {}",
28                            self.multiplayer_score.my_score,
29                            self.multiplayer_score.competitor_score
30                        )),
31                        ResetColor,
32                        RestorePosition,
33                    )?;
34                }

Throughout this project, I gained invaluable insights, including:

PS: I am currently seeking a new remote software engineering opportunity with a focus on backend development. My flexibility extends accommodating time zones within a range of +/- 3 hours from ICT (Indochina Time). If you have any information regarding companies or positions that are actively hiring for such roles, I would greately approciate it if you can kindly leave a comment or get in touch. Your assistance is sincerely valued. Thank you.

Tags: rust tetris

Edit on GitHub

Related Posts: