Como Fazer um Tetris na Godot: Tutorial Completo em GDScript

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()
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.


