Voltar para o Blog
Quest Log

Como Fazer um Jogo de Xadrez na Godot 4: Lógica Pura em GDScript

Tabuleiro de xadrez digital feito na Godot 4, com casas destacadas mostrando os movimentos legais de uma peça selecionada

Como fazer um jogo de xadrez na Godot 4 com GDScript tipado: tabuleiro em array 2D, movimentos legais por peça, captura e xeque, tudo passo a passo.

Se você quer treinar lógica de programação de verdade, aprender como fazer um jogo de xadrez é um dos melhores exercícios que existem. As regras já estão prontas há séculos, todo mundo conhece o objetivo e não há nenhuma decisão de game design para tomar. Sobra o que interessa: representar estado, validar movimento e manter a lógica separada do visual. Neste tutorial, vamos montar um xadrez funcional na Godot 4 com GDScript tipado, do tabuleiro ao xeque, e ser honestos sobre o que fica de fora.

Por que o xadrez é um projeto de aprendizado tão bom

A maioria dos projetos de iniciante falha por excesso de decisões abertas: qual mecânica, qual arte, qual escopo. O xadrez corta tudo isso. As regras são fixas, a "arte" é um tabuleiro de duas cores e o escopo é conhecido antes de você abrir a engine. Se mesmo assim ele parecer grande demais para o seu momento, comece um degrau abaixo com um jogo da velha na Godot e volte aqui com a base pronta.

O que o xadrez exige em troca é disciplina de lógica:

  • Estado puro: o jogo inteiro cabe em uma estrutura de dados de 64 casas. Se o seu array está certo, o jogo está certo.
  • Validação de regras: cada peça tem um padrão de movimento diferente, e você vai implementar todos.
  • Separação lógica/visual: o tabuleiro que o jogador vê é só um espelho do array. Essa separação é a lição mais valiosa do projeto e vale para qualquer jogo que você fizer depois.

Já falamos dessa filosofia no guia de como fazer um jogo de tabuleiro digital, que cobre a escolha do jogo base e a estrutura de turnos. Aqui vamos direto ao caso mais famoso de todos.

Como fazer um jogo de xadrez: a representação do tabuleiro

Antes de qualquer pixel na tela, decida como o tabuleiro vive em memória. As duas opções clássicas são um array 2D de 8x8 ou um dicionário de posição para peça. O array é mais didático e é o que vamos usar. Primeiro, a peça:

class_name Peca
extends RefCounted

enum Tipo { PEAO, TORRE, CAVALO, BISPO, DAMA, REI }
enum Cor { BRANCA, PRETA }

var tipo: Tipo
var cor: Cor

func _init(novo_tipo: Tipo, nova_cor: Cor) -> void:
    tipo = novo_tipo
    cor = nova_cor

Um RefCounted com dois enums resolve. Nada de nó de cena aqui: peça é dado, não visual. Agora o tabuleiro, em um script principal:

extends Node2D

var tabuleiro: Array[Array] = []

func _ready() -> void:
    criar_tabuleiro()
    posicionar_pecas()

func criar_tabuleiro() -> void:
    tabuleiro.clear()
    for x in 8:
        var coluna: Array = []
        coluna.resize(8)
        tabuleiro.append(coluna)

func peca_em(casa: Vector2i) -> Peca:
    return tabuleiro[casa.x][casa.y]

func casa_valida(casa: Vector2i) -> bool:
    return casa.x >= 0 and casa.x < 8 and casa.y >= 0 and casa.y < 8

A convenção usada aqui: Vector2i(x, y) onde x é a coluna e y é a fileira, com y = 0 no topo. As pretas começam em cima (fileiras 0 e 1) e as brancas embaixo (fileiras 6 e 7). Casas vazias ficam como null. A função posicionar_pecas é um loop que preenche as fileiras iniciais: peões nas fileiras 1 e 6, e a ordem torre, cavalo, bispo, dama, rei, bispo, cavalo, torre nas fileiras 0 e 7.

Escolher Vector2i para as casas não é detalhe. Ele soma direto (casa + direcao), compara por igualdade sem erro de ponto flutuante e é o mesmo tipo que a Godot usa em grids e tilemaps. Se um dia você quiser dar inteligência de movimento a outro jogo em grade, o AStarGrid2D para pathfinding em grid trabalha exatamente com esse tipo.

Desenhando o tabuleiro na tela

Para um grid fixo de 8x8, a solução mais simples é um GridContainer com 64 ColorRect, alternando as cores. TileMapLayer também funciona, mas o container te dá o layout de graça:

@onready var grid: GridContainer = $CanvasLayer/Grid

const TAMANHO_CASA: int = 64
const COR_CLARA: Color = Color("f0d9b5")
const COR_ESCURA: Color = Color("b58863")

func desenhar_tabuleiro() -> void:
    grid.columns = 8
    for y in 8:
        for x in 8:
            var casa: ColorRect = ColorRect.new()
            casa.custom_minimum_size = Vector2(TAMANHO_CASA, TAMANHO_CASA)
            casa.color = COR_CLARA if (x + y) % 2 == 0 else COR_ESCURA
            grid.add_child(casa)

As peças visuais podem ser Sprite2D ou até Label com caracteres Unicode de xadrez enquanto você não tem arte. O ponto crucial: o visual nunca é a fonte da verdade. Depois de cada jogada, você percorre o array e redesenha (ou reposiciona) as peças. Se visual e array discordarem, o array vence.

Selecionar e mover: do clique à casa

Converter o clique do mouse em uma casa do tabuleiro é uma divisão inteira:

func _unhandled_input(event: InputEvent) -> void:
    if event is InputEventMouseButton \
            and event.pressed \
            and event.button_index == MOUSE_BUTTON_LEFT:
        var local: Vector2 = grid.get_local_mouse_position()
        var casa: Vector2i = Vector2i(local / float(TAMANHO_CASA))
        if casa_valida(casa):
            ao_clicar(casa)

A lógica de seleção é uma máquina de dois estados: nenhuma peça selecionada ou uma peça selecionada.

var casa_selecionada: Vector2i = Vector2i(-1, -1)
var destinos_validos: Array[Vector2i] = []

func ao_clicar(casa: Vector2i) -> void:
    if casa_selecionada == Vector2i(-1, -1):
        var peca: Peca = peca_em(casa)
        if peca != null and peca.cor == cor_da_vez:
            casa_selecionada = casa
            destinos_validos = movimentos_legais(casa)
            destacar_casas(destinos_validos)
    else:
        if casa in destinos_validos:
            mover_peca(casa_selecionada, casa)
        limpar_selecao()

Primeiro clique seleciona (se houver peça da cor da vez na casa), segundo clique move (se o destino for legal) ou cancela. Simples e suficiente.

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

Gerando movimentos legais por peça

Aqui mora o coração do projeto. Cada peça é um padrão diferente, mas quase tudo se resume a duas técnicas: deslizar em direções até colidir e testar offsets fixos.

Torre, bispo e dama: deslizar até colidir

Pense como um raycast em grade: a partir da casa da peça, avance em uma direção casa por casa. Casa vazia entra na lista. Casa com inimigo entra na lista e para (captura). Casa com aliado para sem entrar.

const DIR_TORRE: Array[Vector2i] = [
    Vector2i.RIGHT, Vector2i.LEFT, Vector2i.UP, Vector2i.DOWN,
]
const DIR_BISPO: Array[Vector2i] = [
    Vector2i(1, 1), Vector2i(1, -1), Vector2i(-1, 1), Vector2i(-1, -1),
]

func movimentos_deslizantes(casa: Vector2i, direcoes: Array[Vector2i]) -> Array[Vector2i]:
    var resultado: Array[Vector2i] = []
    var minha_cor: Peca.Cor = peca_em(casa).cor
    for direcao: Vector2i in direcoes:
        var atual: Vector2i = casa + direcao
        while casa_valida(atual):
            var alvo: Peca = peca_em(atual)
            if alvo == null:
                resultado.append(atual)
            else:
                if alvo.cor != minha_cor:
                    resultado.append(atual)
                break
            atual += direcao
    return resultado

Com essa única função, três peças estão prontas: torre usa DIR_TORRE, bispo usa DIR_BISPO e a dama usa as duas listas somadas. O rei usa as mesmas oito direções, mas limitado a um passo (troque o while por um único teste).

Cavalo: offsets fixos

O cavalo não desliza, ele salta. São oito destinos possíveis, sempre os mesmos em relação à casa atual:

const SALTOS_CAVALO: Array[Vector2i] = [
    Vector2i(1, 2), Vector2i(2, 1), Vector2i(2, -1), Vector2i(1, -2),
    Vector2i(-1, -2), Vector2i(-2, -1), Vector2i(-2, 1), Vector2i(-1, 2),
]

func movimentos_cavalo(casa: Vector2i) -> Array[Vector2i]:
    var resultado: Array[Vector2i] = []
    var minha_cor: Peca.Cor = peca_em(casa).cor
    for salto: Vector2i in SALTOS_CAVALO:
        var destino: Vector2i = casa + salto
        if not casa_valida(destino):
            continue
        var alvo: Peca = peca_em(destino)
        if alvo == null or alvo.cor != minha_cor:
            resultado.append(destino)
    return resultado

Peão: o mais chato de todos

O peão é a peça mais simples de mover no tabuleiro real e a mais cheia de casos especiais no código: só anda para frente, a direção depende da cor, a captura é na diagonal e a primeira jogada pode ser dupla.

func movimentos_peao(casa: Vector2i) -> Array[Vector2i]:
    var resultado: Array[Vector2i] = []
    var peca: Peca = peca_em(casa)
    var direcao: int = -1 if peca.cor == Peca.Cor.BRANCA else 1
    var frente: Vector2i = casa + Vector2i(0, direcao)
    if casa_valida(frente) and peca_em(frente) == null:
        resultado.append(frente)
        var fileira_inicial: int = 6 if peca.cor == Peca.Cor.BRANCA else 1
        var frente_dupla: Vector2i = casa + Vector2i(0, direcao * 2)
        if casa.y == fileira_inicial and peca_em(frente_dupla) == null:
            resultado.append(frente_dupla)
    for dx: int in [-1, 1]:
        var diagonal: Vector2i = casa + Vector2i(dx, direcao)
        if casa_valida(diagonal):
            var alvo: Peca = peca_em(diagonal)
            if alvo != null and alvo.cor != peca.cor:
                resultado.append(diagonal)
    return resultado

Repare que a jogada dupla só existe se a casa da frente também estiver livre: peão não pula peça. Uma função movimentos_da_peca(casa: Vector2i) -> Array[Vector2i] com um match peca.tipo despacha para a função certa e fecha o quebra-cabeça.

Destacar casas, mover e capturar

Com destinos_validos em mãos, destacar é cosmético: guarde referência aos 64 ColorRect em um array paralelo e mude a cor (ou adicione um filho semitransparente) das casas da lista. Ao limpar a seleção, restaure as cores originais.

Mover e capturar é a mesma operação no array:

func mover_peca(origem: Vector2i, destino: Vector2i) -> void:
    var capturada: Peca = peca_em(destino)
    if capturada != null:
        registrar_captura(capturada)
    tabuleiro[destino.x][destino.y] = peca_em(origem)
    tabuleiro[origem.x][origem.y] = null
    trocar_turno()
    redesenhar_pecas()

Como o destino já passou pela validação de movimentos legais, chegar em uma casa ocupada por inimigo é captura por definição: a peça antiga simplesmente sai do array (e do placar, se você mantiver um).

Xeque básico: o rei está atacado?

A pergunta "estou em xeque?" tem resposta direta: alguma peça inimiga alcança a casa do meu rei?

func rei_em_xeque(cor: Peca.Cor) -> bool:
    var casa_rei: Vector2i = encontrar_rei(cor)
    for y in 8:
        for x in 8:
            var casa: Vector2i = Vector2i(x, y)
            var peca: Peca = peca_em(casa)
            if peca != null and peca.cor != cor:
                if casa_rei in movimentos_da_peca(casa):
                    return true
    return false

func movimento_seguro(origem: Vector2i, destino: Vector2i) -> bool:
    var peca: Peca = peca_em(origem)
    var capturada: Peca = peca_em(destino)
    tabuleiro[destino.x][destino.y] = peca
    tabuleiro[origem.x][origem.y] = null
    var seguro: bool = not rei_em_xeque(peca.cor)
    tabuleiro[origem.x][origem.y] = peca
    tabuleiro[destino.x][destino.y] = capturada
    return seguro

O truque do movimento_seguro é a simulação: aplica a jogada no array, pergunta se o rei ficou atacado e desfaz tudo antes de retornar. Filtre movimentos_legais com essa função e o jogador nunca conseguirá se colocar em xeque, que é a regra real do xadrez. Varrer 64 casas para cada teste não é elegante, mas em um tabuleiro deste tamanho é instantâneo. Otimização fica para quando (e se) precisar.

O que deixar para depois, com honestidade

Um xadrez "completo de verdade" tem regras que não valem a pena no primeiro passe. Deixe registradas como melhorias futuras:

  • Roque: movimento duplo de rei e torre com quatro condições (nenhum dos dois se moveu, caminho livre, rei não atravessa casa atacada).
  • En passant: captura de peão que depende do histórico da jogada anterior. Exige guardar o último movimento.
  • Promoção: peão que chega à última fileira vira outra peça. Simples de codar, mas pede interface de escolha.
  • Xeque-mate e afogamento: detectar mate completo significa verificar que nenhum movimento legal existe para o jogador da vez. É uma extensão natural do movimento_seguro, mas dobra o cuidado com os casos de empate.
  • IA de oponente: o caminho clássico é o algoritmo minimax com poda alfa-beta, avaliando posições algumas jogadas à frente. É assunto avançado que rende um projeto inteiro. Enquanto isso, um oponente que escolhe um movimento legal aleatório já deixa o jogo testável sozinho.

Nada disso é fracasso de escopo. Um xadrez jogável entre duas pessoas no mesmo teclado, com movimentos legais e xeque, já é um projeto de portfólio que demonstra exatamente o que empregadores e colegas de jam querem ver: lógica limpa e estado bem representado.

Próximos passos

Dois caminhos naturais depois do tabuleiro funcionar. O primeiro é transformar o xadrez em jogo online: como a lógica inteira vive em um array e cada jogada é só um par origem e destino, sincronizar dois jogadores pela API de multiplayer da Godot (com MultiplayerSynchronizer e RPCs enviando Vector2i) é bem mais tranquilo do que em um jogo de ação em tempo real. Jogo de turno perdoa latência.

O segundo é usar o projeto como argumento na sua decisão de engine. Se você chegou até aqui, percebeu que a Godot resolve interface, input e desenho sem atrapalhar a lógica, e o GDScript tipado pega erros de casa inválida antes de rodar. Se ainda está avaliando ferramentas, veja se vale a pena aprender Godot para entender onde a engine brilha e onde não.

O xadrez não vai ser o jogo mais bonito do seu portfólio. Vai ser, provavelmente, o mais bem programado. E essa é a habilidade que todos os outros projetos vão herdar.

Perguntas frequentes

Preciso saber jogar xadrez bem para programar um jogo de xadrez?

Não. Você precisa conhecer as regras de movimento de cada peça, que cabem em uma página. Programar o jogo é um exercício de lógica e estado, não de habilidade no tabuleiro. Aliás, muita gente entende as regras de verdade só depois de implementá-las.

Devo usar TileMap ou ColorRect para desenhar o tabuleiro de xadrez na Godot?

Para um tabuleiro fixo de 8x8, 64 ColorRect dentro de um GridContainer é a opção mais simples: você gera tudo em um loop e ganha o layout de graça. TileMapLayer vale mais quando o grid é grande, variável ou usa tiles de arte. Nos dois casos, a lógica do jogo deve viver em um array, nunca nos nós visuais.

Como converter a posição do mouse em uma casa do tabuleiro?

Pegue a posição do mouse relativa ao nó do tabuleiro com get_local_mouse_position(), divida pelo tamanho da casa em pixels e converta para Vector2i. O resultado é a coluna e a fileira clicadas. Valide se está dentro do intervalo 0 a 7 antes de usar.

Como detectar xeque no xadrez em código?

Gere os movimentos de todas as peças do adversário e verifique se alguma alcança a casa do rei. Para impedir jogadas ilegais, simule o movimento no array, rode essa verificação e desfaça a simulação. Se o rei ficaria atacado, o movimento é descartado.

Como fazer uma IA de oponente para o xadrez?

O caminho clássico é o algoritmo minimax com poda alfa-beta: a IA simula jogadas alternadas, avalia cada posição resultante e escolhe o melhor movimento. É um assunto avançado que merece projeto próprio. Antes dele, um oponente que joga um movimento legal aleatório já serve para testar o jogo.

Vale a pena fazer xadrez como primeiro projeto na Godot?

Como primeiro projeto absoluto, não: comece com algo menor, como um jogo da velha. Como segundo ou terceiro projeto, é excelente. O xadrez elimina o design de regras da equação e concentra o esforço em representação de estado, validação de movimento e separação entre lógica e visual.