Como Criar um Sistema de Diálogo para Jogos: Tutorial Completo

Aprenda a criar sistemas de diálogo profissionais para jogos com tutorial passo a passo em Godot. Inclui código, UI/UX e narrativa interativa.
Quase todo dev que eu vejo travado em RPG ou aventura tropeça no mesmo ponto: o sistema de diálogo. Parece simples (é só mostrar texto na tela, certo?) e aí você descobre que precisa de ramificação, condições, efeito de digitação, escolhas que mudam o jogo, histórico, localização. Vira um nó.
Esse post é o caminho que eu queria ter tido quando comecei. Vou montar um sistema de diálogo em Godot do zero, com código que de fato roda, e ir explicando por que cada peça existe. Sem mágica, sem framework gigante. Você sai daqui com uma base que dá pra colocar no seu projeto hoje e expandir depois. Se você está dando os primeiros passos, um caminho mais leve pra começar é fazer uma visual novel no Godot, que usa uma versão enxuta dessas mesmas ideias.
O que um sistema de diálogo precisa fazer
No fundo, um sistema de diálogo gerencia quatro coisas:
- Texto na tela: como e onde a fala aparece.
- Fluxo da conversa: a ordem das falas e onde elas se dividem.
- Escolhas do jogador: opções que mudam o rumo da narrativa.
- Consequências: o que cada escolha dispara no resto do jogo (item, quest, relacionamento).
Olha Undertale, Disco Elysium ou The Witcher 3. O que faz o diálogo desses jogos funcionar não é efeito visual, é a sensação de que sua escolha importou. Esse é o objetivo. O resto é encanamento.
Componente 1: como guardar os diálogos
Antes de escrever uma linha de código, decida onde o texto vai morar. As opções que valem a pena:
- JSON ou YAML: fácil de editar, qualquer escritor mexe sem saber programar. É por onde eu recomendo começar.
- Custom Resources do Godot: integração nativa com a engine, dá pra editar no inspetor. Bom quando o projeto cresce.
- Ink ou Yarn Spinner: linguagens feitas pra narrativa ramificada. Valem quando a história fica complexa de verdade (falo delas mais pra frente).
Banco de dados eu só usaria num jogo com dezenas de milhares de linhas, o que quase nunca é o seu caso no começo. Comece simples.
Aqui está uma estrutura JSON enxuta pra um diálogo com escolhas:
{
"dialogue_id": "merchant_greeting",
"speaker": "Mercador",
"lines": [
{
"text": "Bem-vindo à minha loja! Procurando algo especial?",
"choices": [
{ "text": "Mostre-me suas armas", "next": "merchant_weapons" },
{ "text": "Preciso de poções", "next": "merchant_potions" },
{ "text": "Só estou olhando", "next": "merchant_browsing" }
]
}
]
}
Repare numa coisa: cada escolha tem um campo next que aponta pro dialogue_id de outro arquivo. É assim que a ramificação acontece. Escolheu "armas", o sistema carrega merchant_weapons.json. Guarde isso, porque é o que o gerenciador vai precisar resolver.
Componente 2: o gerenciador de diálogo
O DialogueManager é o cérebro. Ele carrega o JSON, controla em que linha você está, emite sinais quando algo acontece e carrega o próximo diálogo quando você escolhe uma opção. Uso sinais (signal) de propósito: assim a UI não precisa conhecer o gerenciador por dentro, só ouve o que ele anuncia.
extends Node
class_name DialogueManager
var current_dialogue: Dictionary = {}
var current_line_index: int = 0
var dialogue_history: Array = []
signal line_displayed(text: String, speaker: String)
signal choices_available(choices: Array)
signal dialogue_ended()
func load_dialogue(dialogue_id: String) -> void:
var path := "res://dialogues/%s.json" % dialogue_id
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_error("Diálogo não encontrado: " + path)
return
var parsed = JSON.parse_string(file.get_as_text())
if parsed == null:
push_error("JSON inválido em: " + path)
return
current_dialogue = parsed
current_line_index = 0
display_current_line()
func display_current_line() -> void:
if current_line_index >= current_dialogue["lines"].size():
dialogue_ended.emit()
return
var line: Dictionary = current_dialogue["lines"][current_line_index]
line_displayed.emit(line["text"], current_dialogue["speaker"])
if line.has("choices"):
choices_available.emit(line["choices"])
func choose_option(choice_index: int) -> void:
var line: Dictionary = current_dialogue["lines"][current_line_index]
var choice: Dictionary = line["choices"][choice_index]
dialogue_history.append({
"dialogue": current_dialogue["dialogue_id"],
"choice": choice["text"],
})
if choice.has("next"):
load_dialogue(choice["next"])
else:
next_line()
func next_line() -> void:
current_line_index += 1
display_current_line()
Duas decisões importantes aqui que vão te poupar dor de cabeça:
Primeiro, eu checo file == null e parsed == null antes de usar. JSON quebrado e arquivo faltando são os dois bugs mais comuns nessa parte, e sem o push_error você fica olhando pra uma tela travada sem saber o porquê.
Segundo, acesso os campos do JSON com colchetes (current_dialogue["lines"]), não com ponto. Quando o Godot faz parse de JSON, o resultado é um Dictionary puro. A notação de ponto funciona em alguns casos, mas com colchetes você nunca é pego de surpresa.
Componente 3: a interface
A UI ouve os sinais do gerenciador e desenha. Aqui ela faz três coisas: revela o texto com efeito de digitação, monta os botões de escolha na hora, e deixa o jogador pular a animação ou avançar com uma tecla.
extends Control
@onready var speaker_name: Label = $DialogueBox/SpeakerName
@onready var dialogue_text: Label = $DialogueBox/DialogueText
@onready var choices_container: VBoxContainer = $ChoicesContainer
@onready var continue_indicator: Control = $DialogueBox/ContinueIndicator
var dialogue_manager: DialogueManager
var is_typing: bool = false
var current_text: String = ""
var typing_speed: float = 0.04
func _ready() -> void:
dialogue_manager = DialogueManager.new()
add_child(dialogue_manager)
dialogue_manager.line_displayed.connect(_on_line_displayed)
dialogue_manager.choices_available.connect(_on_choices_available)
dialogue_manager.dialogue_ended.connect(_on_dialogue_ended)
hide()
func start_dialogue(dialogue_id: String) -> void:
show()
dialogue_manager.load_dialogue(dialogue_id)
func _on_line_displayed(text: String, speaker: String) -> void:
speaker_name.text = speaker
current_text = text
dialogue_text.text = ""
continue_indicator.hide()
is_typing = true
for i in text.length():
if not is_typing:
dialogue_text.text = current_text # jogador pulou: mostra tudo
break
dialogue_text.text += text[i]
await get_tree().create_timer(typing_speed).timeout
is_typing = false
continue_indicator.show()
func _on_choices_available(choices: Array) -> void:
for child in choices_container.get_children():
child.queue_free()
for i in choices.size():
var button := Button.new()
button.text = choices[i]["text"]
var index := i # captura o valor agora, não a variável do loop
button.pressed.connect(func(): dialogue_manager.choose_option(index))
choices_container.add_child(button)
choices_container.show()
func _on_dialogue_ended() -> void:
choices_container.hide()
hide()
func _input(event: InputEvent) -> void:
if not visible:
return
if event.is_action_pressed("ui_accept"):
if is_typing:
is_typing = false # pula a digitação
elif choices_container.get_child_count() == 0:
dialogue_manager.next_line()
Tem um detalhe sutil em _on_choices_available que pega quase todo mundo. Dentro do loop eu copio i pra uma variável index antes de usar na lambda. Se você fechar o connect direto sobre i, todas as escolhas acabam apontando pro mesmo valor de i quando o botão é clicado, e você seleciona sempre a opção errada. Copiar pra uma variável local resolve. Esse é o tipo de bug que custa uma tarde se você não souber que ele existe.
O resto é direto: o efeito de digitação é só um loop que adiciona um caractere e espera um timer. Apertou ui_accept no meio? is_typing vira false, o loop revela o texto inteiro de uma vez e para. É a interação que todo jogador espera de uma caixa de diálogo.
Recursos que separam o protótipo do jogo
A base acima já funciona. Agora os pedaços que fazem o diálogo parecer profissional.
Efeito de digitação com som
Em vez de concatenar caractere por caractere, dá pra usar visible_characters num RichTextLabel. O texto inteiro já está no label, você só controla quantos caracteres aparecem. Fica mais leve e funciona melhor com BBCode (negrito, cor, etc).
func typewriter_effect(text: String, label: RichTextLabel, speed: float = 0.04) -> void:
label.text = text
label.visible_characters = 0
for i in text.length():
label.visible_characters += 1
if text[i] != " ":
$TypingSound.play()
await get_tree().create_timer(speed).timeout
Um aviso de quem já errou isso: tocar o som de digitação em todo caractere fica irritante rápido. Na prática eu toco o som a cada dois ou três caracteres, ou pulo vogais. Teste com fone, não com o som no talo, porque o que parece ok baixinho vira metralhadora no volume normal.
Variáveis e condições
Pra um diálogo reagir ao estado do jogo, você precisa de duas coisas: substituir variáveis no texto (o nome do jogador, por exemplo) e checar condições pra liberar ou esconder escolhas.
var game_state: Dictionary = {
"player_name": "Herói",
"merchant_friendship": 0,
"has_sword": false,
"completed_quests": [],
}
func check_condition(condition: String) -> bool:
match condition:
"has_sword":
return game_state["has_sword"]
"high_friendship":
return game_state["merchant_friendship"] >= 50
"completed_first_quest":
return "first_quest" in game_state["completed_quests"]
return false
func replace_variables(text: String) -> String:
for key in game_state:
text = text.replace("{%s}" % key, str(game_state[key]))
return text
Com isso, uma fala como "E aí, {player_name}, já decidiu?" vira "E aí, Herói, já decidiu?" na tela. E uma escolha com um campo "requires": "high_friendship" só aparece se a amizade com o mercador passou de 50. É aqui que o diálogo começa a parecer vivo.
Ramificação de verdade
Com next e condições montados, a ramificação é só estruturar o JSON. Uma escolha moral, por exemplo:
{
"dialogue_id": "moral_choice",
"speaker": "Narrador",
"lines": [
{
"text": "Você vê um ladrão fugindo. O que faz?",
"choices": [
{ "text": "[Parar o ladrão]", "next": "heroic_path", "effects": ["alignment_good 10"] },
{ "text": "[Deixar escapar]", "next": "neutral_path" },
{ "text": "[Ajudar o ladrão]", "next": "villainous_path", "effects": ["alignment_evil 10"], "requires": "has_lockpick" }
]
}
]
}
A última escolha só aparece se o jogador tem gazua, e cada caminho dispara um effect no fim. Como esses efeitos viram ação no jogo é o próximo assunto.
Conectando o diálogo ao resto do jogo
Um sistema de diálogo isolado não vale nada. Ele precisa ser disparado por algo e precisa mudar algo.
Disparar a conversa
O jeito mais comum é uma Area2D no mundo. O jogador entra, a conversa começa. Eu deixo configurável: disparo automático ou via botão de interação, e a opção de rodar só uma vez.
extends Area2D
@export var dialogue_id: String = ""
@export var auto_trigger: bool = true
@export var trigger_once: bool = true
var has_triggered: bool = false
func _ready() -> void:
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node) -> void:
if not body.is_in_group("player"):
return
if trigger_once and has_triggered:
return
if auto_trigger:
trigger_dialogue()
else:
show_interaction_prompt()
func trigger_dialogue() -> void:
var ui := get_node("/root/DialogueUI")
ui.start_dialogue(dialogue_id)
has_triggered = true
func show_interaction_prompt() -> void:
pass # mostre aqui o "[E] Falar", e chame trigger_dialogue() no input
Os efeitos das escolhas
Lembra dos effects no JSON? Aqui eles viram ação. Eu uso strings simples ("give_item espada 1") e faço o parse na hora. É menos elegante que um sistema de eventos formal, mas é fácil de escrever pra quem está montando os diálogos, que muitas vezes não é programador.
func process_dialogue_effects(effects: Array) -> void:
for effect in effects:
var parts: PackedStringArray = effect.split(" ")
match parts[0]:
"start_quest":
QuestManager.start_quest(parts[1])
"complete_quest":
QuestManager.complete_quest(parts[1])
"give_item":
Inventory.add_item(parts[1], int(parts[2]))
"change_relationship":
game_state[parts[1]] += int(parts[2])
QuestManager e Inventory aqui são autoloads (singletons) do seu projeto. O ponto não é o nome deles, é o padrão: o diálogo não sabe nada sobre quests ou inventário, ele só grita "start_quest tal" e quem precisa escuta.
Quando partir pra uma ferramenta pronta
Tudo que eu mostrei até aqui você escreve à mão e controla 100%. Mas chega um ponto, normalmente quando a história tem muitas ramificações que se reencontram e condições aninhadas, em que escrever JSON na mão vira sofrimento. Aí vale olhar ferramenta dedicada.
Ink (da inkle) é uma linguagem de roteiro feita pra prosa ramificada. Foi usada em jogos como Heaven's Vault e 80 Days. A sintaxe é pensada pra escritor, não pra programador:
=== merchant_greeting ===
Bem-vindo à minha loja!
+ [Mostre suas armas] -> weapons
+ [Preciso de poções] -> potions
+ [Até logo] -> END
=== weapons ===
Temos as melhores lâminas da região.
{ player_gold >= 100: -> can_afford | -> too_poor }
Yarn Spinner é o mais popular no Godot e no Unity hoje, com plugin oficial pras duas engines. O formato lembra um roteiro de filme:
title: MerchantGreeting
---
Mercador: Olá, viajante! Como posso ajudar?
-> Mostre suas armas
Mercador: Ah, um conhecedor! Veja estas belezas.
<<jump WeaponsShop>>
-> Preciso de poções
Mercador: Temos os melhores elixires!
<<jump PotionsShop>>
===
Minha regra: se o seu jogo tem diálogo simples e linear com poucas escolhas, o sistema próprio que montamos aqui é mais que suficiente e você não carrega dependência. Se a narrativa é o coração do jogo e os escritores vão mexer no texto sem você, parta pro Yarn Spinner. Não escreva seu próprio Ink, alguém já fez e fez melhor.
UI/UX: os detalhes que ninguém comenta mas todo mundo sente
O código faz funcionar. Esses detalhes fazem parecer bom.
Legibilidade primeiro. Fonte sem serifa, contraste alto, e um padding generoso na caixa. Texto colado na borda parece amador. Limite o comprimento da linha pra algo em torno de 60 a 70 caracteres, porque linha muito longa o olho se perde voltando.
Feedback constante. O indicador de "continuar" precisa se mexer (pisca ou balança), senão o jogador não sabe se a fala acabou ou travou. Botão de escolha precisa de estado de hover óbvio. Transição com fade ao trocar de diálogo, nunca corte seco.
Respeite o tempo do jogador. Três coisas que parecem opcionais mas mudam tudo em jogo com muito texto:
- Pular fala já vista: ninguém quer reler diálogo depois de morrer no boss.
- Log de histórico: salva o que já foi dito, pro jogador que se perdeu.
- Velocidade ajustável: tem gente que lê rápido e odeia esperar a animação.
var dialogue_seen: Dictionary = {}
var dialogue_log: Array = []
func mark_as_seen(dialogue_id: String) -> void:
dialogue_seen[dialogue_id] = true
func can_skip(dialogue_id: String) -> bool:
return dialogue_seen.has(dialogue_id)
func add_to_log(speaker: String, text: String) -> void:
dialogue_log.append({ "speaker": speaker, "text": text })
Localização: pense nisso cedo
Traduzir um jogo depois que ele está pronto, com texto espalhado pelo código, é um dos piores retrabalhos que existe. Se há qualquer chance de você lançar em mais de um idioma, separe o texto desde o começo.
O Godot já tem um sistema de tradução nativo bom (arquivos CSV ou .po que você carrega como Translation, e a função tr()). Use ele de verdade num projeto sério. Mas pra você entender o conceito sem complicar, a ideia é só ter uma camada que troca a chave pelo texto no idioma atual:
var current_language: String = "pt_BR"
var translations: Dictionary = {
"pt_BR": { "merchant_greeting": "Bem-vindo à minha loja!" },
"en_US": { "merchant_greeting": "Welcome to my shop!" },
}
func get_text(key: String) -> String:
var lang: Dictionary = translations.get(current_language, {})
return lang.get(key, key) # cai pra chave se faltar tradução
Repare que se a tradução não existe, ele devolve a própria chave em vez de quebrar. Isso te avisa visualmente no jogo (você vê merchant_greeting na tela) qual texto faltou traduzir. Pequeno truque, salva muito tempo de revisão.
Antes de considerar pronto: teste isto
Sistema de diálogo é traiçoeiro porque um caminho ramificado pode estar quebrado e você só descobre quando o jogador escolhe aquela opção específica. Rode esta lista:
- Todo diálogo carrega sem erro (cheque o console por
push_error). - Toda escolha leva ao diálogo certo. Teste os caminhos, não só o principal.
- Variáveis são substituídas (nenhum
{player_name}aparece cru na tela). - Condições liberam e escondem as escolhas certas.
- Não existe beco sem saída: todo caminho ou continua ou chega ao fim.
- A UI fica legível em resoluções diferentes.
- Pular fala e histórico funcionam.
Pra caçar os bugs de fluxo, deixe uma função de debug à mão:
func debug_dialogue_state() -> void:
print("Diálogo atual: ", current_dialogue.get("dialogue_id", "?"))
print("Linha: ", current_line_index)
print("Histórico: ", dialogue_history)
Por onde começar
Não tente fazer o sistema completo de uma vez. Esse é o erro clássico, e eu vi muito projeto morrer assim. O caminho que funciona:
- Faça só o básico: carregar JSON, mostrar texto, avançar com uma tecla.
- Escreva três ou quatro conversas reais do seu jogo e teste com elas.
- Aí sim adicione escolhas e ramificação.
- Depois o efeito de digitação e o som.
- Por último, condições, efeitos e localização, quando o jogo pedir.
Cada passo desses já é um sistema utilizável. Você nunca fica com um monte de código pela metade que não roda. Esse é o jeito que eu construo qualquer sistema hoje, depois de anos fazendo o contrário e me arrependendo.
O melhor sistema de diálogo não é o mais cheio de recurso. É o que some na frente da sua história e deixa a escolha do jogador importar. Comece pequeno, coloque texto de verdade pra rodar logo, e expanda quando o jogo precisar, não antes.
::blog-cta{title="Pronto para Criar Jogos Profissionais?" description="Aprenda a desenvolver sistemas complexos como diálogo, inventário e IA em nossos cursos práticos. Do básico ao avançado." buttonText="Candidate-se Agora" icon="fas fa-comments" variant="highlight"}::


