Jogo Idle Clicker na Unity: Números Grandes e Progresso Offline
Um unity idle clicker parece o jogo mais simples do mundo: clica, ganha moeda, compra upgrade. As partes que dão trabalho estão escondidas: números que passam de trilhões e precisam aparecer como "1,23 T", custo de upgrade que cresce exponencialmente, renda passiva por segundo e o progresso offline, calculado com DateTime.UtcNow quando o jogador volta depois de horas. Este guia resolve cada uma em C# para o Unity 6.3 LTS, com save em JSON que funciona no celular.
No fim você tem um idle jogável que ganha dinheiro com o app fechado e não quebra quando o jogador mexe no relógio do telefone.
O loop do idle clicker na Unity
O gênero gira em torno de um ciclo curto:
- O jogador clica e ganha moeda.
- Compra um upgrade que gera moeda sozinho (renda passiva).
- A renda passiva paga o próximo upgrade, cada vez mais caro.
- O jogador sai, volta horas depois e encontra o que acumulou.
O que mantém alguém jogando é a relação entre o passo 2 e o 3: o custo cresce mais rápido que a renda, então cada compra demora um pouco mais. Se demorar demais, o jogador desiste; se for rápido, o jogo acaba em uma tarde. Esse equilíbrio é um problema de economia de jogo e aparece em números neste post.
Estrutura do projeto: uma cena com Canvas (Scale With Screen Size, 1080x1920), um botão grande para clicar, um texto de moedas, um texto de renda por segundo e uma lista vertical de upgrades. Um IdleGame central guarda o estado; a UI só lê.
Clique e moeda principal
O estado inteiro do jogo cabe numa classe serializável. Isso facilita o save: o que está nessa classe é salvo, o que não está é recalculado.
Arquivo IdleState.cs:
using System;
using System.Collections.Generic;
[Serializable]
public class IdleState
{
public double coins;
public double totalEarned;
public List<int> upgradeLevels = new List<int>();
public long lastSaveUtcTicks;
}
Arquivo UpgradeDefinition.cs:
using UnityEngine;
[CreateAssetMenu(fileName = "Upgrade", menuName = "Idle/Upgrade")]
public class UpgradeDefinition : ScriptableObject
{
[SerializeField] private string displayName = "Minerador";
[SerializeField] private double baseCost = 15;
[SerializeField] private double costGrowth = 1.15;
[SerializeField] private double incomePerLevel = 0.5;
public string DisplayName => displayName;
public double IncomePerLevel => incomePerLevel;
public double CostForLevel(int ownedLevel)
{
return baseCost * System.Math.Pow(costGrowth, ownedLevel);
}
}
O clique soma um valor fixo mais uma fração da renda passiva, o que mantém o clique relevante quando a renda cresce:
public void Click()
{
double gain = 1 + IncomePerSecond * 0.05;
AddCoins(gain);
}
Esse método fica no IdleGame, mostrado completo mais abaixo.
Upgrades com custo exponencial
A fórmula do custo é custo = base x taxa ^ nível. Com base 15 e taxa 1,15, o primeiro minerador custa 15, o décimo custa cerca de 53 e o quinquagésimo passa de 14 mil. A renda de cada nível é linear (0,5 por segundo por nível). Custo exponencial contra renda linear é o que cria a curva de espera.
Taxas de partida que funcionam:
- 1,07: crescimento lento, o jogador compra muitos níveis. Bom para upgrades baratos.
- 1,15: o padrão do gênero. Cada nível custa 15% a mais.
- 1,25 ou mais: upgrades raros e caros, que o jogador compra poucas vezes.
Comprar 10 de uma vez (o botão "x10") pede a soma de uma progressão geométrica, e não um laço: o custo de comprar n níveis a partir do nível k é base x taxa^k x (taxa^n - 1) / (taxa - 1). Com 1000 níveis, o laço custaria 1000 multiplicações; a fórmula, duas potências.
public static double BulkCost(double baseCost, double growth, int owned, int amount)
{
return baseCost * System.Math.Pow(growth, owned) * (System.Math.Pow(growth, amount) - 1) / (growth - 1);
}
Renda passiva por segundo
A renda passiva é somada em Update multiplicando pela fração de segundo do frame. Não use um contador que soma 1 segundo de renda por vez com coroutine: se o jogo travar por 2 segundos, você perde a renda desse tempo.
Arquivo IdleGame.cs:
using System;
using System.Collections.Generic;
using UnityEngine;
public class IdleGame : MonoBehaviour
{
[SerializeField] private List<UpgradeDefinition> upgrades = new List<UpgradeDefinition>();
[SerializeField] private float autosaveInterval = 30f;
[SerializeField] private double maxOfflineHours = 8;
private IdleState state;
private float nextAutosave;
public double Coins => state.coins;
public double IncomePerSecond { get; private set; }
public double LastOfflineEarnings { get; private set; }
public IReadOnlyList<UpgradeDefinition> Upgrades => upgrades;
public event Action Changed;
private void Awake()
{
state = SaveSystem.Load() ?? new IdleState();
while (state.upgradeLevels.Count < upgrades.Count)
{
state.upgradeLevels.Add(0);
}
RecalculateIncome();
ApplyOfflineProgress();
}
private void Update()
{
AddCoins(IncomePerSecond * Time.unscaledDeltaTime);
if (Time.unscaledTime >= nextAutosave)
{
nextAutosave = Time.unscaledTime + autosaveInterval;
Save();
}
}
public void Click()
{
double gain = 1 + IncomePerSecond * 0.05;
AddCoins(gain);
}
public int LevelOf(int index)
{
return state.upgradeLevels[index];
}
public double CostOf(int index)
{
return upgrades[index].CostForLevel(state.upgradeLevels[index]);
}
public bool TryBuy(int index)
{
double cost = CostOf(index);
if (state.coins < cost) return false;
state.coins -= cost;
state.upgradeLevels[index]++;
RecalculateIncome();
Changed?.Invoke();
return true;
}
private void AddCoins(double amount)
{
state.coins += amount;
state.totalEarned += amount;
Changed?.Invoke();
}
private void RecalculateIncome()
{
double total = 0;
for (int i = 0; i < upgrades.Count; i++)
{
total += upgrades[i].IncomePerLevel * state.upgradeLevels[i];
}
IncomePerSecond = total;
}
private void ApplyOfflineProgress()
{
if (state.lastSaveUtcTicks <= 0) return;
long nowTicks = DateTime.UtcNow.Ticks;
double seconds = TimeSpan.FromTicks(nowTicks - state.lastSaveUtcTicks).TotalSeconds;
if (seconds <= 0) return;
double capped = Math.Min(seconds, maxOfflineHours * 3600.0);
LastOfflineEarnings = IncomePerSecond * capped;
AddCoins(LastOfflineEarnings);
}
public void Save()
{
state.lastSaveUtcTicks = DateTime.UtcNow.Ticks;
SaveSystem.Save(state);
}
private void OnApplicationPause(bool paused)
{
if (paused) Save();
}
private void OnApplicationFocus(bool hasFocus)
{
if (!hasFocus) Save();
}
private void OnApplicationQuit()
{
Save();
}
}
Time.unscaledDeltaTime em vez de Time.deltaTime mantém a renda correndo mesmo se você pausar o jogo com Time.timeScale = 0 para abrir um menu. No idle, o dinheiro não para.
O evento Changed dispara a cada frame por causa da renda. Para UI simples tudo bem; se a lista de upgrades crescer, atualize os textos no máximo 10 vezes por segundo.
Números grandes: formatar e quando double não basta
Ninguém quer ler 1234567890123 na tela. O formato do gênero é número com 3 dígitos significativos e sufixo: 1,23 T.
Arquivo BigNumberFormat.cs:
using System;
using System.Globalization;
public static class BigNumberFormat
{
private static readonly string[] Suffixes = { "", "K", "M", "B", "T", "Qa", "Qi", "Sx", "Sp", "Oc", "No", "Dc" };
private static readonly CultureInfo PtBr = new CultureInfo("pt-BR");
public static string Format(double value)
{
if (double.IsNaN(value) || double.IsInfinity(value)) return "∞";
if (value < 1000) return Math.Floor(value).ToString("0", PtBr);
int group = (int)Math.Floor(Math.Log10(value) / 3);
double scaled = value / Math.Pow(1000, group);
string suffix = group < Suffixes.Length ? Suffixes[group] : LetterSuffix(group - Suffixes.Length);
string number = scaled >= 100 ? scaled.ToString("0", PtBr) : scaled >= 10 ? scaled.ToString("0.0", PtBr) : scaled.ToString("0.00", PtBr);
return number + " " + suffix;
}
private static string LetterSuffix(int index)
{
char first = (char)('a' + index / 26 % 26);
char second = (char)('a' + index % 26);
return first.ToString() + second;
}
}
O Log10(value) / 3 diz quantos grupos de mil o número tem: 1.234.567 tem log10 6,09, então grupo 2, sufixo "M", e 1,23 M. Quando acabam os sufixos nomeados (decilhão, 10 elevado a 33), o formato passa para letras: aa, ab, ac, um padrão comum em idles longos. O CultureInfo("pt-BR") usa vírgula decimal, como o jogador brasileiro espera.
Quando o double não basta. O double chega a cerca de 1,8 x 10^308 e guarda de 15 a 17 dígitos significativos. Em idle, isso é quase sempre suficiente, porque você mostra 3 dígitos e o erro de arredondamento na 17ª casa não aparece. Os dois casos em que ele falha:
- O jogo passa de 10^308. Alguns idles com prestígio chegam lá. O
doubleviraInfinity, e oIsInfinitydo formatador é sua rede de segurança. - Você precisa de inteiro exato com muitos dígitos, como somar 1 a 10^20 e ver a diferença. Com double, 10^20 + 1 é igual a 10^20.
Para esses casos, System.Numerics.BigInteger faz parte do .NET Standard 2.1, perfil de API padrão dos projetos Unity 6, e guarda inteiros de qualquer tamanho. O custo: é mais lento, aloca memória e não serializa com JsonUtility (salve como string com ToString() e leia com BigInteger.Parse). A alternativa que muitos idles usam é uma struct própria de mantissa e expoente (um double entre 1 e 10 e um long de expoente), rápida e sem limite prático. Comece com double; troque só se o design pedir.
Até aqui o idle tem clique, upgrades, renda e números legíveis. Os próximos passos são os que fazem um idle segurar o jogador por semanas: prestígio, balanceamento de longo prazo, animação de feedback, anúncio recompensado e publicação na loja. É a parte em que fazer sozinho costuma travar, porque cada decisão de economia muda todas as outras.
Progresso offline na Unity
O progresso offline está no ApplyOfflineProgress do IdleGame, e cada linha tem um motivo:
DateTime.UtcNowe nãoDateTime.Now. O horário local muda com fuso e horário de verão. Se o jogador viaja,Nowpode andar para trás uma hora e zerar o ganho, ou para frente e dar um bônus que ele não mereceu. UTC não muda.- Ticks em
long. OJsonUtilitynão serializaDateTime, mas serializalong.Ticksé a contagem de intervalos de 100 nanossegundos desde o ano 1, e voltar paraDateTimeouTimeSpané direto. - Limite de horas. Sem limite, quem fica um mês sem abrir volta com uma fortuna que quebra a economia. Oito horas é um teto comum; muitos jogos deixam o jogador comprar upgrades que aumentam esse teto.
- Tempo negativo ignorado. Se o relógio do celular foi atrasado, o tempo decorrido sai negativo. O
if (seconds <= 0) return;impede que o jogador perca moedas.
Mostre o resultado: um painel "Você ganhou 1,23 M enquanto estava fora" lendo LastOfflineEarnings ao abrir o jogo. É o momento de recompensa que traz o jogador de volta, e é ali que entra o botão "dobrar com anúncio" em jogos mobile.
OnApplicationPause no Android. No celular, o jogador não fecha o app: ele aperta Home e o sistema mata o processo horas depois. A documentação da Unity avisa que, em plataformas móveis, o OnApplicationQuit pode não ser chamado, e recomenda salvar no OnApplicationPause. Por isso o IdleGame salva em três lugares: pausa, perda de foco e autosave a cada 30 segundos. O OnApplicationQuit fica só para o PC.
Salvar com JSON
Arquivo SaveSystem.cs:
using System.IO;
using UnityEngine;
public static class SaveSystem
{
private static string FilePath => Path.Combine(Application.persistentDataPath, "idle_save.json");
public static void Save(IdleState state)
{
string json = JsonUtility.ToJson(state);
string tempPath = FilePath + ".tmp";
File.WriteAllText(tempPath, json);
if (File.Exists(FilePath)) File.Delete(FilePath);
File.Move(tempPath, FilePath);
}
public static IdleState Load()
{
if (!File.Exists(FilePath)) return null;
string json = File.ReadAllText(FilePath);
return JsonUtility.FromJson<IdleState>(json);
}
}
O Application.persistentDataPath aponta para uma pasta gravável que sobrevive a atualizações do app. O truque do arquivo temporário evita save corrompido: se o celular desligar no meio da escrita, o arquivo bom anterior continua intacto. O JsonUtility serializa double, long, int e List<int> sem configuração. O post sobre sistema de save em JSON na Unity cobre versões de save e migração quando você muda a estrutura.
Erros comuns: relógio do celular, overflow
- Ganho offline gigante. Jogador adiantou o relógio. O teto de horas limita o estrago; para jogos com ranking, só um horário de servidor resolve de vez.
- Perda de moedas ao voltar. Tempo negativo não tratado.
- Progresso perdido no Android. Salvar só no
OnApplicationQuit. - Número virando "Infinity" ou "NaN". O double passou do limite ou houve divisão por zero (taxa de crescimento igual a 1 no
BulkCost). Valide a taxa no ScriptableObject. - Usar
intpara moedas. Ointestoura em pouco mais de 2,1 bilhões e vira negativo. Em idle, isso acontece no primeiro dia. - Custo do upgrade recalculado num laço. Para compra em massa, use a fórmula da progressão geométrica.
- UI atualizando a cada frame com string nova. Gera lixo constante. Atualize o texto num intervalo.
Próximo passo
Acrescente o prestígio: um botão que zera moedas e upgrades em troca de uma moeda especial que multiplica toda a renda futura (por exemplo, +10% por ponto de prestígio, com pontos = raiz quadrada do total ganho dividido por um milhão). É o sistema que dá vida longa ao gênero e o melhor exercício de economia que existe. Para ver a mesma estrutura em outra engine, compare com o idle clicker na Godot.
Perguntas frequentes
Como fazer progresso offline num idle game na Unity?
Ao salvar, guarde o horário em UTC (DateTime.UtcNow.Ticks). Ao abrir o jogo, calcule quanto tempo passou, limite a um máximo (por exemplo 8 horas) e multiplique pela renda por segundo. Ignore tempos negativos, que aparecem quando o jogador volta o relógio do celular.
Double é suficiente para números de jogo idle?
Para a maioria dos idle games, sim. O double vai até cerca de 1,8 x 10 elevado a 308 e guarda de 15 a 17 dígitos significativos, o que basta quando você mostra o número abreviado (1,23 T). BigInteger só é necessário se o jogo passar desse limite ou precisar de inteiros exatos com muitos dígitos.
Qual fórmula usar para o custo de upgrade num clicker?
A mais comum é custo = custo base x taxa elevado à quantidade já comprada, com taxa entre 1,07 e 1,15. A renda do upgrade cresce de forma linear e o custo cresce exponencialmente, então cada compra demora um pouco mais que a anterior, o que cria o ritmo de espera típico do gênero.
Onde salvar o progresso de um idle game no celular?
Num arquivo JSON em Application.persistentDataPath, que é uma pasta gravável que sobrevive a atualizações do app. Salve quando o app vai para segundo plano (OnApplicationPause com true) e a cada 30 segundos, porque no Android e no iOS o OnApplicationQuit pode não ser chamado.



