Pitch: Como desenvolvi uma game engine em rust e tomei algumas decisões e arquitetura
Minhas decisões de arquitetura em minha engine
Há algum tempo eu tinha uma engine 2D em Rust chamada milim-2d: rodava sobre SDL2, tinha um EngineContext concreto passado pra todo lugar, e um punhado de módulos soltos na raiz do crate. Funcionava. Mas toda vez que eu tentava adicionar uma feature nova — multiplayer, por exemplo — eu sentia o design resistindo.
Decidi não fazer um ajuste incremental. Fiz uma reescrita de arquitetura de propósito, e o resultado se chama alone-engine. Esse post é sobre quatro decisões específicas que tomei nessa reescrita, o raciocínio por trás de cada uma, e — o mais importante — o que elas custaram. Nenhuma decisão de arquitetura é de graça, e eu acho que posts que só mostram o lado bonito da escolha enganam quem tá decidindo a própria arquitetura.
Decisão 1: trocar SDL2 por winit + pixels
A milim-2d usava SDL2 — bindings C, feature bundled, um renderer/adapter.rs fazendo a ponte. Funcionava bem, mas eu queria dois efeitos colaterais específicos: (a) remover a dependência de compilar C em toda plataforma, e (b) ter controle total sobre o buffer de pixels, porque eu já sabia que ia querer fazer coisas "por pixel" (efeitos, iluminação) mais pra frente.
Migrei pra winit (janela/eventos) + pixels (framebuffer), com uma resolução lógica fixa:
pub const LOGICAL_WIDTH: u32 = 480;
pub const LOGICAL_HEIGHT: u32 = 270;
Tudo é desenhado nesse canvas de 480×270 e escalado pra tela com nearest-neighbor. Isso não foi só estética retrô — foi uma aposta consciente: um framebuffer desse tamanho (~130 mil pixels) é pequeno o suficiente pra eu fazer manipulação por pixel em CPU sem me preocupar com performance. Iluminação dinâmica, paletas, distorção — tudo isso fica barato quando sua "tela" cabe quase inteira no cache L2.
O custo: eu perdi, de graça, tudo que o SDL2 me dava pronto — rotação de sprite, escala suave, filtros. Meu blit hoje é uma cópia de linha simples com alpha blend inteiro (nada de ponto flutuante no loop quente):
pub fn blending_pixel(dst_px: &mut [u8; 4], src_px: &[u8; 4]) {
let sa = src_px[3] as u32;
if sa == 0 { return; }
if sa == 255 { *dst_px = *src_px; return; }
let inv = 255u32 - sa;
*dst_px = [
(((src_px[0] as u32 * sa + dst_px[0] as u32 * inv + 128) * 257) >> 16) as u8,
// ... g, b
255,
];
}
Decisão 2: EngineContext (struct) virou EngineApi (trait)
Essa é a mudança que eu mais bato o pé que valeu a pena. Antes:
fn update(&mut self, ctx: &mut EngineContext, delta: f32) { ... }
Agora:
fn update(&mut self, ctx: &mut impl EngineApi, delta: f32) { ... }
Parece cosmético, mas não é. EngineContext era uma struct concreta — pra chamar a lógica de um GameObject eu precisava ter uma instância real do motor rodando, renderer incluso. Isso vira um problema no momento em que você quer, por exemplo, rodar a mesma lógica de jogo num servidor headless pra multiplayer autoritativo, sem abrir janela nenhuma.
Com EngineApi como trait (composto de InputApi + CollisionApi + ...), a mesma struct Player que roda no cliente pode, em teoria, rodar num contexto que não tem renderer nenhum implementado — só implementa a parte de física/colisão/rede. Isso é o que abriu espaço pra eu adicionar objects/network/{client,server}.rs sem reescrever a metade do motor.
O custo: genéricos (impl Trait) em todo canto do dispatch tornam a árvore de tipos mais pesada de ler pra quem chega no código pela primeira vez, e o compilador de Rust não é rápido com muito código genérico. Meu tempo de build cresceu perceptivelmente depois dessa mudança. Trade-off real: flexibilidade de arquitetura por velocidade de compilação.
Decisão 3: componentes-em-struct, não ECS de archetypes
Essa é a decisão que mais gera discussão quando mostro o código pra outros devs Rust. Eu não fiz um ECS de verdade (tipo Bevy, com archetypes e storage colunar). Fiz algo mais parecido com Godot: um GameObject é uma struct normal, com campos marcados #[component], e uma macro derive gera o dispatch:
#[derive(GameObject)]
pub struct Player {
#[base]
base: Base,
#[component(interface = IBody)]
body: Body,
#[component]
collision: Collider,
#[component]
sprite: Sprite,
}
impl GameObject for Player {
type Message = ();
fn fixed_update(&mut self, ctx: &mut impl EngineApi, delta: f32) {
let speed = 300.0;
let gravity = 1500.0;
let direction = ctx.get_key_axis(KeyCode::KeyA, KeyCode::KeyD);
self.velocity_mut().x = speed * direction;
self.velocity_mut().y += gravity * delta;
if self.is_on_floor() && ctx.is_key_just_pressed(KeyCode::Space, true) {
self.velocity_mut().y = -600.0;
}
self.move_and_slide(ctx, delta);
}
}
A macro (~530 linhas hoje) gera o impl que chama start/update/fixed_update/draw de cada componente na ordem certa. Não tem storage colunar, não tem cache-friendliness de ECS de verdade, não tem query paralela.
Escolhi isso de propósito porque o público-alvo da engine é eu mesmo fazendo jogos pequenos/jams, não um estúdio rodando 50 mil entidades. Pra esse tamanho de projeto, a ergonomia de "é só uma struct com campos" ganha da performance teórica de um ECS que eu nunca vou saturar.
O custo: se um dia eu quiser um jogo com milhares de entidades simultâneas (uma simulação de partículas pesada, por exemplo), essa arquitetura não escala tão bem quanto um ECS de archetypes escalaria — cada GameObject é alocado e despachado individualmente, sem os ganhos de localidade de memória que vêm de agrupar componentes do mesmo tipo em arrays contíguos.
Decisão 4: mensagem só pro que é assíncrono — o resto acessa o sistema direto
Essa é a decisão com a história mais longa, e a que eu acho mais valiosa de contar porque o caminho até ela teve becos sem saída de verdade, não só uma linha reta.
Comecei olhando pro Yew (framework de front-end em Rust) e gostei de como cada Component tem seu próprio type Message associado, tratado num on_message. Isso evita um enum "deus" com todos os eventos possíveis do projeto inteiro — cada objeto só lida com o que é dele. Isso ficou.
Só que daí eu fui na direção de implementar isso do jeito mais "puro" possível e caí de cabeça no modelo de atores: nenhum objeto acessa o outro diretamente, tudo se comunica mandando mensagem endereçada por Id, e o sistema roteia. Cheguei a definir eventos próprios e um sistema de subscription em cima disso.
E foi aí que travei. Gerenciar atores e cenas ao mesmo tempo ficou complexo demais pra manter na cabeça. Hoje eu entendo melhor o porquê — meio que descobri o motivo tentando explicar aqui: modelo de ator pressupõe que cada unidade roda de forma independente e assíncrona, sem uma ordem global garantida. Um game loop é o oposto disso — ele precisa rodar update de tudo, numa ordem determinística, todo frame. Tentar encaixar um supervisor de atores dentro de um loop de jogo é lutar contra o formato do problema.
A saída foi um híbrido: fui pra composição (peguei a Godot como referência — um GameObject com componentes/filhos encaixados) pra resolver a parte estrutural e de execução por frame, mas não larguei o endereçamento por mensagem — ele virou o mecanismo de comunicação entre objetos que não deveriam se conhecer diretamente.
E aí veio a parte que eu considero a decisão consciente mais importante desse capítulo inteiro: usar mensagem pra tudo introduziria atraso (delay) nas interações que precisam ser imediatas — física e colisão, por exemplo, já passavam pelo ctx direto até no meu modelo de atores, porque não dava pra esperar um ciclo de mensagem pra saber se colidiu com uma parede. Então separei conscientemente em dois caminhos:
- Acesso direto via
ctx— pra sistemas de todo frame, com dependência de ordem, onde delay não é aceitável:ctx.move_and_slide(...),ctx.translate_my_colliders(...). Chamada síncrona, sem alocação de mensagem, sem downcast. - Mensagem endereçada por
Id— pro que é esporádico e desacoplado: umTimerque dispara um evento, umCollidersensor que avisa "algo entrou no trigger". Isso passa pela mailbox (ctx.send_boxed_any(id, evento)), que aceita o custo de alocação porque não acontece 60 vezes por segundo.
Não vou fingir que cheguei nessa formulação toda de uma vez — ainda tô no processo de conseguir explicar isso com mais precisão técnica (tipo, colocar em palavras exatas por que "delay" é o problema real e não só uma sensação). Mas na prática, escrevendo o código, a régua que usei foi: se isso precisa acontecer nesse frame, sem exceção, é ctx direto; se pode esperar a próxima vez que o destinatário for processado, é mensagem.
O custo: essa régua não está escrita em lugar nenhum do código hoje — ela existe só na minha cabeça. Isso é um risco real: outra pessoa (ou eu mesmo daqui a uns meses) pode acabar colocando lógica de física atrás de mensagem "porque parecia mais desacoplado", sem saber que isso reintroduz exatamente o delay que a separação existe pra evitar.
O padrão que emergiu: toda decisão “ergonômica” cobrou um preço em generalidade
Reparando em retrospecto, as quatro decisões têm o mesmo formato: eu troquei generalidade/robustez teórica por simplicidade de uso no meu caso de uso real. winit+pixels sem rotação em troca de controle total do pixel. EngineApi genérico em troca de compile times piores. Componentes em struct em troca de não escalar pra ECS de verdade. E o híbrido composição+mensagem em troca de uma regra de bolso que só existe na minha cabeça, não documentada.
Nenhuma dessas trocas é "certa" no abstrato — elas são certas pro escopo que eu tenho hoje (jogos pixel art pequenos, feitos sozinho ou em jam). Se o escopo mudar, pelo menos uma dessas decisões provavelmente vai precisar ser revisitada.
Apos descarregar todo o codigo da minha engine e porque decidi cada coisa esse post foi criado, podem perguntar a vontade.