Match-3 na Unity: Grade, Troca e Combinação
Um unity match 3 jogável se apoia num princípio: o tabuleiro de verdade é um array 2D de números, e as gemas na tela só refletem esse array. Com isso separado, você implementa uma grade 8x8, gera o tabuleiro inicial sem nenhuma combinação pronta, troca peças com animação, detecta linhas de 3 ou mais, remove, faz as peças caírem, repõe por cima e repete em cascata até o tabuleiro estabilizar. Este guia faz tudo isso em C# para o Unity 6.3 LTS, usando Awaitable para sequenciar as animações.
O erro que derruba a maioria dos primeiros match-3, aceitar input durante a animação, é resolvido com uma linha, e você vai ver onde ela entra.
Como funciona um match-3 por dentro
Por baixo das gemas brilhantes, um match-3 é um laço:
- O jogador troca duas peças vizinhas.
- O jogo procura combinações (3 ou mais iguais em linha ou coluna).
- Se não achou, desfaz a troca.
- Se achou, remove as peças combinadas e soma pontos.
- As peças acima caem para ocupar os buracos.
- Peças novas entram pelo topo.
- Volta ao passo 2: a queda pode ter criado combinações novas (a cascata).
- Quando não há mais combinação, devolve o controle ao jogador.
Os passos 2 a 7 são lógica pura sobre um array. A animação acontece depois de cada passo, mas não decide nada. Esse jeito de separar regra e apresentação é o que a lógica de programação para jogos chama de pensar no estado antes de pensar na tela.
Grade em array 2D
O tabuleiro lógico é um int[,] onde cada número é um tipo de peça (0 a 5 para seis cores) e -1 é célula vazia. Ao lado dele, um Gem[,] guarda a referência para o GameObject de cada célula.
Arquivo Gem.cs (no prefab da gema, com um SpriteRenderer e um BoxCollider2D do tamanho da célula):
using UnityEngine;
[RequireComponent(typeof(SpriteRenderer))]
public class Gem : MonoBehaviour
{
[SerializeField] private Sprite[] sprites;
private SpriteRenderer spriteRenderer;
public int Type { get; private set; }
public Vector2Int Cell { get; set; }
private void Awake()
{
spriteRenderer = GetComponent<SpriteRenderer>();
}
public void SetType(int type)
{
Type = type;
spriteRenderer.sprite = sprites[type];
}
}
A posição na tela é calculada a partir da célula: origem + new Vector3(x, y) * tamanhoDaCélula. Use uma câmera Orthographic com Size 5 e um tabuleiro de 8 células de 1 unidade, centralizado com origem em (-3,5, -3,5). Ele cabe na tela de um celular em pé com sobra para a interface.
Gerar tabuleiro sem combinação inicial
Se o tabuleiro já nasce com três iguais, o jogo começa sozinho com uma cascata que o jogador não causou. O jeito ingênuo é gerar aleatório e sortear de novo até não haver combinação, o que pode levar muitas tentativas. O jeito certo é impedir na hora de preencher.
O preenchimento vai coluna por coluna da esquerda para a direita e, em cada coluna, de baixo para cima. Quando você chega na célula (x, y), as células à direita e acima ainda estão vazias. Então só duas situações formam combinação: as duas peças à esquerda são iguais ao tipo sorteado, ou as duas abaixo são. Basta excluir esses tipos do sorteio. O método abaixo faz isso; ele entra no arquivo BoardMatches.cs, mostrado completo mais adiante, então não cole duas vezes.
private int PickTypeWithoutMatch(int x, int y)
{
List<int> options = new List<int>();
for (int t = 0; t < gemTypes; t++)
{
bool leftMatch = x >= 2 && board[x - 1, y] == t && board[x - 2, y] == t;
bool downMatch = y >= 2 && board[x, y - 1] == t && board[x, y - 2] == t;
if (!leftMatch && !downMatch) options.Add(t);
}
return options[Random.Range(0, options.Count)];
}
Com 6 tipos, no máximo 2 são excluídos, então sempre sobram ao menos 4 opções. Com 3 tipos, poderia sobrar 1 só, o que deixa o tabuleiro previsível: use pelo menos 5 tipos num 8x8.
Match-3 na Unity: trocar peças
O jogador arrasta de uma gema para a vizinha. O Input System trata mouse e toque pelo mesmo Pointer.current, então o mesmo código serve para PC e celular.
Arquivo Board.cs, com toda a lógica (os métodos de busca, queda e animação aparecem nas próximas seções):
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
public partial class Board : MonoBehaviour
{
[SerializeField] private int width = 8;
[SerializeField] private int height = 8;
[SerializeField] private int gemTypes = 6;
[SerializeField] private Gem gemPrefab;
[SerializeField] private Vector2 origin = new Vector2(-3.5f, -3.5f);
[SerializeField] private float swapDuration = 0.15f;
[SerializeField] private float fallDuration = 0.2f;
private int[,] board;
private Gem[,] gems;
private bool busy;
private Vector2Int? dragStart;
private Camera cam;
private int score;
private void Start()
{
cam = Camera.main;
board = new int[width, height];
gems = new Gem[width, height];
for (int x = 0; x < width; x++)
{
for (int y = 0; y < height; y++)
{
board[x, y] = -1;
}
}
for (int x = 0; x < width; x++)
{
for (int y = 0; y < height; y++)
{
board[x, y] = PickTypeWithoutMatch(x, y);
gems[x, y] = CreateGem(x, y, board[x, y], CellToWorld(x, y));
}
}
}
private Vector3 CellToWorld(int x, int y)
{
return new Vector3(origin.x + x, origin.y + y, 0f);
}
private Gem CreateGem(int x, int y, int type, Vector3 position)
{
Gem gem = Instantiate(gemPrefab, position, Quaternion.identity, transform);
gem.SetType(type);
gem.Cell = new Vector2Int(x, y);
return gem;
}
private void Update()
{
if (busy) return;
Pointer pointer = Pointer.current;
if (pointer == null) return;
Vector2 screen = pointer.position.ReadValue();
Vector3 world = cam.ScreenToWorldPoint(new Vector3(screen.x, screen.y, -cam.transform.position.z));
Vector2Int cell = new Vector2Int(Mathf.RoundToInt(world.x - origin.x), Mathf.RoundToInt(world.y - origin.y));
if (pointer.press.wasPressedThisFrame && IsInside(cell))
{
dragStart = cell;
}
if (dragStart.HasValue && pointer.press.isPressed && cell != dragStart.Value)
{
Vector2Int delta = cell - dragStart.Value;
Vector2Int dir = Mathf.Abs(delta.x) > Mathf.Abs(delta.y)
? new Vector2Int((int)Mathf.Sign(delta.x), 0)
: new Vector2Int(0, (int)Mathf.Sign(delta.y));
Vector2Int target = dragStart.Value + dir;
Vector2Int from = dragStart.Value;
dragStart = null;
if (IsInside(target))
{
_ = SwapAndResolve(from, target);
}
}
if (pointer.press.wasReleasedThisFrame)
{
dragStart = null;
}
}
private bool IsInside(Vector2Int c)
{
return c.x >= 0 && c.y >= 0 && c.x < width && c.y < height;
}
private void SwapData(Vector2Int a, Vector2Int b)
{
(board[a.x, a.y], board[b.x, b.y]) = (board[b.x, b.y], board[a.x, a.y]);
(gems[a.x, a.y], gems[b.x, b.y]) = (gems[b.x, b.y], gems[a.x, a.y]);
gems[a.x, a.y].Cell = a;
gems[b.x, b.y].Cell = b;
}
}
O arraste converte a posição do ponteiro para célula, e quando o dedo sai da célula inicial, a direção dominante (horizontal ou vertical) define a vizinha. Isso aceita arrastes tortos e nunca troca na diagonal. A classe é partial para dividir o código em arquivos menores; os outros pedaços ficam em arquivos com outro nome, todos declarando public partial class Board. Só o arquivo Board.cs precisa ter o nome da classe.
Detectar combinações
A busca percorre cada linha e cada coluna contando sequências do mesmo tipo. Quando uma sequência termina com 3 ou mais, marca todas as células num bool[,]. Marcar em vez de remover na hora permite pegar combinações em L e em T, em que uma célula pertence a uma linha e a uma coluna ao mesmo tempo.
Arquivo BoardMatches.cs:
using System.Collections.Generic;
using UnityEngine;
public partial class Board
{
private bool[,] FindMatches()
{
bool[,] matched = new bool[width, height];
for (int y = 0; y < height; y++)
{
int run = 1;
for (int x = 1; x <= width; x++)
{
bool same = x < width && board[x, y] != -1 && board[x, y] == board[x - 1, y];
if (same) { run++; continue; }
if (run >= 3)
{
for (int k = x - run; k < x; k++) matched[k, y] = true;
}
run = 1;
}
}
for (int x = 0; x < width; x++)
{
int run = 1;
for (int y = 1; y <= height; y++)
{
bool same = y < height && board[x, y] != -1 && board[x, y] == board[x, y - 1];
if (same) { run++; continue; }
if (run >= 3)
{
for (int k = y - run; k < y; k++) matched[x, k] = true;
}
run = 1;
}
}
return matched;
}
private static int CountTrue(bool[,] grid)
{
int count = 0;
foreach (bool b in grid) if (b) count++;
return count;
}
private int PickTypeWithoutMatch(int x, int y)
{
List<int> options = new List<int>();
for (int t = 0; t < gemTypes; t++)
{
bool leftMatch = x >= 2 && board[x - 1, y] == t && board[x - 2, y] == t;
bool downMatch = y >= 2 && board[x, y - 1] == t && board[x, y - 2] == t;
if (!leftMatch && !downMatch) options.Add(t);
}
return options[Random.Range(0, options.Count)];
}
}
O laço vai até x <= width (uma posição além do fim) de propósito: é o jeito de fechar a última sequência da linha sem repetir o código de checagem depois do laço.
Com troca e detecção funcionando, você tem a mecânica que define o gênero. O que separa isso de um match-3 publicado é muito trabalho em volta: fases com objetivo, peças especiais, tela de vitória, efeitos, som, progressão e build otimizada para celular. Cada camada mexe no fluxo de cascata, e é aqui que quem faz sozinho costuma travar.
Remover, cair e repor
Arquivo BoardResolve.cs:
using System.Collections.Generic;
using UnityEngine;
public partial class Board
{
private async Awaitable SwapAndResolve(Vector2Int a, Vector2Int b)
{
busy = true;
SwapData(a, b);
await AnimateToCells(swapDuration);
bool[,] matched = FindMatches();
if (CountTrue(matched) == 0)
{
SwapData(a, b);
await AnimateToCells(swapDuration);
busy = false;
return;
}
int combo = 1;
while (CountTrue(matched) > 0)
{
score += CountTrue(matched) * 10 * combo;
RemoveMatched(matched);
await Awaitable.WaitForSecondsAsync(0.1f, destroyCancellationToken);
CollapseAndRefill();
await AnimateToCells(fallDuration);
matched = FindMatches();
combo++;
}
busy = false;
}
private void RemoveMatched(bool[,] matched)
{
for (int x = 0; x < width; x++)
{
for (int y = 0; y < height; y++)
{
if (!matched[x, y]) continue;
Destroy(gems[x, y].gameObject);
gems[x, y] = null;
board[x, y] = -1;
}
}
}
private void CollapseAndRefill()
{
for (int x = 0; x < width; x++)
{
int writeY = 0;
for (int y = 0; y < height; y++)
{
if (board[x, y] == -1) continue;
if (y != writeY)
{
board[x, writeY] = board[x, y];
gems[x, writeY] = gems[x, y];
gems[x, writeY].Cell = new Vector2Int(x, writeY);
board[x, y] = -1;
gems[x, y] = null;
}
writeY++;
}
int spawnOffset = 0;
for (int y = writeY; y < height; y++)
{
int type = Random.Range(0, gemTypes);
board[x, y] = type;
Vector3 spawnPos = CellToWorld(x, height + spawnOffset);
gems[x, y] = CreateGem(x, y, type, spawnPos);
spawnOffset++;
}
}
}
private async Awaitable AnimateToCells(float duration)
{
List<Gem> moving = new List<Gem>();
List<Vector3> starts = new List<Vector3>();
foreach (Gem gem in gems)
{
if (gem == null) continue;
Vector3 target = CellToWorld(gem.Cell.x, gem.Cell.y);
if ((gem.transform.position - target).sqrMagnitude < 0.0001f) continue;
moving.Add(gem);
starts.Add(gem.transform.position);
}
float t = 0f;
while (t < 1f)
{
t = Mathf.Min(1f, t + Time.deltaTime / duration);
float eased = 1f - (1f - t) * (1f - t);
for (int i = 0; i < moving.Count; i++)
{
Vector3 target = CellToWorld(moving[i].Cell.x, moving[i].Cell.y);
moving[i].transform.position = Vector3.LerpUnclamped(starts[i], target, eased);
}
await Awaitable.NextFrameAsync(destroyCancellationToken);
}
}
}
A queda usa a técnica do "ponteiro de escrita": para cada coluna, percorre de baixo para cima e copia cada peça existente para a posição writeY, que só avança quando encontra peça. Os buracos sobem naturalmente para o topo. Depois, as células de writeY até o topo recebem peças novas, criadas acima do tabuleiro (height + spawnOffset) para que caiam para dentro da tela em vez de surgirem do nada.
O AnimateToCells move de uma vez todas as gemas que estão fora do lugar, com uma curva de desaceleração. Como a lógica já atualizou Cell de cada gema, a animação só precisa ir de onde a gema está até onde ela deveria estar.
Sobre Awaitable: é a classe do Unity 6 para escrever sequências com async e await. Awaitable.NextFrameAsync espera um frame e WaitForSecondsAsync espera um tempo, com o mesmo papel do yield return null e do WaitForSeconds em coroutines. O destroyCancellationToken cancela a espera se o tabuleiro for destruído, por exemplo ao trocar de cena. A documentação avisa que instâncias de Awaitable são reaproveitadas pela Unity e não devem ser aguardadas duas vezes no mesmo método. Se você prefere coroutines, o fluxo é idêntico com IEnumerator e yield return StartCoroutine(...), como mostra o guia de coroutines e WaitForSeconds.
Cascata e pontuação
A cascata é o while (CountTrue(matched) > 0) no SwapAndResolve. Depois de cada queda, a busca roda de novo; se as peças novas ou as que caíram formaram combinação, o laço remove, derruba e repõe outra vez. A variável combo cresce a cada volta e multiplica os pontos: 10 por peça na primeira combinação, 20 na segunda cascata, 30 na terceira. É essa multiplicação que faz a cascata parecer um prêmio.
Cuidado ao balancear: a reposição usa Random.Range puro, sem a proteção do tabuleiro inicial. Isso é intencional, porque as cascatas espontâneas são parte da graça. Se elas acontecerem demais, reduza para 5 tipos de gema em vez de 6 e veja o efeito contrário: menos tipos, mais cascatas.
Tabuleiro sem jogada possível
Depois da cascata, pode acontecer de nenhuma troca formar combinação. O jogador fica olhando para o tabuleiro sem saber que não há saída. A checagem é força bruta e barata num 8x8: para cada célula, simule a troca com a vizinha da direita e com a de cima no array (SwapData sem animar), rode FindMatches, desfaça a troca e pare no primeiro resultado positivo. São no máximo 112 trocas testadas, nada que pese num frame.
Se nenhuma troca funciona, embaralhe: recolha os tipos, sorteie de novo com a mesma regra do tabuleiro inicial e anime as gemas trocando de lugar. Esse mesmo teste serve para a dica que muitos jogos mostram depois de alguns segundos parados: a primeira troca válida encontrada é a que pisca na tela.
Erros comuns: input durante animação
- Clicar durante a animação quebra o tabuleiro. É o erro número um. A flag
busyviratrueno começo doSwapAndResolvee só volta afalsedepois da última cascata. OUpdateretorna logo na primeira linha enquanto ela está ligada. - Troca sem combinação fica feita. Faltou desfazer: trocar de novo os dados e animar de volta.
- Combinação em L conta peças duas vezes. Remover na hora da busca em vez de marcar num
bool[,]. - Peças surgindo no lugar em vez de cair. As novas foram criadas na célula final. Crie acima do tabuleiro e deixe a animação levar.
- Erro de referência nula depois de trocar de cena. A sequência async continuou rodando sem o tabuleiro. Passe
destroyCancellationTokenpara as esperas. - Arraste em diagonal trocando peças erradas. Use a direção dominante do arraste, não a célula final.
Próximo passo
Acrescente a peça especial de 4 em linha: quando uma sequência tem exatamente 4, em vez de remover a peça do meio, transforme-a numa gema que limpa a linha inteira quando combinada. Isso exige guardar o tamanho de cada sequência no FindMatches, um bom exercício de estender a busca sem quebrá-la. Para comparar a mesma mecânica em outra engine, veja o match-3 na Godot.
Perguntas frequentes
Como fazer um jogo tipo Candy Crush na Unity?
Guarde o tabuleiro num array 2D de tipos de peça, separado dos GameObjects visuais. A cada troca, procure sequências de 3 ou mais iguais nas linhas e colunas, remova, faça as peças de cima caírem, preencha o topo com peças novas e repita a busca até não haver mais combinação. A troca que não forma combinação é desfeita.
Como evitar combinações prontas no começo do match-3?
Ao preencher cada célula, sorteie entre os tipos que não completam três iguais com as duas peças à esquerda nem com as duas abaixo. Como o preenchimento vai da esquerda para a direita e de baixo para cima, só essas duas direções importam. O tabuleiro nasce limpo sem precisar sortear de novo.
Coroutine ou async para animar o match-3 na Unity 6?
Os dois funcionam. Coroutines são o caminho clássico com yield return. No Unity 6, a classe Awaitable permite escrever a mesma sequência com async e await, o que deixa o fluxo de troca, remoção, queda e cascata mais legível. Só não aguarde a mesma instância de Awaitable duas vezes, porque elas são reaproveitadas pela Unity.
Por que meu match-3 quebra quando clico rápido?
Porque o jogador consegue iniciar uma troca enquanto a anterior ainda está animando, e as duas mexem no mesmo array. Use uma flag de ocupado que bloqueia o input do começo da troca até o fim da última cascata.



