Unity 2D Tetris Tutorial
Foreword
In this Tutorial, we'll be implementing our very own Tetris clone. Yes, the russian game that took the world by storm ever since its first playable release on the 6th of June 1984. Our clone will be just as simple, with only about 130 lines of code and two assets while retaining the simple yet highly addictive gameplay. While the game seems rather simple to implement, it still comes with quite a few challenges and will be a great exercise for beginners and experienced users working with Unity.
As usual, everything will be explained as easy as possible so everyone can understand it.
Requirements
Knowledge
Our Tutorial does not require any special skills. If you know your way around Unity and heard about GameObjects, Prefabs and Transforms before, then you are ready to go. And if you didn't, don't worry about it too much.
Feel free to read our easier Unity Tutorials like Unity 2D Pong Game to get used to the engine.
Unity Version
Our Tetris Tutorial will use Unity 2018.3 just like the Unity 2D Pong Game tutorial. Newer versions should work fine as well, older versions may or may not work. It's important that we use at least Unity 2018.3 since it is a recent version of Unity that is widely used by thousands of developers and has everything we need for this tutorial.
Project Setup
Alright, let's make our Tetris game! We will create a new Unity Project using the Unity Hub. Firstly, we choose a directory to save it in, select the 2D Unity game template and clicking Create. Please note that these screenshots where taken on a Windows PC with Unity Hub 2.x installed. Please adapt accordingly if your Unity Hub looks different from the screenshots shown below.
If we select the Main Camera in the Hierarchy then we can set the Background Color to black, adjust the Size and the Position like shown in the following image:
Note: it's important that our camera is at X=4.5 because this will be the center of our scene later on.
About Blocks and Groups
Let's make some definitions so we all know what we are talking about. We will have blocks and groups. A group will contain a few blocks also known as "tetrominos" in the original game, but for the sake of it being easier to remember, we'll simply call them blocks:
There are several types of blocks in Tetris, which are the I, J, L, O, S, T and Z blocks:
Creating the Game Art
As seen in the above images, we will keep the art style simple. Every group can be created with just one block type with a green, rounded rectangle:
Note: right click on the image, select Save As... and save it in the project's Assets folder.
Let's select the block image in the Project Area and then adjust the import settings in the Inspector:
Note: the Pixels to Units property specifies the size in game.
We will use one more asset for the borders to give the player some visual aid:
Note: right click on the image, select Save As... and save it in the project's Assets folder.
We will use the following import settings for the border:
Adding Borders
Alright now that we have the game art all set up, we can drag the border from the Project Area into the Hierarchy twice:
In our implementation, the Tetris scene will be exactly 10 blocks wide, and around 20 or so high. So a blocks' coordinates are always between (0, 0) and (9, 19).
Note: if you count it, that makes it 10 units horizontally and 20 units vertically, starting at 0.
Now the borders should be at the left and at the right of our game, so one of them will be somewhere at X=0 and the other at X=9. Its also a good idea to add some spacing, so let's select one border after another in the Hierarchy and then adjust the positions and scales like shown below:
Here is how our borders look like if we press Play:
Creating the Groups
Okay now it's time to create the I, J, L, O, S, T and Z groups. To be more exact, we want one Prefab for each.
We will start by creating an empty GameObject by selecting GameObject -> Create Empty from the top menu. This adds an empty GameObject to our Hierarchy:
We can drag the block image into the empty GameObject 4 times, so the 4 blocks are its children:
Now the trick is to position the blocks so they become the O Group:
Here are the coordinates that we used for the four blocks. Start at the first GameObject and work your way one by one:
- X=0 Y=0
- X=0 Y=1
- X=1 Y=0
- X=1 Y=1
Note: Since Tetris is 2D, we do not worry about the Z axis. That's why there's no Z axis in the positions above.
It's important that we use rounded coordinates like 1 instead of 1.1 because our block size is always exact 1x1 and a block will always move by 1 unit. So if we would use block sizes like 1.1 and move it by 1 then we would end up at a coordinate like 2.1 - which might be inside another block.
Or in other words: as long as we use rounded coordinates, we will be fine.
Alright, let's rename the (not anymore empty) GameObject to GroupO in the Inspector. This can be done by selecting the GameObject, and pressing F2 on Windows for Rename or right-clicking and choosing Rename from the pop-up menu that appears. This is how it looks like in the Hierarchy now:
Now we can drag it into the ProjectArea to create a Prefab:
We don't need it in the Hierarchy anymore, so we can Delete it by selecting it in the Hierarchy and pressing the Delete key on our keyboard, or right-clicking it and choosing delete from the pop-up menu that appears.
We will repeat the same work-flow for the rest of the groups:
The Tetromino Spawner
Let's create another empty GameObject, name it Spawner and position it at the top of the Scene:
The Spawner will provide a spawnNext function that spawns a random group when needed. Let's right click in the Project Area, select Create -> C# Script and name it Spawner. At first we will add a public GameObject[] array which allows us to drag all the groups into the Inspector later on:
using UnityEngine;
using System.Collections;
public class Spawner : MonoBehaviour {
// Groups
public GameObject[] groups;
}
Note: array means that it's a whole bunch of GameObjects, and not just one.
Now we can create the spawnNext function that selects a random element from the groups array and throws it into the world by using Instantiate:
public void spawnNext() {
// Random Index
int i = Random.Range(0, groups.Length);
// Spawn Group at current Position
Instantiate(groups[i],
transform.position,
Quaternion.identity);
}
Note: transform.position is the Spawner's position, Quaternion.identity is the default rotation.
The Spawner should also spawn a random group as soon as the game starts. This is what the Start function is for:
void Start() {
// Spawn initial Group
spawnNext();
}
Note: the Start function will automatically be called by Unity when the game scene is loaded and the Spawner script starts up.
So far so good. Let's select the Spawner in the Hierarchy and then click on Add Component -> Scripts -> Spawner in the Inspector. Afterwards we drag one group after another from our project area into the Groups slot:
If we press Play then we can see how the Spawner spawns the first group:
The Playfield Class
Motivation
To implement the rest of the gameplay features as seen in the original Tetris game, we will need a few helper functions to:
- Check if all the blocks are between the borders
- Check if all the blocks are above y=0
- Check if a group can be moved towards a certain position
- Check if a row is full of blocks
- Delete a row
- Decrease a row's y coordinate
The obvious way to check stuff with other blocks in Unity would be by using FindGameObjectsWithTag. Besides performance issues, the major issue with this function is that we can't really find out if there is a block at a given position. Instead we would always have to loop through all the blocks and check their positions.
The Data Structure
The solution to solve this problem is to implement a grid, or in other words: a two dimensional array (or matrix). You may have heard the term from Math classes during school. The data structure looks pretty much like this:
___|_0_|_1_|_2_|...
0 | o | x | x |...
1 | o | x | o |...
2 | x | x | o |...
...|...|...|...|...
The x means that there is a block, the o means that there is no block. So at coordinate (0,0) there is no block, at (0,1) there is a block and so on.
And here is how easy we can access a block at a certain position then:
// Is there a block at (3,4)?
if (grid[3,4] != null) {
// Do Stuff...
}
Now we know the answer to our problem, there's one catch. Unfortunately, if we called our new script Grid, this will likely cause a conflict against a internal Unity class of the same name. So let's create a new C# script and name it Playfield. It will store the grid itself and a few useful functions to work with it. Here is how we can define a 2-dimensional array in C#:
Creating the Playfield Script
using UnityEngine;
using System.Collections;
public class Playfield : MonoBehaviour {
// The Grid itself
public static int w = 10;
public static int h = 20;
public static Transform[,] grid = new Transform[w, h];
}
Easy, right? The grid might as well be of type GameObject, but by making it of type Transform we won't have to write something.transform.position all the time. And since every GameObject has a Transform, it will work just fine.
The roundVec2 helper Function
Our first helper function will round a vector. For example, a vector like (1.0001, 2) becomes (1, 2). We will need this function because rotations may cause the coordinates to not be round anymore. Anyway, here is the function:
public static Vector2 roundVec2(Vector2 v) {
return new Vector2(Mathf.Round(v.x),
Mathf.Round(v.y));
}
Note: a public static function allows it to be accessed by other scripts too. Very useful for helper/utility functions.
The insideBorder helper Function
The next function will be just as easy. It will help us to find out if a certain coordinate is in between the borders or if it's outside of the borders:
public static bool insideBorder(Vector2 pos) {
return ((int)pos.x >= 0 &&
(int)pos.x < w &&
(int)pos.y >= 0);
}
What happens is that it first tests the x position which has to be between 0 and the grid width w, and afterwards it finds out if the y position is still positive.
Note: it doesn't check if pos.y < h because groups don't really move upwards, except for some rotations.
The deleteRow helper Function
The next function deletes all Blocks in a certain row. It will be useful when the player managed to fill every entry in a row (in which case it will be deleted):
public static void deleteRow(int y) {
for (int x = 0; x < w; ++x) {
Destroy(grid[x, y].gameObject);
grid[x, y] = null;
}
}
The function takes the y parameter which is the row that is supposed to be deleted from the playfield. Then it loops through every block in that row, Destroys it from the game and clears the reference to it by setting the grid entry to null.
The decreaseRow helper function
Whenever a row was deleted, the above rows should fall towards the bottom by one unit. The following function will take care of that:
public static void decreaseRow(int y) {
for (int x = 0; x < w; ++x) {
if (grid[x, y] != null) {
// Move one towards bottom
grid[x, y-1] = grid[x, y];
grid[x, y] = null;
// Update Block position
grid[x, y-1].position += new Vector3(0, -1, 0);
}
}
}
Similar to our previous function, this one takes the row's y value as its parameter, going through every block in that row within the for loop and then moves it one unit to the bottom. However, what we have to keep in mind here is that we also need to update the block's world position. Otherwise the block would be assigned to the correct grid entry, but it would still look like it is at the old position in the game world causing a unwanted visual glitch.
The block's world position is modified by adding the Vector (0, -1, 0) to it. Or in other words, we are decreasing the y coordinate of the block by one.
The decreaseRowsAbove function
Our next function will use the previous decreaseRow function and use it on every row above a certain index because whenever a row was deleted, we want to decrease all rows above it, not just one:
public static void decreaseRowsAbove(int y) {
for (int i = y; i < h; ++i)
decreaseRow(i);
}
Like before, the function takes the parameter y which is the row. It then loops through all above rows by using i, starting at y and looping while i < h (in other words, we loop while i is less than h).
The isRowFull function
We mentioned before that a row should be deleted when it's full of blocks. So let's jump right into it and create a function that finds out if a row is full of blocks:
public static bool isRowFull(int y) {
for (int x = 0; x < w; ++x)
if (grid[x, y] == null)
return false;
return true;
}
This function is rather easy. It takes the row parameter y, loops through every grid entry and returns false as soon as there is no block in a grid entry. If the for loop was finished and we still didn't return false, then the row must be full of blocks, in which case we return true.
The deleteFullRows function
Now its time to put everything together and write a function that deletes all full rows and then always decreases the above row's y coordinate by one. None of this is hard anymore, now that we have all our helper functions:
public static void deleteFullRows() {
for (int y = 0; y < h; ++y) {
if (isRowFull(y)) {
deleteRow(y);
decreaseRowsAbove(y+1);
--y;
}
}
}
Note: --y decreases y by one whenever a row was deleted. It's to make sure that the next step of the for loop continues at the correct index (which must be decreased by one, because we just deleted a row).
And that's all we need for our grid class. What we just did here is known as Bottom-Up Programming. We started with the easiest function and then created more and more functions that make use of the previously created ones.
The benefit of this development technique is that it makes our lives a bit easier because we don't have to do so much backtracking in our minds.
The Group Script
Creating the Script
It's time to finally add some gameplay to our Unity Tetris clone. Let's create a new C# Script and name it Group:
using UnityEngine;
using System.Collections;
public class Group : MonoBehaviour {
// Use this for initialization
void Start () {
}
// Update is called once per frame
void Update () {
}
}
Creating the Helper Functions
At first we will add two more helper functions. Remember how we put several blocks into one GameObject and called it group? We will need a function that helps us to verify each child block's position:
bool isValidGridPos() {
foreach (Transform child in transform) {
Vector2 v = Playfield.roundVec2(child.position);
// Not inside Border?
if (!Playfield.insideBorder(v))
return false;
// Block in grid cell (and not part of same group)?
if (Playfield.grid[(int)v.x, (int)v.y] != null &&
Playfield.grid[(int)v.x, (int)v.y].parent != transform)
return false;
}
return true;
}
The function is really easy to understand. At first it loops through every child by using foreach, then it stores the child's rounded position in a variable. Afterwards it finds out if that position is inside the border, and then it finds out if there already is a block in the same grid entry or not.
Before we go too much further, there is one edge case that we have to take care of here. We have to allow intersections between blocks within the same group, to make sure that some rotations and translations won't be detected as invalid. For example, if an I group moves one unit downwards, then most of the blocks within that group would intersect with each other as shown below, where a is the first position and b is the second position:
a
a b <- intersection
a b <- intersection
a b <- intersection
b
We can avoid these kind of intersections by comparing a block's parent Transform with the current transform like we did above.
Alright, let's create the last helper function. If a group changed its position, then it has to remove all the old block positions from the grid and add all the new block positions to the grid:
void updateGrid() {
// Remove old children from grid
for (int y = 0; y < Playfield.h; ++y)
for (int x = 0; x < Playfield.w; ++x)
if (Playfield.grid[x, y] != null)
if (Playfield.grid[x, y].parent == transform)
Playfield.grid[x, y] = null;
// Add new children to grid
foreach (Transform child in transform) {
Vector2 v = Playfield.roundVec2(child.position);
Playfield.grid[(int)v.x, (int)v.y] = child;
}
}
As done many times previously, we looped through the grid and then checked if the block (if there is one) is part of the group by using the parent property. If the block's parent equals the current group's transform, then it's a child of that group. Afterwards we loop through all children again to add them to the grid.
Move and Fall
Okay, now we can add some game-play. Soon it will all make sense...
We will modify our Update function so it checks for key presses now, beginning with the left arrow key:
void Update() {
// Move Left
if (Input.GetKeyDown(KeyCode.LeftArrow)) {
// Modify position
transform.position += new Vector3(-1, 0, 0);
// See if it's valid
if (isValidGridPos())
// It's valid. Update grid.
updateGrid();
else
// Its not valid. revert.
transform.position += new Vector3(1, 0, 0);
}
}
This is where all our helper functions come in handy. All we have to do in order to move the group to the left is:
- wait for key press
- move it to the left
- find out if the position is still valid
- if so, update the grid with new position
- if not, move back to the right again
Here is how we can move to the right:
// Move Right
else if (Input.GetKeyDown(KeyCode.RightArrow)) {
// Modify position
transform.position += new Vector3(1, 0, 0);
// See if valid
if (isValidGridPos())
// It's valid. Update grid.
updateGrid();
else
// It's not valid. revert.
transform.position += new Vector3(-1, 0, 0);
}
Here is how we can rotate:
// Rotate
else if (Input.GetKeyDown(KeyCode.UpArrow)) {
transform.Rotate(0, 0, -90);
// See if valid
if (isValidGridPos())
// It's valid. Update grid.
updateGrid();
else
// It's not valid. revert.
transform.Rotate(0, 0, 90);
}
Notice how it's always the exact same work flow?
And here is how we can move downwards:
// Fall
else if (Input.GetKeyDown(KeyCode.DownArrow)) {
// Modify position
transform.position += new Vector3(0, -1, 0);
// See if valid
if (isValidGridPos()) {
// It's valid. Update grid.
updateGrid();
} else {
// It's not valid. revert.
transform.position += new Vector3(0, 1, 0);
// Clear filled horizontal lines
Playfield.deleteFullRows();
// Spawn next Group
FindObjectOfType<Spawner>().spawnNext();
// Disable script
enabled = false;
}
}
This time a few more things are happening. When we moved a block downwards and the new position is not valid anymore, then we have to disable the movement, delete all the full rows and spawn the next group of blocks. Since we have a helper function for everything, it's all short and easy.
The group should automatically fall downwards once a second, we can do this by first creating a variable that keeps track of the last fall time:
// Time since last gravity tick
float lastFall = 0;
And then modifying our move downwards function so it also gets triggered once a second and not just when the player presses the down arrow button:
// Move Downwards and Fall
else if (Input.GetKeyDown(KeyCode.DownArrow) ||
Time.time - lastFall >= 1) {
// Modify position
transform.position += new Vector3(0, -1, 0);
// See if valid
if (isValidGridPos()) {
// It's valid. Update grid.
updateGrid();
} else {
// It's not valid. revert.
transform.position += new Vector3(0, 1, 0);
// Clear filled horizontal lines
Playfield.deleteFullRows();
// Spawn next Group
FindObjectOfType<Spawner>().spawnNext();
// Disable script
enabled = false;
}
lastFall = Time.time;
}
And here is the final Update function of our Group script:
void Update() {
// Move Left
if (Input.GetKeyDown(KeyCode.LeftArrow)) {
// Modify position
transform.position += new Vector3(-1, 0, 0);
// See if valid
if (isValidGridPos())
// It's valid. Update grid.
updateGrid();
else
// It's not valid. revert.
transform.position += new Vector3(1, 0, 0);
}
// Move Right
else if (Input.GetKeyDown(KeyCode.RightArrow)) {
// Modify position
transform.position += new Vector3(1, 0, 0);
// See if valid
if (isValidGridPos())
// It's valid. Update grid.
updateGrid();
else
// It's not valid. revert.
transform.position += new Vector3(-1, 0, 0);
}
// Rotate
else if (Input.GetKeyDown(KeyCode.UpArrow)) {
transform.Rotate(0, 0, -90);
// See if valid
if (isValidGridPos())
// It's valid. Update grid.
updateGrid();
else
// It's not valid. revert.
transform.Rotate(0, 0, 90);
}
// Move Downwards and Fall
else if (Input.GetKeyDown(KeyCode.DownArrow) ||
Time.time - lastFall >= 1) {
// Modify position
transform.position += new Vector3(0, -1, 0);
// See if valid
if (isValidGridPos()) {
// It's valid. Update grid.
updateGrid();
} else {
// It's not valid. revert.
transform.position += new Vector3(0, 1, 0);
// Clear filled horizontal lines
Playfield.deleteFullRows();
// Spawn next Group
FindObjectOfType<Spawner>().spawnNext();
// Disable script
enabled = false;
}
lastFall = Time.time;
}
}
It's really that easy!
There is one more thing that we have to watch out for. If a new group was spawned and it immediately collides with something else, then the game is over:
void Start() {
// Default position not valid? Then it's game over
if (!isValidGridPos()) {
Debug.Log("GAME OVER");
Destroy(gameObject);
}
}
Almost done. For each prefab that we made in the Project Area, click the prefab and then click the "Open Prefab" button in the Inspector. Follow the screenshots below for guidance:
Select the Group script - the first on this list or you can find it under Scripts -> Group. You will see it appear in the prefab inspector:
Finally, click the back arrow to leave this Prefab Editor mode. Prefab changes will be saved automatically.
Note: You need to repeat this for every other block prefab we've created. Otherwise you will get an error at play time.
If we press Play then we can now enjoy a nice round of Tetris:
Summary
Congratulations! You've made yourself a fully functional Tetris clone in around 130 lines of code. As usual, a pretty long Tutorial for such few lines of code.
It might seem surprising how we spent so much time on working on helper functions and how quickly the game was finished without having to worry about anything in the end. This is the magic of bottom up programming.
Where to from here?
Now it's up to you, the reader, to make the game even more fun. Here's some ideas that you could tackle:
- Spawn the tetrominos with different colors
- Speed up the game play as you clear lines
- Add the good old Tetris sound effects
- Hold and Next Piece mechanics
- Implement a better game over system with restart
- The possibilities are endless. Make your own variant of Tetris!
For more background information on Tetris, please visit the Wikipedia Tetris article.
Download Source Code & Project Files
The Unity 2D Tetris Tutorial source code & project files can be downloaded by Premium members.All Tutorials. All Source Codes & Project Files. One time Payment.
Get Premium today!