Como Fazer um Sokoban na Godot: Jogo de Empurrar Caixas em GDScript

Como fazer Sokoban na Godot 4: tutorial passo a passo com movimento em grid, empurrar caixas, desfazer jogadas e níveis em texto, tudo em GDScript tipado.
Sokoban é aquele clássico japonês de empurrar caixas até os alvos dentro de um armazém. Parece humilde, mas é provavelmente o melhor exercício de lógica de grid que existe: sem física, sem timing, sem inimigos, só estado e regras. Neste tutorial você vai montar um Sokoban completo na Godot 4 com GDScript tipado: nível carregado de uma String, movimento discreto com empurrão de caixas, condição de vitória, contador de passos e desfazer jogada com pilha de snapshots.
Por que o Sokoban é o melhor exercício de lógica de grid
Quase todo iniciante tenta fazer o primeiro puzzle com física: caixa com RigidBody2D, jogador com CharacterBody2D, colisões resolvendo tudo. O resultado é caixa desalinhada, empurrão que atravessa parede e bugs impossíveis de reproduzir. O Sokoban ensina o caminho certo, porque ele é um jogo de movimento discreto: o mundo é uma grade de células, cada coisa ocupa exatamente uma célula, e um movimento ou acontece por inteiro ou não acontece.
Isso força três aprendizados que valem para roguelikes, jogos de tabuleiro, match-3 e estratégia por turnos:
- Estado em dados, não em nós: as posições vivem em dicionários com chaves
Vector2i. Os nós só desenham. - Validação de regras antes do movimento: você pergunta "posso?" antes de mover, em vez de mover e corrigir depois.
- Estado pequeno e serializável: como todo o jogo cabe em três estruturas, salvar, desfazer e carregar níveis viram problemas triviais.
Se você quer entender o lado de design da coisa, por que um nível de Sokoban é interessante e outro é chato, o guia de design de puzzle cobre exatamente isso. Aqui o foco é o código.
A estrutura de dados: três dicionários e um Vector2i
A regra de ouro dos jogos de grid: o visual nunca guarda estado. O estado do Sokoban inteiro cabe nisto:
extends Node2D
const FONTE_TILES: int = 0
const TILE_CHAO: Vector2i = Vector2i(0, 0)
const TILE_PAREDE: Vector2i = Vector2i(1, 0)
const TILE_ALVO: Vector2i = Vector2i(2, 0)
const TILE_CAIXA: Vector2i = Vector2i(3, 0)
@export_multiline var nivel_texto: String = """
#######
#. @ #
# $ $ #
#. # #
#######
"""
var paredes: Dictionary[Vector2i, bool] = {}
var caixas: Dictionary[Vector2i, bool] = {}
var alvos: Array[Vector2i] = []
var jogador: Vector2i = Vector2i.ZERO
var passos: int = 0
var historico: Array[Dictionary] = []
var jogo_ativo: bool = true
@onready var chao: TileMapLayer = $Chao
@onready var camada_caixas: TileMapLayer = $Caixas
@onready var sprite_jogador: Sprite2D = $Jogador
@onready var rotulo_passos: Label = $HUD/RotuloPassos
func _ready() -> void:
_carregar_nivel(nivel_texto)
Alguns pontos importantes:
Dictionary[Vector2i, bool]é um dicionário tipado, disponível a partir da Godot 4.4. Se você está em uma versão anterior, useDictionarypuro que o código funciona igual. Usamos o dicionário como um conjunto: a chave é a posição, o valor é sótrue. A pergunta "tem parede em (3, 2)?" viraparedes.has(Vector2i(3, 2)), custo constante, sem varrer lista nenhuma.jogadoré umVector2i, coordenada de célula, não de pixel. A conversão para pixels acontece só na hora de desenhar.@export_multilinedeixa você editar o nível direto no Inspector, sem abrir o script.
A cena tem essa estrutura: um Node2D raiz com o script acima, duas TileMapLayer chamadas Chao e Caixas usando o mesmo TileSet (tiles de 64x64 servem bem), um Sprite2D chamado Jogador e um CanvasLayer chamado HUD com um Label dentro. As duas camadas de tile precisam existir porque uma célula de TileMapLayer só comporta um tile: o chão com alvos fica embaixo, as caixas ficam por cima. Se você nunca configurou um TileSet com atlas, o tutorial de TileMap na Godot 4 mostra o processo completo.
Carregando níveis de texto simples
O formato clássico de níveis de Sokoban usa um caractere por célula: # é parede, $ é caixa, . é alvo, @ é o jogador e espaço é chão livre. Existem ainda * para caixa que começa sobre um alvo e + para jogador sobre alvo. O parser percorre a String linha por linha, caractere por caractere:
func _carregar_nivel(texto: String) -> void:
paredes.clear()
caixas.clear()
alvos.clear()
historico.clear()
passos = 0
jogo_ativo = true
chao.clear()
camada_caixas.clear()
var linhas: PackedStringArray = texto.strip_edges().split("\n")
for y: int in linhas.size():
var linha: String = linhas[y]
for x: int in linha.length():
var pos: Vector2i = Vector2i(x, y)
var simbolo: String = linha[x]
if simbolo != "#":
chao.set_cell(pos, FONTE_TILES, TILE_CHAO)
match simbolo:
"#":
paredes[pos] = true
chao.set_cell(pos, FONTE_TILES, TILE_PAREDE)
"$":
caixas[pos] = true
".":
alvos.append(pos)
chao.set_cell(pos, FONTE_TILES, TILE_ALVO)
"*":
caixas[pos] = true
alvos.append(pos)
chao.set_cell(pos, FONTE_TILES, TILE_ALVO)
"@":
jogador = pos
"+":
jogador = pos
alvos.append(pos)
chao.set_cell(pos, FONTE_TILES, TILE_ALVO)
_redesenhar_caixas()
_atualizar_jogador()
_atualizar_hud()
E as três funções de desenho que sincronizam o visual com os dados:
func _redesenhar_caixas() -> void:
camada_caixas.clear()
for pos: Vector2i in caixas:
camada_caixas.set_cell(pos, FONTE_TILES, TILE_CAIXA)
func _atualizar_jogador() -> void:
sprite_jogador.position = chao.map_to_local(jogador)
func _atualizar_hud() -> void:
rotulo_passos.text = "Passos: %d" % passos
Repare no map_to_local: ele converte a coordenada de célula para a posição em pixels do centro daquela célula, respeitando o tamanho do tile. Você nunca multiplica por 64 na mão, e se um dia trocar o tamanho do tile, nada quebra.
O ganho desse formato é enorme: qualquer pessoa cria níveis em um editor de texto, e a internet tem milhares de coleções de níveis de Sokoban nesse padrão exato, prontas para colar no seu jogo.
Movimento em grid: parede bloqueia, caixa empurra
Aqui mora o coração do Sokoban. O input é discreto: um toque na seta, um movimento de uma célula. Nada de _physics_process, nada de velocidade:
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("desfazer"):
_desfazer()
return
if event.is_action_pressed("reiniciar"):
_carregar_nivel(nivel_texto)
return
if not jogo_ativo:
return
var direcao: Vector2i = Vector2i.ZERO
if event.is_action_pressed("ui_right"):
direcao = Vector2i.RIGHT
elif event.is_action_pressed("ui_left"):
direcao = Vector2i.LEFT
elif event.is_action_pressed("ui_up"):
direcao = Vector2i.UP
elif event.is_action_pressed("ui_down"):
direcao = Vector2i.DOWN
if direcao != Vector2i.ZERO:
_tentar_mover(direcao)
Crie as ações desfazer (tecla Z) e reiniciar (tecla R) no Input Map do projeto. As ações ui_left, ui_right, ui_up e ui_down já vêm mapeadas nas setas por padrão.
A lógica do movimento cabe em uma função só, e a ordem das checagens é a regra do jogo escrita em código:
func _tentar_mover(direcao: Vector2i) -> void:
var destino: Vector2i = jogador + direcao
if paredes.has(destino):
return
var empurrou: bool = false
if caixas.has(destino):
var alem: Vector2i = destino + direcao
if paredes.has(alem) or caixas.has(alem):
return
empurrou = true
_salvar_estado()
if empurrou:
var nova_pos: Vector2i = destino + direcao
caixas.erase(destino)
caixas[nova_pos] = true
camada_caixas.erase_cell(destino)
camada_caixas.set_cell(nova_pos, FONTE_TILES, TILE_CAIXA)
jogador = destino
passos += 1
_atualizar_jogador()
_atualizar_hud()
if _venceu():
jogo_ativo = false
rotulo_passos.text = "Nível completo em %d passos! R para reiniciar" % passos
Leia a função de cima para baixo e você lê as regras do Sokoban:
- A célula alvo tem parede? Movimento cancelado.
- Tem caixa? Então olhe a célula seguinte na mesma direção. Se lá tem parede ou outra caixa, o empurrão é impossível e nada acontece. É isso que impede empurrar duas caixas de uma vez, a regra que define o jogo.
- Só depois que o movimento foi aprovado a gente salva o estado (para o desfazer), move a caixa nos dados e no tile, e move o jogador.
Note que mover a caixa custa duas operações de dicionário e duas chamadas de tile. Sem nós instanciados por caixa, sem colisão, sem sinal. Essa mesma disciplina de "checar a célula antes de agir" é a base do campo minado na Godot, que é um ótimo projeto irmão deste: lá a vizinhança importa mais que o movimento, aqui é o contrário.
Condição de vitória e contador de passos
A vitória do Sokoban é uma pergunta simples: todos os alvos têm caixa em cima? Com as estruturas que montamos, a resposta é um laço de quatro linhas:
func _venceu() -> bool:
for alvo: Vector2i in alvos:
if not caixas.has(alvo):
return false
return true
Não importa qual caixa está em qual alvo, só importa que cada alvo esteja coberto. A checagem roda depois de cada movimento, dentro de _tentar_mover. Como um nível de Sokoban raramente passa de algumas dezenas de alvos, o custo é irrelevante.
O contador de passos já está funcionando: passos += 1 a cada movimento válido e o Label atualizado em _atualizar_hud. Movimentos cancelados (andar contra a parede, empurrão bloqueado) não contam, porque a função retorna antes do incremento. Esse contador é o que transforma o Sokoban em jogo rejogável: terminar o nível é fácil, terminar no menor número de passos é o desafio de verdade.
Desfazer jogada: pilha de snapshots
O desfazer é obrigatório em Sokoban. Um empurrão errado pode encurralar uma caixa no canto e travar o nível inteiro, e obrigar o jogador a reiniciar do zero por causa de um toque é crueldade. A implementação mais robusta é também a mais simples: antes de cada movimento, tire uma foto completa do estado e empilhe.
func _salvar_estado() -> void:
historico.append({
"jogador": jogador,
"caixas": caixas.duplicate(),
"passos": passos
})
func _desfazer() -> void:
if historico.is_empty():
return
jogo_ativo = true
var estado: Dictionary = historico.pop_back()
jogador = estado["jogador"]
passos = estado["passos"]
caixas.clear()
var caixas_salvas: Dictionary = estado["caixas"]
for pos: Vector2i in caixas_salvas:
caixas[pos] = true
_redesenhar_caixas()
_atualizar_jogador()
_atualizar_hud()
Dois detalhes fazem toda a diferença aqui:
caixas.duplicate()é inegociável. Dicionários em GDScript são passados por referência. Se você empilharcaixasdireto, todos os snapshots apontam para o mesmo dicionário, que continua sendo modificado, e o desfazer restaura o presente em vez do passado. É o bug clássico desse padrão.- Snapshot completo em vez de movimento inverso. Dava para guardar só "andou para a direita empurrando caixa" e escrever a lógica que inverte isso. Mas essa lógica inversa é um segundo lugar onde as regras do jogo existem, e dois lugares significa que uma hora eles discordam. Como o estado inteiro do Sokoban são dois
Vector2ie um dicionário pequeno, a cópia completa custa quase nada e o desfazer nunca mente.
O pop_back remove e retorna o último snapshot, então apertar Z várias vezes rebobina a partida movimento a movimento até o início do nível. E como paredes e alvos nunca mudam durante o jogo, eles ficam fora do snapshot de propósito: só se copia o que muda.
Próximos passos
Você tem um Sokoban completo e correto: níveis em texto, movimento com empurrão validado, vitória, passos e desfazer ilimitado. Daqui em diante é polimento e ambição, nessa ordem:
- Tile de caixa no alvo: adicione um quinto tile e, ao redesenhar, use ele quando
alvos.has(pos)for verdadeiro para a caixa. Feedback visual imediato de progresso. - Movimento animado: troque a atribuição direta de
positionpor umTweencurto de 0.1 segundo entre as duas células. A lógica continua discreta, só o desenho desliza. - Vários níveis: transforme
nivel_textoem umArray[String]e avance o índice na vitória. - Detector de deadlock: marcar caixa encurralada em canto sem alvo é um problema clássico e um excelente exercício extra de lógica de grid.
- Resolver o nível sozinho: um solver de Sokoban é busca em grafo de estados, o mesmo raciocínio de algoritmos de pathfinding aplicado a estados do tabuleiro. Excelente desafio quando o resto estiver pronto.
O Sokoban ensina em duzentas linhas o que muitos projetos grandes ensinam em dez mil: dados mandam, visual obedece, e regra validada antes do movimento nunca gera estado impossível. Se você aprendeu a programar jogos com os cursos do CursoGame.Dev, esse é o tipo de projeto que consolida a base antes de partir para algo maior.
Perguntas frequentes
Preciso de física para fazer um Sokoban na Godot?
Não, e usar física é o erro mais comum nesse projeto. O Sokoban é um jogo de movimento discreto: o jogador e as caixas pulam de célula em célula, sem velocidade nem colisão contínua. Toda a lógica cabe em dicionários com chaves Vector2i e comparações simples. RigidBody2D e CharacterBody2D só trariam imprecisão e bugs de empurrão duplo.
Como funciona o movimento em grid do jogador no Sokoban?
A posição do jogador é um Vector2i, não um Vector2 de pixels. A cada input você soma a direção (Vector2i.RIGHT, LEFT, UP ou DOWN) e checa a célula alvo: se for parede, o movimento é cancelado; se for caixa, você checa a célula seguinte na mesma direção e só empurra se ela estiver livre; se estiver vazia, o jogador anda. O sprite só é reposicionado depois que a lógica aprova o movimento.
Como implementar o desfazer jogada no Sokoban?
Com uma pilha de snapshots. Antes de cada movimento válido, você guarda em um Array um dicionário com a posição do jogador, uma cópia do dicionário de caixas (usando duplicate) e o contador de passos. Desfazer é dar pop_back nesse Array e restaurar os três valores. Como o estado do Sokoban é minúsculo, guardar cópias completas é barato e muito mais simples do que inverter movimentos.
Como criar níveis novos para o Sokoban?
Use o formato de texto clássico do Sokoban: cerquilha para parede, cifrão para caixa, ponto para alvo e arroba para o jogador. Cada linha da String vira uma linha do grid, e um parser de vinte linhas converte os caracteres em dicionários de posições. Você edita níveis em qualquer editor de texto e existem milhares de níveis da comunidade nesse mesmo formato prontos para importar.
Devo usar TileMapLayer ou sprites soltos para desenhar o tabuleiro?
TileMapLayer para tudo que é estático ou trocado por célula: chão, paredes, alvos e as próprias caixas. Com set_cell e erase_cell você move uma caixa sem gerenciar nós. Só o jogador compensa como Sprite2D separado, posicionado com map_to_local, porque depois fica fácil animar o deslizamento entre células com Tween. O importante é que o visual nunca guarda estado: os dicionários mandam, as camadas obedecem.
Sokoban é um bom projeto para iniciante em Godot?
É um dos melhores segundos projetos. Ele não exige física, arte animada nem timing, então você foca no que mais derruba iniciante: representar estado, validar regras e separar dados de visual. Além disso ele ensina pilha de estados com o desfazer e parsing de arquivo com os níveis de texto, duas habilidades que você vai reaproveitar em qualquer jogo maior.


