C# nos jogos: Unity, Godot, MonoGame e o game loop

C# nos jogos: Unity, Godot, MonoGame e o game loop

Como o C# chegou aos jogos pela Unity, pelo XNA e MonoGame e pelo Godot, a diferença entre motor e framework, o jogo da Fase 9, e o game loop que todos compartilham: tempo variável e seus erros, passo fixo com acumulador, interpolação e o orçamento de 16,67 ms por quadro.
Linguagem C#

• • 20 min de leitura

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:

  1. Ler a entrada: que teclas estão pressionadas, onde está o mouse, o estado do controle.
  2. Atualizar o mundo: mover a bibliotecária, aplicar gravidade aos livros, detectar colisões, somar pontos.
  3. 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 Mundo acima.
  • Pools de objetos reaproveitados, em vez de new para cada livro que cai.
  • Nada de LINQ, lambdas que capturam variáveis ou concatenação de strings dentro de Update.
  • Span<T> e stackalloc para 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

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.

Comentários

Mais em Linguagem C#

Login, área administrativa, upload de capas e notificações em tempo real
Login, área administrativa, upload de capas e notificações em tempo real

O site da Livraria ganha login por OpenID Connect com Keycloak e PKCE, tokens…

LINQ, Parte I: Perguntas ao Catálogo em Vez de Laços
LINQ, Parte I: Perguntas ao Catálogo em Vez de Laços

Consultas com LINQ em C#: Where, Select, OfType, ordenação, Count, Any e All…

Autenticação e autorização com JWT: quem é você e o que pode fazer
Autenticação e autorização com JWT: quem é você e o que pode fazer

Quem pode fazer o quê na API da Livraria: 401 contra 403, a anatomia do JWT…