I wrote tetrs (https://github.com/mkhan45/tetrs) over the summer after playing Tetris with some friends on https://jstris.jezevec10.com/. One of my goals when writing it was for it to be written mostly using functional programming. I did complete the actual Tetris part back then, but the UI was pretty bad so I never really used it. I finally got around to finishing it a few weeks ago because of coronabreak.

As usual, I wrote it in Rust using ggez as a game engine.


The main difficulties in writing Tetris come a few main areas:

  • Block collision
  • Tetromino rotation
  • Projection for the predicted tetromino placement at the bottom

A few months ago I read a post on r/programminghorror or somewhere similar about tetris collision detection function that was hundreds of lines long. As it turns out, Tetris collision detection is really simple if you separate tetrominoes and squares. For that reason, the logic for the board is separated into 3 structs:

  • Square - A single square, logically just a position tuple but it also has Rect and Color fields for drawing.
  • Block - A tetromino, but I didn't know that they were called tetrominoes at the time. This is composed of an array of 4 Squares, a BlockType enum, and an Orientation enum.
  • GameState - well a GameState has the state of the whole game; the relevant fields are squares, an expandible array of squares, and current_block, which is a Block. I thought about having an array of Option<Square>s for squares, but a preallocated Vec<Square> is easier.

With these structs, collision is easily detected by checking if any of the squares in GameState have the same position tuple as any of the squares in current_block. Functional programming ideas help here. Block has a translate method. With normal OOP, you'd probably make the signature fn translate(&mut self, x, y), but to preserve immutability, I used fn translate(&self, x, y) -> Block.

This is helpful later on in this method in GameState:

pub fn try_translate(&mut self, x: i8, y: i8) {
   let translated = self.current_block.translate(x, y);
   if translated.is_valid(&self.squares) {
    self.current_block = translated;
   }
}

I don't actually use try_translate on current_block because a lot more stuff has to happen if translated is not valid. Instead I did this:

// create a temporary block that is the current_block translated down by one
let translated = self.current_block.translate(0, 1);

if translated.is_valid(&self.squares) {
   self.current_block = translated;
} else {
   // handle everything that has to do with the current_block colliding 
   ...
}

Tetromino rotation was a lot less elegant. As far as I know, it must be hardcoded. The bulk of the logic is in Block::new(), which just specifies an array of squares for every combination of BlockType and Orientation. I managed to make it look okay though. Here's a snippet:

(BlockType::T, Orientation::Up) => {
    block_from_squares(blocktype, orientation, 
     [
          (1, 0), 
      (0, 1), (1, 1), (2, 1)
     ]
    )
}

Of course, autoformatting is turned off for that function.

Rotation is also hardcoded because blocks have to be translated a bit on rotation to remain consistent. There's some standard way to rotate blocks that I followed.


Making the projection at the bottom was surprisingly difficult. It would be easy to just test every downward translation of the current block, but that would be pretty inefficient. Instead, I tried to find the max drop distance for each square in the block. At first I filtered it to only include the squares at the bottom of the tetromino, but there's only 4 anyway so that actually makes it slower.

This function finds the max distance that a tetromino can be translated down before hitting something:

pub fn max_drop(&self, board: &[Square]) -> i8 {
  self.squares.iter().fold(Y_SQUARES + 5, |max_dist, square| {
    let square_max = square.max_y_translate(board);
    if square_max < max_dist {
      square_max
    } else {
      max_dist
    }
  }) - 1
}

Y_SQUARES + 5 is the max distance a square could be dropped from the time it was spawned.

square.max_y_translate is shown here:

pub fn max_y_translate(&self, board: &[Square]) -> i8 {
  // starts by filtering the board to only squares on the same x axis, and then
  // looks down
  let max_square = board
    .iter()
    .filter(|square| square.pos.0 == self.pos.0 && square.pos.1 >= self.pos.1)
    .fold(Square::bottom(self.pos.0), |max_square, current_square| {
      if current_square.pos.1 <= max_square.pos.1 {
        *current_square
      } else {
        max_square
      }
    });
  max_square.pos.1 - self.pos.1
}

Overall, I'm pretty happy with how it turned out. I was able to go back to it after six or so months and add a main menu and fix some bugs without too much difficulty, and I don't think there's too much inefficiency anywhere.