Rust — Artigo #42
Rust para GameDev — Motores de Jogo, ECS e Bevy
Por Prof. Dr. Marcelo Fontes | Série: Dominando Rust em 1 Ano
Desenvolvimento de jogos é um dos domínios mais exigentes da computação. Você precisa de performance determinística para manter 60 frames por segundo, segurança de memória para não vazar recursos em sessões de jogo longas, e uma arquitetura que gerencie centenas ou milhares de entidades interagindo simultaneamente.
Rust está se tornando uma escolha séria para gamedev. Bevy — o motor de jogo mais popular escrito em Rust — demonstra como o sistema de tipos pode expressar uma arquitetura ECS (Entity-Component-System) com ergonomia surpreendente. Este artigo explora os conceitos fundamentais e constrói um jogo completo do zero.
O padrão ECS — Entity-Component-System
ECS é a arquitetura dominante em jogos modernos. Em vez de hierarquias de objetos com herança, tudo é decomposto em três conceitos:
Entity: Um ID único — apenas um número inteiro
Component: Dados puros — posição, velocidade, saúde, sprite
System: Lógica — processa todas as entidades que têm certos componentes
Exemplo tradicional (OOP):
class Personagem {
posicao: Vec2
velocidade: Vec2
saude: i32
fn atualizar() { ... }
fn renderizar() { ... }
}
Exemplo ECS:
Entidade 1: [Posicao(0,0), Velocidade(1,0), Saude(100), Sprite("player")]
Entidade 2: [Posicao(5,3), Velocidade(-1,0), Saude(50), Sprite("inimigo")]
Entidade 3: [Posicao(2,7), Luz(255,200,0), Raio(5.0)]
Sistema Movimento: para cada entidade com (Posicao, Velocidade) → posicao += velocidade
Sistema Render: para cada entidade com (Posicao, Sprite) → desenha sprite na posicao
Sistema Luz: para cada entidade com (Posicao, Luz) → aplica iluminação
A vantagem sobre OOP: entidades com apenas os componentes que precisam, cache-friendly por design, paralelismo natural entre sistemas independentes.
ECS minimalista do zero
Antes de usar Bevy, vamos implementar um ECS simples para entender os fundamentos:
use std::any::{Any, TypeId};
use std::collections::HashMap;
// Identificador de entidade
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct EntidadeId(u64);
// Componentes são qualquer tipo que implemente Any + Send + Sync
pub trait Componente: Any + Send + Sync {}
impl<T: Any + Send + Sync> Componente for T {}
// Armazenamento de componentes — um Vec por tipo
pub struct ArmazenamentoComponentes {
dados: HashMap<TypeId, Box<dyn Any>>,
}
impl ArmazenamentoComponentes {
fn novo() -> Self {
ArmazenamentoComponentes { dados: HashMap::new() }
}
fn inserir<T: Componente>(&mut self, entidade: EntidadeId, componente: T) {
let tipo = TypeId::of::<T>();
let mapa = self.dados
.entry(tipo)
.or_insert_with(|| Box::new(HashMap::<EntidadeId, T>::new()));
let mapa_tipado = mapa
.downcast_mut::<HashMap<EntidadeId, T>>()
.unwrap();
mapa_tipado.insert(entidade, componente);
}
fn obter<T: Componente>(&self, entidade: EntidadeId) -> Option<&T> {
let tipo = TypeId::of::<T>();
self.dados.get(&tipo)?
.downcast_ref::<HashMap<EntidadeId, T>>()?
.get(&entidade)
}
fn obter_mut<T: Componente>(&mut self, entidade: EntidadeId) -> Option<&mut T> {
let tipo = TypeId::of::<T>();
self.dados.get_mut(&tipo)?
.downcast_mut::<HashMap<EntidadeId, T>>()?
.get_mut(&entidade)
}
fn todos<T: Componente>(&self) -> impl Iterator<Item = (EntidadeId, &T)> {
let tipo = TypeId::of::<T>();
self.dados.get(&tipo)
.and_then(|d| d.downcast_ref::<HashMap<EntidadeId, T>>())
.map(|m| m.iter().map(|(&id, c)| (id, c)))
.into_iter()
.flatten()
}
}
// Mundo — contém todas as entidades e componentes
pub struct Mundo {
proximo_id: u64,
componentes: ArmazenamentoComponentes,
entidades_vivas: Vec<EntidadeId>,
}
impl Mundo {
pub fn novo() -> Self {
Mundo {
proximo_id: 0,
componentes: ArmazenamentoComponentes::novo(),
entidades_vivas: Vec::new(),
}
}
pub fn criar_entidade(&mut self) -> EntidadeId {
let id = EntidadeId(self.proximo_id);
self.proximo_id += 1;
self.entidades_vivas.push(id);
id
}
pub fn adicionar<T: Componente>(&mut self, entidade: EntidadeId, c: T) -> &mut Self {
self.componentes.inserir(entidade, c);
self
}
pub fn obter<T: Componente>(&self, entidade: EntidadeId) -> Option<&T> {
self.componentes.obter(entidade)
}
pub fn obter_mut<T: Componente>(&mut self, entidade: EntidadeId) -> Option<&mut T> {
self.componentes.obter_mut(entidade)
}
pub fn consultar<T: Componente>(&self) -> impl Iterator<Item = (EntidadeId, &T)> {
self.componentes.todos()
}
}
// Componentes do jogo
#[derive(Debug, Clone, Copy)]
struct Posicao { x: f32, y: f32 }
#[derive(Debug, Clone, Copy)]
struct Velocidade { dx: f32, dy: f32 }
#[derive(Debug, Clone)]
struct Saude { atual: i32, maxima: i32 }
#[derive(Debug, Clone)]
struct Nome(String);
#[derive(Debug, Clone, Copy)]
struct Colisao { raio: f32 }
// Sistemas
fn sistema_movimento(mundo: &mut Mundo, dt: f32) {
let entidades_com_vel: Vec<(EntidadeId, Velocidade)> = mundo
.consultar::<Velocidade>()
.map(|(id, v)| (id, *v))
.collect();
for (id, vel) in entidades_com_vel {
if let Some(pos) = mundo.obter_mut::<Posicao>(id) {
pos.x += vel.dx * dt;
pos.y += vel.dy * dt;
}
}
}
fn sistema_limites(mundo: &mut Mundo, largura: f32, altura: f32) {
let entidades: Vec<EntidadeId> = mundo
.consultar::<Posicao>()
.map(|(id, _)| id)
.collect();
for id in entidades {
if let (Some(pos), Some(vel)) = (
mundo.obter_mut::<Posicao>(id),
mundo.obter_mut::<Velocidade>(id),
) {
// Reflexão nas bordas
if pos.x < 0.0 || pos.x > largura { vel.dx = -vel.dx; }
if pos.y < 0.0 || pos.y > altura { vel.dy = -vel.dy; }
pos.x = pos.x.clamp(0.0, largura);
pos.y = pos.y.clamp(0.0, altura);
}
}
}
fn sistema_render(mundo: &Mundo) {
println!("── Frame ──────────────────");
for (id, pos) in mundo.consultar::<Posicao>() {
let nome = mundo.obter::<Nome>(id)
.map(|n| n.0.as_str())
.unwrap_or("?");
let saude = mundo.obter::<Saude>(id)
.map(|s| format!("[{}/{}]", s.atual, s.maxima))
.unwrap_or_default();
println!(" {:10} ({:6.1}, {:6.1}) {}", nome, pos.x, pos.y, saude);
}
}
fn main() {
let mut mundo = Mundo::novo();
// Cria entidades
let jogador = mundo.criar_entidade();
mundo
.adicionar(jogador, Posicao { x: 50.0, y: 50.0 })
.adicionar(jogador, Velocidade { dx: 10.0, dy: 5.0 })
.adicionar(jogador, Saude { atual: 100, maxima: 100 })
.adicionar(jogador, Colisao { raio: 1.5 })
.adicionar(jogador, Nome("Jogador".to_string()));
let inimigo = mundo.criar_entidade();
mundo
.adicionar(inimigo, Posicao { x: 80.0, y: 20.0 })
.adicionar(inimigo, Velocidade { dx: -7.0, dy: 3.0 })
.adicionar(inimigo, Saude { atual: 50, maxima: 50 })
.adicionar(inimigo, Colisao { raio: 1.0 })
.adicionar(inimigo, Nome("Inimigo".to_string()));
// Luz — entidade sem saúde nem velocidade
let luz = mundo.criar_entidade();
mundo
.adicionar(luz, Posicao { x: 50.0, y: 50.0 })
.adicionar(luz, Nome("Luz".to_string()));
println!("Simulação ECS — 5 frames
");
for frame in 0..5 {
println!("Frame {frame}:");
sistema_render(&mundo);
sistema_movimento(&mut mundo, 0.016); // ~60fps
sistema_limites(&mut mundo, 100.0, 100.0);
println!();
}
}
Bevy — o motor ECS completo
Bevy é o motor de jogo mais ativo do ecossistema Rust. Usa um ECS com ergonomia excepcional graças ao sistema de tipos:
[dependencies]
bevy = "0.13"
Jogo completo: Asteroides
use bevy::prelude::*;
use std::f32::consts::PI;
// ── Componentes ───────────────────────────────────────
#[derive(Component)]
struct Jogador {
velocidade: f32,
rotacao_vel: f32,
}
#[derive(Component)]
struct Asteroide {
velocidade: Vec2,
tamanho: f32,
}
#[derive(Component)]
struct Projetil {
velocidade: Vec2,
vida: f32, // segundos até sumir
}
#[derive(Component)]
struct Saude(i32);
#[derive(Component)]
struct Colisao {
raio: f32,
}
// Marcadores — componentes sem dados
#[derive(Component)] struct VidaUtil;
// ── Recursos ──────────────────────────────────────────
#[derive(Resource, Default)]
struct Placar {
pontos: u32,
}
#[derive(Resource)]
struct ConfigJogo {
largura: f32,
altura: f32,
vel_projetil: f32,
cooldown_tiro: f32,
}
impl Default for ConfigJogo {
fn default() -> Self {
ConfigJogo {
largura: 800.0,
altura: 600.0,
vel_projetil: 500.0,
cooldown_tiro: 0.25,
}
}
}
#[derive(Resource, Default)]
struct TimerTiro(f32);
// ── Eventos ───────────────────────────────────────────
#[derive(Event)]
struct EventoColisao {
entidade_a: Entity,
entidade_b: Entity,
}
#[derive(Event)]
struct EventoPontuou {
pontos: u32,
}
// ── Setup ─────────────────────────────────────────────
fn setup(
mut commands: Commands,
config: Res<ConfigJogo>,
) {
// Câmera 2D
commands.spawn(Camera2dBundle::default());
// Jogador — triângulo no centro
commands.spawn((
SpriteBundle {
sprite: Sprite {
color: Color::rgb(0.3, 0.8, 1.0),
custom_size: Some(Vec2::new(30.0, 40.0)),
..default()
},
transform: Transform::from_xyz(0.0, 0.0, 0.0),
..default()
},
Jogador { velocidade: 300.0, rotacao_vel: 3.0 },
Saude(3),
Colisao { raio: 15.0 },
));
// Asteroides iniciais
for i in 0..5 {
let angulo = (i as f32) * (2.0 * PI / 5.0);
let distancia = 200.0;
let x = angulo.cos() * distancia;
let y = angulo.sin() * distancia;
let vel_x = (angulo + PI / 2.0).cos() * 80.0;
let vel_y = (angulo + PI / 2.0).sin() * 80.0;
commands.spawn((
SpriteBundle {
sprite: Sprite {
color: Color::rgb(0.7, 0.5, 0.3),
custom_size: Some(Vec2::new(60.0, 60.0)),
..default()
},
transform: Transform::from_xyz(x, y, 0.0),
..default()
},
Asteroide {
velocidade: Vec2::new(vel_x, vel_y),
tamanho: 60.0,
},
Colisao { raio: 30.0 },
));
}
}
// ── Sistemas de Input ─────────────────────────────────
fn sistema_input_jogador(
teclado: Res<ButtonInput<KeyCode>>,
tempo: Res<Time>,
config: Res<ConfigJogo>,
mut timer_tiro: ResMut<TimerTiro>,
mut comandos: Commands,
mut query: Query<(&Jogador, &mut Transform)>,
) {
let Ok((jogador, mut transform)) = query.get_single_mut() else {
return;
};
let dt = tempo.delta_seconds();
// Rotação
if teclado.pressed(KeyCode::ArrowLeft) || teclado.pressed(KeyCode::KeyA) {
transform.rotate_z(jogador.rotacao_vel * dt);
}
if teclado.pressed(KeyCode::ArrowRight) || teclado.pressed(KeyCode::KeyD) {
transform.rotate_z(-jogador.rotacao_vel * dt);
}
// Movimento (aceleração na direção que o jogador aponta)
if teclado.pressed(KeyCode::ArrowUp) || teclado.pressed(KeyCode::KeyW) {
let direcao = transform.rotation * Vec3::Y;
transform.translation += direcao * jogador.velocidade * dt;
}
// Tiro
timer_tiro.0 -= dt;
if (teclado.pressed(KeyCode::Space) || teclado.pressed(KeyCode::KeyZ))
&& timer_tiro.0 <= 0.0
{
timer_tiro.0 = config.cooldown_tiro;
let direcao = transform.rotation * Vec3::Y;
let vel = Vec2::new(direcao.x, direcao.y) * config.vel_projetil;
comandos.spawn((
SpriteBundle {
sprite: Sprite {
color: Color::rgb(1.0, 1.0, 0.3),
custom_size: Some(Vec2::new(4.0, 12.0)),
..default()
},
transform: Transform {
translation: transform.translation + direcao * 25.0,
rotation: transform.rotation,
..default()
},
..default()
},
Projetil { velocidade: vel, vida: 2.0 },
Colisao { raio: 4.0 },
));
}
}
// ── Sistemas de Física ────────────────────────────────
fn sistema_mover_asteroides(
tempo: Res<Time>,
config: Res<ConfigJogo>,
mut query: Query<(&Asteroide, &mut Transform)>,
) {
let dt = tempo.delta_seconds();
let hw = config.largura / 2.0;
let hh = config.altura / 2.0;
for (ast, mut transform) in &mut query {
transform.translation.x += ast.velocidade.x * dt;
transform.translation.y += ast.velocidade.y * dt;
// Wrapping — sai de um lado, aparece do outro
if transform.translation.x > hw { transform.translation.x = -hw; }
if transform.translation.x < -hw { transform.translation.x = hw; }
if transform.translation.y > hh { transform.translation.y = -hh; }
if transform.translation.y < -hh { transform.translation.y = hh; }
}
}
fn sistema_mover_projeteis(
tempo: Res<Time>,
mut comandos: Commands,
mut query: Query<(Entity, &mut Projetil, &mut Transform)>,
) {
let dt = tempo.delta_seconds();
for (entidade, mut proj, mut transform) in &mut query {
transform.translation.x += proj.velocidade.x * dt;
transform.translation.y += proj.velocidade.y * dt;
proj.vida -= dt;
if proj.vida <= 0.0 {
comandos.entity(entidade).despawn();
}
}
}
// ── Sistema de Colisão ────────────────────────────────
fn sistema_colisao_projetil_asteroide(
mut comandos: Commands,
mut placar: ResMut<Placar>,
projeteis: Query<(Entity, &Transform, &Colisao), With<Projetil>>,
asteroides: Query<(Entity, &Transform, &Colisao, &Asteroide)>,
mut ev_pontuou: EventWriter<EventoPontuou>,
) {
for (ep, tp, cp) in &projeteis {
for (ea, ta, ca, ast) in &asteroides {
let distancia = tp.translation.distance(ta.translation);
if distancia < cp.raio + ca.raio {
// Colisão!
comandos.entity(ep).despawn();
comandos.entity(ea).despawn();
let pontos = (100.0 / ast.tamanho * 60.0) as u32;
placar.pontos += pontos;
ev_pontuou.send(EventoPontuou { pontos });
// Fragmenta asteroide maior em menores
if ast.tamanho > 25.0 {
let novo_tamanho = ast.tamanho / 2.0;
for angulo_offset in [-PI/4.0, PI/4.0] {
let vel_nova = Vec2::new(
ast.velocidade.x * angulo_offset.cos()
- ast.velocidade.y * angulo_offset.sin(),
ast.velocidade.x * angulo_offset.sin()
+ ast.velocidade.y * angulo_offset.cos(),
) * 1.5;
comandos.spawn((
SpriteBundle {
sprite: Sprite {
color: Color::rgb(0.6, 0.4, 0.2),
custom_size: Some(Vec2::splat(novo_tamanho)),
..default()
},
transform: Transform::from_translation(ta.translation),
..default()
},
Asteroide {
velocidade: vel_nova,
tamanho: novo_tamanho,
},
Colisao { raio: novo_tamanho / 2.0 },
));
}
}
}
}
}
}
// ── UI de Placar ─────────────────────────────────────
#[derive(Component)]
struct TextoPlacar;
fn setup_ui(mut comandos: Commands) {
comandos.spawn((
TextBundle::from_section(
"Pontos: 0",
TextStyle {
font_size: 32.0,
color: Color::WHITE,
..default()
},
)
.with_style(Style {
position_type: PositionType::Absolute,
top: Val::Px(10.0),
left: Val::Px(10.0),
..default()
}),
TextoPlacar,
));
}
fn sistema_atualizar_placar(
placar: Res<Placar>,
mut query: Query<&mut Text, With<TextoPlacar>>,
) {
if placar.is_changed() {
for mut texto in &mut query {
texto.sections[0].value = format!("Pontos: {}", placar.pontos);
}
}
}
// ── Plugin e App ──────────────────────────────────────
pub struct PluginAsteroid;
impl Plugin for PluginAsteroid {
fn build(&self, app: &mut App) {
app
.init_resource::<Placar>()
.init_resource::<ConfigJogo>()
.init_resource::<TimerTiro>()
.add_event::<EventoColisao>()
.add_event::<EventoPontuou>()
.add_systems(Startup, (setup, setup_ui))
.add_systems(Update, (
sistema_input_jogador,
sistema_mover_asteroides,
sistema_mover_projeteis,
sistema_colisao_projetil_asteroide,
sistema_atualizar_placar,
));
}
}
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Asteroides — Bevy ECS".to_string(),
resolution: (800.0, 600.0).into(),
..default()
}),
..default()
}))
.add_plugins(PluginAsteroid)
.run();
}
Estados de jogo com Bevy States
use bevy::prelude::*;
#[derive(States, Debug, Clone, PartialEq, Eq, Hash, Default)]
enum EstadoJogo {
#[default]
MenuPrincipal,
Jogando,
Pausado,
GameOver,
}
fn sistema_menu(
teclado: Res<ButtonInput<KeyCode>>,
mut prox_estado: ResMut<NextState<EstadoJogo>>,
) {
if teclado.just_pressed(KeyCode::Enter) {
prox_estado.set(EstadoJogo::Jogando);
}
}
fn sistema_pausar(
teclado: Res<ButtonInput<KeyCode>>,
estado: Res<State<EstadoJogo>>,
mut prox_estado: ResMut<NextState<EstadoJogo>>,
) {
if teclado.just_pressed(KeyCode::Escape) {
match estado.get() {
EstadoJogo::Jogando => prox_estado.set(EstadoJogo::Pausado),
EstadoJogo::Pausado => prox_estado.set(EstadoJogo::Jogando),
_ => {}
}
}
}
pub struct PluginEstados;
impl Plugin for PluginEstados {
fn build(&self, app: &mut App) {
app
.init_state::<EstadoJogo>()
// Sistemas que rodam apenas em estados específicos
.add_systems(
Update,
sistema_menu.run_if(in_state(EstadoJogo::MenuPrincipal))
)
.add_systems(
Update,
(
sistema_input_jogador,
sistema_mover_asteroides,
).run_if(in_state(EstadoJogo::Jogando))
)
.add_systems(
Update,
sistema_pausar.run_if(in_state(EstadoJogo::Jogando)
.or_else(in_state(EstadoJogo::Pausado)))
)
// Cleanup ao sair de um estado
.add_systems(
OnExit(EstadoJogo::Jogando),
limpar_entidades_jogo
);
}
}
fn limpar_entidades_jogo(
mut comandos: Commands,
entidades: Query<Entity, Or<(With<Asteroide>, With<Projetil>)>>,
) {
for entidade in &entidades {
comandos.entity(entidade).despawn_recursive();
}
}
Assets e recursos
use bevy::prelude::*;
// Gerenciamento de assets tipado
#[derive(Resource)]
struct AssetsJogo {
sprite_jogador: Handle<Image>,
sprite_asteroide: Handle<Image>,
som_explosao: Handle<AudioSource>,
som_tiro: Handle<AudioSource>,
fonte_ui: Handle<Font>,
}
fn carregar_assets(
mut comandos: Commands,
servidor_assets: Res<AssetServer>,
) {
let assets = AssetsJogo {
sprite_jogador: servidor_assets.load("sprites/jogador.png"),
sprite_asteroide: servidor_assets.load("sprites/asteroide.png"),
som_explosao: servidor_assets.load("sons/explosao.ogg"),
som_tiro: servidor_assets.load("sons/tiro.ogg"),
fonte_ui: servidor_assets.load("fontes/principal.ttf"),
};
comandos.insert_resource(assets);
}
// Reproduz som ao pontuar
fn sistema_som_pontuacao(
mut eventos: EventReader<EventoPontuou>,
assets: Res<AssetsJogo>,
mut comandos: Commands,
) {
for _ in eventos.read() {
comandos.spawn(AudioBundle {
source: assets.som_explosao.clone(),
settings: PlaybackSettings::DESPAWN,
});
}
}
Bevy para WebAssembly
Uma das características mais atraentes do Bevy: o mesmo código roda no desktop e no browser:
# Instala o target WASM
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli
# Compila para WASM
cargo build --release --target wasm32-unknown-unknown
# Gera os arquivos JavaScript
wasm-bindgen
--out-dir ./web/out
--target web
target/wasm32-unknown-unknown/release/meu_jogo.wasm
index.html:
<!DOCTYPE html>
<html>
<head>
<title>Asteroides em Rust + Bevy</title>
<style>
body { margin: 0; background: #000; display: flex;
justify-content: center; align-items: center;
height: 100vh; }
canvas { display: block; }
</style>
</head>
<body>
<script type="module">
import init from './out/meu_jogo.js';
await init();
</script>
</body>
</html>
Performance: sistemas paralelos
Bevy executa sistemas em paralelo automaticamente quando não há conflitos de acesso:
use bevy::prelude::*;
// Estes sistemas rodam em paralelo — acessam componentes diferentes
fn sistema_a(query: Query<&mut Transform, With<Jogador>>) { /* ... */ }
fn sistema_b(query: Query<&mut Saude, With<Asteroide>>) { /* ... */ }
// Este sistema é serial com sistema_a — ambos acessam Transform
fn sistema_c(query: Query<&Transform>) { /* ... */ }
// Ordem explícita quando necessário
fn plugin_com_ordem(app: &mut App) {
app.add_systems(Update, (
sistema_input_jogador,
sistema_mover_asteroides.after(sistema_input_jogador),
sistema_colisao_projetil_asteroide
.after(sistema_mover_asteroides),
sistema_atualizar_placar
.after(sistema_colisao_projetil_asteroide),
));
}
Fontes e leituras recomendadas
- Bevy Book — guia oficial — https://bevyengine.org/learn/book/introduction/
- Bevy Examples — exemplos oficiais — https://bevyengine.org/examples/
- "Unofficial Bevy Cheat Book" — referência prática — https://bevy-cheatbook.github.io
hecscrate — ECS minimalista — https://docs.rs/hecslegioncrate — ECS de alta performance — https://docs.rs/legion- "Game Programming Patterns" — Robert Nystrom — livro gratuito, fundamental — https://gameprogrammingpatterns.com
- "Data-Oriented Design" — Richard Fabian — filosofia por trás do ECS
- Bevy Discord — comunidade ativa — https://discord.gg/bevy
macroquad— alternativa simples ao Bevy — https://docs.rs/macroquad
Artigo #42 de 52 | Série: Dominando Rust em 1 Ano Próximo → Artigo #43: Distributed Systems — Consenso, mensageria e tolerância a falhas