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.
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.
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.
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.
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.
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:
Rename it to GameManager
and assign it the tag
GameManager
.
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
script.
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.
GemData
script.
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
.
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.
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.
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.
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.
GemsArray
filled with prefabs.
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.
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.
MoveGem
script attached.
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.
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.
GridChecker
script attached.
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.
RefillGrid
script attached.
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.