Building Tetris in Rust

Introduction

Recently, I had watched the movie Tetris, where it tells the story of how a few parties are in a race for negotiating with the bureaucrats of Soviet Union for a license for Tetris.

Before watching this movie, I did not know that Tetris was that big of a deal back in the days.

I remember playing it on my Nintendo Gameboy Color when I was still little, but as I could not grasp hold of how to play it, I did not understand how could it be fun.

Though, I should've probably known, as the game has some pretty impressive achievements today.

Even after almost 40 years, the game is known by everyone worldwide, and still has an active playerbase. It is available on every platform imaginable, holding the world record for the most ported video game.

Thus, I was interested in building it myself to see what it took for such a simple game to be that timeless.

What I used

Rust

As I was learning Rust, I felt this was a perfect opportunity to build this in.

The game had simple rules and gameplay that I felt that wouldn't be too difficult before I dig too deep.

SDL2

For rendering, I used SDL2, in which there was a crate that provided Rust bindings for it.

I used SDL2 as I wanted to a simple 2D renderer, I didn't want to use something big like the bevy as it was too much for my needs and I want to built everything mostly from the ground up.

Other libs

  • rand - Random generation
  • anyhow - Easy error handling

Implementation

Shapes

To define a shape, I implemented it by having an array of 2D coordinate positions.

For example, to create an I block, we can construct one by doing:

struct Position(i32, i32);

struct Block {
    position: Position,
    shape: Vec<Position>,
    color: Color
}

let block_i = Block { 
    position: (4, 8),
    shape: vec![
        Position(0, 0),
        Position(1, 0),
        Position(2, 0),
        Position(3, 0),
    ],
    color: Color::RGB(0, 255, 255)
};

// O: empty space
// X: block
//
// Output:
// O O O O
// X X X X
// O O O O
// O O O O

The tuple Position represent x and y values on a 2D coordinate plane.

So, to define an I block at a horizontal rotation, we can do that by having the x position to be in the range of 0..3 as done above.

The position in the Block struct defines the world-space position of the block.

We can use this to translate the positions of the shapes from local-space to world-space.

impl Block {
    fn world_block_positions(&self) -> Vec<Position> {
        self.shape
            .iter()
            .map(|local_pos| Position(block.position.0 + local_pos.0, 
                                      block.position.1 + local_pos.1))
            .collect()
    }
}

So, using the same example above where we defined an I block, the world-position would be:

(4, 8),
(5, 8),
(6, 8),
(7, 8)

Rotations

To handle rotations, I would have an array of the shapes, I am only supporting up to 4 rotation states. We can then increment and decrement an index to cycle through the rotations.

For example, for 2 rotation states for the I block:

struct Position(i32, i32);

struct Block {
    position: Position,
    shapes: [Vec<Position>; 4],
    shape_index: usize,
    color: Color
}

let blocks_i = Block { 
    position: (4, 8),
    shapes: [
        vec![
            Position(0, 0),
            Position(1, 0),
            Position(2, 0),
            Position(3, 0),
        ],
        vec![
            Position(2, -1),
            Position(2, 0),
            Position(2, 1),
            Position(2, 2),
        ],
    ],
    shape_index: 0,
    color: Color::RGB(0, 255, 255),
};

// O: empty space
// X: block
//
// Output:
// O O O O | O O X O
// X X X X | O O X O
// O O O O | O O X O
// O O O O | O O X O

Grid

The grid is built out of a 2D array, with the values of an optional Color value.

struct Grid {
    position: Position,
    cells: Vec<Vec<Option<Color>>
}

The position here refers to the grid's world position, and defines the position most top-left position of the grid in world-space.

If the value of a cell is None, it means that it is an empty space on the grid.

And then when we lock a block onto the grid, we can use the block's color values to populate the grid at that position.

Collisions

As we are working with a 2D grid, collisions are relatively simple.

We can deem anything as colliding if they are in the same position in the grid in world-space.

However, since our grid is in local-space, to index it, we have to normalize our positions to local-space.

To do this, we get the difference from the block and grid positions in world-space.

pub enum Collision {
    None,
    Left,
    Right,
    Top,
    Bottom,
}

impl Grid {
    fn is_colliding(&self, block: &Block) -> Collision {
        for block_position in block.world_block_positions() {
            let x = block_position.0 - self.position.0;
            let y = block_position.1 - self.position.1;
        }

        Collision::None
    }
}

Now we have our indexes, we can use it check if at a certain cell position, it has a value, which means it is occupied by a locked block.

To make things simpler, we can define this behaviour as colliding at the bottom position as we assume this to be "stacking", though it doesn't necessarily have to.

pub enum Collision {
    None,
    Left,
    Right,
    Top,
    Bottom,
}

impl Grid {
    fn is_colliding(&self, block: &Block) -> Collision {
        for block_position in block.world_block_positions() {
            let x = block_position.0 - self.position.0;
            let y = block_position.1 - self.position.1;

            if (self.cells[y as usize][x as usize].is_some()) {
                return Collision::Bottom;
            }
        }

        Collision::None
    }
}

Now to check if we are colliding outside the surroundings of the grid, we can just do some bounds checking on the size of the grids.

pub enum Collision {
    None,
    Left,
    Right,
    Top,
    Bottom,
}

impl Grid {
    fn is_colliding(&self, block: &Block) -> Collision {
        for block_position in block.world_block_positions() {
            let x = block_position.0 - self.position.0;
            let y = block_position.1 - self.position.1;

            if x < 0 {
                return Collision::Left;
            }

            if x >= self.cells[0].len() as i32 {
                return Collision::Right;
            }

            if y < 0 {
                return Collision::Top;
            }

            if y >= self.cells.len() as i32 
                || self.cells[y as usize][x as usize].is_some() {
                return Collision::Bottom;
            }
        }

        Collision::None
    }
}

Shortcomings

At this time of writing, the project still has some outstanding issues that has not been addressed.

Perhaps, in the future these will be fixed. But for now, I think the project is fine as it is.

Handling collisions with rotations

When rotating blocks, it does not account if the new shape positions will collide with any locked blocks.

As a result, in the event that happens, it will lock the current block in place and replace any position that it may have collided.

Extra space with rotations

As we are storing each rotation state with its positions, this is effectively storing 4 times the amount of memory for each block that we instantiate.

The alternative that I considered was to use an anchor point and use some math to rotate the positions around that.

Though this may be a more complex solution, but you effectively only have to define your shape once, and you have the rest of the rotations already figured out with that algorithm.

Some unfinished features

  • Hard drop
  • Hold block
  • Rendering blocks queue

Conclusion

After playing Tetris a few times and building it, I finally understood why Tetris can be addicting for some.

For such a simple game, it has great replayability, and can be quite thrilling to clear multiple rows consecutively to save yourself as you build up enough height.

I had definitely learned a lot building it, though not entirely perfect, but I would say it is minimal but complete. Ultimately, it was worth it.

If you want to have a look or give it a try, here is a link to the GitHub repository: https://github.com/dante1130/tetris

Immutability and why should you care

Now, before we begin, I want to make it clear that I am not talking about immutability in the context of functional programming. Instead, I am talking about immutability in the context of programming in general.

What is immutability?

Immutability is the property of an object that prevents it from being modified after it is created, it is in other words, read-only.

In most popular programming languages, the default behaviour is for objects to be mutable, but you can make them immutable often by using the const keyword.

Here is an example in C++:

auto main() -> int {
    const int x = 5;
}

Here we have created an immutable integer x with the value of 5. If we try to modify x, the compiler will throw an error.

auto main() -> int {
    const int x = 5;
    x = 10;
}
error: cannot assign to variable 'x' with const-qualified type 'const int'
         x = 10;
         ~ ^
note: variable 'x' declared const here
         const int x = 5;
         ~~~~~~~~~~^~~~~

Some of you may be thinking; "Well, I can just not use the const keyword and make everything mutable. That way, I won't ever run into this problem. JavaScript and Python for life!".

While yes, you can do that, but it is not a good idea.

And, before you ready your pitchforks, I am not saying that you should never use mutable variables, but rather, you should prefer immutability whenever possible.

Now, let's talk about why you should care about immutability and how it can help you write better code.

Why should you care?

Initialization

From a presentation by Matt Miller at BlueHat IL 2019, he mentioned that 70% of the vulnerabilities found in Microsoft products are memory safety issues.

According to the root cause analysis of these vulnerabilities, the 4th most common root cause is "uninitialized use".

This is when a variable is used before it is initialized, which can lead to undefined behaviour.

Here is an example in C++:

auto main() -> int {
    int x;
    std::cout << x;
}
2045585552

Here, we have created an integer x without initializing it. Then, when we try to print x, we get garbage values.

As x is uninitialized, when we try to print x, we are printing whatever value is stored at that memory address.

While this example is trivial, it will lead to serious bugs in more complex programs, which can be hard to debug.

If you're using pointers, you can get a segfault if you try to dereference an uninitialized pointer, as it is pointing to a random memory address that you do not own.

In this case, what if we had used x in a calculation, and assumed that it was 0? We would've gotten the wrong result.

Worst of all, this won't crash your program, so you may not notice the bug until it's too late.

What if I tell you, this bug would've been caught at compile time if we had made x immutable?

auto main() -> int {
    const int x;
    std::cout << x; 
}
error: default initialization of an object of const type 'const int'
         const int x;
                   ^
                     = 0

Here, the compiler will throw an error because we are trying to create an const integer x while attempting to perform a default initialization.

However, as int is a primitive type that is const, they do not have a default constructor, so the compiler does not know how to initialize x implicitly.

To fix this, we can follow the compiler's suggestion and initialize x explicitly to 0.

This is good as not only does immutability prevent us from modifying a variable, but it also prevents us from using a variable before it is initialized.

It is a nice bonus best practice that you get for free, just by preferring immutability by default.

Clarity and correctness

Immutability makes it clear to the reader that the variable will not be modified after it is created.

If you've ever worked on a large codebase, you will know that it is hard to keep track of all the variables and their values.

You step through the code with a debugger, go through the call stack, and try to figure out what is going on with one variable, only to realize "Wait, this variable is not being modified at all". So then you go back to the call stack and try to figure out what is going on with another variable.

For example, let's say we have a function that takes in a vector of integers and returns the sum of all the integers in the vector.

auto sum(std::vector<int>& numbers) -> int {
    int result = 0;
    for (auto number : numbers) {
        result += number;
    }
    return result;
}

auto main() -> int {
    std::vector<int> numbers = {1, 2, 3, 4, 5};
    int total = sum(numbers);
    // more code here
}

We declare numbers as a vector of integers from 1 to 5, and result as the sum of all the integers in numbers.

Let's say there's more code after this, we cannot be confident whether that numbers and total have been modified down the path.

So instead, we would have to go through the code and track down how they are being used.

But what if we had made numbers and total immutable?

auto sum(const std::vector<int>& numbers) -> int {
    int result = 0;
    for (const auto number : numbers) {
        result += number;
    }
    return result;
}

auto main() -> int {
    const std::vector<int> numbers = {1, 2, 3, 4, 5};
    const int total = sum(numbers);
    // more code here
}

Now to me, this is very clear that numbers and total could be read in this scope, but they will not be modified.

Also, notice the change we made to the sum function, we made numbers parameter immutable as well.

That is a contract that we are making with the caller of the function, that we will not modify numbers in the function, and this gives the caller the confidence that the object they passed in won't change, without having to go through the function to check.

In the sum function, we also make number immutable when we iterate through numbers, as we do not need to modify number in the loop.

But now that we have done all this, it becomes clear that in this entire snippet, the only thing that can have its state changed is result.

Not is this code clearer, but it is also correct and safer, as we have made it impossible to modify numbers and total by accident. I'm sure your reviewer will appreciate as well.

Conclusion

Immutability is a simple but powerful way you can use to write code that is safer, clearer and correct. If you use them, more than likely, you will run into fewer bugs and surprises.

These are just some of the benefits of immutability, and there are more. But in my opinion, these are the most important ones.

If you wish to start using immutability in your code, here are some tips:

  • Always make your variables immutable by default, and only make them mutable when you need to.
  • If you need to make a variable mutable, try to limit its scope as much as possible. The root of all evil is shared mutable state.
  • If you need to perform a complex initialization, you may use a lambda function, and const initialize the variable with the result of the lambda function.

E.g.

auto main() -> int {
    const std::string name = []() {
        std::string name;
        std::cin >> name;
        return name;
    }();
}
  • Try the Rust programming language, as it is designed with immutability by default when declaring variables. It is very powerful and allows to easily create immutable variables with expressions.

E.g.

// Rust uses immutable variables by default
// To declare a mutable variable, you need to use the `mut` keyword
fn main() {
    let vector = vec![1, 2, 3, 4, 5];
    let total = vector.iter().sum();
}

I hope that this article has convinced you to prefer immutability whenever possible, if you have any questions or feedback, feel free to reach out to me in the Contact me section of my about page.