Home

Match-3 Game — First Steps

Welcome to this Unity 2D tutorial. In this lesson, we’ll build a basic foundation for a match-3 game step by step. No prior Unity experience is required. Install Unity 6.0 LTS (or any other version with 2D support) if you haven’t already — and let’s get started.

In fact, there are many ways to create such a game. We’ll make a simple yet flexible implementation using a 2D space, sprites, and movement via transform.Translate. To detect selected elements, we’ll use the Raycast 2D system. We’ll split the game logic into separate modules so the code remains clean and easy to extend.

Step 1. Creating the project

Open Unity Hub and create a new 2D project by selecting the 2D (Built-In render Pipeline) template. To do this, go to the Projects tab and click New Project. Find the 2D (Built-In render Pipeline) template — it already includes the basic 2D graphics and physics. Depending on your Unity version, the template name might differ — if you don’t see it, pick 2D (URP) or the standard 2D.

Enter a project name, like MatchGame (or use your own) and choose a folder where it will be created. Click Create project and wait for the environment to set up.

Step 2. Importing the sprite

For this game prototype, a single sprite will be enough, whose appearance we’ll later vary by changing its color.

You can use the sample image below or pick your own. The only requirement is that the sprite should be 100×100 pixels, as this will be the base size of the grid elements.

Gem sprite
Click the image to download it.

To import the image into Unity: right-click in the Assets window, select Import New Asset..., find the downloaded file and click Import.

💡 Tip: you can simply drag the image file directly into the Assets window — it might be even faster.
💡 Tip: start simple — you can always improve the visuals later.

Step 3. The logical foundation of the game

Our game will consist of several key logic blocks. First, we will fill the game grid with gems. During this process, it’s important to immediately check to make sure that no lines of three or more identical gems are formed — this prevents accidental matches at the initial setup.

Once the grid is filled, we will give the turn to the player. For simplicity, at this stage the player can swap any two adjacent gems horizontally or vertically, not just moves that lead to matches.

After the player makes a move, the gems will swap places. Then we will check the entire grid for matches. If matches of three or more identical gems are found, we’ll remove them and fill the empty spaces with new gems. If there are no matches, the turn goes back to the player.

Step 4. Managing the game logic

To control the main phases of the game, we'll create a separate script. Create a new C# script (MonoBehaviour) and name it GameManager. This script will determine the current game phase and launch the appropriate logic blocks. It will also store arrays for the game grid and the set of game objects (such as gems).

At this stage, running the logic blocks is disabled — we will connect them later when we implement the respective functions.


using UnityEngine;

public class GameManager : MonoBehaviour
{
    // game grid array
    public GameObject[,] gemsGrid = new GameObject[6, 6];
    // array of gem objects (prefabs)
    public GameObject[] gemsArray;
    // counter of moving gems
    public int gemCounter = 0;
    // flag to allow player move
    public bool canMove = false;
    // flag to allow match checking
    public bool canCheck = false;
    // flag to allow refilling the grid
    public bool canRefill = false;
    // flag indicating there are no matches
    public bool noMatches = true;

    private void Update()
    {
        // if there are no moving gems and we can check for matches
        if (canCheck && gemCounter <= 0)
        {
            canCheck = false;
            gemCounter = 0;
            //GetComponent<GridChecker>().CheckForMatches();
        }

        // if there are no moving gems and we can refill the grid
        if (canRefill && gemCounter <= 0)
        {
            canRefill = false;
            gemCounter = 0;
            //GetComponent<RefillGrid>().Refill();
        }

        // if there are no matches and no other processes — give turn to player
        if (!canRefill && noMatches && !canCheck && gemCounter <= 0)
        {
            gemCounter = 0;
            canMove = true;
        }
    }
}
  

Now create an empty GameObject:

💡 GameObject → Create Empty

Rename it to GameManager and assign it the tag GameManager.

💡 If such a tag doesn’t exist yet, add a new one through the tag selection menu.

Attach the GameManager script to this object. To do this, select the object in the hierarchy and drag the script to the inspector field or use the Add Component button.

GameManager object with attached GameManager script
GameManager object with the attached GameManager script.

Step 5. Creating the gem template

Now let’s create a gem template for our game. In step two, you already imported the needed sprite. Drag it into the scene — Unity will automatically create a new GameObject.

In this game, we’ll move gems using transform.Translate, changing their position in space. We’ll also use built-in 2D physics to register interactions with the player.

Part of the game logic will be placed directly on the gem object, so we won’t need a separate controller script. Each gem will store some of its own needed information.

First, add a Circle Collider 2D component to the gem, so we can use collision events.

Now create a new MonoBehaviour script and name it GemData:


using UnityEngine;

public class GemData : MonoBehaviour
{
    // This gem’s position in the grid array,
    // matches its position in Unity world space
    public int gridPosX, gridPosY;

    // Reference to this gem’s collider component
    private CircleCollider2D col;

    // Flag to allow movement to the target position
    private bool canMoveToPos = false;

    // Movement speed
    public float speed;

    // The final point the gem is moving to
    private Vector3 endPos;

    // Reference to the main game management script
    private GameManager manager;

    private void Awake()
    {
        // Find the object with the GameManager tag and get its script
        manager = GameObject.FindWithTag("GameManager").GetComponent<GameManager>();
        // Get this gem’s CircleCollider2D
        col = GetComponent<CircleCollider2D>();
    }

    private void Update()
    {
        // If allowed to move
        if (canMoveToPos)
        {
            // Calculate normalized direction to the target point
            Vector3 dir = (endPos - transform.position).normalized;
            // Move in this direction with the set speed
            transform.Translate(dir * speed * Time.deltaTime);

            // If almost at the final point
            if ((endPos - transform.position).sqrMagnitude < 0.05f)
            {
                // Snap exactly to the final position
                transform.position = endPos;

                // Re-enable the collider to register collisions again
                col.enabled = true;

                // Decrease the moving gems counter in the manager
                manager.gemCounter--;

                // Restore normal sorting order
                GetComponent<SpriteRenderer>().sortingOrder = 0;

                // Disable the movement flag
                canMoveToPos = false;
            }
        }
    }

    // Called to start moving the gem
    public void Move()
    {
        // Update the final position from grid coordinates
        endPos = new Vector3(gridPosX, gridPosY);

        // Disable the collider to avoid collisions while moving
        col.enabled = false;

        // Increase the moving gems counter in the manager
        manager.gemCounter++;

        // Set higher sorting order so this gem draws above others
        GetComponent<SpriteRenderer>().sortingOrder = 10;

        // Enable movement
        canMoveToPos = true;
    }
}

Notice an important point: although the gem has a 2D collider, we’re using transform.Translate for movement without a Rigidbody2D. This approach is faster for simple logic, but it requires manually disabling the collider while moving.

When moving the gem, we also change its sorting order so it draws on top of other elements. Once movement finishes, we restore the sorting order.

To simplify things, our grid coordinates match the world scene. Since by default in Unity one world unit equals 100 pixels, a sprite of 100×100 pixels fits exactly into one cell. This means that a gem with position (1,4) in the array will appear at the same point in the scene.

Change the speed in the inspector to 1 — you can adjust this later for the best feel.

Done: now we have a full gem template. In the next step, we’ll add variety so they differ from each other.

Gem template with attached GemData script
Gem template with the attached GemData script.

Step 6. Creating gem prefabs

Now we already have a gem template in the scene, and it’s time to turn it into real game prefabs. It’s important to keep balance here: if there are too few variations, matches will happen too often, and the player will just watch the board clear and refill itself (though maybe that’s exactly the effect you want). But if there are too many types, matches will be rare, and the game might get stuck without possible moves for a long time.

For our 6×6 board, using 4–5 types is optimal. We’ll go with five to keep a fun balance between randomness and player planning.

Select the gem in the scene (or in the hierarchy window) and rename it to Gem-Red.

💡 The object name doesn’t affect gameplay directly, since we’ll use tags. So you can name it differently if you want.

Create a new tag red and assign it to the Gem-Red object. Then in the Sprite Renderer component, change the gem’s color to red. Now drag the Gem-Red object from the hierarchy into your Assets folder — Unity will automatically create a prefab from it.

Then select the gem object in the scene again and repeat the process to create four more prefabs.

💡 After saving the first prefab, you’re working with an object linked to that prefab. When creating the next one, Unity will ask if you want to create a new original prefab or a variant. Choose Original Prefab to make an independent new prefab.

In the end, I got these prefabs: Gem-Red with tag red, Gem-Blue with tag blue, Gem-Yellow with tag yellow, Gem-Green with tag green, and Gem-Fuchsia with tag fuchsia. Each with matching colors.

Once you’ve created all five prefabs, you can safely delete the gem object from the scene — all needed templates are now stored in your project as prefabs.

Five gem prefabs
Five different gem prefabs.

Step 7. Initial filling with gems

The first logic step of our game is to fill the board with random gems. To start, we’ll prepare an array to hold the possible gem prefabs for spawning.

Select GameManager in the hierarchy. In the inspector, find the GameManager script which has the GemsArray field. It’s empty right now. Drag your five gem prefabs into it.

💡 You can also simply set the array size to 5 and pick the needed prefabs from the Assets browser in the newly appeared slots.

Now let’s create a script to handle the initial filling of the board. Create a new MonoBehaviour script and name it SpawnController. Add it to the same GameManager object.


using UnityEngine;
// For using List
using System.Collections.Generic;

public class SpawnController : MonoBehaviour
{
    private GameManager manager;

    void Start()
    {
        // Get reference to the GameManager
        manager = GetComponent<GameManager>();
        // Fill the grid once at start
        FillTheGrid();
    }

    public void FillTheGrid()
    {
        // Loop through the board on X and Y
        for (int i = 0; i < 6; i++)
        {
            for (int j = 0; j < 6; j++)
            {
                Vector2 position = new Vector2(i, j);
                // Make a copy of the prefab array to filter
                List<GameObject> candidateTypes = new List<GameObject>(manager.gemsArray);

                // Check left neighbor: if exists, remove matching type
                if (i >= 1)
                    candidateTypes.RemoveAll(gem => gem.tag == manager.gemsGrid[i - 1, j].tag);

                // Check bottom neighbor: if exists, remove matching type
                if (j >= 1)
                    candidateTypes.RemoveAll(gem => gem.tag == manager.gemsGrid[i, j - 1].tag);

                // Pick a random gem from remaining options
                GameObject gem = Instantiate(
                    candidateTypes[Random.Range(0, candidateTypes.Count)],
                    position,
                    Quaternion.identity
                );

                // Set the gem’s position in the grid
                GemData gemData = gem.GetComponent<GemData>();
                gemData.gridPosX = i;
                gemData.gridPosY = j;

                // Store reference in the board array
                manager.gemsGrid[i, j] = gem;
            }
        }

        // Allow the player to make a move
        manager.canMove = true;
    }
}

This script runs once at the start of the game. It takes the array of available gem prefabs, copies it into a dynamic list, and while looping through each cell, it filters out any gem types that would immediately match with the gem to the left or below. That way we avoid starting the game with instant matches.

Then it randomly selects a gem from the remaining options, creates it in the scene, sets its grid coordinates, and stores it in the gemsGrid array. After filling the board, the player is allowed to make the first move.

At this point, you can already run the game and see the board fill up with random gems.

GameManager with GemsArray filled with prefabs
GameManager with GemsArray filled with prefabs.

Step 8. Moving gems

Now the player already sees a filled board and can make their first move. To do this, we’ll create a script that will handle mouse input and move the selected gems.

Create a new MonoBehaviour script and name it MoveGem. Add this script to the GameManager object.


using UnityEngine;

public class MoveGem : MonoBehaviour
{
    // Minimum distance to recognize a swipe
    public float swipeThreshold = 0.5f;

    // Text direction indicator
    private string direction;

    // Selected gem
    private GameObject selectedGem;

    // Mouse start and end points
    private Vector2 startMouse;
    private Vector2 endMouse;

    private GameManager manager;

    void Start()
    {
        manager = GetComponent<GameManager>();
    }

    void Update()
    {
        // If left mouse button is pressed and move is allowed
        if (Input.GetMouseButtonDown(0) && manager.canMove)
        {
            startMouse = Camera.main.ScreenToWorldPoint(Input.mousePosition);
            RaycastHit2D hit = Physics2D.Raycast(startMouse, Vector2.zero);

            if (hit.collider != null)
            {
                // Remember the selected gem
                selectedGem = hit.collider.gameObject;
            }
        }

        // If left mouse button is released and a gem was selected
        if (Input.GetMouseButtonUp(0) && selectedGem != null && manager.canMove)
        {
            endMouse = Camera.main.ScreenToWorldPoint(Input.mousePosition);
            Vector2 delta = endMouse - startMouse;
            direction = "";

            // Check if swipe is large enough
            if (delta.magnitude > swipeThreshold)
            {
                // Determine direction
                if (Mathf.Abs(delta.x) >= Mathf.Abs(delta.y))
                    direction = delta.x > 0 ? "right" : "left";
                else
                    direction = delta.y > 0 ? "up" : "down";
            }

            if (direction != "")
            {
                // Start swapping gems
                SwitchGems(direction);
            }
        }
    }

    void SwitchGems(string direction)
    {
        switch (direction)
        {
            case "right": GemSwap(Vector2Int.right); break;
            case "left":  GemSwap(Vector2Int.left);  break;
            case "up":    GemSwap(Vector2Int.up);    break;
            case "down":  GemSwap(Vector2Int.down);  break;
        }
    }

    void GemSwap(Vector2Int dir)
    {
        int x1 = selectedGem.GetComponent<GemData>().gridPosX;
        int y1 = selectedGem.GetComponent<GemData>().gridPosY;
        int x2 = x1 + dir.x;
        int y2 = y1 + dir.y;

        // Check board bounds
        if (x2 < 0 || x2 >= manager.gemsGrid.GetLength(0) || y2 < 0 || y2 >= manager.gemsGrid.GetLength(1))
            return;

        // Select two gems to swap
        GameObject gem1 = manager.gemsGrid[x1, y1];
        GameObject gem2 = manager.gemsGrid[x2, y2];

        var data1 = gem1.GetComponent<GemData>();
        var data2 = gem2.GetComponent<GemData>();

        // Update their grid coordinates
        data1.gridPosX = x2;
        data1.gridPosY = y2;
        data2.gridPosX = x1;
        data2.gridPosY = y1;

        // Swap them in the array
        manager.gemsGrid[x1, y1] = gem2;
        manager.gemsGrid[x2, y2] = gem1;

        // Command them to move
        data1.Move();
        data2.Move();

        // Disable further moves until checking for matches
        manager.canMove = false;
        manager.canCheck = true;
    }
}
  

This script listens for and handles mouse clicks. When the player clicks the left mouse button, it stores the start point and the selected gem. When the button is released, it checks how far the cursor moved — this prevents accidental small clicks. If the movement is large enough, it determines the direction and calls a function to swap the gems.

Then we find the pair of gems (the selected one and its neighbor in the chosen direction), update their positions in the array, update the coordinates in the GemData scripts, and command both to move to their new positions. After that, further moves are disabled until movement finishes and we check for matches.

💡 The camera might not be centered on the board. For convenience, you can set its position to x = 2.5 and y = 2.5 to center the board on screen.

Now you can run the game and see how the gems swap places.

GameManager with MoveGem script
GameManager with MoveGem script attached.

Step 9. Checking the board

The next logical step after the player's move is to check the board for matches. For this, we'll create a new MonoBehaviour script and name it GridChecker. Add this script to the GameManager object.


using UnityEngine;
using System.Collections.Generic;

public class GridChecker : MonoBehaviour
{
    // List of lists where we store groups of matched gems (3+)
    private List<List<GameObject>> matchedGems;
    private GameManager manager;
    GameObject[,] gemsGrid;

    public void CheckForMatches()
    {
        manager = GetComponent<GameManager>();
        gemsGrid = manager.gemsGrid;

        matchedGems = new List<List<GameObject>>();
        GameObject firstGemToCheck = null;
        // temporary list to collect matches
        List<GameObject> tmpGems = new List<GameObject>();

        // Check horizontals
        for (int y = 0; y < gemsGrid.GetLength(1); y++)
        {
            firstGemToCheck = null;
            tmpGems.Clear();

            for (int x = 0; x < gemsGrid.GetLength(0); x++)
            {
                GameObject current = gemsGrid[x, y];

                // If we hit an empty spot (no gem), handle and reset
                if (current == null)
                {
                    if (tmpGems.Count >= 3)
                        matchedGems.Add(new List<GameObject>(tmpGems));
                    tmpGems.Clear();
                    firstGemToCheck = null;
                    continue;
                }

                // If first in line or different tag than previous
                if (firstGemToCheck == null || current.tag != firstGemToCheck.tag)
                {
                    if (tmpGems.Count >= 3)
                        matchedGems.Add(new List<GameObject>(tmpGems));
                    tmpGems.Clear();
                    tmpGems.Add(current);
                    firstGemToCheck = current;
                }
                else
                {
                    // Same tag, continue chain
                    tmpGems.Add(current);
                }
            }

            // If we ended with a chain of 3+
            if (tmpGems.Count >= 3)
                matchedGems.Add(new List<GameObject>(tmpGems));
        }

        // Check verticals — same logic
        for (int x = 0; x < gemsGrid.GetLength(0); x++)
        {
            firstGemToCheck = null;
            tmpGems.Clear();

            for (int y = 0; y < gemsGrid.GetLength(1); y++)
            {
                GameObject current = gemsGrid[x, y];

                if (current == null)
                {
                    if (tmpGems.Count >= 3)
                        matchedGems.Add(new List<GameObject>(tmpGems));
                    tmpGems.Clear();
                    firstGemToCheck = null;
                    continue;
                }

                if (firstGemToCheck == null || current.tag != firstGemToCheck.tag)
                {
                    if (tmpGems.Count >= 3)
                        matchedGems.Add(new List<GameObject>(tmpGems));
                    tmpGems.Clear();
                    tmpGems.Add(current);
                    firstGemToCheck = current;
                }
                else
                {
                    tmpGems.Add(current);
                }
            }

            if (tmpGems.Count >= 3)
                matchedGems.Add(new List<GameObject>(tmpGems));
        }

        // Remove all matched gems from grid and scene
        foreach (List<GameObject> group in matchedGems)
        {
            foreach (GameObject gem in group)
            {
                if (gem != null)
                {
                    GemData data = gem.GetComponent<GemData>();
                    manager.gemsGrid[data.gridPosX, data.gridPosY] = null;
                    Destroy(gem);
                }
            }
        }

        // Check if we had matches this pass
        bool hadMatches = matchedGems.Count > 0;
        matchedGems.Clear();

        // If there were matches — trigger refill
        if (hadMatches)
        {
            manager.noMatches = false;
            manager.canRefill = true;
            manager.canCheck = false;
        }
        else
        {
            // Otherwise return turn to player via GameManager
            manager.noMatches = true;
            manager.canRefill = false;
            manager.canCheck = false;
        }
    }
}

This script checks rows and columns on the board to find groups of three or more identical gems. It uses a temporary list tmpGems to collect consecutive gems of the same type. When it encounters a different type or an empty space, it checks if there were at least three and if so, saves them to the global matchedGems list.

After checking, all matched gems are destroyed, and depending on whether there were any matches, the game either triggers filling empty spaces or gives the turn back to the player.

💡 Right now, you can uncomment the line GetComponent<GridChecker>().CheckForMatches(); in your GameManager script to see how the matching works. Note that interacting with empty spaces might still cause errors — we’ll handle that later.
GameManager with GridChecker script
GameManager with GridChecker script attached.

Step 10. Refilling the board

Now we have the last logical step of the game. At this stage, there are empty cells on the board — where gems were previously removed. We need to find these empty spots, move the gems above them down, and then spawn new gems into the remaining empty cells.

To do this, create a new MonoBehaviour script called RefillGrid. Add this script to the GameManager.


using UnityEngine;
using System.Collections;

public class RefillGrid : MonoBehaviour
{
    private GameManager manager;
    private GameObject[,] gemsGrid;
    private GameObject[] gemsArray;

    private void Start()
    {
        manager = GetComponent<GameManager>();
        gemsArray = manager.gemsArray;
        gemsGrid = manager.gemsGrid;
    }

    public void Refill()
    {
        // Starts the sequence: fall -> spawn -> wait -> allow matching
        StartCoroutine(DoRefillSequence());
        manager.canRefill = false;
    }

    IEnumerator DoRefillSequence()
    {
        // first existing gems fall
        yield return StartCoroutine(SlowFall());
        // then new ones spawn in empty spots
        yield return StartCoroutine(SlowSpawn()); 
        // give a short moment to complete movement
        yield return new WaitForSeconds(0.2f); 
        // now we can check for matches
        manager.canCheck = true;                   
    }

    IEnumerator SlowFall()
    {
        for (int x = 0; x < gemsGrid.GetLength(0); x++)
        {
            for (int y = 0; y < gemsGrid.GetLength(1) - 1; y++)
            {
                // If we find an empty spot in the column
                if (gemsGrid[x, y] == null)
                {
                    // Look above to find the first gem to "drop"
                    for (int aboveY = y + 1; aboveY < gemsGrid.GetLength(1); aboveY++)
                    {
                        if (gemsGrid[x, aboveY] != null)
                        {
                            // Move the found gem down into the empty slot
                            GameObject gem = gemsGrid[x, aboveY];
                            gemsGrid[x, y] = gem;
                            gemsGrid[x, aboveY] = null;

                            // Update grid position and tell it to move
                            GemData data = gem.GetComponent<GemData>();
                            data.gridPosY = y;
                            data.Move();

                            // Pause to make the fall look step-by-step
                            yield return new WaitForSeconds(0.1f);
                            break;
                        }
                    }
                }
            }
        }
    }

    IEnumerator SlowSpawn()
    {
        for (int x = 0; x < gemsGrid.GetLength(0); x++)
        {
            for (int y = 0; y < gemsGrid.GetLength(1); y++)
            {
                // If still empty after falling — spawn a new gem
                if (gemsGrid[x, y] == null)
                {
                    // spawn slightly above the field
                    GameObject newGem = Instantiate(
                        gemsArray[Random.Range(0, gemsArray.Length)],
                        new Vector2(x, gemsGrid.GetLength(1) + 1), 
                        Quaternion.identity
                    );

                    gemsGrid[x, y] = newGem;

                    // Set target grid coordinates and tell it to move
                    GemData data = newGem.GetComponent<GemData>();
                    data.gridPosX = x;
                    data.gridPosY = y;
                    data.Move();

                    // Delay for step-by-step visualization
                    yield return new WaitForSeconds(0.2f);
                }
            }
        }
    }
}

This script does two key tasks: first it looks for empty spaces and moves gems above them down, then it finds any remaining empty cells and spawns new gems there.

With this, your core game logic is complete! Now you can uncomment the calls to GetComponent<GridChecker>().CheckForMatches() and GetComponent<RefillGrid>().Refill() in the GameManager script — and your game will run in a full cycle.

GameManager with RefillGrid script
GameManager with RefillGrid script attached.

Step 11. Let your creativity flow

Hope you didn’t forget to uncomment the calls to CheckForMatches() and Refill() in your GameManager script — your game is now fully working!

From here, it’s all up to your imagination 🤗. Add special effects, animations, sounds, power-ups, timers, levels — and turn your match-3 into something truly unique.