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 MonoBehaviour script 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
{
    // 2D array representing the game board (6×6 grid)
    public GameObject[,] gemsGrid = new GameObject[6, 6];

    // Array of available gem prefabs (different colors)
    public GameObject[] gemsArray;

    // Counter to track how many gems are currently moving
    public int gemCounter = 0;

    // Flag: allows the player to make a move
    public bool canMove = false;

    // Flag: triggers match checking
    public bool canCheck = false;

    // Flag: triggers grid refill after matches are cleared
    public bool canRefill = false;

    // Flag: whether there are no matches left on the board
    public bool noMatches = true;

    private void Update()
    {
        // If all gems have stopped and match check is allowed — perform match check
        if (canCheck && gemCounter <= 0)
        {
            canCheck = false;
            gemCounter = 0;
            //GetComponent<GridChecker>().CheckForMatches();
        }

        // If all gems have stopped and refill is allowed — refill the grid
        if (canRefill && gemCounter <= 0)
        {
            canRefill = false;
            gemCounter = 0;
            //GetComponent<RefillGrid>().Refill();
        }

        // If no processes are running and there are no matches — allow player to move
        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
{
    // Position of this gem in the grid array,
    // matches its position in Unity world space
    public int gridPosX, gridPosY;

    // Reference to the gem's CircleCollider2D component
    private CircleCollider2D col;

    // Flag: whether movement towards the target position is allowed
    private bool canMoveToPos = false;

    // Movement speed
    public float speed;

    // Final position the gem should move to
    private Vector3 endPos;

    // Reference to the main GameManager 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 own CircleCollider2D
        col = GetComponent<CircleCollider2D>();
    }

    private void Update()
    {
        // If movement is allowed — move towards the target
        if (canMoveToPos)
        {
            // Calculate the normalized direction to the target position
            Vector3 dir = (endPos - transform.position).normalized;

            // Move in that direction at the defined speed
            transform.Translate(dir * speed * Time.deltaTime);

            // If we're almost at the target position
            if ((endPos - transform.position).sqrMagnitude < 0.05f)
            {
                // Snap exactly to the final position to avoid jitter
                transform.position = endPos;

                // Re-enable the collider so the gem can interact again
                col.enabled = true;

                // Inform the GameManager that one gem finished moving
                manager.gemCounter--;

                // Reset rendering order to normal
                GetComponent<SpriteRenderer>().sortingOrder = 0;

                // Stop movement
                canMoveToPos = false;
            }
        }
    }

    // Called to start gem movement to its grid position
    public void Move()
    {
        // Calculate world position from grid coordinates
        endPos = new Vector3(gridPosX, gridPosY);

        // Disable collider during movement to avoid false collisions
        col.enabled = false;

        // Increase the counter of active movements in GameManager
        manager.gemCounter++;

        // Raise rendering order so this gem appears 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 a reference to the GameManager on the same GameObject
        manager = GetComponent<GameManager>();

        // Fill the game grid with gems when the scene starts
        FillTheGrid();
    }

    public void FillTheGrid()
    {
        // Loop through each grid cell by X and Y coordinates
        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 gem prefab array for filtering
                List<GameObject> candidateTypes = new List<GameObject>(manager.gemsArray);

                // Check the gem to the left — remove if same type
                if (i >= 1)
                    candidateTypes.RemoveAll(gem => gem.tag == manager.gemsGrid[i - 1, j].tag);

                // Check the gem below — remove if same type
                if (j >= 1)
                    candidateTypes.RemoveAll(gem => gem.tag == manager.gemsGrid[i, j - 1].tag);

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

                // Assign grid coordinates to the gem
                GemData gemData = gem.GetComponent<GemData>();
                gemData.gridPosX = i;
                gemData.gridPosY = j;

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

        // Allow the player to make the first 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 swipe distance to trigger a move (in world units)
    public float swipeThreshold = 0.5f;

    // Direction string: "left", "right", "up", "down"
    private string direction;

    // The gem selected by the player
    private GameObject selectedGem;

    // Start and end positions of the mouse
    private Vector2 startMouse;
    private Vector2 endMouse;

    private GameManager manager;

    void Start()
    {
        // Get reference to GameManager on the same GameObject
        manager = GetComponent<GameManager>();
    }

    void Update()
    {
        // Mouse button pressed — start swipe
        if (Input.GetMouseButtonDown(0) && manager.canMove)
        {
            // Save starting mouse position (world space)
            startMouse = Camera.main.ScreenToWorldPoint(Input.mousePosition);

            // Check if we clicked on something
            RaycastHit2D hit = Physics2D.Raycast(startMouse, Vector2.zero);
            if (hit.collider != null)
            {
                // Store selected gem
                selectedGem = hit.collider.gameObject;
            }
        }

        // Mouse button released — end swipe
        if (Input.GetMouseButtonUp(0) && selectedGem != null && manager.canMove)
        {
            // Save end mouse position and calculate movement vector
            endMouse = Camera.main.ScreenToWorldPoint(Input.mousePosition);
            Vector2 delta = endMouse - startMouse;
            direction = "";

            // Only accept swipe if long enough
            if (delta.magnitude > swipeThreshold)
            {
                // Determine direction by dominant axis
                if (Mathf.Abs(delta.x) >= Mathf.Abs(delta.y))
                    direction = delta.x > 0 ? "right" : "left";
                else
                    direction = delta.y > 0 ? "up" : "down";
            }

            // If valid direction — trigger gem swap
            if (direction != "")
            {
                SwitchGems(direction);
            }
        }
    }

    // Convert direction string to actual movement
    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;
        }
    }

    // Swap two adjacent gems in the grid
    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 bounds
        if (x2 < 0 || x2 >= manager.gemsGrid.GetLength(0) || y2 < 0 || y2 >= manager.gemsGrid.GetLength(1))
            return;

        // Get both gems from the grid
        GameObject gem1 = manager.gemsGrid[x1, y1];
        GameObject gem2 = manager.gemsGrid[x2, y2];

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

        // Update grid coordinates in each gem
        data1.gridPosX = x2;
        data1.gridPosY = y2;
        data2.gridPosX = x1;
        data2.gridPosY = y1;

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

        // Start movement
        data1.Move();
        data2.Move();

        // Disable further input until matches are checked
        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;
// For using lists (dynamic arrays)
using System.Collections.Generic;

// Class that checks the grid for 3+ gem matches
public class GridChecker : MonoBehaviour
{
    // List of all groups of matching gems
    private List<List<GameObject>> matchedGems;

    // Reference to the main GameManager
    private GameManager manager;

    // Cached grid of all gem objects
    private GameObject[,] gemsGrid;

    // Main method to find and remove matches
    public void CheckForMatches()
    {
        // Get GameManager and the gem grid
        manager = GetComponent<GameManager>();
        gemsGrid = manager.gemsGrid;

        // Create a new list to store all matches
        matchedGems = new List<List<GameObject>>();

        // First gem in a potential matching chain
        GameObject firstGemToCheck = null;

        // Temporary list for building a chain
        List<GameObject> tmpGems = new List<GameObject>();

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

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

                // If the slot is empty, finish current chain
                if (current == null)
                {
                    if (tmpGems.Count >= 3)
                        matchedGems.Add(new List<GameObject>(tmpGems));
                    tmpGems.Clear();
                    firstGemToCheck = null;
                    continue;
                }

                // New type or start of line
                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 type — continue chain
                    tmpGems.Add(current);
                }
            }

            // Save last chain in row
            if (tmpGems.Count >= 3)
                matchedGems.Add(new List<GameObject>(tmpGems));
        }

        // === Vertical check ===
        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);
                }
            }

            // Save last vertical chain
            if (tmpGems.Count >= 3)
                matchedGems.Add(new List<GameObject>(tmpGems));
        }

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

                    // Remove from scene
                    Destroy(gem);
                }
            }
        }

        // Check if any matches were found
        bool hadMatches = matchedGems.Count > 0;

        // Clear the matches list
        matchedGems.Clear();

        // === Update game state ===
        if (hadMatches)
        {
            manager.noMatches = false;
            manager.canRefill = true;
            manager.canCheck = false;
        }
        else
        {
            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;
// Needed for coroutines (IEnumerator)
using System.Collections;

public class RefillGrid : MonoBehaviour
{
    // Reference to the main GameManager
    private GameManager manager;

    // The grid holding all gem objects
    private GameObject[,] gemsGrid;

    // Array of gem prefabs to choose from
    private GameObject[] gemsArray;

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

    // Starts the refill process
    public void Refill()
    {
        // Start the sequence: falling → spawning → checking
        StartCoroutine(DoRefillSequence());

        // Disable refill flag until finished
        manager.canRefill = false;
    }

    // Sequence of falling and spawning gems
    IEnumerator DoRefillSequence()
    {
        // First, let existing gems fall into empty spots
        yield return StartCoroutine(SlowFall());

        // Then spawn new gems where needed
        yield return StartCoroutine(SlowSpawn());

        // Small pause to allow animations to complete
        yield return new WaitForSeconds(0.2f);

        // Allow the system to check for new matches
        manager.canCheck = true;
    }

    // Moves gems downward into empty cells
    IEnumerator SlowFall()
    {
        for (int x = 0; x < gemsGrid.GetLength(0); x++)
        {
            for (int y = 0; y < gemsGrid.GetLength(1) - 1; y++)
            {
                // If the cell is empty
                if (gemsGrid[x, y] == null)
                {
                    // Look for a gem above to fall into this spot
                    for (int aboveY = y + 1; aboveY < gemsGrid.GetLength(1); aboveY++)
                    {
                        if (gemsGrid[x, aboveY] != null)
                        {
                            // Move gem down
                            GameObject gem = gemsGrid[x, aboveY];
                            gemsGrid[x, y] = gem;
                            gemsGrid[x, aboveY] = null;

                            // Update grid position and start moving
                            GemData data = gem.GetComponent<GemData>();
                            data.gridPosY = y;
                            data.Move();

                            // Wait to create a step-by-step fall effect
                            yield return new WaitForSeconds(0.1f);
                            break;
                        }
                    }
                }
            }
        }
    }

    // Spawns new gems into empty positions
    IEnumerator SlowSpawn()
    {
        for (int x = 0; x < gemsGrid.GetLength(0); x++)
        {
            for (int y = 0; y < gemsGrid.GetLength(1); y++)
            {
                // If still empty, spawn a new gem
                if (gemsGrid[x, y] == null)
                {
                    // Spawn it just above the top of the grid
                    GameObject newGem = Instantiate(
                        gemsArray[Random.Range(0, gemsArray.Length)],
                        new Vector2(x, gemsGrid.GetLength(1) + 1),
                        Quaternion.identity
                    );

                    // Store it in the grid
                    gemsGrid[x, y] = newGem;

                    // Set its target position and begin movement
                    GemData data = newGem.GetComponent<GemData>();
                    data.gridPosX = x;
                    data.gridPosY = y;
                    data.Move();

                    // Wait to visualize one-by-one spawning
                    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.