Voltar para o Blog
Quest Log

Match-3 no Godot 4: a Mecanica Basica Passo a Passo

Tabuleiro quadriculado de jogo match-3 com pecas coloridas alinhadas e codigo GDScript ao lado no editor do Godot 4.

Como montar a mecanica match-3 no Godot 4: representar o tabuleiro, trocar pecas, detectar combinacoes de 3, aplicar gravidade e preencher os vazios.

Jogos como Candy Crush e Bejeweled parecem simples por fora, mas por dentro escondem uma maquina de estados bem definida: trocar duas pecas, conferir se isso formou uma linha, apagar o que combinou, deixar o resto cair e preencher os buracos. Montar um match-3 no Godot 4 e um otimo exercicio de arquitetura 2D justamente porque obriga voce a separar com clareza a logica do tabuleiro da parte visual. Neste tutorial voce vai construir a mecanica match-3 central passo a passo, com GDScript tipado e comentado: como representar a grade em uma matriz, detectar o swap de pecas vizinhas, varrer combinacoes de 3 ou mais na horizontal e na vertical, remover as pecas combinadas e aplicar a gravidade que faz tudo cair e se preencher de novo.

Match-3 no Godot 4: a Mecanica Basica

A ideia central de qualquer jogo match-3 e que o estado real do jogo nao vive nos Sprites na tela. Ele vive em uma matriz de numeros. Cada celula guarda um identificador de cor (ou tipo) da peca. Os Sprites sao apenas um espelho visual dessa matriz. Quando voce internaliza isso, todo o resto fica simples: trocar duas pecas e trocar dois valores no array, detectar um match e contar valores iguais em sequencia, e a gravidade e empurrar valores para baixo. A tela so precisa redesenhar o que a matriz diz.

Antes de seguir, vale ter os fundamentos de cena 2D firmes. Se voce ainda esta engatinhando com nodes e a estrutura de um projeto, o guia de como fazer um jogo 2D no Godot cobre a base que vamos assumir aqui.

Representando o tabuleiro como matriz

O tabuleiro e uma grade de largura por altura. A forma mais direta de representar isso em GDScript e um Array de Arrays, onde cada posicao guarda um inteiro que identifica o tipo de peca. Vamos usar -1 para representar uma celula vazia, e 0 em diante para as cores.

Crie um node Node2D chamado Tabuleiro e cole o script abaixo. Comecamos definindo as dimensoes, as cores possiveis e a matriz em si.

extends Node2D

const COLUNAS: int = 8
const LINHAS: int = 8
const TAMANHO_CELULA: int = 64
const TIPOS: int = 5  # quantidade de cores diferentes
const VAZIO: int = -1

# A matriz logica do jogo. grade[x][y] guarda o tipo da peca.
# x e a coluna, y e a linha. Vazio = -1.
var grade: Array = []

var rng := RandomNumberGenerator.new()

func _ready() -> void:
    rng.randomize()
    _inicializar_grade()
    _preencher_inicial()

Repare que grade e tipada como Array e indexada por coluna e depois por linha (grade[x][y]). Manter essa convencao fixa o resto do tempo evita o erro classico de inverter linha com coluna no meio do codigo. O TAMANHO_CELULA so importa na hora de posicionar os Sprites, a logica em si nunca pensa em pixels.

A funcao de inicializacao cria as colunas vazias:

func _inicializar_grade() -> void:
    grade = []
    for x in COLUNAS:
        var coluna: Array = []
        for y in LINHAS:
            coluna.append(VAZIO)
        grade.append(coluna)

Preenchendo a grade sem matches de largada

Um detalhe que separa um match-3 amador de um polido: o tabuleiro inicial nao pode comecar com combinacoes ja formadas, senao o jogo resolve tudo sozinho antes do jogador tocar em nada. A solucao e, ao sortear cada peca, evitar repetir a mesma cor duas vezes seguidas a esquerda ou abaixo.

func _preencher_inicial() -> void:
    for x in COLUNAS:
        for y in LINHAS:
            grade[x][y] = _tipo_valido_para(x, y)

# Sorteia um tipo que nao forme match imediato com os vizinhos ja preenchidos.
func _tipo_valido_para(x: int, y: int) -> int:
    var proibidos: Array = []

    # Dois iguais a esquerda formariam trinca horizontal.
    if x >= 2 and grade[x - 1][y] == grade[x - 2][y]:
        proibidos.append(grade[x - 1][y])

    # Dois iguais abaixo formariam trinca vertical.
    if y >= 2 and grade[x][y - 1] == grade[x][y - 2]:
        proibidos.append(grade[x][y - 1])

    var tipo: int = rng.randi_range(0, TIPOS - 1)
    while tipo in proibidos:
        tipo = rng.randi_range(0, TIPOS - 1)
    return tipo

Aqui a logica olha apenas para as duas pecas ja colocadas a esquerda e abaixo da posicao atual. Se as duas forem iguais, aquela cor entra na lista de proibidos, porque uma terceira igual fecharia um trio. O while re-sorteia ate cair em uma cor permitida. Com 5 tipos sempre sobra opcao, entao o loop nunca trava.

Separando logica de tabuleiro da parte visual

Antes de continuar, uma decisao de arquitetura que vale ouro: tudo que vimos ate aqui mexe so com numeros. Nenhuma linha tocou em Sprite. Esse e o ponto. A logica do match-3 (matriz, deteccao, colapso) deve ser pura, funcionando como uma simulacao que voce poderia rodar no terminal. A camada visual e um observador que le a grade e desenha.

Na pratica, mantenha um dicionario que liga cada coordenada de celula ao seu Sprite na tela. Assim, quando a logica muda a matriz, voce sabe exatamente qual node atualizar ou animar.

# Mapeia Vector2i (coluna, linha) -> node visual da peca.
var pecas_visuais: Dictionary = {}

func _criar_visual(coord: Vector2i, tipo: int) -> void:
    var sprite := Sprite2D.new()
    sprite.texture = _textura_do_tipo(tipo)
    sprite.position = _celula_para_pixel(coord)
    add_child(sprite)
    pecas_visuais[coord] = sprite

# Converte coordenada de grade em posicao de tela.
func _celula_para_pixel(coord: Vector2i) -> Vector2:
    return Vector2(coord.x, coord.y) * TAMANHO_CELULA

A funcao _textura_do_tipo voce implementa carregando suas imagens (um Array de Texture2D indexado pelo tipo resolve). O importante e enxergar a fronteira: a logica fala em int e Vector2i, a visual fala em Sprite2D e Vector2. Quem ja montou movimento baseado em celulas reconhece o padrao. Se quiser aprofundar essa conversao entre coordenada de grade e pixels, o tutorial de movimento em grade no Godot trabalha exatamente essa ponte com local_to_map e map_to_local.

Detectando o swap de duas pecas vizinhas

O jogador interage trocando duas pecas adjacentes. A troca em si e trivial: e so trocar os dois valores na matriz. O cuidado esta em validar que as pecas sao realmente vizinhas (distancia de exatamente uma celula) e em desfazer a troca caso ela nao gere nenhum match.

# Troca os valores de duas celulas na matriz logica.
func _trocar(a: Vector2i, b: Vector2i) -> void:
    var temp: int = grade[a.x][a.y]
    grade[a.x][a.y] = grade[b.x][b.y]
    grade[b.x][b.y] = temp

# Confere se duas celulas sao vizinhas ortogonais (sem diagonal).
func _sao_vizinhas(a: Vector2i, b: Vector2i) -> bool:
    var dist: Vector2i = (a - b).abs()
    return (dist.x + dist.y) == 1

O uso de Vector2i paga aqui: subtrair duas coordenadas, tirar o valor absoluto e somar os componentes diz na hora se elas estao coladas. Soma 1 significa um passo na horizontal ou vertical, nunca diagonal.

O fluxo completo de uma jogada amarra tudo. Tente a troca, veja se ela criou matches e, se nao criou, desfaca para nao deixar o jogador "gastar" um movimento invalido.

func tentar_jogada(a: Vector2i, b: Vector2i) -> void:
    if not _sao_vizinhas(a, b):
        return

    _trocar(a, b)
    var combinacoes: Array = detectar_matches()

    if combinacoes.is_empty():
        # Troca nao gerou nada: desfaz e devolve as pecas.
        _trocar(a, b)
        return

    # Troca valida: resolve a cascata de matches e quedas.
    await _resolver_cascata()

A mecanica match-3: detectando combinacoes de 3 ou mais

Esse e o coracao do match-3. A estrategia confiavel e fazer duas varreduras independentes: uma horizontal, linha por linha, e outra vertical, coluna por coluna. Em cada uma voce conta sequencias de pecas do mesmo tipo. Quando uma sequencia atinge 3 ou mais, todas as suas posicoes vao para um conjunto de remocao.

Usar um conjunto (aqui simulado com um Dictionary ou checando duplicatas) e o que evita contar a mesma peca duas vezes quando ela participa de um match em formato de L ou T, onde uma linha horizontal cruza com uma vertical.

# Retorna um Array de Vector2i com todas as celulas que fazem parte de algum match.
func detectar_matches() -> Array:
    var combinadas: Array = []

    # Varredura horizontal: percorre cada linha contando sequencias.
    for y in LINHAS:
        var inicio: int = 0
        while inicio < COLUNAS:
            var tipo: int = grade[inicio][y]
            var fim: int = inicio
            # Avanca enquanto a proxima peca for igual e nao vazia.
            while fim + 1 < COLUNAS and grade[fim + 1][y] == tipo and tipo != VAZIO:
                fim += 1
            # Sequencia de 3 ou mais: marca todas as celulas.
            if tipo != VAZIO and (fim - inicio + 1) >= 3:
                for x in range(inicio, fim + 1):
                    _marcar(combinadas, Vector2i(x, y))
            inicio = fim + 1

    # Varredura vertical: mesma logica, percorrendo cada coluna.
    for x in COLUNAS:
        var inicio: int = 0
        while inicio < LINHAS:
            var tipo: int = grade[x][inicio]
            var fim: int = inicio
            while fim + 1 < LINHAS and grade[x][fim + 1] == tipo and tipo != VAZIO:
                fim += 1
            if tipo != VAZIO and (fim - inicio + 1) >= 3:
                for y in range(inicio, fim + 1):
                    _marcar(combinadas, Vector2i(x, y))
            inicio = fim + 1

    return combinadas

# Adiciona uma coordenada ao Array so se ela ainda nao estiver la.
func _marcar(lista: Array, coord: Vector2i) -> void:
    if not coord in lista:
        lista.append(coord)

A logica de cada varredura usa dois ponteiros: inicio marca onde uma possivel sequencia comeca e fim avanca enquanto encontra pecas iguais. Quando a sequencia quebra, voce sabe seu tamanho exato (fim - inicio + 1). Se for 3 ou mais, marca a faixa toda. Depois inicio pula para fim + 1 e o processo recomeca, garantindo que cada peca e visitada uma unica vez por varredura. O check tipo != VAZIO impede que celulas vazias (-1) sejam contadas como uma "cor" combinavel.

Próximo nível
Quer aprender isso na prática?

No CursoGame.Dev você sai dos tutoriais soltos e constrói jogos publicáveis, com trilha progressiva, quests práticas e feedback real.

Conhecer a plataforma
+500 alunos4.9/5Garantia 7 dias

Removendo as pecas combinadas

Com a lista de coordenadas combinadas em maos, remover e simples na matriz: marque cada uma como VAZIO. Na camada visual, e o momento de tocar uma animacao de explosao ou de fade antes de apagar o Sprite. A separacao de camadas brilha aqui, porque a logica nem precisa esperar a animacao terminar para considerar a peca removida.

func _remover_combinadas(combinadas: Array) -> void:
    for coord in combinadas:
        grade[coord.x][coord.y] = VAZIO
        # Camada visual: anima e remove o Sprite correspondente.
        if pecas_visuais.has(coord):
            var sprite: Sprite2D = pecas_visuais[coord]
            sprite.queue_free()
            pecas_visuais.erase(coord)

Em um jogo de verdade voce adicionaria pontuacao proporcional ao tamanho do match aqui, e talvez efeitos especiais para combos de 4 ou 5. Mas a mecanica nao precisa disso para funcionar.

Gravidade: fazendo as pecas cairem

Depois de abrir buracos, as pecas de cima precisam cair para ocupar os espacos vazios. A forma mais limpa de resolver isso e trabalhar coluna por coluna, de baixo para cima, com um "ponteiro de escrita" que aponta para a posicao vazia mais baixa disponivel.

# Aplica a gravidade: empurra as pecas existentes para baixo em cada coluna.
func _aplicar_gravidade() -> void:
    for x in COLUNAS:
        # escrita aponta para a linha mais baixa que precisa ser preenchida.
        var escrita: int = LINHAS - 1
        # Percorre de baixo para cima procurando pecas reais.
        for y in range(LINHAS - 1, -1, -1):
            if grade[x][y] != VAZIO:
                # Move a peca para a posicao de escrita atual.
                if y != escrita:
                    grade[x][escrita] = grade[x][y]
                    grade[x][y] = VAZIO
                escrita -= 1

A logica e mais simples do que parece. Para cada coluna, escrita comeca no fundo. Voce sobe lendo as celulas: toda vez que encontra uma peca real, joga ela na posicao de escrita e move o ponteiro uma casa para cima. As celulas vazias sao simplesmente puladas, entao no fim as pecas ficaram compactadas embaixo e os vazios restantes ficaram todos no topo da coluna, prontos para receber pecas novas.

Preenchendo os espacos vazios com novas pecas

Agora o topo de cada coluna tem celulas VAZIO que precisam de pecas novas. Como _aplicar_gravidade ja jogou todos os vazios para o topo, basta varrer cada coluna de cima para baixo e, enquanto encontrar vazio, sortear uma cor.

func _preencher_topo() -> void:
    for x in COLUNAS:
        for y in LINHAS:
            if grade[x][y] == VAZIO:
                var tipo: int = rng.randi_range(0, TIPOS - 1)
                grade[x][y] = tipo
                # Camada visual: cria a nova peca (idealmente caindo do topo).
                _criar_visual(Vector2i(x, y), tipo)

Repare que aqui nao tentamos evitar matches, ao contrario do preenchimento inicial. Isso e proposital: pecas novas podem formar combinacoes em cadeia, e e exatamente esse efeito cascata que da o sabor viciante do genero. Para sortear as cores novas usamos um RandomNumberGenerator proprio, mantendo o fluxo de acaso sob controle e reproduzivel se voce fixar o seed para testes.

Amarrando a cascata em loop

Os pedacos isolados ja existem. Falta o maestro que os toca na ordem certa, em loop, ate o tabuleiro parar de gerar matches. Esse e o ciclo: detectar, remover, aplicar gravidade, preencher e detectar de novo. Enquanto houver combinacao, a cascata continua.

func _resolver_cascata() -> void:
    var combinacoes: Array = detectar_matches()
    while not combinacoes.is_empty():
        _remover_combinadas(combinacoes)
        _aplicar_gravidade()
        _preencher_topo()
        # Pequena pausa para a animacao visual respirar entre os passos.
        await get_tree().create_timer(0.2).timeout
        # Re-detecta: as pecas novas podem ter formado outros matches.
        combinacoes = detectar_matches()

O while e o que cria as reacoes em cadeia. Uma jogada do jogador pode disparar tres ou quatro rodadas de remocao e queda em sequencia, cada uma alimentando a proxima. O await no timer da o respiro para as animacoes acontecerem antes da proxima varredura. Sem ele, toda a cascata resolveria em um unico quadro e o jogador veria apenas o resultado final, perdendo a graca visual.

Por que essa arquitetura aguenta crescer

O que montamos cabe em um arquivo, mas a divisao entre matriz logica e camada visual e o que permite escalar sem dor. Quer adicionar pecas especiais que explodem uma linha inteira? E uma regra nova na deteccao e na remocao, a gravidade nao muda. Quer um modo cronometrado ou objetivos de "limpe 20 pecas azuis"? Sao contadores que leem a matriz. Quer trocar a arte toda? Mexe so na camada de Sprites. A logica nunca soube qual era a textura.

Esse e o tipo de pensamento que separa quem copia um tutorial de quem entende a engenharia por tras dele. Se voce quer construir essa base de raciocinio com Godot de forma estruturada, com projeto guiado e feedback, vale conhecer o melhor curso de Godot para acelerar a curva sem ficar travado em bugs de arquitetura.

Proximo passo pratico

Pegue os trechos deste post e monte um script unico no seu Tabuleiro. Comece sem visual nenhum: imprima a matriz no terminal com print, force uma troca via codigo e confira no console se detectar_matches acha o trio, se a gravidade compacta a coluna e se o preenchimento fecha os buracos. Quando a logica estiver correta nos numeros, plugue os Sprites por cima. Depois adicione o input do mouse para o jogador selecionar e arrastar pecas, ligando o clique ao tentar_jogada. Em pouco tempo voce sai de uma matriz de inteiros para um match-3 jogavel, e com uma base limpa o suficiente para virar um jogo de verdade.

Perguntas frequentes

Preciso usar TileMap para fazer um match-3 no Godot?

Nao. O tabuleiro de um match-3 e melhor representado por uma matriz (Array de Arrays) em GDScript, separada da parte visual. O TileMap serve para cenarios, mas um match-3 precisa de controle fino de cada peca individual, entao instanciar Sprites ou cenas e ler a logica de uma matriz costuma ser mais simples e flexivel.

Como detectar combinacoes de 3 ou mais pecas?

Voce varre o tabuleiro em duas passagens, uma horizontal e uma vertical. Em cada linha e cada coluna voce conta quantas pecas iguais aparecem em sequencia. Quando a contagem chega a 3 ou mais, todas as posicoes daquela sequencia entram em um conjunto de pecas a remover. Usar um conjunto evita marcar a mesma peca duas vezes quando ela faz parte de um match em L ou T.

Como fazer as pecas cairem depois de remover um match?

A queda (gravidade) e feita coluna por coluna, de baixo para cima. Em cada coluna voce empurra as pecas existentes para as posicoes vazias mais baixas e, no topo, gera pecas novas para preencher o que sobrou. Esse processo de colapso roda em loop com a deteccao de matches ate o tabuleiro estabilizar.

Devo separar a logica da grade da parte visual?

Sim, essa e a decisao de arquitetura mais importante. A logica do tabuleiro (matriz, deteccao de match, colapso) deve funcionar so com numeros e Vector2i, sem depender de nodes. A parte visual apenas le esse estado e atualiza os Sprites. Isso facilita testar a logica, trocar a arte e evitar bugs dificeis.

Qual o melhor tipo de dado para as coordenadas da grade?

Use Vector2i para coordenadas de celula, onde x e a coluna e y a linha. Ele e um vetor de inteiros, perfeito para indexar a matriz sem erros de arredondamento de float, e deixa o codigo legivel ao trocar duas posicoes vizinhas ou somar deslocamentos.