At this point in our series, we have the entire C# model of our Tetris game ready. What we're going to do now is create a set of Blazor components to actually display the game grid and a falling tetromino.

At the end of this post, we will be able to:

  • Display the game grid.
  • Click a "Start" button to begin the game.
  • Have a randomly-generated tetromino "fall" down the grid.

Ready? Let's go!

The Sample Project

The BlazorGames repository over on GitHub contains all of the code used in this series! Check it out!

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.
Tetris in Blazor Part 3: Tetrominos
Four parts each, in different layouts. Let’s build them all!

Goal 1: The Game Grid

Let's recall what our game grid is supposed to look like:

A standard game of Tetris has a grid that is comprised of cells. The grid is 20 rows tall and 10 columns wide.

We're going to first make a Blazor Component that represents an individual cell, then another component (which will be our main one) that will display every cell.

Cells

An individual cell on a Tetris game grid will need both a row and a column value.

Cells can be "occupied", either by the currently-falling tetromino, or by "solidified" cells that have already been placed. An occupied cell has a CSS class applied to it, depending on the color it needs to be. Our cell component, therefore, will need a reference to a Tetromino instance and a Grid instance.

The basic GridCell Blazor component looks like this:

@code {
    [Parameter]
    public int Row { get; set; }

    [Parameter]
    public int Column { get; set; }

    [Parameter]
    public Tetromino Tetromino { get; set; }

    [Parameter]
    public Grid Grid { get; set; }
}

@{ 
    //TODO - Cell CSS
}
<div id="@(Row + "-" + Column)" class="tetris-cell @extraCSS"></div>

See the @extraCSS property? We use that to set the color of the cell, if it is occupied.

We will call Tetromino.CoveredCells to check if the cell is currently occupied by the falling tetromino. Similarly, we will invoke one of the extension methods from Part 2, Grid.Cells.Contains(), to determine if the cell is occupied by a solidified piece.

The code we use to set the cell's CSS class would then be:

@code {
    //... Parameters
}

@{ 
    string extraCSS = "";
    if (Grid.IsStarted)
    {
        if (Tetromino.CoveredCells.Contains(Row, Column))
        {
            extraCSS = "tetris-piece " + Tetromino.CssClass;
        }

        if (Grid.Cells.Contains(Row, Column))
        {
            extraCSS = "tetris-piece " + Grid.Cells.GetCssClass(Row, Column);
        }
    }
}
<div id="@(Row + "-" + Column)" class="tetris-cell @extraCSS"></div>

With that, our GridCell component is complete! Now we can start coding up the main grid component.

The Grid Component

Let's start with a simple Blazor component Tetris which has an instance of Grid, as well as a property for the currently-falling tetromino:

@page "/tetris"
@using BlazorGames.Models.Tetris;
@using BlazorGames.Models.Tetris.Tetrominos;
@using BlazorGames.Pages.Partials;
@using BlazorGames.Models.Tetris.Enums;

@code {
    Grid grid = new Grid();
    
    Tetromino currentTetromino;
}

<PageTitle Title="Tetris" />

<div class="row">
    <div class="col">
        <!--Grid goes here-->
    </div>
</div>

Note: We covered using the PageTitle component in an earlier post:

Setting the Page Title in a Blazor App
Let’s set the page title using a Blazor component and a bit of JavaScript!

We now need some code to display each cell using the GridCell component we wrote earlier in this post. The code is do this uses two foreach loops, and is otherwise pretty straightfoward:

<!-- page URL, usings, and injects -->

@code { //Code implementation }

<!-- page title -->

<div class="row">
    <div class="col">
        <div class="tetris-container" tabindex="0">
            @for (int i = grid.Height; i >= 1; i--)
            {
                <div class="tetris-row">
                    @for (int j = 1; j <= grid.Width; j++)
                    {
                        <TetrisGridCell Row="i" 
                                        Column="j" 
                                        Tetromino="currentTetromino" 
                                        Grid="grid" />
                    }
                </div>
            }
        </div>
    </div>
</div>

Note that we are numbering the rows from 20 at the top of the grid to 1 at the bottom of it.

When we run the app, the page now looks like this:

Progress! We've completed the first of our three goals. Now we can work on the "start game" button.

Goal 2: The "Start Game" Button

As we discussed in Part 2, the Grid instance is what tracks the current state of the game. If the state is NotStarted, we want to display a "Start Game" button.

That button will invoke a method called RunGame(). We only want to run the game as long as there are no "solidified" cells in the Grid with a row higher than the grid's row count (e.g. a cell at row 21 when the grid only goes to row 20); at that point the game is over.

The markup and code that we need on the main component for the "Start Game" button is the following:

@page "tetris"
<!-- using statements -->

@code {
    //... Other properties

    public async Task RunGame()
    {
        //Start playing the game
        grid.State = GameState.Playing;

        //Where there is no piece with a row of 21 or greater
        while(!grid.Cells.HasRow(21))
        {
            //TODO - Game Loop
        }

        //Once there is a piece with a row of 21 or greater, 
        //the game is over.
        grid.State = GameState.GameOver;
    }
}

<!---page title -->

<div class="row">
    <div class="col">
        @if (grid.State == GameState.NotStarted)
        {
            <button @onclick="RunGame"
                    class="btn btn-primary">Start!</button>
        }
    </div>
</div>

<!-- grid markup -->

Pay special attention to the //TODO - Game Loop comment here. Any code in that while loop will be part of the code comprising the "game loop" or the code that actually allows the game to function.

If we run the app now, we get the start button to appear:

The last thing we will do in this post is make a tetromino (the placeholder Block from earlier) begin to fall after the user presses the start button.

The question at the moment is: how long does the piece wait until it moves down a row?

The Standard Delay

We're going to use a custom Delay() method in this implementation which looks like this:

@page "tetris"   

//...Usings and injects

@code {
    //...Other properties

    //The standard delay is how long the game waits 
    //before dropping the current piece by one row.
    int standardDelay = 1000;

    //This flag is set if the player "hard drops" 
    //a tetromino all the way to the bottom
    bool skipDelay = false;

    //...Other methods

    //Delays the game up to the passed-in amount of milliseconds 
    //in 50 millisecond intervals
    public async Task Delay(int millis)
    {
        int totalDelay = 0;
        while (totalDelay < millis && !skipDelay)
        {
            totalDelay += 50;
            await Task.Delay(50);
        }
        skipDelay = false;
    }
}

In a game of Tetris, as your score gets higher, the "standard delay" before the falling tetromino drops another row gets shorter. It begins at 1000 milliseconds at the start of the game, and becomes 100 milliseconds shorter after the player goes up each level.

You'll see more of drops, scoring, and levels in the next post in this series. For now, just trust me that this method works as intended.

Goal 3: Implementing the Falling Tetromino

Our main Blazor component has a property for the currently-falling tetromino:

@code {
    Grid grid = new Grid();
    
    Tetromino currentTetromino;
}

When the game loop starts, we need to instantiate that tetromino. By doing so, the Tetromino class will place the instance's center cell at the top of the Grid.

Within the game loop, we need to keep track of whether or not the current tetromino can move down. If it can, we delay for the standard amount, and then drop the tetromino one row. If it cannot, we "solidify" the tetromino by adding its currently-occupied cells to the Grid instance and then create a new tetromino that will fall. We also need a couple invocations of StateHasChanged().

Here's the code that comprises our current game loop:

@code {
    //... Other properties

    public async Task RunGame()
    {
        //...Other code

        //Where there is no tetromino with a row of 21 or greater
        while(!grid.Cells.HasRow(21))
        {
            StateHasChanged();
           
            await RunCurrentTetromino();
        
            //TODO - Game Loop
        }

        //Once there is a tetromino with a row of 21 or greater, 
        //the game is over.
        grid.State = GameState.GameOver;
    }
    
    public async Task RunCurrentTetromino()
    {
        currentTetromino = new Block(grid);

        //While the tetromino can still move down
        while (currentTetromino.CanMoveDown())
        {
            //Wait for the standard delay
            await Delay(standardDelay);

            //Move the tetromino down one row
            currentTetromino.MoveDown();

            //Update the display
            StateHasChanged();
        }

        //"Solidify" the current tetromino by 
        //adding its covered squares to the board's cells
        grid.Cells.AddMany(currentTetromino.CoveredCells.GetAll(),
                           currentTetromino.CssClass);
    }
}

When we run the app, we will now see that a block tetromino gets placed on the game grid, and starts "falling" automatically, when we press the Start button.

All right! We accomplished our three goals for this post!

Summary

In this part of our Tetris in Blazor series, we were able to display the game grid for a Tetris game. We also now have a start button, and most importantly, the initial tetromino can be created and will "fall" down the grid! We made a bunch of progress!

In the next part of this series, we'll make this a working Tetris game by:

  • Adding keyboard inputs
  • Focusing user input on the grid
  • Clearing completed rows
  • Calculating the score AND
  • Implementing user levels

Stick around! You won't want to miss that.

Happy Coding!

The Rest of the Series

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.