Voltar para o Blog
Quest Log

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

Troféus e medalhas de conquistas sendo desbloqueados sobre uma interface de 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.

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

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 desbloqueadas chamando _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.