Como Fazer um Jogo de Quiz na Unity com ScriptableObject
Um unity quiz game bem feito guarda cada pergunta como um asset ScriptableObject, monta os botões de resposta em runtime, embaralha as alternativas sem perder qual é a correta e mostra uma tela de resultado no fim. Com essa estrutura, adicionar a pergunta número 51 é criar um arquivo pelo menu e arrastar para uma lista, sem abrir o código.
Este guia monta o quiz completo em C# para o Unity 6.3 LTS: dados, interface, embaralhamento, feedback de acerto, pontuação e reinício, mais uma seção opcional para carregar perguntas de um arquivo JSON.
Por que quiz ensina arquitetura
Um quiz parece simples demais para ser projeto de estudo, e é justamente por isso que ele ensina. Não tem física, não tem animação de personagem, não tem câmera. Sobra o que importa em qualquer jogo: separar dados (as perguntas) de lógica (o fluxo do quiz) e de apresentação (a UI).
O jeito ingênuo de fazer um quiz é um script com um if para cada pergunta e textos escritos no código. Funciona com 5 perguntas e vira um pesadelo com 30. O jeito data-driven inverte: o código não sabe quantas perguntas existem nem o que elas dizem. Ele só sabe pegar a próxima, mostrar e conferir a resposta. Esse mesmo raciocínio aparece em inventários, diálogos, cartas, inimigos e fases, e é um dos padrões de projeto para jogos mais úteis que existem.
Se você já viu a versão para a outra engine, o quiz na Godot usa Resources no papel que aqui é dos ScriptableObjects. A ideia é a mesma.
Pergunta como ScriptableObject
ScriptableObject é uma classe da Unity para guardar dados como asset no projeto, independente de cena. O atributo [CreateAssetMenu] adiciona uma entrada no menu Assets > Create para criar instâncias pelo editor.
Arquivo QuestionData.cs:
using UnityEngine;
[CreateAssetMenu(fileName = "Question", menuName = "Quiz/Question")]
public class QuestionData : ScriptableObject
{
[TextArea(2, 5)]
[SerializeField] private string questionText;
[SerializeField] private string[] answers = new string[4];
[SerializeField] private int correctIndex;
[SerializeField] private Sprite image;
public string QuestionText => questionText;
public string[] Answers => answers;
public int CorrectIndex => correctIndex;
public Sprite Image => image;
private void OnValidate()
{
if (answers == null || answers.Length == 0) return;
correctIndex = Mathf.Clamp(correctIndex, 0, answers.Length - 1);
}
}
Os campos são privados com [SerializeField] e expostos só para leitura. Ninguém em runtime consegue trocar a resposta correta de um asset por acidente, o que é importante: alterações em ScriptableObject durante o Play Mode no editor ficam gravadas no asset. O OnValidate roda no editor sempre que você muda um valor no Inspector e impede um correctIndex fora do intervalo.
Crie uma pasta Assets/Quiz/Questions, clique com o botão direito e use Create > Quiz > Question. Preencha 5 perguntas para testar. Um bom hábito: nomeie os arquivos com prefixo de número (Q01_capital_brasil), assim a ordem na pasta é previsível.
Para agrupar perguntas por tema ou dificuldade, um segundo ScriptableObject funciona como pacote.
Arquivo QuizSet.cs:
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(fileName = "QuizSet", menuName = "Quiz/Quiz Set")]
public class QuizSet : ScriptableObject
{
[SerializeField] private string title = "Conhecimentos gerais";
[SerializeField] private List<QuestionData> questions = new List<QuestionData>();
[SerializeField] private bool shuffleQuestions = true;
public string Title => title;
public IReadOnlyList<QuestionData> Questions => questions;
public bool ShuffleQuestions => shuffleQuestions;
}
Quiz na Unity: UI de pergunta e alternativas
Monte a UI uma vez, com espaço para N botões:
- GameObject > UI > Canvas. No Canvas Scaler, UI Scale Mode: Scale With Screen Size, referência 1080x1920 para celular em pé.
- Dentro do Canvas, um painel
QuestionPanelcom um Text - TextMeshPro para a pergunta, uma Image opcional e um objeto vazioAnswersContainercom Vertical Layout Group (Spacing 24, Child Force Expand Width marcado). - Crie um botão UI > Button - TextMeshPro, ajuste a altura para 140, transforme em prefab
AnswerButtone apague da cena. - Um segundo painel
ResultPanel, desativado, com um texto de resultado e um botão "Jogar de novo".
No Unity 6 o TextMeshPro vem dentro do pacote de UI (com.unity.ugui). Na primeira vez que você cria um texto TMP, aceite Import TMP Essentials.
O botão de resposta tem seu próprio script para guardar a referência ao texto e à imagem e para mudar de cor no feedback.
Arquivo AnswerButton.cs:
using TMPro;
using UnityEngine;
using UnityEngine.UI;
[RequireComponent(typeof(Button))]
public class AnswerButton : MonoBehaviour
{
[SerializeField] private TMP_Text label;
[SerializeField] private Image background;
[SerializeField] private Color normalColor = new Color(0.16f, 0.18f, 0.24f);
[SerializeField] private Color correctColor = new Color(0.2f, 0.7f, 0.35f);
[SerializeField] private Color wrongColor = new Color(0.8f, 0.2f, 0.25f);
public Button Button { get; private set; }
private void Awake()
{
Button = GetComponent<Button>();
}
public void Setup(string text)
{
label.text = text;
background.color = normalColor;
Button.interactable = true;
}
public void ShowCorrect()
{
background.color = correctColor;
}
public void ShowWrong()
{
background.color = wrongColor;
}
}
Embaralhar alternativas sem perder a correta
Se a resposta certa está sempre no botão A, o jogador descobre na terceira pergunta. É preciso embaralhar, e o cuidado é não perder a informação de qual alternativa é a correta.
A solução é não embaralhar os textos, e sim uma lista de índices. Para 4 alternativas, a lista começa como [0, 1, 2, 3] e depois do embaralhamento pode virar [2, 0, 3, 1]. O botão na posição 0 mostra a alternativa 2, e assim por diante. Na hora de conferir, basta ver se o índice original do botão clicado é o correctIndex.
O algoritmo é o Fisher-Yates: percorre a lista de trás para frente e troca cada posição com uma posição aleatória anterior ou igual. Ele dá a mesma chance para todas as permutações, o que não acontece com o atalho comum de ordenar por número aleatório.
Arquivo ListShuffle.cs:
using System.Collections.Generic;
using UnityEngine;
public static class ListShuffle
{
public static void Shuffle<T>(IList<T> list)
{
for (int i = list.Count - 1; i > 0; i--)
{
int j = Random.Range(0, i + 1);
(list[i], list[j]) = (list[j], list[i]);
}
}
}
Atenção ao Random.Range(0, i + 1): com inteiros, o segundo parâmetro do Random.Range da Unity é exclusivo. Escrever Random.Range(0, i) deixa a posição i fora do sorteio e quebra a distribuição.
Com dados, interface e embaralhamento prontos, falta o fluxo que dá vida ao quiz. E é aqui que o projeto cresce: depois do fluxo vêm menu, áudio, recorde salvo, UI que se adapta a qualquer tela de celular e a build para a loja. É nesse ponto que fazer sozinho costuma travar, porque cada peça nova mexe nas anteriores e ninguém te mostra a ordem.
Feedback, pontuação e próxima pergunta
O QuizManager junta tudo. Ele copia as perguntas do QuizSet para uma lista de sessão, embaralha se configurado, cria os botões e controla o fluxo.
Arquivo QuizManager.cs:
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
public class QuizManager : MonoBehaviour
{
[SerializeField] private QuizSet quizSet;
[SerializeField] private TMP_Text questionLabel;
[SerializeField] private TMP_Text progressLabel;
[SerializeField] private Image questionImage;
[SerializeField] private Transform answersContainer;
[SerializeField] private AnswerButton answerButtonPrefab;
[SerializeField] private GameObject questionPanel;
[SerializeField] private ResultScreen resultScreen;
[SerializeField] private float feedbackDelay = 1.2f;
private readonly List<QuestionData> session = new List<QuestionData>();
private readonly List<AnswerButton> buttons = new List<AnswerButton>();
private readonly List<int> order = new List<int>();
private int currentIndex;
private int score;
private void Start()
{
StartQuiz();
}
public void StartQuiz()
{
session.Clear();
session.AddRange(quizSet.Questions);
if (quizSet.ShuffleQuestions)
{
ListShuffle.Shuffle(session);
}
currentIndex = 0;
score = 0;
questionPanel.SetActive(true);
resultScreen.Hide();
ShowQuestion();
}
private void ShowQuestion()
{
QuestionData question = session[currentIndex];
questionLabel.text = question.QuestionText;
progressLabel.text = (currentIndex + 1) + " / " + session.Count;
questionImage.gameObject.SetActive(question.Image != null);
questionImage.sprite = question.Image;
EnsureButtons(question.Answers.Length);
order.Clear();
for (int i = 0; i < question.Answers.Length; i++)
{
order.Add(i);
}
ListShuffle.Shuffle(order);
for (int slot = 0; slot < buttons.Count; slot++)
{
AnswerButton button = buttons[slot];
bool used = slot < order.Count;
button.gameObject.SetActive(used);
if (!used) continue;
int originalIndex = order[slot];
button.Setup(question.Answers[originalIndex]);
button.Button.onClick.RemoveAllListeners();
button.Button.onClick.AddListener(() => OnAnswer(originalIndex));
}
}
private void EnsureButtons(int count)
{
while (buttons.Count < count)
{
AnswerButton created = Instantiate(answerButtonPrefab, answersContainer);
buttons.Add(created);
}
}
private void OnAnswer(int chosenOriginalIndex)
{
StartCoroutine(AnswerRoutine(chosenOriginalIndex));
}
private IEnumerator AnswerRoutine(int chosen)
{
QuestionData question = session[currentIndex];
bool isCorrect = chosen == question.CorrectIndex;
if (isCorrect) score++;
for (int slot = 0; slot < order.Count; slot++)
{
AnswerButton button = buttons[slot];
button.Button.interactable = false;
if (order[slot] == question.CorrectIndex)
{
button.ShowCorrect();
}
else if (order[slot] == chosen)
{
button.ShowWrong();
}
}
yield return new WaitForSeconds(feedbackDelay);
currentIndex++;
if (currentIndex < session.Count)
{
ShowQuestion();
}
else
{
questionPanel.SetActive(false);
resultScreen.Show(score, session.Count);
}
}
}
Os pontos que mais causam bug estão neste script:
Captura de variável no AddListener. Repare na linha int originalIndex = order[slot]; dentro do laço. A lambda () => OnAnswer(originalIndex) captura essa variável local, que é nova a cada volta. Se você escrever AddListener(() => OnAnswer(order[slot])), a lambda captura slot, que é a mesma variável do for em todas as voltas; quando o jogador clica, slot já vale o número final e todos os botões respondem a mesma coisa (ou dão erro de índice). No foreach o C# cria uma variável nova por volta, no for não.
RemoveAllListeners antes de adicionar. Os botões são reaproveitados de pergunta em pergunta. Sem limpar, o terceiro clique dispara três respostas acumuladas.
Travar os botões durante o feedback. interactable = false impede o clique duplo que contaria dois pontos ou pularia uma pergunta.
Mostrar a correta mesmo quando o jogador erra. O jogador aprende com o erro, e isso é metade da graça de um quiz.
Tela de resultado e reinício
Arquivo ResultScreen.cs:
using TMPro;
using UnityEngine;
using UnityEngine.UI;
public class ResultScreen : MonoBehaviour
{
[SerializeField] private GameObject panel;
[SerializeField] private TMP_Text scoreLabel;
[SerializeField] private TMP_Text messageLabel;
[SerializeField] private Button restartButton;
[SerializeField] private QuizManager quizManager;
private void Awake()
{
restartButton.onClick.AddListener(quizManager.StartQuiz);
}
public void Show(int score, int total)
{
panel.SetActive(true);
scoreLabel.text = score + " de " + total;
float ratio = total > 0 ? (float)score / total : 0f;
messageLabel.text = ratio >= 0.8f ? "Mandou muito bem!" : ratio >= 0.5f ? "Quase lá." : "Bora tentar de novo?";
SaveBest(score);
}
public void Hide()
{
panel.SetActive(false);
}
private void SaveBest(int score)
{
int best = PlayerPrefs.GetInt("quiz_best", 0);
if (score > best)
{
PlayerPrefs.SetInt("quiz_best", score);
PlayerPrefs.Save();
}
}
}
O ResultScreen fica num objeto que continua ativo; quem liga e desliga é o panel interno. Se o próprio objeto do script for desativado, o Awake não roda até ele ser ativado, e o botão de reinício fica sem listener. Para recorde simples, PlayerPrefs atende; para progresso maior, use um save em JSON.
Carregar perguntas de JSON (opcional)
Quando as perguntas vêm de uma planilha, de um professor ou de um servidor, escrever asset por asset não escala. O caminho é um arquivo JSON e o JsonUtility, que já vem na Unity. Ele não lê um array na raiz, então o JSON precisa de um objeto em volta:
{
"questions": [
{ "text": "Qual linguagem a Unity usa para scripts?", "answers": ["C#", "Python", "Lua", "Java"], "correct": 0 },
{ "text": "Qual componente detecta colisão em 2D?", "answers": ["Collider2D", "Mesh", "Animator", "Light"], "correct": 0 }
]
}
Arquivo JsonQuizLoader.cs:
using System;
using UnityEngine;
[Serializable]
public class JsonQuestion
{
public string text;
public string[] answers;
public int correct;
}
[Serializable]
public class JsonQuestionList
{
public JsonQuestion[] questions;
}
public class JsonQuizLoader : MonoBehaviour
{
[SerializeField] private TextAsset jsonFile;
public JsonQuestion[] Load()
{
JsonQuestionList list = JsonUtility.FromJson<JsonQuestionList>(jsonFile.text);
return list != null && list.questions != null ? list.questions : Array.Empty<JsonQuestion>();
}
}
O arquivo .json dentro de Assets vira um TextAsset arrastável. Os campos das classes JsonQuestion precisam ter exatamente os nomes das chaves do JSON e ser públicos (ou [SerializeField]), senão o JsonUtility ignora sem avisar. Esse silêncio é a causa número um de "carregou zero perguntas": uma chave "Text" com maiúscula no JSON não casa com o campo text.
Para o QuizManager não precisar saber de onde a pergunta veio, converta cada JsonQuestion num QuestionData criado em memória com ScriptableObject.CreateInstance. Acrescente este método de inicialização no QuestionData.cs, logo abaixo do OnValidate:
public void Initialize(string text, string[] options, int correct)
{
questionText = text;
answers = options;
correctIndex = Mathf.Clamp(correct, 0, options.Length - 1);
}
E use no carregador, num método extra do JsonQuizLoader.cs:
public System.Collections.Generic.List<QuestionData> LoadAsQuestionData()
{
var result = new System.Collections.Generic.List<QuestionData>();
foreach (JsonQuestion item in Load())
{
QuestionData data = ScriptableObject.CreateInstance<QuestionData>();
data.Initialize(item.text, item.answers, item.correct);
result.Add(data);
}
return result;
}
As instâncias criadas assim existem só em memória, não viram arquivo no projeto, e servem exatamente como os assets feitos à mão. No QuizManager, basta um campo opcional para o carregador e, no StartQuiz, somar loader.LoadAsQuestionData() à lista session quando ele estiver preenchido. O resto do fluxo não muda uma linha, e essa é a prova de que a separação entre dados e lógica funcionou.
Se as perguntas vêm de um servidor, o mesmo JsonUtility.FromJson lê o texto baixado com UnityWebRequest. Só lembre de guardar uma cópia local para o quiz abrir sem internet.
Erros comuns
- Todos os botões respondem igual. Captura de variável do
forna lambda. Copie para uma local. - Clique acumulando. Faltou
RemoveAllListenersao reaproveitar botões. - Resposta certa sempre na mesma posição. Faltou embaralhar os índices, ou o embaralhamento usa
Random.Range(0, i). - Pergunta "perde" a resposta correta depois de embaralhar. Você embaralhou os textos e não os índices.
- Asset de pergunta alterado sozinho. Algum script escreveu no ScriptableObject durante o Play Mode. Exponha só leitura.
- Texto TMP rosa ou invisível. Os TMP Essential Resources não foram importados.
- UI quebrada no celular. Canvas Scaler em Constant Pixel Size. Use Scale With Screen Size.
Próximo passo
Coloque 20 perguntas em dois QuizSet (fácil e difícil) e acrescente um tempo limite por pergunta com uma barra que esvazia. O timer muda o quiz de leitura para decisão sob pressão, e você vai precisar parar a contagem durante o feedback, um bom exercício de fluxo. Para entender melhor quando e por que usar esse tipo de asset, leia o guia de ScriptableObject na Unity, e para organizar sistemas maiores, os padrões de projeto para jogos.
Perguntas frequentes
Como fazer um quiz na Unity sem escrever as perguntas no código?
Crie uma classe que herda de ScriptableObject com o texto da pergunta, as alternativas e o índice da correta, e marque com [CreateAssetMenu]. Cada pergunta vira um asset criado pelo menu Assets > Create. O gerenciador do quiz recebe uma lista desses assets, então adicionar pergunta é criar um arquivo novo e arrastar para a lista.
Por que todos os botões do meu quiz respondem a mesma alternativa?
É a captura de variável em closure. Se você usa um for e passa i direto para o AddListener, todos os botões enxergam o valor final de i quando são clicados. Copie o valor para uma variável local dentro do laço (int index = i) e use essa variável na lambda.
ScriptableObject ou JSON para guardar perguntas de quiz?
ScriptableObject é melhor quando quem cria as perguntas está dentro da Unity: tem Inspector, validação e referência a imagens e sons. JSON é melhor quando as perguntas vêm de fora, de uma planilha ou de um servidor, ou quando são centenas. Dá para combinar os dois: JSON importado para a mesma estrutura de dados.
Preciso instalar o TextMeshPro no Unity 6?
Não como pacote separado. No Unity 6 o TextMeshPro faz parte do pacote de UI (com.unity.ugui). Na primeira vez que você cria um texto TMP, a Unity pede para importar os TMP Essential Resources, que são as fontes e shaders padrão.



