Tudo o que construímos até aqui espera por algo: uma requisição, um clique, uma mensagem na fila. Um jogo não espera. Ele roda sessenta vezes por segundo, com um orçamento de milissegundos em cada volta, quer o jogador faça algo ou não. Este artigo abre a Fase 9 mostrando como o C# se tornou uma das linguagens mais usadas no desenvolvimento de jogos, que ferramentas existem e qual é o mecanismo que todas compartilham.
Por que C# está nos jogos
Durante décadas, jogos profissionais foram escritos em C e C++, pelas razões que os cursos de C e C++ desta série exploraram: controle total da memória e desempenho previsível. C# entrou nesse mundo por uma porta lateral, e ela se chama Unity.
Lançada em 2005 como motor para Mac, a Unity adotou desde cedo o Mono como linguagem de script. Os desenvolvedores escreviam a lógica do jogo em C# (houve também UnityScript e Boo, abandonados depois), e o motor, escrito em C++, cuidava de renderização, física e áudio. A combinação de um editor visual acessível, exportação para dezenas de plataformas e uma linguagem produtiva fez da Unity o motor mais usado do mundo, especialmente em jogos independentes e para celular. Hollow Knight, Cuphead, Cities: Skylines, Ori and the Blind Forest e Among Us são jogos Unity. Uma geração inteira de programadores de jogos aprendeu C# assim.
Duas outras linhagens completam o quadro.
XNA e MonoGame. Em 2006 a Microsoft lançou o XNA, um framework em C# para jogos no Windows e no Xbox 360, e com ele o programa Xbox Live Indie Games. Foi o XNA que viabilizou Terraria, Fez, Bastion e a versão original de Stardew Valley. A Microsoft o descontinuou em 2013, e a comunidade continuou o trabalho com o MonoGame, uma reimplementação aberta da mesma API que roda em Windows, macOS, Linux, celulares e consoles. Stardew Valley migrou para o MonoGame. O FNA, outra reimplementação fiel do XNA, sustenta os ports de Celeste e de vários outros títulos.
Godot. O Godot é um motor completo, gratuito e de código aberto sob licença MIT, criado por Juan Linietsky e Ariel Manzur e aberto em 2014. Sua linguagem principal é o GDScript, parecido com Python, e desde a versão 3 ele suporta C#. O Godot 4, de 2023, trocou o Mono pelo .NET moderno. Em setembro de 2023, a Unity anunciou uma cobrança por instalação dos jogos (a runtime fee), que foi recebida com revolta pelos estúdios e cancelada um ano depois. Nesse intervalo, o Godot ganhou uma onda de estúdios e desenvolvedores migrando, e boa parte deles trouxe o C# consigo.
Existem outros: Stride (antigo Xenko, motor 3D aberto escrito em C#), Flax Engine, e bibliotecas como raylib-cs e Silk.NET para quem quer apenas gráficos e janelas. Mas Unity, Godot e MonoGame cobrem a maior parte do que se faz em C# hoje.
Motor ou framework
A primeira escolha de quem começa um jogo em C# é entre um motor e um framework:
| Motor (Unity, Godot) | Framework (MonoGame, FNA) | |
|---|---|---|
| Editor visual | sim: cenas, prefabs, animações, partículas | não; tudo é código |
| Física, colisões, áudio espacial, animação | incluídos | você escreve ou escolhe bibliotecas |
| Game loop | do motor; você preenche métodos | do framework, mas visível e controlável |
| Exportação | dezenas de plataformas com um clique | você configura cada uma |
| Controle e entendimento | menor; o motor decide muita coisa | total |
| Curva inicial | rápida para ver algo na tela | mais lenta |
A comparação lembra a de "Interfaces no .NET": o motor entrega muito pronto e impõe seu modelo. O framework entrega pouco e deixa tudo à mostra. Por isso a Fase 9 usa os dois, em ordem: primeiro o MonoGame, para construir um jogo do zero e ver cada mecanismo funcionando, e depois o Godot, para ver como um motor organiza esses mesmos mecanismos e o que ele poupa.
A Unity fica de fora dos exemplos por dois motivos. Sua licença e sua integração com o .NET são próprias. Ela usa o Mono (com C# 9 e uma API de biblioteca mais antiga) ou o compilador AOT IL2CPP, e anunciou uma migração para o CoreCLR que ainda está em andamento. Além disso, o editor dela não é de código aberto. Tudo o que esta fase ensina, porém, vale para ela: game loop, tempo fixo, componentes, física e cuidado com alocações são os mesmos conceitos com outros nomes.
O jogo da Fase 9
Os próximos artigos constroem um jogo 2D completo, que no capstone vai enviar pontuações para a API da Livraria. O tema, naturalmente, é uma livraria:
Biblioteca em Apuros. Uma estante gigante desabou, e os livros estão caindo do alto. O jogador controla uma bibliotecária que corre de um lado para o outro para apanhar os livros antes que eles toquem o chão. Cada livro apanhado vale pontos, e livros raros, mais rápidos, valem mais. Traças voam entre as prateleiras e roubam uma vida a cada toque. A velocidade aumenta com o tempo, e a partida termina quando as vidas acabam.
É simples o bastante para caber em poucos artigos, e tem tudo o que um jogo real tem: entrada do jogador, movimento, gravidade, colisões, pontuação, dificuldade crescente, sons, telas de menu e de fim de jogo, e um recorde que vale a pena salvar e publicar.
O loop de um programa comum
Para entender o game loop, vale comparar com o que já fizemos. A API da Livraria, o site e o caixa são orientados a eventos. Eles passam quase todo o tempo parados, esperando. O sistema operacional ou o Kestrel os acorda quando algo acontece, eles reagem e voltam a dormir. Se nada acontecer por uma hora, a CPU fica ociosa por uma hora.
Um jogo é diferente, porque o mundo muda mesmo sem ninguém tocar em nada. O livro continua caindo. A traça continua voando. O relógio da partida continua correndo. O programa precisa, continuamente:
- Ler a entrada: que teclas estão pressionadas, onde está o mouse, o estado do controle.
- Atualizar o mundo: mover a bibliotecária, aplicar gravidade aos livros, detectar colisões, somar pontos.
- Desenhar o quadro: limpar a tela e desenhar tudo na posição atual.
E repetir. A 60 quadros por segundo, cada volta tem 16,67 milissegundos. A 144 Hz, que muitos monitores de jogos usam, 6,94 ms. Se uma volta demorar mais, a imagem trava e o jogador percebe na hora. É uma mudança de mentalidade: na API, uma requisição de 50 ms é rápida. No jogo, uma pausa de 50 ms é um defeito visível.
Tempo variável: o primeiro loop, e o primeiro erro
A versão mais simples de um game loop usa o tempo real decorrido entre um quadro e outro, o delta time:
var relogio = Stopwatch.StartNew();
var anterior = relogio.Elapsed;
var posicaoDoLivro = 0.0; // metros a partir do topo
var velocidade = 0.0;
const double Gravidade = 9.8;
while (rodando)
{
var agora = relogio.Elapsed;
var dt = (agora - anterior).TotalSeconds;
anterior = agora;
velocidade += Gravidade * dt;
posicaoDoLivro += velocidade * dt;
Desenhar(posicaoDoLivro);
}
Multiplicar pela duração do quadro torna o movimento independente da velocidade da máquina, em princípio. Num computador que roda a 120 quadros por segundo, cada passo é menor, e há mais passos. Num que roda a 30, os passos são maiores. O livro cai a mesma distância por segundo real.
Só que não exatamente. A integração acima (o método de Euler) produz resultados diferentes para passos diferentes. Depois de um segundo de queda, com passos de 1/120 s o livro terá percorrido cerca de 4,94 m. Com passos de 1/30 s, cerca de 5,06 m. A física exata dá 4,9 m. Parece pouco, mas tem consequências:
- O jogo não é o mesmo em máquinas diferentes. Um salto que alcança uma plataforma a 60 quadros pode não alcançar a 144. Num jogo competitivo, quem tem o computador mais rápido pula mais baixo, ou mais alto.
- Picos atravessam paredes. Se o sistema engasga e um quadro demora 200 ms, o livro percorre num único passo uma distância enorme e pode "atravessar" a bibliotecária sem que nenhuma verificação de colisão o encontre no caminho. O fenômeno tem nome: tunneling.
- Nada é reproduzível. Gravar a entrada do jogador e reproduzir a partida não dá o mesmo resultado, porque os passos de tempo nunca são os mesmos. Replays, testes automatizados e multiplayer determinístico ficam impossíveis.
Tempo fixo com acumulador
A solução clássica foi popularizada por Glenn Fiedler no artigo Fix Your Timestep!, de 2004: a simulação avança sempre em passos de tamanho fixo, e o tempo real só decide quantos passos dar em cada volta do loop.
using System.Diagnostics;
const double Passo = 1.0 / 60.0; // a simulação sempre avança 1/60 s
const double QuadroMaximo = 0.25; // nunca simular mais de 250 ms de uma vez
var relogio = Stopwatch.StartNew();
var anterior = relogio.Elapsed.TotalSeconds;
var acumulado = 0.0;
var estadoAnterior = Mundo.Inicial;
var estadoAtual = Mundo.Inicial;
while (rodando)
{
var agora = relogio.Elapsed.TotalSeconds;
var quadro = Math.Min(agora - anterior, QuadroMaximo);
anterior = agora;
acumulado += quadro;
var entrada = LerEntrada();
while (acumulado >= Passo)
{
estadoAnterior = estadoAtual;
estadoAtual = Mundo.Atualizar(estadoAtual, entrada, Passo);
acumulado -= Passo;
}
var alfa = acumulado / Passo; // quanto já avançamos rumo ao próximo passo
Desenhar(Mundo.Interpolar(estadoAnterior, estadoAtual, alfa));
}
O mecanismo é um acumulador de tempo. O tempo real decorrido é depositado nele, e cada passo de simulação retira exatamente 1/60 s. Numa máquina rápida, que dá voltas a cada 7 ms, algumas voltas não simulam nada e só redesenham. Numa lenta, que leva 33 ms, cada volta simula dois passos. Em todas, a simulação vê sempre o mesmo Passo, e o resultado é idêntico, passo a passo, em qualquer máquina. É o que torna possível gravar entradas e reproduzir partidas, e testar a física com asserções exatas.
Dois detalhes fecham o mecanismo.
O limite de 250 ms evita a espiral da morte. Se a simulação ficar mais lenta do que o tempo que ela simula, cada volta acumula mais tempo do que consegue processar, precisa de mais passos na volta seguinte, fica ainda mais lenta, e o jogo congela. Limitar o quadro faz o jogo simplesmente ficar mais lento que o tempo real nesses momentos, em vez de travar. Depois de uma pausa do sistema, como uma janela arrastada ou o notebook saindo da hibernação, o mundo não "pula" 30 segundos de uma vez.
A interpolação suaviza o desenho. Quando a tela atualiza a 144 Hz e a simulação a 60 Hz, desenhar sempre o último estado simulado faria o movimento andar "aos pulos", com vários quadros idênticos seguidos de um salto. O alfa diz em que fração do caminho entre o penúltimo e o último estado o tempo real está, e o desenho mistura os dois:
public readonly record struct Mundo(double PosicaoDoLivro, double Velocidade)
{
public static Mundo Inicial => new(0, 0);
public static Mundo Atualizar(Mundo m, Entrada entrada, double passo)
{
var velocidade = m.Velocidade + 9.8 * passo;
return new Mundo(m.PosicaoDoLivro + velocidade * passo, velocidade);
}
public static Mundo Interpolar(Mundo a, Mundo b, double alfa) =>
new(a.PosicaoDoLivro + (b.PosicaoDoLivro - a.PosicaoDoLivro) * alfa, b.Velocidade);
}
O estado do mundo aqui é um readonly record struct, e isso não é detalhe estético. Guardar o estado anterior e o atual, a cada passo, é uma cópia de valor, sem nenhuma alocação no heap. É a primeira aparição, nesta fase, do assunto que "Memória no .NET: Pilha, Heap, Coletor de Lixo e IDisposable" e "Span<T> e Memory<T>: Olhar Sem Copiar, Processar Sem Alocar" prepararam lá atrás, e ao qual voltaremos em detalhe.
Os três motores falando a mesma língua
Com o mecanismo entendido, ele fica fácil de reconhecer em cada ferramenta.
MonoGame expõe o loop em uma classe Game com métodos para sobrescrever, e implementa o tempo fixo por você:
public sealed class BibliotecaEmApuros : Game
{
private readonly GraphicsDeviceManager graficos;
private SpriteBatch pincel = null!;
public BibliotecaEmApuros()
{
graficos = new GraphicsDeviceManager(this);
IsFixedTimeStep = true;
TargetElapsedTime = TimeSpan.FromSeconds(1.0 / 60.0);
}
protected override void LoadContent() => pincel = new SpriteBatch(GraphicsDevice);
protected override void Update(GameTime tempo)
{
// ler entrada e atualizar o mundo: chamado em passos fixos de 1/60 s
base.Update(tempo);
}
protected override void Draw(GameTime tempo)
{
GraphicsDevice.Clear(Color.Bisque);
pincel.Begin();
// desenhar
pincel.End();
base.Draw(tempo);
}
}
Com IsFixedTimeStep = true, o MonoGame chama Update quantas vezes forem necessárias para manter os 60 passos por segundo, e Draw uma vez por volta. É o acumulador acima, dentro do framework.
Godot organiza o jogo em nós. Cada nó pode ter dois métodos de atualização: _Process(delta), chamado a cada quadro desenhado, com tempo variável, para animações e interface, e _PhysicsProcess(delta), chamado em passos fixos (60 por segundo por padrão), para movimento e física:
using Godot;
public partial class Bibliotecaria : CharacterBody2D
{
[Export] public float Velocidade { get; set; } = 320f;
public override void _PhysicsProcess(double delta)
{
var direcao = Input.GetAxis("mover_esquerda", "mover_direita");
Velocity = new Vector2(direcao * Velocidade, Velocity.Y);
MoveAndSlide();
}
}
A classe é partial porque o Godot usa source generators para ligá-la ao motor, as mesmas ferramentas de "Source generators: código que escreve código". [Export] expõe a propriedade no editor, onde o designer ajusta a velocidade sem tocar no código.
Unity tem o mesmo par: Update() a cada quadro, com Time.deltaTime, e FixedUpdate() em passos fixos (50 por segundo por padrão), com Time.fixedDeltaTime, em classes que herdam de MonoBehaviour:
public class Bibliotecaria : MonoBehaviour
{
[SerializeField] private float velocidade = 6f;
private Rigidbody2D corpo;
private void Awake() => corpo = GetComponent<Rigidbody2D>();
private void FixedUpdate()
{
var direcao = Input.GetAxisRaw("Horizontal");
corpo.linearVelocity = new Vector2(direcao * velocidade, corpo.linearVelocity.y);
}
}
Três ferramentas, três nomes, um mecanismo: um passo fixo para a simulação e um passo variável para o desenho. Quem entende o acumulador entende as três.
O orçamento de cada quadro
Os 16,67 ms de um quadro precisam cobrir entrada, simulação, desenho e tudo o que o runtime fizer por conta própria. É aqui que o C# exige cuidados que a API nunca exigiu. Na API, uma coleta de lixo de 20 ms aparece como uma latência p99 um pouco maior, e ninguém percebe. No jogo, uma coleta de 20 ms no meio de uma partida é um engasgo visível: um quadro perdido, o livro "pulando" na tela.
O coletor do .NET moderno é geracional e concorrente, e as coletas da geração 0 costumam levar menos de um milissegundo. Mas a regra continua: cada alocação dentro do loop é uma pequena dívida, e o coletor cobra em algum quadro. A 60 quadros por segundo, uma lista criada por quadro são 3.600 listas por minuto para coletar. Por isso os programadores de jogos em C# desenvolvem hábitos que parecem exagerados em outros contextos:
- Estado do mundo em structs e arrays pré-alocados, como o
Mundoacima. - Pools de objetos reaproveitados, em vez de
newpara cada livro que cai. - Nada de LINQ, lambdas que capturam variáveis ou concatenação de strings dentro de
Update. Span<T>estackallocpara buffers temporários.
Na Unity, o cuidado é ainda maior, porque o coletor do Mono usado por ela é mais simples e não compacta a memória. Voltaremos a isso em "Arquitetura de jogo e por que não alocar dentro do loop", com medições.
O que vem de C# e o que vem do jogo
Quase tudo o que o curso ensinou até aqui continua valendo:
- Tipos, classes e interfaces organizam entidades do jogo.
- Records e structs modelam estado imutável ou por valor.
- Genéricos e coleções guardam entidades, só que pré-alocadas.
- Eventos e delegates reaparecem como os sinais do Godot.
- async/await ajuda em carregamento de recursos e chamadas de rede, mas não dentro da simulação, que precisa ser determinística e síncrona, passo a passo.
- Testes funcionam muito bem para a lógica do jogo, justamente porque o passo fixo torna a simulação reproduzível.
O que é novo é a mentalidade: um programa que nunca para, com um orçamento de tempo apertado, em que a sensação de controle do jogador depende de detalhes como a ordem entre ler a entrada e atualizar o mundo.
O C# chegou aos jogos pela Unity, se consolidou com o XNA e o MonoGame e ganhou uma casa aberta com o Godot. Em todas essas ferramentas, o coração é o mesmo: um loop que lê a entrada, avança a simulação em passos fixos, desenha com interpolação e protege cada milissegundo do quadro.
Fontes e leituras recomendadas
- Glenn Fiedler — Fix Your Timestep! (2004): https://gafferongames.com/post/fix_your_timestep/
- Robert Nystrom — Game Programming Patterns, capítulo Game Loop (Genever Benning, 2014): https://gameprogrammingpatterns.com/game-loop.html
- MonoGame — documentação oficial: https://docs.monogame.net/
- Godot Engine — C# basics e Idle and Physics Processing: https://docs.godotengine.org/en/stable/tutorials/scripting/c_sharp/
- Unity — Order of execution for event functions: https://docs.unity3d.com/Manual/execution-order.html
- Jason Gregory — Game Engine Architecture, 3ª edição (CRC Press, 2018), capítulo sobre o loop e o tempo.
Exercícios
Exercício 1
Implemente, num programa de console, a queda de um livro com tempo variável e com tempo fixo (acumulador), simulando 2 segundos com quadros de 1/30 s e de 1/144 s. Compare as posições finais nas quatro combinações e explique o resultado.
Ver resposta
✓ Resposta: Com tempo variável, cada combinação usa o próprio dt na integração de Euler. Com 1/30 s o livro termina perto de 19,93 m, e com 1/144 s perto de 19,67 m (o valor exato, ½·9,8·2², é 19,6 m). As duas máquinas discordam. Com tempo fixo, a simulação sempre usa passos de 1/60 s, independentemente de o loop rodar a 30 ou 144 voltas por segundo: a de 30 dá 2 passos por volta, a de 144 dá um passo em cerca de 40% das voltas. Nas duas, após 120 passos, a posição é exatamente a mesma, perto de 19,76 m, porque a sequência de cálculos é idêntica. O erro de integração continua existindo (Euler não é exato), mas é o mesmo em todas as máquinas, e é isso que importa para jogabilidade justa e reproduzível. Pode haver uma diferença de no máximo um passo no momento exato em que o laço termina, porque o acumulador pode reter uma fração.
Exercício 2
O que acontece com o loop de tempo fixo se Mundo.Atualizar passar a levar 20 ms, sem o limite QuadroMaximo? E com ele?
Ver resposta
✓ Resposta: Sem o limite: cada passo simula 16,67 ms de jogo, mas custa 20 ms de tempo real. Numa volta que acumulou 16,67 ms, o único passo leva 20 ms. Na volta seguinte o acumulador recebe 20 ms, faz um passo (20 ms) e sobra tempo, que vai crescendo. Em pouco tempo cada volta precisa de dois passos (40 ms), depois três (60 ms), e o atraso cresce sem parar: é a espiral da morte, e o jogo congela porque passa todo o tempo tentando alcançar o relógio. Com o limite de 250 ms, o acumulador nunca recebe mais que 250 ms por volta, o que dá no máximo 15 passos. O jogo roda em câmera lenta (menos de 60 passos simulados por segundo real), mas continua respondendo e desenhando. A correção de verdade, claro, é fazer a atualização caber no orçamento. O limite é só uma rede de segurança.
Exercício 3
Por que a leitura da entrada fica fora do laço interno de passos, e não dentro de cada passo? Em que situação ler dentro faria diferença?
Ver resposta
✓ Resposta: A entrada vem do sistema operacional e muda no tempo real, não no tempo simulado. Numa volta em que o acumulador executa três passos seguidos, os três acontecem em microssegundos: ler a entrada três vezes daria o mesmo estado de teclado nas três, sem ganho, e com custo de chamadas ao sistema. Lendo uma vez por volta, todos os passos daquela volta usam o mesmo estado, que é o mais recente disponível. Faz diferença em dois casos: (1) quando se quer registrar eventos de entrada com marcação de tempo precisa (um jogo de ritmo, por exemplo), e aí os eventos são coletados com o horário real e distribuídos aos passos correspondentes; (2) em replays e multiplayer determinístico, em que a entrada precisa ser atribuída a um passo específico para que a reprodução seja idêntica. Nesses casos o padrão é guardar a entrada por número de passo, e não "a entrada da volta".
Exercício 4
Explique por que Mundo é um readonly record struct e não uma record class, considerando que o loop cria um estado novo a cada passo.
Ver resposta
✓ Resposta: A 60 passos por segundo, o loop cria 60 estados novos por segundo e descarta os antigos. Com record class, cada new Mundo(...) seria uma alocação no heap, 3.600 por minuto só para esse objeto, todas lixo quase imediatamente. O coletor lidaria bem com isso (são objetos de vida curtíssima, coletados na geração 0), mas cada coleta é uma pausa potencial num quadro, e num jogo real o estado teria dezenas de campos e haveria muitos objetos assim. Com struct, o estado vive na pilha ou dentro de outras estruturas, e "criar um novo" é copiar alguns bytes, sem heap e sem coletor. O readonly garante que nenhum campo é alterado depois de criado, o que evita cópias defensivas do compilador e mantém a semântica de "estado imutável por passo" que facilita a interpolação e os testes. O custo é que structs grandes são caras de copiar. Para estados maiores, a alternativa é um arranjo pré-alocado de dois buffers, alternados a cada passo.
Exercício 5
O Godot chama _PhysicsProcess a 60 Hz e _Process a cada quadro desenhado. Para cada item, diga em qual dos dois ele deve ficar na "Biblioteca em Apuros", e por quê: (a) movimento da bibliotecária; (b) animação do contador de pontos pulsando; (c) verificação de colisão entre livros e a bibliotecária; (d) rotação decorativa de uma traça.
Ver resposta
✓ Resposta: (a) _PhysicsProcess: o movimento afeta colisões e a jogabilidade, e precisa ser determinístico e independente da taxa de quadros. (b) _Process: é puramente visual e fica mais suave se atualizado a cada quadro desenhado. Não afeta o estado do jogo, então pequenas diferenças entre máquinas não importam. (c) _PhysicsProcess, ou melhor, os sinais de colisão do motor de física, que já rodam no passo fixo. Verificar colisões em _Process faria o resultado depender da taxa de quadros, com risco de tunneling em quadros longos. (d) _Process, se for só decorativa, girando o sprite sem influenciar a hitbox. Se a rotação mudasse a forma de colisão da traça, ela passaria a fazer parte da simulação e iria para _PhysicsProcess. A regra geral: o que afeta o resultado do jogo fica no passo fixo, e o que só afeta a aparência fica no passo de desenho.