noobtuts

Unity 2D Tetris Tutorial

Unity 2D Tetris

Foreword

In today's Tutorial we will make a clone of one of the best games of all time: Tetris! Originally released on June 1984, it became popular due to its simple, yet highly addictive game-play.

Our Tetris clone will be just as simple, with only about 130 lines of code and two assets. While the game seems rather simple, it still comes with quite a few challenges and will be a great exercise for beginners.

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 4.5.1f3. Newer versions should work fine as well, older versions may or may not work. It's important that we use at least Unity 4.3 because of the 2D features that came with it.

We won't need any advanced effects, so the free version of Unity will do just fine.

Project Setup

Alright, let's make a Tetris game. We will create a new Unity Project and then save it any directory like C:\tetris and select 2D for the defaults:
Unity Create new 2D Project

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:
Camera Properties

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:
Block vs. Group

There are several types of groups in Tetris, called I, J, L, O, S, T and Z:
Tetris Groups

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:
Green Rounded Tetris Block
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:
Block ImportSettings

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:
Tetris Border
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:
Border ImportSettings

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:
Add Border to Hierarchy

The Tetris scene is exactly 10 blocks wide, and around 20 or so high. So a block's 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:
Left Border in Inspector
Right Border in Inspector

Here is how our borders look like if we press Play:
Borders In-game

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:
Empty GameObject in Hierarchy

We can drag the block image into the empty GameObject 4 times, so the 4 blocks are its children:
Blocks as Children

Now the trick is to position the blocks so they become the O Group:
GroupO

Here are the coordinates that we used for the four blocks:

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 is how it looks like in the Hierarchy now:
Group O in Hierarchy

Now we can drag it into the ProjectArea to create a Prefab:
Create Group O Prefab

We don't need it in the Hierarchy anymore, so let's delete it from there by right clicking it and selecting Delete.

We will repeat the same work-flow for the rest of the groups:
All Groups in Project Area

The Spawner

Let's create another empty GameObject, name it Spawner and position it at the top of the Scene:
Spawner in Inspector

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 as soon as the game starts.

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:
Spawner in Project Area with Groups

If we press Play then we can see how the Spawner spawns the first group:
Spawner with first Group

The Grid Class

Motivation

To implement the rest of the game-play features, we will need a few helper functions to:

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 is a grid, or in other words: a two dimensional array (or matrix). 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...

Okay so let's create a new C# script and name it Grid. 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 Grid Script

using UnityEngine;
using System.Collections;

public class Grid : MonoBehaviour {
    // The Grid itself
    public static int w = 10;
    public static int h = 20;
    public static Transform[,] grid = new Transform[w, h];
}

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: it's public static so it can be accessed by other scripts too.

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. 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);
        }
    }
}

Yet again it takes the row y as parameter, goes through every block in that row with the for loop and then moves it one unit to the bottom. Now what we have to keep in mind here is to also 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.

The block's world position is modified by adding the Vector (0, -1, 0) to it. Or in other words, decreasing the y coordinate 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.

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 game-play 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 = Grid.roundVec2(child.position);

        // Not inside Border?
        if (!Grid.insideBorder(v))
            return false;

        // Block in grid cell (and not part of same group)?
        if (Grid.grid[(int)v.x, (int)v.y] != null &&
            Grid.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.

Now 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 < Grid.h; ++y)
        for (int x = 0; x < Grid.w; ++x)
            if (Grid.grid[x, y] != null)
                if (Grid.grid[x, y].parent == transform)
                    Grid.grid[x, y] = null;

    // Add new children to grid
    foreach (Transform child in transform) {
        Vector2 v = Grid.roundVec2(child.position);
        Grid.grid[(int)v.x, (int)v.y] = child;
    }        
}

Again 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 valid
        if (isValidGridPos())
            // Its 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:

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
        Grid.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
        Grid.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
            Grid.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. Now we just have to select all groups in the Project Area, take a look at the Inspector and select Add Component -> Scripts -> Group:
Group Script in Inspector

If we press Play then we can now enjoy a nice round of Tetris:
Unity Tetris

Summary

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.

Now it's up to you, the reader, to make the game even more fun. Maybe add some more colors, different levels with increasing speed, maybe add the good old Tetris sound that we all love.


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!