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:
Previously in this Series
This post is the last of a six-part series; read the rest of the posts from the bookmarks below.
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.
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!
Goal 5: "Previous High Score" Cookie
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:
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!