In the previous two posts, we defined what we want our model of Tetris in Blazor to look like, and we created classes such as Grid and CellCollection to help model the game.

In this post, we are going to write up the C# classes that will define each tetromino, as well as enumerations for their style and orientation, their common base class, and a generator class that we will use to populate the "upcoming pieces" display in our game.

It's a lot of work, but it'll be super cool when we're done. Let's go!

The Sample Project

Don't forget to check out the entire BlazorGames repository over on GitHub!

exceptionnotfound/BlazorGames
Contribute to exceptionnotfound/BlazorGames development by creating an account on GitHub.

Previously in this Series

Tetris in Blazor WebAssembly
We’re going to build Tetris, a true video game, using Blazor WebAssembly, C#, and ASP.NET. Part 1 of 6. Check it out!
Tetris in Blazor Part 2: Cells, the Grid, and the Game State
Let’s start the process of making Tetris in Blazor by building the C# classes for the grid, the cells, and the game state.

Style and Orientation

A tetromino is a set of four (4) cells that moves as a unit on the Tetris game grid. In Tetris, tetrominos have the following properties:

  • Each tetromino is a different color.
  • The tetromino can be moved left or right on the grid.
  • The tetromino can be "hard dropped" to the bottom of the play area.
  • Tetrominos can be rotated about a central point.
  • The tetromino automatically drops one row after a certain amount of time has elapsed.

Note: In a real game of Tetris, tetrominos can also be "soft dropped" or moved down several rows rather than dropping all the way down. My original game had this, but it was not very nice to use and didn't display well. So, we're leaving out that functionality. If one of my dear readers would like to implement it, test it, and submit a PR, it would be welcomed.

Here's all the possible tetrominos:

As you can see, there are seven tetrominos in Tetris: block, T-shaped, L-shaped, reverse L-shaped, straight, left zig-zag, and right zig-zag.

Because we will want to know what kind of tetromino each instance is, we will need to defined an enumeration TetrominoStyle:

public enum TetrominoStyle
{
    Straight,
    Block,
    TShaped,
    LeftZigZag,
    RightZigZag,
    LShaped,
    ReverseLShaped
}

Tetrominos can also be rotated. Most tetrominos have four possible orientations. Take, the T-Shaped tetromino, for example:

For a another example, let's use the L-shaped tetromino:

Six of the seven tetrominos can rotate. We will therefore want another enumeration to represent the current orientation of the tetromino.

We'll name the values in this enumeration based upon the apparent orientation of the tetromino: left-to-right, up-to-down, right-to-left, and down-to-up. Note that we will have to define what each of these mean for each tetromino.

public enum TetrominoOrientation
{
    UpDown,
    LeftRight,
    DownUp,
    RightLeft
}

The Base Class

A little bit of experience with object-oriented modeling tells us that each tetromino should probably inherit from a common class. This common class will keep the common properties, such as color. Let's work out what these common properties are.

  • We already know about the color, which we will implement as a CSS class.
  • We also need to know the current coordinate for the center cell of the tetromino, since this cell will define the point from which the other cells are shown.
  • We need the tetromino's style and current orientation.
  • We need an instance of CellCollection from Part 2 to store the "occupied" cells.
  • We need a reference to the Grid object that this tetromino exists in.

All of that together leads to our skeleton Tetromino class:

public class Tetromino
{
    // Represents the grid on which this tetromino can move.
    public Grid Grid { get; set; }

    // The current orientation of this tetromino. 
    // Tetrominos rotate about their center.
    public TetrominoOrientation Orientation { get; set; } 
        = TetrominoOrientation.LeftRight;

    // The X-coordinate of the center piece.
    public int CenterPieceRow { get; set; }

    // The Y-coordinate of the center piece.
    public int CenterPieceColumn { get; set; }

    // The style of this tetromino, e.g. Straight, Block, T-Shaped, etc.
    public virtual TetrominoStyle Style { get; }

    // The CSS class that is unique to this style of tetromino.
    public virtual string CssClass { get; }

    // A collection of all spaces currently occupied by this tetromino.
    // This collection is calculated by each style.
    public virtual CellCollection CoveredCells { get; }
}

Note that the properties for Style, CssClass, and CoveredCells are all marked virtual. Each class that implements a specific tetromino will need to override those properties; we'll do that after we finish coding up the base Tetromino class.

Constructor

When we create a new tetromino, we assume that it must exist on an instance of Grid; to do this, we pass it to the Tetromino object's constructor.  We will also initialize it's CenterPieceRow to the top row of the grid, and the CenterPieceColumn property to the midpoint of the grid's width.

The resulting constructor looks like this:

public class Tetromino
{
    //...Other properties and methods
    
    public Tetromino(Grid grid)
    {
        Grid = grid;
        CenterPieceRow = grid.Height;
        CenterPieceColumn = grid.Width / 2;
    }
}

We can now begin to implement the common functionality between all tetrominos.

Moving Left and Right

You may recall that in Part 2 we defined the methods GetLeftmost(), GetRightmost() and GetLowest() in the CellCollection class. This is where we put those methods to use.

A tetromino is allowed to move left or right if there are no occupied cells in the way, and the piece is not up against the sides of the game grid. Let's two methods, which check to see if the tetromino can move left or right respectively.

public class Tetromino
{
    //...Other properties and methods
    
    public bool CanMoveLeft()
    {
        //For each of the covered spaces, 
        //get the space immediately to the left
        foreach (var cell in CoveredCells.GetLeftmost())
        {
            if (Grid.Cells.Contains(cell.Row, cell.Column - 1))
                return false;
        }

        //If any of the covered spaces are currently in the leftmost column,
        //the piece cannot move left.
        if (CoveredCells.HasColumn(1))
            return false;

        return true;
    }
    
    public bool CanMoveRight()
    {
        //For each of the covered spaces, 
        //get the space immediately to the right
        foreach (var cell in CoveredCells.GetRightmost())
        {
            if (Grid.Cells.Contains(cell.Row, cell.Column + 1))
                return false;
        }

        //If any of the covered spaces are currently in the rightmost column,
        //the piece cannot move right.
        if (CoveredCells.HasColumn(Grid.Width))
            return false;

        return true;
    }
}

These methods use the Tetromino class's reference to a Grid instance to check if that grid has occupied cells to the immediate left and right of the tetromino.

With those methods in place, our MoveLeft() and MoveRight() methods become very straightforward.

public class Tetromino
{
    //...Other properties and methods
    
    public void MoveLeft()
    {
        if (CanMoveLeft())
            CenterPieceColumn--;
    }
    
    public void MoveRight()
    {
        if (CanMoveRight())
            CenterPieceColumn++;
    }
}

Moving Down

We need two more methods to check if the tetromino can move down one row, and to actually move the tetromino down.

public class Tetromino
{
    //...Other properties and methods
    
    public bool CanMoveDown()
    {
        //For each of the covered spaces, get the space immediately below
        foreach (var coord in CoveredCells.GetLowest())
        {
            if (Grid.Cells.Contains(coord.Row - 1, coord.Column))
                return false;
        }

        //If any of the covered spaces are currently in the lowest row, 
        //the piece cannot move down.
        if (CoveredCells.HasRow(1))
            return false;

        return true;
    }
    
    public void MoveDown()
    {
        if (CanMoveDown())
            CenterPieceRow--;
    }
}

Rotating

We have defined the orientation of each tetromino to be represented by the TetrominoOrientation enum. Therefore, when a tetromino is rotated, we change the Style property to represent the new orientation.

We must also account for a potential bug: if the tetromino, once rotated, will exist outside the bounds of the play area, we must adjust it so that the entire tetromino remains in the grid.

We don't want any of these situations happening.

Our method to do both of these looks like this:

public class Tetromino 
{
    //... Other properties and methods

    // Rotates the tetromino around the center piece. 
    // Tetrominos always rotate clockwise.
    public void Rotate() 
    { 
        switch(Orientation)
        {
            case TetrominoOrientation.UpDown:
                Orientation = TetrominoOrientation.RightLeft;
                break;

            case TetrominoOrientation.RightLeft:
                Orientation = TetrominoOrientation.DownUp;
                break;

            case TetrominoOrientation.DownUp:
                Orientation = TetrominoOrientation.LeftRight;
                break;

            case TetrominoOrientation.LeftRight:
                Orientation = TetrominoOrientation.UpDown;
                break;
        }

        var coveredSpaces = CoveredCells;

        //If the new rotation of the tetromino means it would be outside the
        //play area, shift the center cell so as to 
        //keep the entire tetromino visible.
        if(coveredSpaces.HasColumn(-1))
        {
            CenterPieceColumn += 2;
        }
        else if (coveredSpaces.HasColumn(12))
        {
            CenterPieceColumn -= 2;
        }
        else if (coveredSpaces.HasColumn(0))
        {
            CenterPieceColumn++;
        }
        else if (coveredSpaces.HasColumn(11))
        {
            CenterPieceColumn--;
        }
    }
}

Building the Individual Tetrominos

We've finished the implementation of the base Tetromino class, and are ready to move on to building classes for each kind of tetromino.

We're not going to build every single class completely here; after doing a few, the method by which we can code them up becomes clear. Instead, we'll tackle building three of the seven tetrominos: the block, the straight, and the L-shaped.

Tetromino 1: Block

Let's start this section by building the Block tetromino.

Our Block class is going to inherit from the base Tetromino, and needs to override three properties:

public class Block : Tetromino
{
    public Block(Grid grid) : base(grid) { }

    public override TetrominoStyle Style => TetrominoStyle.Block;

    public override string CssClass => "tetris-yellow-cell";

    public override CellCollection CoveredCells
    {
        get
        {
            //TODO
        }
    }
}

The trickiest part of defining the individual tetromino classes is what to do with the CoveredCells property. This property is intended to be populated with the cells in the Grid that are currently occupied by this tetromino.

The first thing we have to do is to establish which cell is the center cell. On the block tetromino, we'll define it as being the lower-left cell.

We now need CoveredCells to return an instance of CellCollection that has all the covered cells in it. We know that one of them will be the center cell, so we add a Cell instance with the center cell's coordinates:

public class Block : Tetromino
{
    //... Other properties

    public override CellCollection CoveredCells
    {
        get
        {
            CellCollection cells = new CellCollection();
            cells.Add(CenterPieceRow, CenterPieceColumn);
            //TODO
        }
    }
}

From the center cell of the block, we need to occupy the following cells:

  • Center Row + 1, Center Column - Upper-left cell
  • Center Row, Center Column + 1 - Lower-right cell
  • Center Row + 1, Center Column + 1 - Upper-right cell.

So, our implementation of CoveredCells looks like this:

public class Block : Tetromino
{
    //... Other properties

    public override CellCollection CoveredCells
    {
        get
        {
            CellCollection cells = new CellCollection();
            cells.Add(CenterPieceRow, CenterPieceColumn);
            cells.Add(CenterPieceRow - 1, CenterPieceColumn);
            cells.Add(CenterPieceRow, CenterPieceColumn + 1);
            cells.Add(CenterPieceRow - 1, CenterPieceColumn + 1);
            return cells;
        }
    }
}

Unlike all the other tetrominos, the block tetromino does not rotate. Because of this, our implementation for Block is pretty straightforward. How would the implementation for a slightly-more-complex tetromino look?

Tetromino 2: Straight

The initial implementation of the Straight tetromino is very similar to the Block:

public class Straight : Tetromino
{
    public Straight(Grid grid) : base(grid) { }

    public override TetrominoStyle Style => TetrominoStyle.Straight;

    public override string CssClass => "tetris-lightblue-cell";

    public override CellCollection CoveredCells
    {
        get
        {
            CellCollection cells = new CellCollection();
            cells.Add(CenterPieceRow, CenterPieceColumn);

            //TODO
        }
    }
}

Just like with Block, the Straight needs to include the center piece as part of the CoveredCells property. However, unlike Block, an instance of Straight can be rotated by the user. We therefore need to determine what cells are part of CoveredCells for each possible orientation.

In the default orientation of left-to-right, the center piece of a Straight instance is third from the left.

Consequently, the CoveredCells implementation looks like this:

public class Straight : Tetromino
{
    //... Other properties

    public override CellCollection CoveredCells
    {
        get
        {
            CellCollection cells = new CellCollection();
            cells.Add(CenterPieceRow, CenterPieceColumn);

            if (Orientation == TetrominoOrientation.LeftRight)
            {
                cells.Add(CenterPieceRow, CenterPieceColumn - 1);
                cells.Add(CenterPieceRow, CenterPieceColumn - 2);
                cells.Add(CenterPieceRow, CenterPieceColumn + 1);
            }
        }
    }
}

The remaining orientations (up-to-down, right-to-left, and down-to-up) make the Straight "point" in different directions.

Each of these must be implemented as part of CoveredCells.

public class Straight : Tetromino
{
    //... Other properties

    public override CellCollection CoveredCells
    {
        get
        {
            CellCollection cells = new CellCollection();
            cells.Add(CenterPieceRow, CenterPieceColumn);

            if (Orientation == TetrominoOrientation.LeftRight)
            {
                cells.Add(CenterPieceRow, CenterPieceColumn - 1);
                cells.Add(CenterPieceRow, CenterPieceColumn - 2);
                cells.Add(CenterPieceRow, CenterPieceColumn + 1);
            }
            else if (Orientation == TetrominoOrientation.DownUp)
            {
                cells.Add(CenterPieceRow - 1, CenterPieceColumn);
                cells.Add(CenterPieceRow + 1, CenterPieceColumn);
                cells.Add(CenterPieceRow + 2, CenterPieceColumn);
            }
            else if(Orientation == TetrominoOrientation.RightLeft)
            {
                cells.Add(CenterPieceRow, CenterPieceColumn - 1);
                cells.Add(CenterPieceRow, CenterPieceColumn + 1);
                cells.Add(CenterPieceRow, CenterPieceColumn + 2);
            }
            else //UpDown
            {
                cells.Add(CenterPieceRow - 1, CenterPieceColumn);
                cells.Add(CenterPieceRow - 2, CenterPieceColumn);
                cells.Add(CenterPieceRow + 1, CenterPieceColumn);
            }

            return cells;
        }
    }
}

Tetromino 3: L-Shaped

The L-Shaped tetromino has four possible orientations:

Our LShaped class looks like this:

public class LShaped : Tetromino
{
    public LShaped(Grid grid) : base(grid) { }

    public override TetrominoStyle Style => TetrominoStyle.LShaped; 

    public override string CssClass => "tetris-orange-cell";

    public override CellCollection CoveredCells
    {
        get
        {
            CellCollection cells = new CellCollection();
            cells.Add(CenterPieceRow, CenterPieceColumn);
                
            switch(Orientation)
            {
                case TetrominoOrientation.LeftRight:
                    cells.Add(CenterPieceRow, CenterPieceColumn - 1);
                    cells.Add(CenterPieceRow, CenterPieceColumn - 2);
                    cells.Add(CenterPieceRow + 1, CenterPieceColumn);
                    break;

                case TetrominoOrientation.DownUp:
                    cells.Add(CenterPieceRow, CenterPieceColumn + 1);
                    cells.Add(CenterPieceRow + 1, CenterPieceColumn);
                    cells.Add(CenterPieceRow + 2, CenterPieceColumn);
                    break;

                case TetrominoOrientation.RightLeft:
                    cells.Add(CenterPieceRow, CenterPieceColumn + 1);
                    cells.Add(CenterPieceRow, CenterPieceColumn + 2);
                    cells.Add(CenterPieceRow - 1, CenterPieceColumn);
                    break;

                case TetrominoOrientation.UpDown:
                    cells.Add(CenterPieceRow, CenterPieceColumn - 1);
                    cells.Add(CenterPieceRow - 1, CenterPieceColumn);
                    cells.Add(CenterPieceRow - 2, CenterPieceColumn);
                    break;
            }
            return cells;
        }
    }
}

You can see the rest of the individual tetromino classes (Reverse L-Shaped, T-Shaped, Left Zig-Zag, and Right Zig-Zag) in the sample project on GitHub.

exceptionnotfound/BlazorGames
Contribute to exceptionnotfound/BlazorGames development by creating an account on GitHub.

Summary

In this part of our series, we've created the Tetromino class and several individual classes representing each tetromino. We also created enumerations for TetrominoStyle and TetrominoOrientation and a few new methods to CellCollection. We're about halfway done with our implementation.

In the next part of this series, we'll build the blazor component for the game board, and work on the game loop. Stick around!

Happy Coding!

The Rest of the Series

Tetris in Blazor Part 4: Displaying the Grid and a Falling Tetromino
Let’s write up Blazor components to show the game grid, and write a game loop to make the tetrominos fall!
Tetris in Blazor Part 5: Controls, Upcoming Tetrominos, and Clearing Rows
Let’s implement more major features, like keyboard controls, grid focus, clearing rows, and more!
Tetris in Blazor Part 6: Scoring, Levels, Music, and Other Features
All that’s left to do is implement scoring, levels, music, the grace period, a new game button, and a previous high score cookie.