Voltar para o Blog
Quest Log

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

Tutorial de como fazer um Sokoban na Godot 4 com GDScript tipado, jogo de empurrar caixas em grid

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:

  1. Estado em dados, não em nós: as posições vivem em dicionários com chaves Vector2i. Os nós só desenham.
  2. Validação de regras antes do movimento: você pergunta "posso?" antes de mover, em vez de mover e corrigir depois.
  3. 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, use Dictionary puro 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)?" vira paredes.has(Vector2i(3, 2)), custo constante, sem varrer lista nenhuma.
  • jogador é um Vector2i, coordenada de célula, não de pixel. A conversão para pixels acontece só na hora de desenhar.
  • @export_multiline deixa 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.

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

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:

  1. A célula alvo tem parede? Movimento cancelado.
  2. 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.
  3. 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ê empilhar caixas direto, 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 Vector2i e 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 position por um Tween curto de 0.1 segundo entre as duas células. A lógica continua discreta, só o desenho desliza.
  • Vários níveis: transforme nivel_texto em um Array[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.