Voltar para o Blog
Quest Log

Como Fazer um Tetris na Godot: Tutorial Completo em GDScript

Tela de um clone de Tetris feito na Godot mostrando o tabuleiro de blocos coloridos em um grid 10x20

Tetris na Godot: aprenda a criar um clone completo com grid 10x20, tetrominós, rotação, queda por timer, linhas e pontuação em GDScript tipado.

Fazer um Tetris na Godot é um dos melhores exercícios de lógica que existem para quem está aprendendo desenvolvimento de jogos. O jogo inteiro cabe em um único script, não precisa de física, não precisa de assets, e ainda assim cobre os fundamentos que você vai reutilizar pelo resto da carreira: representação de estado em grid, detecção de colisão por consulta a dados, rotação de coordenadas e game loop controlado por timer. Neste tutorial você vai montar o clone completo na Godot 4, com GDScript tipado, do tabuleiro vazio ao game over.

Uma nota honesta antes de começar: "Tetris" é marca registrada da Tetris Company. O mecanismo de blocos que caem é livre e você pode implementá-lo à vontade, mas se um dia quiser publicar comercialmente, chame o seu de "jogo de blocos", use nome e arte próprios e não copie a identidade visual oficial. Para estudo e portfólio, siga em frente sem medo.

Por que um Tetris na Godot é a melhor escola de lógica de grid

Quase todo jogo 2D que você vai fazer depois tem um grid escondido em algum lugar: o mapa de um roguelike, o tabuleiro de um match-3, a malha de construção de um jogo de estratégia. O Tetris expõe esse grid sem nenhuma distração. Não há inimigos, não há física, não há câmera. Só existe uma pergunta que se repete o jogo inteiro: "esta célula está ocupada?".

Se você responder bem essa pergunta, o resto do jogo é consequência. Colisão vira uma consulta ao Array. Travar a peça vira uma escrita no Array. Limpar linha vira remover uma sublista e inserir outra. É exatamente o mesmo raciocínio que você usaria em um match-3 na Godot, que também vive e morre pela qualidade da representação do tabuleiro.

Vamos usar uma única cena com um Node2D e um script. O desenho será feito com _draw(), sem TileMap e sem ColorRect: para um tabuleiro que muda o tempo todo, redesenhar retângulos direto no canvas é a abordagem mais simples e a mais rápida de iterar.

O tabuleiro: um Array bidimensional de ints

O estado do jogo inteiro mora em uma matriz de 10 colunas por 20 linhas. Cada célula guarda um int: 0 significa vazio, e de 1 a 7 identifica qual tipo de peça travou ali (o que nos dá a cor de graça na hora de desenhar).

Crie uma cena com um Node2D chamado Board, anexe um script e comece assim:

extends Node2D

const COLS: int = 10
const ROWS: int = 20
const CELL: int = 32

var grid: Array = []
var score: int = 0
var level: int = 1
var lines_cleared: int = 0
var game_is_over: bool = false

func _ready() -> void:
    reset_grid()

func reset_grid() -> void:
    grid = []
    for y in ROWS:
        var row: Array = []
        for x in COLS:
            row.append(0)
        grid.append(row)

Repare na convenção: grid[y][x], linha primeiro. Manter isso consistente evita a fonte número um de bugs em jogos de grid, que é inverter linha e coluna em um único lugar do código.

As 7 peças como arrays de Vector2i

Cada tetrominó é só uma lista de 4 offsets Vector2i dentro de uma caixa imaginária. A posição real de cada bloco é sempre posição da peça + offset. Defina as 7 peças e uma cor para cada uma:

const PIECES: Array = [
    [Vector2i(0, 1), Vector2i(1, 1), Vector2i(2, 1), Vector2i(3, 1)], # I
    [Vector2i(0, 0), Vector2i(1, 0), Vector2i(0, 1), Vector2i(1, 1)], # O
    [Vector2i(1, 0), Vector2i(0, 1), Vector2i(1, 1), Vector2i(2, 1)], # T
    [Vector2i(1, 0), Vector2i(2, 0), Vector2i(0, 1), Vector2i(1, 1)], # S
    [Vector2i(0, 0), Vector2i(1, 0), Vector2i(1, 1), Vector2i(2, 1)], # Z
    [Vector2i(0, 0), Vector2i(0, 1), Vector2i(1, 1), Vector2i(2, 1)], # J
    [Vector2i(2, 0), Vector2i(0, 1), Vector2i(1, 1), Vector2i(2, 1)], # L
]

const COLORS: Array = [
    Color.CYAN, Color.YELLOW, Color.MAGENTA, Color.GREEN,
    Color.RED, Color.BLUE, Color.ORANGE,
]

var current_type: int = 0
var current_blocks: Array = []
var piece_pos: Vector2i = Vector2i(3, 0)

Sortear e posicionar uma peça nova é direto. Se a peça nasce já colidindo, o tabuleiro encheu e o jogo acabou:

func spawn_piece() -> void:
    current_type = randi() % PIECES.size()
    current_blocks = PIECES[current_type].duplicate()
    piece_pos = Vector2i(3, 0)
    if not can_move(piece_pos, current_blocks):
        game_over()

Colisão: uma consulta ao grid, nada mais

Aqui está o coração do jogo. Antes de qualquer movimento ou rotação, perguntamos: "se a peça estivesse nesta posição com estes blocos, ela seria válida?". Três condições invalidam: sair pela lateral, sair pelo fundo, ou pisar em célula ocupada.

func can_move(pos: Vector2i, blocks: Array) -> bool:
    for b: Vector2i in blocks:
        var cell: Vector2i = pos + b
        if cell.x < 0 or cell.x >= COLS or cell.y >= ROWS:
            return false
        if cell.y >= 0 and grid[cell.y][cell.x] != 0:
            return false
    return true

O detalhe cell.y >= 0 importa: a peça pode nascer parcialmente acima do tabuleiro, e células com y negativo não devem ser consultadas no Array (isso causaria um índice inválido). Acima do topo só valem os limites laterais.

Com can_move() pronta, mover é trivial:

func try_move(dir: Vector2i) -> void:
    if can_move(piece_pos + dir, current_blocks):
        piece_pos += dir
        queue_redraw()
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

Queda por timer e aceleração por nível

A gravidade do Tetris não é física: é um tique de relógio. Um nó Timer criado por código resolve, e a dificuldade progressiva vira só uma mudança no wait_time. Adicione ao _ready() e crie o callback:

var fall_timer: Timer

func _ready() -> void:
    reset_grid()
    fall_timer = Timer.new()
    fall_timer.wait_time = 0.8
    fall_timer.timeout.connect(_on_fall_timeout)
    add_child(fall_timer)
    spawn_piece()
    fall_timer.start()

func _on_fall_timeout() -> void:
    if game_is_over:
        return
    if can_move(piece_pos + Vector2i(0, 1), current_blocks):
        piece_pos += Vector2i(0, 1)
    else:
        lock_piece()
    queue_redraw()

A lógica do tique é elegante: se dá para descer, desce. Se não dá, é porque a peça pousou, então ela trava no grid. Não existe um terceiro caso.

Entrada do jogador: mover, rotacionar, acelerar

Leia o teclado no _process() usando as ações padrão da Godot (ui_left, ui_right, ui_up, ui_down), que já vêm mapeadas para as setas:

func _process(_delta: float) -> void:
    if game_is_over:
        if Input.is_action_just_pressed("ui_accept"):
            restart()
        return
    if Input.is_action_just_pressed("ui_left"):
        try_move(Vector2i(-1, 0))
    if Input.is_action_just_pressed("ui_right"):
        try_move(Vector2i(1, 0))
    if Input.is_action_just_pressed("ui_up"):
        try_rotate()
    if Input.is_action_just_pressed("ui_down"):
        _on_fall_timeout()

A seta para baixo simplesmente antecipa o tique do timer: mesmo código, zero duplicação.

Para a rotação, usamos a fórmula clássica de rotacionar 90 graus em torno de um pivô: o offset relativo (x, y) vira (-y, x). Usamos Vector2i(1, 1) como pivô, que é o centro da caixa 3x3 onde as peças foram definidas. A peça O não gira (girar um quadrado é perda de tempo), e a rotação só é aceita se a posição resultante for válida:

func try_rotate() -> void:
    if current_type == 1:
        return
    var pivot: Vector2i = Vector2i(1, 1)
    var rotated: Array = []
    for b: Vector2i in current_blocks:
        var rel: Vector2i = b - pivot
        rotated.append(pivot + Vector2i(-rel.y, rel.x))
    if can_move(piece_pos, rotated):
        current_blocks = rotated
        queue_redraw()

Se a rotação for negada encostada na parede, o jogador só precisa se afastar um bloco e tentar de novo. O Tetris oficial tem um sistema de "wall kick" que empurra a peça automaticamente; fica como exercício depois que o núcleo estiver rodando.

Travar a peça e varrer linhas completas

Quando a peça pousa, seus blocos são gravados no grid com o valor current_type + 1 (o +1 porque 0 é reservado para vazio). Em seguida varremos as linhas:

func lock_piece() -> void:
    for b: Vector2i in current_blocks:
        var cell: Vector2i = piece_pos + b
        if cell.y >= 0:
            grid[cell.y][cell.x] = current_type + 1
    clear_lines()
    spawn_piece()

func clear_lines() -> void:
    var cleared: int = 0
    var y: int = ROWS - 1
    while y >= 0:
        var full: bool = true
        for x in COLS:
            if grid[y][x] == 0:
                full = false
                break
        if full:
            grid.remove_at(y)
            var empty_row: Array = []
            for x in COLS:
                empty_row.append(0)
            grid.insert(0, empty_row)
            cleared += 1
        else:
            y -= 1
    if cleared > 0:
        add_score(cleared)

O truque que torna essa função curta: em vez de "mover tudo para baixo" célula por célula, removemos a linha cheia do Array com remove_at() e inserimos uma linha vazia no topo com insert(0, ...). Tudo acima desce automaticamente, porque as linhas são referências dentro do Array externo. E repare que o y só decrementa quando a linha não está cheia: depois de remover uma linha, a que desceu para o lugar dela também precisa ser checada (é assim que um Tetris de 4 linhas funciona).

Pontuação, nível e game over

A tabela de pontos clássica recompensa limpar várias linhas de uma vez, multiplicada pelo nível atual. A cada 10 linhas o nível sobe e o timer acelera:

const LINE_SCORES: Array = [0, 100, 300, 500, 800]

func add_score(cleared: int) -> void:
    score += LINE_SCORES[cleared] * level
    lines_cleared += cleared
    var new_level: int = 1 + int(lines_cleared / 10.0)
    if new_level != level:
        level = new_level
        fall_timer.wait_time = maxf(0.1, 0.8 - (level - 1) * 0.07)

func game_over() -> void:
    game_is_over = true
    fall_timer.stop()

func restart() -> void:
    reset_grid()
    score = 0
    level = 1
    lines_cleared = 0
    game_is_over = false
    fall_timer.wait_time = 0.8
    spawn_piece()
    fall_timer.start()
    queue_redraw()

O piso de 0.1 segundo no maxf() evita que o timer chegue a zero e o jogo fique impossível de forma abrupta. Esses números (0.8 inicial, 0.07 de degrau) são ponto de partida: ajuste testando, porque a curva de dificuldade é uma decisão de design, não de código. Se quiser se aprofundar em como calibrar esse tipo de curva, o post sobre design de puzzle trata exatamente de como dosar desafio sem frustrar.

Desenhando tudo com _draw()

Escolhemos _draw() como abordagem única de renderização, e todo o visual do jogo cabe em uma função. Desenhamos o fundo de cada célula, as células ocupadas com a cor do tipo que travou ali, e por cima a peça atual:

func _draw() -> void:
    for y in ROWS:
        for x in COLS:
            var r: Rect2 = Rect2(x * CELL, y * CELL, CELL - 1, CELL - 1)
            if grid[y][x] == 0:
                draw_rect(r, Color(0.13, 0.13, 0.18))
            else:
                draw_rect(r, COLORS[grid[y][x] - 1])
    if game_is_over:
        return
    for b: Vector2i in current_blocks:
        var cell: Vector2i = piece_pos + b
        if cell.y < 0:
            continue
        var r: Rect2 = Rect2(cell.x * CELL, cell.y * CELL, CELL - 1, CELL - 1)
        draw_rect(r, COLORS[current_type])

O CELL - 1 no tamanho de cada retângulo deixa 1 pixel de respiro entre as células, criando o visual de grade sem desenhar nenhuma linha. E lembre da regra da Godot 4: _draw() só roda quando você chama queue_redraw(), por isso todas as funções que mudam o estado visual terminam com essa chamada. Para exibir pontuação e nível, adicione um Label na cena e atualize o text dentro de add_score(); para a mensagem de game over, outro Label que você torna visível em game_over().

Rode a cena. Você tem um jogo de blocos completo: peças caem, giram, colidem, travam, linhas somem, a pontuação sobe, a velocidade aumenta e o jogo termina quando o poço transborda. Cerca de 200 linhas de GDScript tipado, nenhum asset externo.

Próximos passos

O núcleo está pronto, e é aqui que o projeto vira aprendizado de verdade. Algumas extensões em ordem de dificuldade: mostrar a próxima peça (sorteie com antecedência e desenhe em uma área lateral), hard drop com a barra de espaço (desça em loop até can_move() falhar e trave), o "ghost piece" que mostra onde a peça vai pousar (mesma lógica do hard drop, mas desenhando com transparência), e o sistema de 7-bag, que embaralha as 7 peças e as entrega sem repetição antes de embaralhar de novo.

Se este foi o seu primeiro jogo de grid, não pare aqui. A lista de ideias de jogos simples para iniciantes tem outros projetos do mesmo tamanho para consolidar o que você praticou, e cada um deles reaproveita algum pedaço do que você acabou de escrever: o Array bidimensional, a consulta de colisão, o timer que dita o ritmo. É assim que se aprende a programar jogos: um sistema pequeno e completo de cada vez. Outro clássico do mesmo porte, trocando o grid pela física de rebote, é o Breakout na Godot: raquete, bola e fileiras de blocos em GDScript tipado.

Perguntas frequentes

Posso publicar um clone de Tetris feito na Godot?

A mecânica de blocos que caem é livre, mas "Tetris" é marca registrada da Tetris Company, e elementos como o nome, o logo e o trade dress são protegidos. Para publicar comercialmente, chame o seu jogo de outra coisa, use arte própria e evite copiar a identidade visual oficial. Como projeto de estudo no seu portfólio, não há problema.

Preciso usar TileMap para fazer um Tetris na Godot?

Não. O TileMap funciona, mas para um tabuleiro de 10x20 células o mais simples é guardar o estado em um Array bidimensional de ints e desenhar tudo com a função _draw() de um Node2D. Você ganha controle total e elimina uma camada de abstração que não ajuda em um jogo de grid puro.

Como funciona a rotação das peças no Tetris?

Cada peça é um conjunto de offsets Vector2i. Para rotacionar 90 graus, você aplica a fórmula (x, y) vira (-y, x) em relação a um pivô. Antes de aceitar a rotação, valide a nova posição contra o grid: se algum bloco cair fora do tabuleiro ou sobre uma célula ocupada, a rotação é cancelada.

Como fazer a peça cair mais rápido conforme o nível sobe?

Use um nó Timer com wait_time inicial de 0.8 segundo. A cada 10 linhas limpas o nível sobe e você reduz o wait_time, por exemplo 0.07 segundo por nível, com um piso mínimo de 0.1 segundo para o jogo continuar jogável.

Quanto tempo leva para programar um Tetris completo?

Com o tutorial em mãos, um iniciante que já conhece o básico de GDScript monta o clone funcional em um fim de semana. O núcleo tem cerca de 200 linhas: grid, peças, colisão, queda, limpeza de linhas e pontuação. Polimento como efeitos, som e menus pode dobrar esse tempo.