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

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


