How Can We Create Match 3 Puzzle Game In Unity.
Unity is a powerful and widely-used game development platform and engine known for its versatility, ease of use, and ability to create stunning interactive experiences across various platforms. With Unity, developers can build games, interactive applications, simulations, and more for a wide range of platforms, including mobile devices, consoles, desktops, and even virtual reality (VR) and augmented reality (AR) devices. Its cross-platform capabilities make it a popular choice for game developers, indie creators, and large studios alike.
Designing a user interface (UI) holds immense significance in video game development, as it facilitates the presentation of crucial information to players, such as health, ammo, and scores. The effectiveness of a game largely depends on the accessibility of this information. Equally vital is the dynamic updating of this data as players engage with the game. For instance, if a player accumulates points, the UI must promptly reflect this progress by updating the on-screen score.
A match-3 puzzle game is a type of video game that involves arranging objects on a game board to create matches of three or more identical items. The gameplay typically revolves around swapping adjacent objects on the board to align them in rows or columns, creating matches that then disappear from the board and score points for the player.
To create a project in Unity you will need to have Unity and Unity Hub installed. You can download it from Unity's website. To complete the project, follow this tutorial from start to end.
1. Create Project:
First, we open the Unity Hub and Create a 2D Project.
2. Creating The Board:
If the Game scene hasn't been opened yet, go ahead and open it now. Upon starting the scene, you'll notice that it features a simple blue background along with a score and move counter. But don't worry, we're about to improve that!
To begin, create a new empty GameObject and assign it the name "BoardManager". This BoardManager will play a pivotal role in generating the game board and ensuring it remains populated with tiles.
Now, navigate to the "Scripts" folder and locate "BoardManager.cs" within the "Board" and "Grid" subdirectories. Drag and drop this script onto the previously created "BoardManager" empty GameObject in the Hierarchy window.
With these steps completed, your setup should resemble the following:
Change Tag name as Board Manager and other properties according to requirements.
Now, let's delve into the code. Open up the "BoardManager.cs" script to review its current content:
public static BoardManager instance; // 1
public List<Sprite> characters = new List<Sprite>(); // 2
public GameObject tile; // 3
public int xSize, ySize; // 4
private GameObject[,] tiles; // 5
public bool IsShifting { get; set; } // 6
void Start () {
instance = GetComponent<BoardManager>(); // 7
Vector2 offset = tile.GetComponent<SpriteRenderer>().bounds.size;
CreateBoard(offset.x, offset.y); // 8
}
private void CreateBoard (float xOffset, float yOffset) {
tiles = new GameObject[xSize, ySize]; // 9
float startX = transform.position.x; // 10
float startY = transform.position.y;
for (int x = 0; x < xSize; x++) { // 11
for (int y = 0; y < ySize; y++) {
GameObject newTile = Instantiate(tile, new Vector3(startX + (xOffset * x), startY + (yOffset * y), 0), tile.transform.rotation);
tiles[x, y] = newTile;
}
}
}
To ensure that other scripts can access the functionality of "BoardManager.cs", the script employs the Singleton design pattern. This involves utilizing a static variable named "instance" which enables its invocation from any script within the project.
The script includes a list named "characters", which holds sprites serving as your tile pieces.
You'll utilize the "tile" game object prefab as the template to instantiate when creating the game board.
The dimensions of the board are defined by "xSize" and "ySize" representing the X and Y dimensions.
The "tiles" 2D array is employed to store the individual tiles that comprise the board. Additionally, there's an encapsulated boolean variable named "IsShifting". This variable is used to notify the game when a match is identified and the board requires refilling.
The "Start()" method ensures the singleton instance of the BoardManager is set up.
Invoke "CreateBoard()" within "Start()", passing the dimensions of the tile sprite size as arguments.
Inside the "CreateBoard()" function, the "tiles" 2D array is initialized.
Determine the initial positions for generating the board.
Iterate through "xSize" and "ySize", creating a new tile instance with each iteration to construct a grid of rows and columns.
Now, proceed to locate your character sprites in the "Sprites\Characters" directory within the Project window. Select the "BoardManager" GameObject within the Hierarchy window.
In the Inspector window, modify the "Character Size" value within the BoardManager script component to 7. This action will allocate space for seven elements within the "Characters" array and present corresponding slots in the Inspector window.
Subsequently, drag and drop each character sprite into the empty slots. Lastly, locate the "Tile" prefab within the "Prefabs" folder and assign it to the "Tile" slot as indicated.
Select BoardManager once more. Set the X Size to 8 and the Y Size to 12 in the BoardManager component of the Inspector window. You will be using a board of this size throughout this instruction.
Press "play". A board is formed, but oddly, it disappears from view:
The reason behind this arrangement is that the board generates tiles upwards and towards the right, commencing from the initial tile placed at the position of the BoardManager.
To address this issue, make an adjustment to the BoardManager's position so that it aligns with the bottom-left corner of your camera's field of view. Modify the BoardManager's X position to -2.66 and the Y position to -3.83.
Upon initiating the playback, you'll observe the improved layout. Nevertheless, the game's appeal would be limited if all the tiles featured the same content. Fortunately, there's a straightforward approach to introduce randomness and diversify the composition of the board.
3. Randomizing The Board:
Navigate to the BoardManager script and incorporate the following code lines within the CreateBoard method, immediately after the line tiles[x, y] = newTile;
newTile.transform.parent = transform; // 1
Sprite newSprite = characters[Random.Range(0, characters.Count)]; // 2
newTile.GetComponent<SpriteRenderer>().sprite = newSprite; // 3
These code lines encompass three essential functions:
Execute the game, and you'll witness a board that has been randomized:
However, you might have observed that your board has the potential to create a matching 3 combo right from the beginning, which somewhat diminishes the entertainment factor.
4. Prevent Repeating Tiles:
The board's generation starts upwards and then proceeds towards the right. To rectify the issue of an "automatic" matching 3 combo, you need to ascertain the sprite situated to the left of your newly created tile as well as the sprite below it.
To address this, introduce two Sprite variables within the CreateBoard method, positioned just above the double for-loops:
Sprite[] previousLeft = new Sprite[ySize];
Sprite previousBelow = null;
These variables will serve to retain references to adjacent tiles, facilitating the substitution of their characters. The concept is depicted in the image below:
The loop systematically traverses all tiles, starting from the bottom left corner and progressing one tile at a time. Each iteration captures the characters displayed to the left and below the present tile and eliminates them from the list of potential new characters. A random character is subsequently chosen from this list and assigned to both the left and bottom tiles. This approach ensures that a row of three identical characters will not appear right from the outset.
For this purpose, insert the following lines directly before the line containing Sprite newSprite = characters[Random.Range(0, characters.Count)];
List<Sprite> possibleCharacters = new List<Sprite>(); // 1
possibleCharacters.AddRange(characters); // 2
possibleCharacters.Remove(previousLeft[y]); // 3
possibleCharacters.Remove(previousBelow);
Additionally, replace the line:
Sprite newSprite = characters[Random.Range(0, characters.Count)];
with
Sprite newSprite = possibleCharacters[Random.Range(0, possibleCharacters.Count)];
This step involves selecting a new sprite from the list of possible characters and storing it.
Finally, append these lines right below newTile.GetComponent().sprite = newSprite;
previousLeft[y] = newSprite;
previousBelow = newSprite;
This assignment allocates the newSprite to both the tile on the left and the one below the current tile, to be utilized in the subsequent iteration of the loop.
Execute the game, and you'll observe your dynamically generated grid featuring diverse tiles without any recurring patterns!
5. Swapping Tiles:
One of the fundamental gameplay mechanics in Match 3 games involves the selection and swapping of adjacent tiles, aiming to create rows of 3 matching tiles. To implement this, additional scripting is required. Let's start by enabling the selection of tiles.
Open the Tile.cs script in a code editor. This script is already structured with relevant variables and two methods: Select and Deselect.
The Select method signifies that a specific tile has been chosen, leading to a change in the tile's color and triggering a selection sound effect. Conversely, the Deselect method restores the tile's original color and communicates that no object is currently selected.
What's currently missing is a mechanism for player interaction with the tiles. Using a left mouse click for controls seems to be a reasonable choice.
Unity conveniently provides a built-in MonoBehaviour method for this purpose: OnMouseDown.
Integrate the following method into Tile.cs, immediately following the Deselect method:
void OnMouseDown() {
// 1
if (render.sprite == null || BoardManager.instance.IsShifting) {
return;
}
if (isSelected) { // 2 Is it already selected?
Deselect();
} else {
if (previousSelected == null) { // 3 Is it the first tile selected?
Select();
} else {
previousSelected.Deselect(); // 4
}
}
}
Ensure that the game permits tile selections. There might be instances when you don't want players to select tiles, such as during game over or when a tile is empty.
The if (isSelected) condition decides whether the tile should be selected or deselected. If it's already selected, it will be deselected.
This code snippet checks if there's another tile already selected. When previousSelected is null, it implies that the current tile is the first one being selected, so it's then marked as selected.
If it's not the first tile to be selected, all tiles are deselected before proceeding with the swapping.
Save the script and return to the Unity editor. You should now have the ability to select and deselect tiles using left-click interactions.
Everything functioning smoothly so far? Fantastic! Now, let's proceed to implement the tile swapping mechanism.
Swapping Tiles:
Let's continue by opening the Tile.cs script and incorporating the following method named SwapSprite right below the OnMouseDown method:
private void SwapSprite(SpriteRenderer render2)
{
if (render2.sprite == GetComponent<SpriteRenderer>().sprite)
return;
Sprite tempSprite = render2.sprite;
render2.sprite = GetComponent<SpriteRenderer>().sprite;
GetComponent<SpriteRenderer>().sprite = tempSprite;
SoundManager.Instance.PlaySwapSound();
}
This method is designed to facilitate the swapping of sprites between two tiles. Here's how it operates:
With the SwapSprite method now integrated, you can invoke it from the OnMouseDown method. Add this line just above previousSelected.Deselect(); within the else statement of the OnMouseDown method:
SwapSprite(previousSelected.GetComponent<SpriteRenderer>());
This code ensures the actual swapping takes place once you've selected the second tile. After saving this script, return to the Unity editor, run the game, and give it a try! You should be able to select two tiles and witness them swapping positions seamlessly.
6. Finding Adjacent Tiles:
You've likely observed that any two tiles on the board can be swapped, making the game overly simple. To bring balance, you need to ensure that tiles can only be swapped with their adjacent counterparts.
So, how do you go about identifying tiles adjacent to a given tile?
Open up the Tile.cs script and introduce the following method right below the SwapSprite method:
private GameObject GetAdjacent(Vector2 castDir)
{
RaycastHit2D hit = Physics2D.Raycast(transform.position, castDir);
if (hit.collider != null)
return hit.collider.gameObject;
return null;
}
This method is designed to find a single adjacent tile by casting a ray in the specified direction castDir. If a tile is detected in that direction, its GameObject is returned.
Next, insert the following method right beneath the GetAdjacent method:
private List<GameObject> GetAllAdjacentTiles() {
List<GameObject> adjacentTiles = new List<GameObject>();
for (int i = 0; i < adjacentDirections.Length; i++) {
adjacentTiles.Add(GetAdjacent(adjacentDirections[i]));
}
return adjacentTiles;
}
This method leverages GetAdjacent() to generate a list of tiles surrounding the current tile. By iterating through all directions, it appends any adjacent tiles found to the adjacentTiles list.
With these new utility methods at your disposal, you can now enforce that tiles can only be swapped with their adjacent counterparts.
Replace the following code snippet within the OnMouseDown method:
Recommended by LinkedIn
else {
SwapSprite(previousSelected.render);
previousSelected.Deselect();
}
Call GetAllAdjacentTiles() and verify whether the previousSelected game object exists in the list of adjacent tiles returned.
If the condition holds true, proceed with swapping the tile sprites.
If the tile is not adjacent to the previously selected one, deselect the prior selection and instead select the newly chosen tile.
Save your modified script, return to the Unity editor, and run your game to ensure that everything functions as intended. You should now only be able to swap tiles that are adjacent to each other.
7. Matching:
The process of matching tiles can be broken down into several fundamental steps:
In the Tile.cs script, introduce the following method right beneath the GetAllAdjacentTiles method:
private List<GameObject> FindMatch(Vector2 castDir) { // 1
List<GameObject> matchingTiles = new List<GameObject>(); // 2
RaycastHit2D hit = Physics2D.Raycast(transform.position, castDir); // 3
while (hit.collider != null && hit.collider.GetComponent<SpriteRenderer>().sprite == render.sprite) { // 4
matchingTiles.Add(hit.collider.gameObject);
hit = Physics2D.Raycast(hit.collider.transform.position, castDir);
}
return matchingTiles; // 5
}
Here's a breakdown of what's happening:
Additionally, at the top of the script, add the following boolean variable just before the Awake method:
private bool matchFound = false;
This variable will be set to true when a match is identified. Then, incorporate the subsequent method right below the FindMatch method:
private void ClearMatch(Vector2[] paths) // 1
{
List<GameObject> matchingTiles = new List<GameObject>(); // 2
for (int i = 0; i < paths.Length; i++) // 3
{
matchingTiles.AddRange(FindMatch(paths[i]));
}
if (matchingTiles.Count >= 2) // 4
{
for (int i = 0; i < matchingTiles.Count; i++) // 5
{
matchingTiles[i].GetComponent<SpriteRenderer>().sprite = null;
}
matchFound = true; // 6
}
}
This method is instrumental in detecting and clearing matches in various directions:
Following this, you need to clear the tiles. Add the subsequent method right beneath the ClearMatch method:
public void ClearAllMatches() {
if (render.sprite == null)
return;
ClearMatch(new Vector2[2] { Vector2.left, Vector2.right });
ClearMatch(new Vector2[2] { Vector2.up, Vector2.down });
if (matchFound) {
render.sprite = null;
matchFound = false;
SFXManager.instance.PlaySFX(Clip.Clear);
}
}
Here's a breakdown of this method:
For this process to function effectively, you need to call ClearAllMatches() every time a swap is executed. In the OnMouseDown method, insert the subsequent line just before previousSelected.Deselect();
previousSelected.ClearAllMatches();
Following that line, add the code below:
ClearAllMatches();
Remember, you must apply ClearAllMatches() to both the previous selected tile and the current tile to account for the possibility that both may have a match.
Save the script, head back to the Unity editor, and press the play button to test the matching mechanics. If you align three tiles of the same type, they will vanish. As the final touch, you'll work on filling the emptied spaces by shifting and refilling the board.
8. Shifting and Refilling Tiles:
Before shifting the tiles, it's crucial to identify the empty ones. Open the BoardManager.cs script and introduce the subsequent coroutine just below the CreateBoard method:
public IEnumerator FindNullTiles() {
for (int x = 0; x < xSize; x++) {
for (int y = 0; y < ySize; y++) {
if (tiles[x, y].GetComponent<SpriteRenderer>().sprite == null) {
yield return StartCoroutine(ShiftTilesDown(x, y));
break;
}
}
}
}
Please note: Upon adding this coroutine, you might encounter an error regarding ShiftTilesDown not existing. Disregard this error, as you will be adding the ShiftTilesDown coroutine in the following steps!
This coroutine systematically scans the entire board to locate tiles with null sprites. Whenever an empty tile is identified, it triggers another coroutine, ShiftTilesDown, to handle the actual shifting.
Now, add the subsequent coroutine right below the previous one:
private IEnumerator ShiftTilesDown(int x, int yStart, float shiftDelay = .03f) {
IsShifting = true;
List<SpriteRenderer> renders = new List<SpriteRenderer>();
int nullCount = 0;
for (int y = yStart; y < ySize; y++) { // 1
SpriteRenderer render = tiles[x, y].GetComponent<SpriteRenderer>();
if (render.sprite == null) { // 2
nullCount++;
}
renders.Add(render);
}
for (int i = 0; i < nullCount; i++) { // 3
yield return new WaitForSeconds(shiftDelay);// 4
for (int k = 0; k < renders.Count - 1; k++) { // 5
renders[k].sprite = renders[k + 1].sprite;
renders[k + 1].sprite = null; // 6
}
}
IsShifting = false;
}
The ShiftTilesDown coroutine operates with X and Y positions as well as a delay. While X remains constant, Y is modified to ensure tiles shift downward.
The coroutine carries out the following steps:
To ensure that the FindNullTiles coroutine is halted and restarted whenever a match occurs, save the BoardManager script and open Tile.cs. Within the ClearAllMatches() method, add the subsequent lines just before SFXManager.instance.PlaySFX(Clip.Clear);
StopCoroutine(BoardManager.instance.FindNullTiles());
StartCoroutine(BoardManager.instance.FindNullTiles());
This will cease the FindNullTiles coroutine and then restart it from the beginning.
After making these changes, save the script and return to the Unity editor. Begin the game again and initiate some matches. You'll observe that the board depletes of tiles as matches occur. To maintain an endless board, it's essential to refill it as tiles are cleared.
Open BoardManager.cs and insert the subsequent method below ShiftTilesDown:
private Sprite GetNewSprite(int x, int y) {
List<Sprite> possibleCharacters = new List<Sprite>();
possibleCharacters.AddRange(characters);
if (x > 0) {
possibleCharacters.Remove(tiles[x - 1, y].GetComponent<SpriteRenderer>().sprite);
}
if (x < xSize - 1) {
possibleCharacters.Remove(tiles[x + 1, y].GetComponent<SpriteRenderer>().sprite);
}
if (y > 0) {
possibleCharacters.Remove(tiles[x, y - 1].GetComponent<SpriteRenderer>().sprite);
}
return possibleCharacters[Random.Range(0, possibleCharacters.Count)];
}
This code snippet generates a list of potential characters that could be used to refill the sprite. By utilizing conditional statements, it ensures that bounds are not exceeded. Inside these statements, it eliminates possible duplicates that might inadvertently cause a match when selecting a new sprite. Ultimately, a random sprite is returned from the list of potential sprites.
Within the ShiftTilesDown coroutine, replace:
renders[k + 1].sprite = null;
with:
renders[k + 1].sprite = GetNewSprite(x, ySize - 1);
This modification ensures that the board remains continuously filled.
When matches are established and tiles shift, there's the possibility of forming another match. Theoretically, this chain could continue indefinitely. To address this, you need to keep checking for potential matches until the board no longer has any possible matches.
9. Combos:
To ensure you catch any potential combos that might have formed during the shifting of tiles, you need to re-examine all the tiles after a match is detected. Open the BoardManager.cs script and locate the FindNullTiles() method.
Now, append the following code snippet at the end of the method, positioned below the nested for loops:
for (int x = 0; x < xSize; x++) {
for (int y = 0; y < ySize; y++) {
tiles[x, y].GetComponent<Tile>().ClearAllMatches();
}
}
By implementing this final step, you can confidently verify that your game mechanics are functioning as expected.
With your work meticulously saved, run the game. Begin to swap tiles and marvel at the seamless influx of new tiles that consistently replenish the board as you continue to play.
10. Moving the Counter and Keeping Score:
Let's move forward by keeping track of the player's moves and their score. Open the GUIManager.cs file located in the Scripts\Managers directory using your preferred code editor. This script takes care of the game's UI elements, including the move counter and score display.
To begin, introduce the following variable at the top of the script, right below private int score;
private int moveCounter;
Now, within the Awake() method, initialize the number of player moves with the following lines of code:
moveCounter = 60;
moveCounterTxt.text = moveCounter.ToString();
Now you need to encapsulate both integers so you can update the UI Text every time you update the value. Add the following code right above the Awake() method:
public int Score {
get {
return score;
}
set {
score = value;
scoreTxt.text = score.ToString();
}
}
public int MoveCounter {
get {
return moveCounter;
}
set {
moveCounter = value;
moveCounterTxt.text = moveCounter.ToString();
}
}
These lines ensure that whenever the Score or MoveCounter variables are modified, the corresponding text components displaying them will be updated as well. This approach is more efficient for performance since it involves dealing with strings.
Now, it's time to implement scoring and move tracking. Whenever the player clears a tile, they should earn points. Save your current script and switch to BoardManager.cs. Insert the following code snippet into the ShiftTilesDown method, just above yield return new WaitForSeconds(shiftDelay);
GUIManager.instance.Score += 50;
This line increases the player's score every time an empty tile is encountered.
Moving on to the Tile.cs script, add the following line right after SFXManager.instance.PlaySFX(Clip.Swap);
within the
ClearAllMatches method:
GUIManager.instance.MoveCounter--;
This will decrement MoveCounter every time a sprite is swapped.
These property setters ensure that the text components are updated whenever the corresponding variables are modified. This way, you maintain a clear and efficient connection between game logic and UI presentation.
11. Game Over Screen:
To ensure that the game ends when the move counter reaches zero, follow these steps:
Open the GUIManager.cs script, and within the setter for MoveCounter, add the following if statement just below moveCounter = value;
if (moveCounter <= 0) {
moveCounter = 0;
GameOver();
}
This statement checks if the move counter has reached or fallen below zero. If this condition is met, it starts a coroutine to wait for the board to finish shifting before triggering the game over sequence.
Now, let's create the WaitForBoardShift coroutine. Add the following code beneath the GameOver method in GUIManager.cs:
private IEnumerator WaitForShifting() {
yield return new WaitUntil(()=> !BoardManager.instance.IsShifting);
yield return new WaitForSeconds(.25f);
GameOver();
}
Now replace the following line in the MoveCounter setter:
GameOver();
With:
StartCoroutine(WaitForShifting());
This coroutine waits for the BoardManager to finish shifting all tiles. It continuously checks the IsShifting flag. Once the shifting is complete (IsShifting becomes false), it proceeds to call the GameOver() method.
By incorporating these changes, the game will conclude only after the move counter reaches zero, and all combos are calculated before triggering the game over state. This ensures that players are rewarded for their strategic matches and the game's conclusion is accurate.
If you like the article please 👍 it, wants to refer somebody 📤 with him/her. We also provide Services of 2D/3D Game Development, 2D/3D Animations,Video Editing, UI/UX Designing.
If you have questions or suggestions about the game or want something to build from us, Feel free to reach out to us anytime!
📱 Mobile: +971 544 614 238
📧 Email: wahhab_mirza@vectorlabzlimited.com