Como Criar um Sistema de Conquistas (Achievements) no Seu Jogo

Aprenda a criar um sistema de conquistas (achievements) para seu jogo: triggers por signal, persistência em JSON e integração com a Steam em GDScript.
Como Criar um Sistema de Conquistas (Achievements) no Seu Jogo
Um sistema de conquistas bem feito é invisível na arquitetura e visível na experiência: o jogador derrota o chefe, o toast aparece no canto da tela, e nada no código do chefe sabe que conquistas existem. Quando isso está errado, o sintoma é clássico: if matou_chefe: desbloquear_conquista() espalhado por vinte scripts, conquista que destrava de novo toda vez que o jogo abre, e pânico na semana do lançamento porque a Steam exige uma integração que ninguém planejou.
Nesse tutorial eu monto o sistema inteiro em GDScript (Godot 4.x): um gerenciador central como autoload, triggers desacoplados via signal, conquistas de progresso (do tipo "mate 100 inimigos"), persistência em disco e, no final, a sincronização com a Steam via GodotSteam. A arquitetura serve pra qualquer engine; o código roda como está no Godot.
A arquitetura: um gerenciador, muitos eventos
A decisão mais importante vem antes de qualquer linha de código: o gameplay nunca sabe que conquistas existem. O inimigo morre e avisa que morreu. O level termina e avisa que terminou. Quem escuta esses eventos e decide se algo foi desbloqueado é um único lugar: o AchievementManager.
Isso resolve três problemas de uma vez:
- Manutenção: adicionar ou remover uma conquista mexe em um arquivo, não em vinte.
- Consistência: a regra de "só desbloqueia uma vez" e o save vivem num lugar só.
- Portabilidade: quando chegar a hora da Steam (ou do PlayStation, ou do Xbox), você troca a camada de plataforma sem tocar no jogo.
No Godot, o encaixe natural é um autoload (singleton). Em Project Settings > Globals > Autoload, registre o script abaixo com o nome AchievementManager e ele fica acessível de qualquer cena.
Definindo as conquistas como dados
Conquista é dado, não código. Cada uma tem um id interno, nome, descrição, e opcionalmente uma meta de progresso e o id correspondente na Steam:
# achievement_manager.gd (autoload)
extends Node
signal achievement_unlocked(id: String, dados: Dictionary)
signal progress_updated(id: String, atual: int, meta: int)
const SAVE_PATH = "user://achievements.json"
var achievements = {
"primeira_vitoria": {
"nome": "Primeira Vitória",
"descricao": "Vença uma partida.",
"steam_id": "ACH_FIRST_WIN",
},
"exterminador": {
"nome": "Exterminador",
"descricao": "Derrote 100 inimigos.",
"meta": 100,
"steam_id": "ACH_KILL_100",
},
"colecionador": {
"nome": "Colecionador",
"descricao": "Encontre os 12 artefatos escondidos.",
"meta": 12,
"steam_id": "ACH_ALL_ARTIFACTS",
},
}
var desbloqueadas: Dictionary = {} # id -> timestamp do desbloqueio
var progresso: Dictionary = {} # id -> contador atual
Por que um dicionário e não um Resource por conquista? Pra um jogo com dez ou trinta conquistas, o dicionário é mais rápido de editar e de revisar num diff. Se o seu projeto tem designer mexendo em conquista pelo editor, aí sim vale promover cada uma a um Resource customizado. Comece simples.
O coração: unlock e progresso
Duas funções públicas resolvem todos os casos. unlock() pra conquistas de evento único e add_progress() pra conquistas de contagem:
func unlock(id: String) -> void:
if not achievements.has(id):
push_warning("Conquista desconhecida: " + id)
return
if desbloqueadas.has(id):
return # já tem, ignora em silêncio
desbloqueadas[id] = Time.get_unix_time_from_system()
_salvar()
achievement_unlocked.emit(id, achievements[id])
func add_progress(id: String, quantidade: int = 1) -> void:
if not achievements.has(id) or desbloqueadas.has(id):
return
var meta: int = achievements[id].get("meta", 1)
progresso[id] = progresso.get(id, 0) + quantidade
progress_updated.emit(id, progresso[id], meta)
if progresso[id] >= meta:
unlock(id)
else:
_salvar()
Repare nos detalhes que importam:
- A checagem
desbloqueadas.has(id)é o que impede o desbloqueio duplo. Sem ela, o toast aparece de novo a cada sessão e a Steam recebe chamadas repetidas. - O timestamp do desbloqueio custa nada pra guardar e depois vira tela de estatísticas de graça ("desbloqueada em 14/03").
- O progresso salva a cada incremento. Em jogo com contadores muito quentes (tipo "ande 10 km"), troque por um save a cada N incrementos ou no fim da sessão, senão você escreve em disco 60 vezes por segundo.
Disparando do gameplay
Do lado do jogo, o código fica com uma linha, sem if, sem lógica:
# no script do inimigo
func morrer() -> void:
AchievementManager.add_progress("exterminador")
queue_free()
# no script da partida
func _on_partida_vencida() -> void:
AchievementManager.unlock("primeira_vitoria")
Se o seu jogo já usa um event bus (um autoload só de signals globais), dá pra ir um passo além: o AchievementManager se conecta aos eventos no _ready() e o gameplay não chama nem essa linha. Pra projetos pequenos, a chamada direta é honesta e suficiente.
Persistência: o sistema de conquistas precisa sobreviver ao fechamento do jogo
Conquista que some quando o jogo fecha é bug dos feios. A boa notícia: serializar dois dicionários em JSON no user:// resolve, e o user:// aponta pro diretório de dados do usuário em qualquer plataforma que o Godot exporta.
func _ready() -> void:
_carregar()
func _salvar() -> void:
var dados = {
"desbloqueadas": desbloqueadas,
"progresso": progresso,
}
var file = FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file == null:
push_error("Falha ao salvar conquistas: %s" % FileAccess.get_open_error())
return
file.store_string(JSON.stringify(dados))
func _carregar() -> void:
if not FileAccess.file_exists(SAVE_PATH):
return
var file = FileAccess.open(SAVE_PATH, FileAccess.READ)
if file == null:
return
var dados = JSON.parse_string(file.get_as_text())
if dados is Dictionary:
desbloqueadas = dados.get("desbloqueadas", {})
progresso = dados.get("progresso", {})
Duas decisões de projeto escondidas aqui:
Salvar separado do save principal. Conquista é meta-progressão: ela pertence ao jogador, não ao slot de save. Se o jogador apaga o save pra recomeçar, as conquistas ficam. Por isso o arquivo próprio.
Não criptografar (por enquanto). Sim, o jogador consegue abrir esse JSON e se dar todas as conquistas. E daí? Conquista local é cosmética, e quem edita o arquivo só engana a si mesmo. Se isso te incomoda, o Godot tem FileAccess.open_encrypted_with_pass(), mas a chave vai embutida no executável de qualquer forma. Não gaste energia aqui; na Steam o anti-abuso é problema deles, não seu.
Mostrando o toast na tela
A notificação visual é uma cena separada (um CanvasLayer com um painel) que escuta o signal do manager. Detalhe que separa o amador do decente: se duas conquistas destravam juntas, elas entram numa fila em vez de uma atropelar a outra.
# achievement_toast.gd
extends CanvasLayer
@onready var painel: PanelContainer = $Painel
@onready var titulo: Label = $Painel/VBox/Titulo
@onready var descricao: Label = $Painel/VBox/Descricao
var fila: Array = []
var exibindo := false
func _ready() -> void:
painel.visible = false
AchievementManager.achievement_unlocked.connect(_on_unlocked)
func _on_unlocked(_id: String, dados: Dictionary) -> void:
fila.append(dados)
if not exibindo:
_exibir_proxima()
func _exibir_proxima() -> void:
if fila.is_empty():
exibindo = false
return
exibindo = true
var dados = fila.pop_front()
titulo.text = dados["nome"]
descricao.text = dados["descricao"]
painel.visible = true
await get_tree().create_timer(3.0).timeout
painel.visible = false
_exibir_proxima()
Adicione essa cena como autoload também (ou como filha de uma cena de UI persistente) e ela funciona em qualquer tela do jogo, inclusive em menu e cutscene. Animação de entrada e saída fica por sua conta com um Tween; o esqueleto acima é o que precisa estar certo.
Integrando com a Steam
Aqui a regra de ouro: a Steam é um espelho, não a fonte da verdade. Seu sistema local continua mandando; quando uma conquista destrava, você replica o desbloqueio na Steam. Assim o jogo funciona offline, funciona no itch.io sem mudar nada, e a integração inteira cabe em uma função.
O caminho no Godot é o GodotSteam, o binding open source da Steamworks SDK (disponível como GDExtension ou como build do editor). Antes do código, o pré-requisito burocrático: você precisa de um appid próprio, o que significa pagar a taxa do Steam Direct (USD 100 por jogo), e cadastrar cada conquista no Steamworks em App Admin > Stats & Achievements, com o API Name exato que você vai usar no código (o steam_id do nosso dicionário).
Com o GodotSteam instalado, a inicialização e a sincronização:
# dentro do achievement_manager.gd
var steam_ativo := false
func _iniciar_steam() -> void:
var resultado: Dictionary = Steam.steamInitEx()
steam_ativo = resultado["status"] == 0
if not steam_ativo:
print("Steam indisponível: ", resultado["verbal"])
func _process(_delta: float) -> void:
if steam_ativo:
Steam.run_callbacks()
func _sync_steam(id: String) -> void:
if not steam_ativo:
return
var steam_id: String = achievements[id].get("steam_id", "")
if steam_id.is_empty():
return
Steam.setAchievement(steam_id)
Steam.storeStats()
Chame _iniciar_steam() no _ready() do manager, logo depois do _carregar(). E dentro do unlock(), depois do _salvar(), entra uma chamada a _sync_steam(id). Só isso. O storeStats() é o que efetivamente envia o desbloqueio pros servidores da Valve e dispara o popup oficial da Steam; sem ele, o setAchievement fica só na memória.
Três avisos de quem já tropeçou nisso:
- Teste com
resetAllStats(). Durante o desenvolvimento você vai querer destravar a mesma conquista dezenas de vezes.Steam.resetAllStats(true)limpa tudo na sua conta de teste. - Sincronize no boot. Se o jogador destravou algo offline, compare o estado local com a Steam quando o jogo iniciar e reenvie o que faltar. É um loop sobre
desbloqueadaschamando_sync_steam(). - Não inverta a dependência. Vi projeto em que o toast só aparecia se a Steam confirmasse o desbloqueio. Resultado: jogo offline sem feedback nenhum. O local manda, a Steam espelha.
Design: o que merece virar conquista
Código pronto, falta o critério. Algumas opiniões formadas depois de ver muita lista de conquistas boa e ruim:
- Conquista guia comportamento. "Termine o jogo sem usar a loja" ensina que dá pra jogar assim. As melhores conquistas são convites pra jogar diferente, não checklist de progresso.
- Cuidado com o grind vazio. "Mate 10.000 slimes" não desafia ninguém, só consome tempo. Se a meta numérica não muda como a pessoa joga, corte ou diminua.
- Conquistas escondidas protegem a história. Marque como ocultas as que revelam spoiler. Na Steam isso é um checkbox no cadastro da conquista.
- Planeje antes de lançar. Adicionar conquista depois do lançamento é possível, mas conquista de "ato 1" que metade dos jogadores já passou nasce com taxa de desbloqueio quebrada. Feche a lista junto com o conteúdo.
Fechando
O resumo do que importa: um autoload dono de toda a lógica, gameplay que só reporta eventos, conquistas definidas como dados, save próprio separado do save de progresso, e Steam como espelho do estado local. Com essa fundação, adicionar a conquista número 31 é acrescentar uma entrada no dicionário e uma chamada de uma linha no lugar certo.
Se quiser fixar, implemente isso num projeto pequeno que você já tenha: três conquistas bastam (uma de evento, uma de progresso, uma escondida). O sistema inteiro sai em uma tarde e é o tipo de peça que você carrega pronta de um projeto pro outro pelo resto da carreira.


