Skip to the content.

Background

As an exercise to improve my familiarity with C++ as a language, I decided to create a game using the language. After some consideration, I settled on Tetris as a good starting point. I had used the SDL2 graphics library in my 1st year at university, but decided to use SFML instead for this project as it claimed to be ‘object-oriented SDL’. It seemed to fulfil all of the criteria I was looking for: relatively low-level (at least compared to something like Unreal Engine), easy to get started with, and designed to be used with C++ over C (as I wanted to get more comfortable with C++’s syntax as a primary goal).

After toying around with creating windows and handling inputs (and making a window that runs away from your mouse cursor), I got started figuring out how to implement Tetris.

Requirements & Design

Functional Requirements

Non-Functional Requirements

Design Assumptions

Going into this project, I had a list of basic assumptions that would guide my development:

Implementation

The Grid

The default Tetris grid is divided into squares, and is drawn to appear 10 squares wide by 20 squares tall. Shapes spawn just above the visible grid, into rows 21 and 22. This means we need a 10x22 array to hold every piece. After some research and testing, the clear choice for this data structure appeared to be two nested std::arrays. I used this over a C-style array (T[][]) to allow for direct assignments of sections of the array (this is useful for shifting lines after one is cleared).

Grid Spaces

The spaces in the grid (the actual values in the arrays) are represented by a wrapper class for SFML’s sf::Color class, called Gridspace. This class is initialised to be transparent (sf::Color::Transparent), and has a method to get an identifying character representation of the space (used for testing in the console).

Shape Movement and Line Clearing

When a shape needs to move (for example, being rotated or lowered), the game first performs a check to ensure that the new position is valid. This is done by passing through the new coordinates as an array, meaning that this method can be used for every type of movement by just changing the method that passes in the parameters. It iterates through each point, checking if any are not transparent, and return false if any non-transparent points are not inside of the shape itself.

bool Grid::CheckMoveLegal(std::array<std::array<int, 2>,4> newCoords, Tetris::Shapes::Shape thisShape){
    // If any space is not transparent, returns false
    int problemPoints = 0;

    for(auto point : newCoords){

        std::cout << point[0] << " " << point[1] << "\n";

        if(point[0] >= GRID_WIDTH || point[0] < 0){
            std::cout << "X coord not in range";
            return false;
        }
        if(point[1] >= GRID_HEIGHT || point[1] < 0){
            std::cout << "Y coord not in range" << "\n";
            return false;
        }

        if(arrGrid[point[0]][point[1]].col != sf::Color::Transparent){
            problemPoints++;

            for(auto myPoint : thisShape.GetCoords()){
                if(point == myPoint){
                    problemPoints--;
                }   
            }  
        }
    }

    if(problemPoints > 0){
        return false;
    }

    return true;
}

In Tetris, the shapes rotate around a specified centre point. For most pieces, this is the centre of one of the ‘middle squares’ (see image). This makes it relatively trivial to apply 2 transformations to the shape’s coordinates using integer matrix multiplication.

std::array<std::array<int, 2>, 2> rotMatrix;

if(clockwise){ // Position number & rotation matrix set based on rotation direction
    rotMatrix = {
        {
            {0, 1},
            {-1, 0}
        }
    };
}
else{
    rotMatrix = {
        {
            {0, -1},
            {1, 0}
        }
    };
}

for(int i = 0; i < 4; i++){ // Performs 90* rotation on the coordinates with the power of matrices
    int x = coords[i][0] - origin[0]; // origin is the coordinates of the centre of rotation
    int y = coords[i][1] - origin[1];

    coords[i][0] = rotMatrix[0][0] * x + rotMatrix[1][0] * y + origin[0];
    coords[i][1] = rotMatrix[0][1] * x + rotMatrix[1][1] * y + origin[1];
}
Image courtesy of TetrisWiki

The O piece (yellow) and the I piece (light blue) have a centre of rotation on the line between two squares, which makes things more complicated. For the O piece, this actually doesn’t matter since it is rotationally symmetrical and therefore rotating it does nothing. However, this means another solution is still required for I pieces. In the end, I decided that the most efficient solution would be to hardcode the rotations for I pieces, since there are only four permutations. This does reduce the elegance of the code somewhat, but it worked and allowed me to move on to making the rest of the game.

The Front-End

As discussed above, the front end of the program is mostly concerned with taking player inputs and drawing a representation of the grid on the screen. It also handles the game’s ticks, which happen every 0.6 seconds. If a piece is stationary for a whole tick, it will drop by one row, and if the bottom of the piece is touching something for a tick, it is locked in place and a new controllable piece spawns.

else if(const auto* keyPressed = event->getIf<sf::Event::KeyPressed>()){ // Checks if a key is pressed down
    if(keyPressed->scancode == sf::Keyboard::Scancode::R){
        if(myGrid.CheckMoveLegal(newShape.TryRotate(), newShape)){ // Tests if piece can rotate before performing rotation
            myGrid.ClearSpaces(newShape.GetCoords()); // clears old occupied spaces
            newShape.Rotate(); // rotates stored coordinates of shape
            myGrid.AddShape(newShape); // adds shape to the 2d array that represents the grid
        }
        else{
            std::cout << "Illegal\n";
        }  

        lastTickTime = time(NULL); // reset last tick to now
    }
    else if...
}

Once any player input is handled, the game checks to see if the current active piece can be moved down a row. If it can, the piece is moved down. If it cannot, the piece is therefore in its final position. If this final position is in the two rows above the grid walls, then the player has losed and the game is over. After all of the logic has concluded, the game draws the grid walls and all of the filled spaces on the screen, before the logic loop restarts for the next frame.

if(difftime(now, lastTickTime) > gameTickLength){ // DoGameTick
    lastTickTime = now;
    if(myGrid.CheckMoveLegal(newShape.TryMove(Tetris::Shapes::Direction::Down), newShape)) // checks if moving downwards is possible
    {   // shape falls one row
        myGrid.ClearSpaces(newShape.GetCoords());
        newShape.Move(Tetris::Shapes::Direction::Down);
        
        myGrid.AddShape(newShape);
    }
    else{ // shape is in final position
        if(!myGrid.CheckIfGameOver(newShape)){ // checks if shape has finished in spawn rows
            myGrid.AddShape(newShape); // draws new position of shape
            newShape = myBag.GetShape(); // pull new shape from the 7-bag
            myGrid.DoLineClears(); // check if any lines have been filled and should be cleared
        }
        else{ // Game has ended and game over message can be displayed
            isGameOngoing = false;
        }
    }
}

Skills Used

Summary

This project helped me to further understand C++ as a language, as well as manage project file structure and using CMake templates to configure builds. The Github repository for LiTetris can be found here.

I used this library to render a front-end for the game, and coded the back end functionality in base C++.

Demo Video