In this sixth and final part of our Tetris in Blazor series, we're going to implement the last major features our Tetris game needs, plus a set of "ease-of-use" features that makes the game easier to play.

For this post, here's the goals we want to implement

  • Scoring and Levels
  • Music (with keyboard controls)
  • A "Grace Period"
  • A "New Game" button
  • A "Previous High Score" cookie

Let's get going!

The Sample Project

As this is the last post in our Tetris in Blazor series, it would be a good time to check out the code used for this project on GitHub:

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

Previously in this Series

This post is the last of a six-part series; read the rest of the posts from the bookmarks below.

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!
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!

Goal 1: Scoring and Levels

Scoring in Tetris is pretty straightforward. For each row you clear, you get a certain number of points. If you clear multiple rows at once, you get more points.

The more points you get, the higher your Level becomes. Level is a multiplier for clearing rows: you get more points for clearing three rows at level 4 than for clearing three rows at level 2. However, the standard delay (e.g. how long the game waits before dropping the current tetromino one row) gets shorter every time your level goes up.

You also score points by "hard dropping" tetrominos, at a rate of one point per row. This score is not affected by the current level.

Let's first create properties on the main Blazor component which holds the score and the current level:

@code {
    //... Other properties and methods
    
    int level = 1;
    int score = 0;
}

In order to make these scoring changes, we need to modify the ClearCompleteRows() and KeyDown() methods from Part 5. Let's start with the hard-dropped tetrominos.

Here's the method KeyDown(). as we left it last time:

@code {
    //... Other properties and methods
    
    protected async Task KeyDown(KeyboardEventArgs e)
    {
        if (grid.State == GameState.Playing)
        {
            if (e.Key == "ArrowRight")
            {
                currentTetromino.MoveRight();
            }
            if (e.Key == "ArrowLeft")
            {
                currentTetromino.MoveLeft();
            }
            if(e.Key == "ArrowDown")
            {
                int addlScore = currentTetromino.Drop();
                
                //Tell the game loop to skip the standard delay
                skipDelay = true;
                
                //TODO
            }
            if(e.Key == "ArrowUp")
            {
                currentTetromino.Rotate();
            }
            StateHasChanged();
        }
    }
}

That //TODO line is where we need to make changes. The Drop() method gets us the score that needs to be added to the main score, so let's add it in:

@code {
    //... Other properties and methods
    
    protected async Task KeyDown(KeyboardEventArgs e)
    {
        if (grid.State == GameState.Playing)
        {
            if (e.Key == "ArrowRight")
            {
                currentTetromino.MoveRight();
            }
            if (e.Key == "ArrowLeft")
            {
                currentTetromino.MoveLeft();
            }
            if(e.Key == "ArrowDown")
            {
                int addlScore = currentTetromino.Drop();
                
                //Tell the game loop to skip the standard delay
                skipDelay = true;
                
                //Add in the hard drop score
                score += addlScore;
                
                //Refresh the display
                StateHasChanged();
            }
            if(e.Key == "ArrowUp")
            {
                currentTetromino.Rotate();
            }
            StateHasChanged();
        }
    }
}

Now we need to modify ClearCompleteRows() for the row-clearing score.

@code {
    //... Other properties and methods
    
    public async Task ClearCompleteRows()
    {
        //For each row
        List<int> rowsComplete = new List<int>();
        for (int i = 1; i <= grid.Height; i++)
        {
            //If every position in that row is filled...
            if (grid.Cells.GetAllInRow(i).Count == grid.Width)
            {
                //Add the "complete" animation CSS class
                grid.Cells.SetCssClass(i, "tetris-clear-row");

                //Mark that row as complete
                rowsComplete.Add(i);
            }
        }

        //If there are any complete rows
        if(rowsComplete.Any())
        {
            StateHasChanged();

            //Collapse the "higher" cells down to fill in the completed rows.
            grid.Cells.CollapseRows(rowsComplete);

            //Calculate the score for the completed row(s)
            switch (rowsComplete.Count)
            {
                case 1:
                    score += 40 * level;
                    break;

                case 2:
                    score += 100 * level;
                    break;

                case 3:
                    score += 300 * level;
                    break;

                case 4:
                    score += 1200 * level;
                    break;
            }

            await Task.Delay(1000);
        }
        grid.State = GameState.Playing;
    }
}

The last thing we need is some markup to display the score and the current level to the player.

<div class="row">
    <div class="col">
        <div class="tetris-container" 
             tabindex="0" 
             @onkeydown="KeyDown"
             @ref="gameBoardDiv">
            <!-- Game grid -->
        </div>
    </div>
    <div class="col">
        @if (grid.IsStarted)
        {
            <!-- Upcoming Pieces and Controls -->
        }
    </div>
    <div class="col">
        <div class="row">
            <div class="col">
                <h2>Score: @score</h2>
            </div>
        </div>
        <div class="row">
            <div class="col">
                <h2>Level: @level</h2>
            </div>
        </div>
    </div>
</div>

Once that's in place, we can play the game to see how the score is calculated and shown to the user.

There's one more piece we need: a method to determine if the player's level should go up based on their score. We will call that method LevelChange() and implement it as part of the game loop:

@code {
    //...Other properties and methods
    
    public async Task RunGame()
    {
        //Rest of implementation

        //Where there is no tetromino with a row of 21 or greater
        while (!grid.Cells.HasRow(21)) //Game loop
        {
            StateHasChanged();

            //Run the current tetromino until it can't move anymore
            await RunCurrentTetromino();

            //If any rows are filled, remove them from the board
            await ClearCompleteRows();

            //If the score is high enough, move the user to a new level
            LevelChange();

            //Rest of game loop
        }
    }
    
    public void LevelChange()
    {
        //The user goes up a level for every 4000 points they score.
        int counter = 1;
        int scoreCopy = score;
        while(scoreCopy > 4000)
        {
            counter++;
            scoreCopy -= 4000;
        }

        int newLevel = counter;
        if(newLevel != level) //If the user has gone up a level
        {
            //Reduce the standard delay by 100 milliseconds.
            standardDelay = 1000 - ((newLevel - 1) * 100);

            //Set the new level
            level = newLevel;
        }
    }
}

All right! Our Tetris Blazor game now has a score and levels!

Goal 2: Music

Let's be real: it's not Tetris without the iconic music. Let's make our game sing!

NOTE: We covered how to make Blazor play a sound in an earlier post. This section uses the same implementation. If you're already familiar with this, skip to the next section.

How to Play a Sound with Blazor and JavaScript
Impress and/or annoy your future Blazor site visitors in five easy steps!

First, we need HTML <audio> markup for the music file:

<audio id="theme" src="../sounds/tetris-theme.ogg" preload="auto" loop="loop" />

Note that we are setting the file to load on page load and loop automatically.

We immediately have a problem: due to the same limitations that we discussed in Part 5 when setting the focus on the grid, Blazor cannot actually play an audio file without invoking JavaScript. We therefore need to write a couple of JavaScript methods that will play and pause this audio file:

window.PlayAudio = (elementName) => {
    document.getElementById(elementName).play();
}

window.PauseAudio = (elementName) => {
    document.getElementById(elementName).pause();
}

When we start the game, we want to automatically play the music:

@code {
    //... Other properties and methods
    
    //This flag changes based on whether or not 
    //the user hits the "M" key to toggle the music.
    bool playAudio = true;
    
    public async Task RunGame()
    {
        //Start playing the theme music
        if (playAudio)
            await _jsRuntime.InvokeAsync<string>("PlayAudio", "theme");
            
        //Rest of implementation
    }
}

Thing is, though, the music can be quite annoying for a lot of people. So let's include a hotkey "M" that toggles the music on and off.

@code {
    //...Other properties and methods
    
    public async Task ToggleAudio()
    {
        playAudio = !playAudio;

        if(playAudio)
            await _jsRuntime.InvokeAsync<string>("PlayAudio", "theme");
        else
            await _jsRuntime.InvokeAsync<string>("PauseAudio", "theme");
    }
    
    protected async Task KeyDown(KeyboardEventArgs e)
    {
        if (grid.State == GameState.Playing)
        {
            //...Other key actions
            if(e.Key == "m")
            {
                await ToggleAudio();
            }
            StateHasChanged();
        }
    }
}

We'll also include a disabled checkbox that shows whether or not the music is currently playing:

<div class="row">
    <div class="col">
        @if (grid.State == GameState.NotStarted)
        {
            <button @onclick="RunGame" class="btn btn-primary">
                Start!
            </button>
        }
        @if (grid.IsStarted)
        {
            @*<label for="playAudio">
                <input id="playAudio" type="checkbox" 
                       @bind="playAudio" disabled />
                Play Music
            </label>*@
        }
    </div>
</div>

Note the use of the @bind property on the <input type="checkbox"> element. This will bind the checkbox's value to the playAudio property we defined earlier.

All of this together makes the game now look like this. Watch closely, as the checkbox will change when I press the M key to toggle the music:

Two goals down, three to go!

Goal 3: The Grace Period

When a tetromino is hard dropped with our current implementation, it immediately becomes stuck and cannot move anymore, as shown in this GIF:

This is a problem, because the official versions of tetris allow you to move the tetromino for a bit longer after it can no longer move down. In particular, we should have been able to move the orange L-shaped tetromino under the green tetromino.

So, we need to add a slight delay to the tetromino's loop that will allow the player to move the tetromino after it can no longer move down. I'm calling this delay the "grace period".

@code {
    //...Other properties and methods
    
    public async Task RunCurrentTetromino() //Tetromino loop
    {
        //While the tetromino can still move down
        while (currentTetromino.CanMoveDown())
        {
            //Rest of tetromino loop

            //If the tetromino can no longer move down 
            //BUT can still move in other directions,
            //delay for an additional half-second 
            //to let the user move if they want.
            if (!currentTetromino.CanMoveDown() 
                && currentTetromino.CanMove())
                await Delay(500);
        }

        //Rest of implementation
    }
}

This allows us to make more moves, as shown in this GIF:

In the GIF, we slide the blue reverse-L-shaped tetromino under the red zig-zag tetromino after the blue one has already hit the bottom of the grid. This allows players to make complete rows more easily.

Only two of our goals are left! Let's keep going.

Goal 4: "New Game" Button

At the moment, when a game of Tetris is done, there's no way to start a new game.

Thanks to contributor John Tomlinson, we can create a way to start a new game of Tetris right away!

First, we need some markup for the button. It will appear only when the game is complete.

<div class="row">
    <div class="col">
        @if (grid.State == GameState.NotStarted)
        {
            <button @onclick="RunGame" class="btn btn-primary">
                Start!
            </button>
        }
        @if (grid.State == GameState.GameOver)
        {
            <button @onclick="NewGame" class="btn btn-primary">
                New Game!
            </button>
        }
        @if (grid.IsStarted)
        {
            <label for="playAudio">
                <input id="playAudio" type="checkbox" 
                       @bind="playAudio" disabled />
                Play Music
            </label>
        }
    </div>
</div>

This button expects a C# method NewGame(), so we need to implement that next.

@code {
    //...Other properties and methods

    public void NewGame()
    {
        grid = new Grid();

        generator = new TetrominoGenerator();

        currentTetromino = null;

        level = 1;
        score = 0;
    }
}

This is deceptively simple: because creating a new Grid instance sets the GameState to NotStarted, this is all we have to do.

At the end of a game, the "New Game" button appears, and clicking it shows the "Start" button once again, as shown in this GIF:

Almost there! Just one of our goals is left!

Let's have our Tetris game keep track of the user's highest score by setting a cookie with that value. Similarly to playing music, there's no inherent way to do this in Blazor, so we'll need to invoke some JavaScript.

First, let's make JavaScript methods for reading and writing a cookie:

window.WriteCookie = (name, value, days) => {
    var expires;
    if (days) {
        var date = new Date();
        date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
        expires = "; expires=" + date.toGMTString();
    }
    else {
        expires = "";
    }
    document.cookie = name + "=" + value + expires + "; path=/";
}

window.ReadCookie = (cname) => {
    var name = cname + "=";
    var decodedCookie = decodeURIComponent(document.cookie);
    var ca = decodedCookie.split(';');
    for (var i = 0; i < ca.length; i++) {
        var c = ca[i];
        while (c.charAt(0) === ' ') {
            c = c.substring(1);
        }
        if (c.indexOf(name) === 0) {
            return c.substring(name.length, c.length);
        }
    }
    return 0;
}
Code taken from this StackOverflow answer.

We will override the OnInitializedAsync() method on our main Blazor component to read the cookie:

@code {
    //... Other properties and methods
    
    int previousHighScore = 0;
    string previousScoreCookieValue = "Nothing";
    
    protected override async Task OnInitializedAsync()
    {
        //Get the previous high score cookie if one exists
        previousScoreCookieValue 
            = await _jsRuntime.InvokeAsync<string>("ReadCookie", 
                                                   "tetrisHighScore");
                                                   
        bool hasHighScore 
            = int.TryParse(previousScoreCookieValue, out previousHighScore);
    }
}

When a game is complete, if the new score is higher than the player's previous high score, we'll update the cookie.

@code {
    //... Other properties and methods
    
    public async Task RunGame()
    {
        //Rest of implementation

        
        while (!grid.Cells.HasRow(21)) 
        {
            //Game loop
        }

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

        //If the current high score is larger than the old high score, 
        //update the cookie
        if(score > previousHighScore)
            await _jsRuntime.InvokeAsync<object>("WriteCookie",         
                                                 "tetrisHighScore", 
                                                 score, 
                                                 14); //14 days
    }
}

Finally, we need some markup to display the previous high score:

<div class="row">
    <div class="col">
        <!-- Grid display -->
    </div>
    <div class="col">
        <!-- Controls and upcoming tetrominos -->
    </div>
    <div class="col">
        <div class="row">
            <div class="col">
                <h2>Score: @score</h2>
                <!-- NEW -->
                <span>Previous High Score: @previousHighScore</span>
                <!-- END NEW -->
            </div>
        </div>
        <div class="row">
            <div class="col">
                <h2>Level: @level</h2>
            </div>
        </div>
    </div>
</div>

The result of which looks like this:

That's our last goal complete! Our Tetris in Blazor implementation is finally finished!

Summary

Thanks for coming on this journey with me. We now have a fully-functional implementation of Tetris running in Blazor WebAssembly!

Do you see anything that can be improved upon? Would you do something differently? I want to know your opinions! Sound off in the comments below.

Happy Coding!