Voltar para o Blog
Quest Log

Como Salvar e Carregar Jogo no Godot: ConfigFile, JSON e Slots de Save

Ilustração de um sistema de save e load em um jogo feito no Godot, com slots de salvamento e dados sendo gravados

Aprenda a salvar jogo no Godot 4 com ConfigFile e JSON: autoload de persistência, slots de save, serialização de dados e os erros que corrompem saves.

Como Salvar e Carregar Jogo no Godot: ConfigFile, JSON e Slots de Save

Salvar jogo no Godot é uma daquelas features que todo mundo deixa pra depois e se arrepende. Quanto mais tarde você implementa, mais sistemas precisam ser adaptados pra persistir estado. E o jogador não perdoa: um save corrompido ou progresso perdido é motivo de refund e review negativa.

A boa notícia é que o Godot 4 te dá tudo de fábrica. Não precisa de plugin, não precisa de banco de dados. Com ConfigFile, FileAccess e JSON você monta um sistema de save completo, com slots, em algumas dezenas de linhas de GDScript.

Nesse tutorial eu mostro o caminho que uso nos meus projetos: ConfigFile pra configurações, JSON pro progresso do jogo, um autoload de persistência pra centralizar tudo, e slots de save pro jogador ter mais de uma partida. Todo código roda no Godot 4.x como está.

Onde os saves vivem: o caminho user://

Antes de gravar qualquer byte, você precisa saber onde gravar. A resposta no Godot é sempre user://.

O res:// aponta pros arquivos do projeto, e depois que você exporta o jogo ele vira um pacote somente leitura. Tentar salvar lá funciona no editor e quebra no build final, que é o pior tipo de bug: o que só aparece na máquina do jogador.

O user:// aponta pra uma pasta gravável que o sistema operacional reserva pro seu jogo. No Windows fica dentro de %APPDATA%\Godot\app_userdata\<nome do projeto>, no Linux dentro de ~/.local/share/godot/, e no macOS dentro de ~/Library/Application Support/. Você não precisa decorar isso: chame OS.get_user_data_dir() pra descobrir em runtime, ou use Project > Open User Data Folder no editor pra abrir a pasta e olhar os arquivos que seu código gerou. Eu faço isso o tempo todo durante o desenvolvimento, é o jeito mais rápido de conferir se o save saiu como você esperava.

Regra prática: configurações e saves em user://, assets do jogo em res://. Sem exceção.

ConfigFile: o jeito rápido pra configurações

O ConfigFile lê e escreve arquivos no formato INI, organizados em seções e chaves. Ele serializa tipos do Godot automaticamente (Vector2, Color, arrays), e o arquivo final é texto legível, fácil de inspecionar.

É a ferramenta certa pra configurações: volume, resolução, keybindings. Dados pequenos, estrutura chata, sem aninhamento profundo.

extends Node

const CONFIG_PATH = "user://settings.cfg"

func salvar_configuracoes() -> void:
    var config = ConfigFile.new()

    config.set_value("audio", "volume_musica", 0.8)
    config.set_value("audio", "volume_sfx", 1.0)
    config.set_value("video", "fullscreen", true)
    config.set_value("video", "vsync", true)

    config.save(CONFIG_PATH)

func carregar_configuracoes() -> void:
    var config = ConfigFile.new()
    var erro = config.load(CONFIG_PATH)

    # Primeira vez que o jogo roda? O arquivo não existe ainda.
    # Os defaults do terceiro parâmetro de get_value resolvem isso.
    if erro != OK:
        return

    var volume = config.get_value("audio", "volume_musica", 1.0)
    var fullscreen = config.get_value("video", "fullscreen", false)

O terceiro parâmetro do get_value é o valor padrão, e ele é mais importante do que parece. Quando você lança a versão 1.1 do jogo com uma opção nova, o arquivo de config antigo do jogador não tem aquela chave. Com default definido, o código não quebra: ele só usa o padrão e segue.

Daria pra salvar o jogo inteiro em ConfigFile? Daria. Mas pra dados de progresso com estrutura aninhada (inventário com itens que têm atributos, lista de quests com estados), o formato de seção e chave fica apertado rápido. Pra isso, JSON.

Salvar jogo no Godot com JSON

JSON te dá dicionários e arrays aninhados à vontade, o arquivo é legível em qualquer editor de texto, e debugar vira trivial: abre o save, lê, acha o problema.

O fluxo tem três peças: montar um dicionário com o estado do jogo, transformar em texto com JSON.stringify(), gravar com FileAccess. Carregar é o caminho inverso.

func salvar_jogo(caminho: String) -> bool:
    var dados = {
        "versao": 1,
        "cena_atual": get_tree().current_scene.scene_file_path,
        "jogador": {
            "vida": 85,
            "moedas": 320,
            "pos_x": 512.0,
            "pos_y": 96.0
        },
        "inventario": ["espada_ferro", "pocao_vida", "chave_torre"]
    }

    var arquivo = FileAccess.open(caminho, FileAccess.WRITE)
    if arquivo == null:
        push_error("Falha ao abrir save: %s" % FileAccess.get_open_error())
        return false

    # O segundo parâmetro indenta o JSON. Custa uns bytes a mais
    # e te poupa horas de debug com um arquivo legível.
    arquivo.store_string(JSON.stringify(dados, "\t"))
    return true

func carregar_jogo(caminho: String) -> Dictionary:
    if not FileAccess.file_exists(caminho):
        return {}

    var arquivo = FileAccess.open(caminho, FileAccess.READ)
    var dados = JSON.parse_string(arquivo.get_as_text())

    # Arquivo corrompido ou editado na mão? parse_string retorna null.
    if typeof(dados) != TYPE_DICTIONARY:
        push_error("Save inválido ou corrompido: %s" % caminho)
        return {}

    return dados

Dois detalhes desse código que evitam bug de verdade:

A chave versao. Hoje parece inútil. Daqui a seis meses, quando você mudar a estrutura do save e precisar migrar saves antigos dos jogadores, ela é o que separa "escrevo um if de migração" de "o update corrompeu todos os saves".

A validação no load. JSON.parse_string() retorna null quando o texto não é JSON válido, e o jogador VAI editar o save na mão pra se dar moedas. Checar o tipo antes de usar evita crash na cara dele.

Uma pegadinha do JSON que o ConfigFile não tem: ele só conhece os tipos dele. Vector2, Color e afins não existem em JSON, por isso a posição do exemplo virou pos_x e pos_y separados. Outra: números inteiros voltam do parse como float, então int(dados.jogador.vida) na hora de usar evita surpresa em comparação.

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

Autoload de persistência: o SaveManager

Se cada cena salvar e carregar do seu jeito, em dois meses você tem cinco formatos de save diferentes e nenhum compatível com o outro. A solução do Godot pra isso é o autoload: um node que carrega junto com o jogo, vive fora da árvore de cenas e fica acessível de qualquer script pelo nome.

Crie um save_manager.gd e registre em Project > Project Settings > Globals > Autoload com o nome SaveManager. A partir daí, qualquer script chama SaveManager.salvar(1) e pronto.

# save_manager.gd (autoload "SaveManager")
extends Node

const SAVE_DIR = "user://saves/"
const VERSAO_SAVE = 1

func _ready() -> void:
    # Garante que a pasta de saves existe antes de qualquer gravação.
    DirAccess.make_dir_recursive_absolute(SAVE_DIR)

func caminho_do_slot(slot: int) -> String:
    return SAVE_DIR + "slot_%d.json" % slot

func salvar(slot: int) -> bool:
    var dados = {
        "versao": VERSAO_SAVE,
        "timestamp": Time.get_unix_time_from_system(),
        "cena_atual": get_tree().current_scene.scene_file_path,
        "objetos": {}
    }

    # Cada node no grupo "persistente" decide o que salvar de si mesmo.
    for node in get_tree().get_nodes_in_group("persistente"):
        if node.has_method("coletar_dados"):
            dados["objetos"][str(node.get_path())] = node.coletar_dados()

    var arquivo = FileAccess.open(caminho_do_slot(slot), FileAccess.WRITE)
    if arquivo == null:
        return false

    arquivo.store_string(JSON.stringify(dados, "\t"))
    return true

O padrão importante aqui é a inversão de responsabilidade: o SaveManager não conhece o player, o inventário nem os baús do mapa. Ele só percorre o grupo persistente e pede pra cada node se descrever. Quem sabe o que importa no player é o script do player:

# No script do player, que está no grupo "persistente"
func coletar_dados() -> Dictionary:
    return {
        "vida": vida,
        "moedas": moedas,
        "pos_x": global_position.x,
        "pos_y": global_position.y
    }

func aplicar_dados(dados: Dictionary) -> void:
    vida = int(dados.get("vida", vida_maxima))
    moedas = int(dados.get("moedas", 0))
    global_position = Vector2(dados.get("pos_x", 0.0), dados.get("pos_y", 0.0))

Quando você cria um sistema novo (uma loja, um diário de quests), basta colocar o node no grupo e implementar os dois métodos. O SaveManager nunca mais é tocado. Esse desacoplamento é o que faz o sistema sobreviver ao projeto crescendo.

No load, o fluxo que funciona bem é: ler o arquivo, trocar pra cena salva com get_tree().change_scene_to_file(dados.cena_atual), esperar a cena carregar e então distribuir os dados pros nodes do grupo chamando aplicar_dados. O ponto de atenção é a ordem: a cena precisa estar pronta antes de você aplicar estado nela, senão os nodes ainda não existem.

Slots de save: deixando o jogador ter várias partidas

Com o SaveManager gravando em slot_%d.json, slots já existem na prática. O que falta é a tela de seleção, e pra ela você precisa listar o que existe na pasta e mostrar um resumo de cada partida.

func listar_slots() -> Array[Dictionary]:
    var slots: Array[Dictionary] = []

    for nome in DirAccess.get_files_at(SAVE_DIR):
        if not nome.ends_with(".json"):
            continue
        var arquivo = FileAccess.open(SAVE_DIR + nome, FileAccess.READ)
        var dados = JSON.parse_string(arquivo.get_as_text())
        if typeof(dados) != TYPE_DICTIONARY:
            continue
        slots.append({
            "arquivo": nome,
            "timestamp": int(dados.get("timestamp", 0)),
            "cena": dados.get("cena_atual", "")
        })

    return slots

Com o timestamp salvo, a tela de slots mostra "Salvo em 11/06 às 21:30" usando Time.get_datetime_dict_from_unix_time(). Dá pra ir além e guardar nome da fase, tempo de jogo e nível do personagem no dicionário raiz do save, de propósito, pra tela de seleção não precisar abrir o save inteiro pra montar o resumo.

Duas decisões de design que recomendo roubar de jogos grandes:

Autosave em slot separado. Reserve o slot 0 pro autosave e nunca grave por cima dos slots manuais do jogador. Autosave que sobrescreve save manual é receita pra progresso perdido e jogador furioso.

Salvar em arquivo temporário primeiro. Se o jogo fechar no meio da gravação (queda de energia, crash), o save fica corrompido pela metade. O padrão seguro é gravar em slot_1.json.tmp e, só depois da escrita completa, renomear por cima do arquivo real com DirAccess.rename_absolute(). Assim o save antigo só morre quando o novo está inteiro.

Os erros que corrompem saves (e como evitar)

Alguns tropeços aparecem em praticamente todo primeiro sistema de save. Lista curta do que já me custou tempo:

Salvar referências de node. Dicionário com um node dentro não serializa. Salve dados (posição, vida, id do item), nunca objetos. No load, você reconstrói os objetos a partir dos dados.

Confiar em store_var sem pensar. O FileAccess.store_var() grava qualquer Variant em binário e parece atalho perfeito. O custo: o arquivo é ilegível pra debug e o formato fica acoplado aos tipos internos do Godot. Pra protótipo, ok. Pra jogo que vai ser mantido por anos, JSON paga o pedágio da serialização manual com juros.

Carregar Resource de fonte não confiável. Salvar com ResourceSaver e carregar com ResourceLoader funciona, mas um arquivo .tres pode embutir script. Se o jogador baixar um save da internet (e em jogo com comunidade isso acontece), carregar esse arquivo executa código arbitrário na máquina dele. JSON não tem esse problema: é só dado.

Salvar só no quit. Jogo fecha por crash, por queda de luz, pelo jogador matando o processo. Salve em checkpoints, em transição de cena, em intervalo de tempo. O evento de fechar janela é o último recurso, não o plano.

Fechando

Um sistema de save sólido no Godot se resume a poucas escolhas: user:// como destino, ConfigFile pra configurações, JSON pro progresso, um autoload SaveManager como porta de entrada única, e nodes que se descrevem via grupo persistente. Com a chave de versão e a gravação atômica via arquivo temporário, você cobre os casos que destroem saves em produção.

Meu conselho prático: implemente isso cedo. Não precisa ser completo, um SaveManager que grava um dicionário com três campos já te força a pensar em "o que é estado persistente?" desde o início, e essa pergunta molda a arquitetura do jogo inteiro. Adaptar dez sistemas prontos pra serem salváveis dói bem mais que criar cada um já sabendo se descrever.

Começa pequeno: salva a posição do player num slot, carrega de volta, abre o arquivo na pasta do user:// e lê o que saiu. A partir daí, cada sistema novo do jogo é só mais um node no grupo.