Salta ai contenuti

Sviluppo di giochi in Rust: Bevy, Macroquad e altro

Tante persone imparano Rust perché vogliono fare un gioco. È un ottimo motivo! I giochi hanno bisogno di velocità, di un uso attento della memoria e di codice che non va in crash nel mezzo di uno scontro con il boss finale, e sono proprio le cose in cui Rust eccelle.

Questa pagina ti dà un quadro onesto: cosa si costruisce già con Rust, quale engine o libreria scegliere e le due idee che usa quasi ogni gioco, il game loop e l’ECS. Poi vedrai un primo programma in Bevy e in Macroquad.

Quali esempi girano dove? Il game loop, il minuscolo mondo ECS e l’esercizio pratico sono Rust puro, quindi hanno un pulsante «Provalo tu» per il Rust Playground. Bevy e Macroquad aprono una finestra, quindi quegli esempi sono da provare sul tuo computer con cargo run. L’esempio Macroquad è stato compilato (anche per il web) mentre scrivevamo questa pagina; l’esempio Bevy segue gli esempi ufficiali di Bevy.

Il sito della community Are We Game Yet? (in inglese) tiene traccia dell’ecosistema dei giochi in Rust. La sua risposta alla domanda del titolo è: «Almost. We have the blocks, bring your own glue.», cioè «Quasi. I mattoni ci sono, la colla portala tu». È un riassunto giusto.

Cosa va bene oggi:

  • Giochi veri escono davvero. Tiny Glade, un rilassante gioco di costruzione di castelli su Steam, e Veloren, un GDR voxel open source, sono entrambi elencati su Are We Game Yet come giochi in Rust.
  • Un engine solido. Bevy ha una community grande e attiva e una nuova release ogni pochi mesi.
  • Tanti mattoni. Are We Game Yet elenca crate per fisica, audio, input, rete, interfaccia e altro.
  • Il tuo gioco può girare sul web. Rust compila in WebAssembly, quindi tanti giochi in Rust girano anche nel browser.

Cosa è ancora difficile:

  • Non c’è ancora uno «Unity di Rust». La maggior parte degli engine Rust si usa scrivendo codice. Esistono editor visuali (Fyrox ne ha uno, e Godot può usare Rust), ma sono più giovani dei grandi strumenti commerciali.
  • Le cose cambiano. Bevy è ancora prima della 1.0, e ogni release porta modifiche incompatibili. Un tutorial dell’anno scorso potrebbe non compilare più.
  • I tempi di compilazione. I grandi engine richiedono minuti per la prima build. Più sotto impari i trucchi per ridurli.

Se il tuo obiettivo è pubblicare l’anno prossimo un gioco 3D commerciale con un team di artisti, Unity, Unreal o Godot sono scelte più sicure. Se vuoi imparare, fare giochi 2D, game jam, tool o simulazioni, o semplicemente ti piace Rust, è un posto molto divertente in cui stare. 🦀

Ci sono due tipi di strumenti. Un engine ti dà una struttura completa: come funzionano gli oggetti di gioco, le scene e gli asset. Una libreria (o framework) ti dà una finestra, il disegno, il suono e l’input, e il resto lo organizzi tu.

Crate Cos’è Adatto a Da sapere
bevy (0.20) Un engine completo 2D e 3D costruito attorno all’ECS Progetti più grandi, 3D, capire come funzionano gli engine moderni L’engine Rust più conosciuto. Modifiche incompatibili a ogni release; prima build lunga
macroquad (0.4) Una semplice libreria 2D, ispirata a raylib Il tuo primo gioco, game jam, giochi web Pochissime dipendenze, compila in fretta. Chiami funzioni di disegno in un ciclo
ggez (0.10) Un framework 2D leggero, ispirato a LÖVE Giochi 2D con una struttura chiara Implementi un trait EventHandler con update e draw
fyrox (1.0) Un engine 2D e 3D con un editor di scene Chi vuole un editor, come in Godot o Unity Ha raggiunto la versione 1.0 nel 2026. Community più piccola di Bevy
raylib (6.0) Binding Rust per la libreria C raylib Chi conosce già raylib dal C Richiede CMake e gli strumenti di build per il C, perché compila la libreria C (vedi FFI)
godot (0.5, «gdext») Binding Rust per l’engine Godot 4 Usare l’editor di Godot con la logica di gioco in Rust Scrivi classi Rust che Godot carica come GDExtension

Una regola semplice: inizia con Macroquad per il tuo primo gioco 2D. Scegli Bevy quando vuoi un engine completo e ti va di imparare l’ECS. Scegli Godot con gdext o Fyrox se vuoi un editor per posizionare le cose visivamente.

Quasi ogni gioco è un unico grande ciclo che gira di continuo finché non esci:

  1. Leggi l’input (tastiera, mouse, controller).
  2. Aggiorna il gioco: muovi le cose, controlla le collisioni, conta i punti.
  3. Disegna la nuova immagine sullo schermo.

Un giro del ciclo si chiama frame. A 60 frame al secondo (FPS), ogni frame ha circa 16 millisecondi. Ma non tutti i frame durano uguale. Il computer potrebbe essere occupato, o un frame potrebbe dover caricare un file.

Se il tuo gioco sposta il giocatore di «2 unità a ogni frame», va più veloce su un computer veloce e più lento su uno lento. Una soluzione molto usata è il fixed timestep (passo fisso): la logica di gioco avanza sempre a passi uguali (qui 10 ms), e ogni frame esegue tutti i passi che stanno nel tempo trascorso. Il tempo avanzato aspetta in un accumulatore fino al frame successivo.

const STEP_MS: u32 = 10; // la logica di gioco avanza sempre di 10 ms alla volta
const SPEED: i32 = 2; // il giocatore si sposta di 2 unità a ogni aggiornamento
fn main() {
// Quanto è durato davvero ogni frame. Un gioco vero lo misura con un orologio.
let frame_times = [16, 16, 45, 5, 16, 33];
let mut accumulator = 0; // tempo trascorso ma non ancora simulato
let mut position = 0;
for (frame, elapsed) in frame_times.into_iter().enumerate() {
accumulator += elapsed;
// Esegui tutti i passi fissi che stanno nel tempo che abbiamo
let mut updates = 0;
while accumulator >= STEP_MS {
position += SPEED; // update(): logica di gioco
accumulator -= STEP_MS;
updates += 1;
}
// render(): disegna una volta per frame, qualunque cosa sia successa
println!("frame {frame}: {elapsed:>2} ms, aggiornamenti: {updates}, giocatore a {position:>2}, avanzano {accumulator} ms");
}
}

Output:

frame 0: 16 ms, aggiornamenti: 1, giocatore a 2, avanzano 6 ms
frame 1: 16 ms, aggiornamenti: 2, giocatore a 6, avanzano 2 ms
frame 2: 45 ms, aggiornamenti: 4, giocatore a 14, avanzano 7 ms
frame 3: 5 ms, aggiornamenti: 1, giocatore a 16, avanzano 2 ms
frame 4: 16 ms, aggiornamenti: 1, giocatore a 18, avanzano 8 ms
frame 5: 33 ms, aggiornamenti: 4, giocatore a 26, avanzano 1 ms
▶ Provalo tu

Guarda il frame 2: è stato lento (45 ms), quindi il gioco ha eseguito 4 aggiornamenti per recuperare. In totale sono passati 131 ms, e il gioco ha eseguito 13 aggiornamenti da 10 ms. Il giocatore finisce a 26 indipendentemente da come il tempo è stato diviso in frame. Così la fisica è stabile e uguale su ogni computer, il che conta anche per i replay e i giochi online. Un pericolo: se un frame è molto lento, il gioco esegue tanti aggiornamenti per recuperare, e anche il frame successivo diventa lento. Per questo i cicli veri limitano anche il numero di aggiornamenti per frame (l’orologio di gioco di Bevy, per esempio, avanza al massimo di 250 ms per frame di default).

L’altro metodo comune è il delta time: misuri i secondi passati dall’ultimo frame e ci moltiplichi le velocità (x += speed * dt). È più semplice, ed è quello che fanno gli esempi Bevy e Macroquad più sotto. Molti engine usano entrambi: delta time per le animazioni, passo fisso per la fisica.

In molti linguaggi creeresti una classe Player, una classe Enemy che eredita da Character, e così via. Diventa presto un groviglio: e un nemico che si può anche raccogliere? La maggior parte degli engine di gioco in Rust usa invece l’ECS (Entity Component System):

  • Un’entità (entity) è solo un ID, come un numero. Non ha dati né comportamento propri.
  • Un componente (component) è un pezzo di dati semplici che attacchi a un’entità: Position, Velocity, Health. In Rust sono normali struct o enum.
  • Un sistema (system) è una funzione che gira su ogni entità che ha certi componenti. Un sistema di movimento, per esempio, lavora su tutto ciò che ha sia una Position sia una Velocity.

Un albero ha una Position ma non una Velocity, quindi il sistema di movimento lo salta. Vuoi che l’albero si muova? Dagli una Velocity. Non cambi nessuna classe. Dati e comportamento restano separati, e questo si sposa bene anche con le regole di borrowing di Rust.

Ecco un minuscolo mondo ECS in Rust puro. Ogni tipo di componente vive nel suo vettore, e l’ID dell’entità è l’indice. None significa «questa entità non ha quel componente» (vedi Option).

struct Position { x: i32, y: i32 }
struct Velocity { dx: i32, dy: i32 }
// Un'entità è solo un ID: un indice in ogni lista di componenti
type Entity = usize;
#[derive(Default)]
struct World {
names: Vec<&'static str>,
positions: Vec<Option<Position>>,
velocities: Vec<Option<Velocity>>,
}
impl World {
fn spawn(&mut self, name: &'static str, pos: Option<Position>, vel: Option<Velocity>) -> Entity {
self.names.push(name);
self.positions.push(pos);
self.velocities.push(vel);
self.names.len() - 1
}
}
// Un sistema: lavora su ogni entità che ha SIA una Position SIA una Velocity
fn movement_system(world: &mut World) {
for (pos, vel) in world.positions.iter_mut().zip(&world.velocities) {
if let (Some(pos), Some(vel)) = (pos, vel) {
pos.x += vel.dx;
pos.y += vel.dy;
}
}
}
// Un altro sistema: stampa ogni entità che ha una Position
fn print_system(world: &World, tick: u32) {
println!("tick {tick}:");
for (id, pos) in world.positions.iter().enumerate() {
if let Some(pos) = pos {
println!(" #{id} {:<9} in ({}, {})", world.names[id], pos.x, pos.y);
}
}
}
fn main() {
let mut world = World::default();
world.spawn("giocatore", Some(Position { x: 0, y: 0 }), Some(Velocity { dx: 1, dy: 0 }));
world.spawn("albero", Some(Position { x: 5, y: 2 }), None); // niente Velocity: non si muove mai
world.spawn("freccia", Some(Position { x: 0, y: 3 }), Some(Velocity { dx: 3, dy: -1 }));
for tick in 1..=2 {
movement_system(&mut world);
print_system(&world, tick);
}
}

Output:

tick 1:
#0 giocatore in (1, 0)
#1 albero in (5, 2)
#2 freccia in (3, 2)
tick 2:
#0 giocatore in (2, 0)
#1 albero in (5, 2)
#2 freccia in (6, 1)
▶ Provalo tu

L’albero resta in (5, 2) perché non ha una Velocity. Nessuno ha scritto «gli alberi non si muovono»: deriva semplicemente dai dati. Le vere librerie ECS sono molto più veloci e furbe di questa (salvano i componenti in modo che la CPU li legga in fretta, ed eseguono i sistemi in parallelo), ma l’idea è la stessa.

Bevy è l’engine Rust più conosciuto, e al suo interno tutto è ECS. La versione 0.20 è uscita a ottobre 2026. Bevy ha bisogno dell’ultima versione stabile di Rust, quindi prima aggiorna, poi crea un progetto:

Terminal window
rustup update
cargo new my_bevy_game
cd my_bevy_game
cargo add bevy

Su Linux ti servono anche alcune librerie di sistema (suono, input, grafica). La guida di installazione (in inglese) di Bevy le elenca per ogni distribuzione. Su Windows ti servono i Visual Studio C++ Build Tools, e su macOS gli strumenti da riga di comando di Xcode.

Questo programma si basa sull’esempio ufficiale 2d/move_sprite di Bevy. Disegna un quadrato arancione che scivola a destra e a sinistra. (L’esempio ufficiale carica un’immagine; qui usiamo un semplice quadrato colorato, così non ti serve nessun file di immagine.)

use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins) // finestra, rendering, input, audio...
.add_systems(Startup, setup) // gira una volta all'avvio
.add_systems(Update, sprite_movement) // gira a ogni frame
.run();
}
// Un componente: in che direzione si muove questa entità
#[derive(Component)]
enum Direction {
Left,
Right,
}
fn setup(mut commands: Commands) {
commands.spawn(Camera2d);
// Crea un'entità con tre componenti
commands.spawn((
Sprite::from_color(Color::srgb(1.0, 0.5, 0.2), Vec2::new(50.0, 50.0)),
Transform::from_xyz(0.0, 0.0, 0.0),
Direction::Right,
));
}
// Un sistema: gira su ogni entità che ha una Direction E una Transform
fn sprite_movement(time: Res<Time>, mut sprite_position: Query<(&mut Direction, &mut Transform)>) {
for (mut logo, mut transform) in &mut sprite_position {
match *logo {
Direction::Right => transform.translation.x += 150. * time.delta_secs(),
Direction::Left => transform.translation.x -= 150. * time.delta_secs(),
}
if transform.translation.x > 200. {
*logo = Direction::Left;
} else if transform.translation.x < -200. {
*logo = Direction::Right;
}
}
}

Confrontalo con il minuscolo mondo ECS di prima. #[derive(Component)] trasforma un normale enum in un componente. commands.spawn((...)) crea un’entità con una tupla di componenti. E Query<(&mut Direction, &mut Transform)> chiede a Bevy «tutte le entità che hanno entrambi», proprio come faceva il nostro movement_system con zip. time.delta_secs() è il delta time della sezione sul game loop, quindi il quadrato si sposta di 150 pixel al secondo su qualsiasi computer.

Eseguilo con cargo run. La prima build compila centinaia di crate e può richiedere diversi minuti, quindi abbi pazienza. Dopo, le ricompilazioni sono molto più veloci.

La guida di installazione di Bevy consiglia due cose. Primo, aggiungi questo a Cargo.toml. Ottimizza Bevy e le altre dipendenze, ma lascia veloce la compilazione del tuo codice:

[dependencies]
bevy = "0.20"
# Un po' di ottimizzazione nel profilo dev.
[profile.dev]
opt-level = 1
# Molta ottimizzazione nel profilo dev per le dipendenze.
[profile.dev.package."*"]
opt-level = 3

Senza questo, una build di debug di un gioco può essere molto lenta: pochi frame al secondo e caricamenti lunghi. Le dipendenze si compilano una volta sola, quindi paghi l’ottimizzazione solo alla prima build. (I profili sono spiegati in Prestazioni.)

Secondo, mentre sviluppi, attiva la feature dynamic_linking. Così Bevy viene compilato come libreria condivisa separata, e Cargo non deve ricollegare tutto Bevy nel tuo programma a ogni modifica:

Terminal window
cargo run --features bevy/dynamic_linking

Due avvertenze dalla guida di Bevy: su Windows, dynamic_linking funziona solo insieme alle impostazioni opt-level di sopra. E non pubblicare il gioco con questa feature: il gioco avrebbe bisogno di un file di libreria in più accanto. Compila la release senza la feature. La guida mostra anche come usare un linker più veloce (lld o mold), che aiuta molto su Linux.

Macroquad dà una sensazione molto diversa. Niente ECS e niente app builder: il game loop lo scrivi tu, e disegni con funzioni semplici come draw_circle. Ha pochissime dipendenze, quindi una build da zero richiede secondi, non minuti.

Terminal window
cargo new move_the_ball
cd move_the_ball
cargo add macroquad
[dependencies]
macroquad = "0.4"

Questo piccolo gioco mostra una pallina che muovi con le frecce:

use macroquad::prelude::*;
#[macroquad::main("Move the Ball")]
async fn main() {
let mut player = vec2(screen_width() / 2.0, screen_height() / 2.0);
let speed = 250.0; // pixel al secondo
loop {
// 1. Input: le frecce muovono il giocatore
let dt = get_frame_time(); // secondi dall'ultimo frame
if is_key_down(KeyCode::Right) {
player.x += speed * dt;
}
if is_key_down(KeyCode::Left) {
player.x -= speed * dt;
}
if is_key_down(KeyCode::Down) {
player.y += speed * dt;
}
if is_key_down(KeyCode::Up) {
player.y -= speed * dt;
}
if is_key_pressed(KeyCode::Escape) {
break;
}
// 2. Tieni il giocatore dentro lo schermo
player.x = player.x.clamp(0.0, screen_width());
player.y = player.y.clamp(0.0, screen_height());
// 3. Disegna tutto per questo frame
clear_background(DARKBLUE);
draw_circle(player.x, player.y, 20.0, ORANGE);
draw_text("Arrow keys to move, Esc to quit", 20.0, 30.0, 28.0, WHITE);
next_frame().await
}
}

Si vedono i tre passi del game loop: input, aggiornamento, disegno. #[macroquad::main("Move the Ball")] apre una finestra con quel titolo. main è async, ma non ti servono Tokio né conoscere l’async: next_frame().await significa semplicemente «mostra questo frame e aspetta il prossimo». Su Linux, il README di Macroquad elenca alcuni pacchetti di sistema da installare prima (pkg-config libx11-dev libxi-dev libgl1-mesa-dev libasound2-dev su Ubuntu).

Sia Macroquad sia Bevy possono compilare il tuo gioco per il browser come WebAssembly. Se Wasm è una novità per te, leggi prima Rust e WebAssembly. Si parte sempre aggiungendo il target Wasm:

Terminal window
rustup target add wasm32-unknown-unknown

Macroquad rende tutto molto facile. Un solo comando crea un file .wasm:

Terminal window
cargo build --release --target wasm32-unknown-unknown

Il file finisce in target/wasm32-unknown-unknown/release/move_the_ball.wasm (per l’esempio di sopra pesava circa 600 KB). Il README di Macroquad fornisce un breve index.html che lo carica con un piccolo file JavaScript, mq_js_bundle.js. Metti entrambi i file in una cartella e servila con un qualsiasi web server locale, per esempio basic-http-server . (lo installi con cargo install basic-http-server). Fare doppio clic sul file HTML di solito non funziona, perché i browser bloccano il caricamento del file .wasm direttamente dal disco.

Bevy usa wasm-bindgen per creare il codice JavaScript di collegamento. Questi passaggi sono adattati dal README degli esempi di Bevy (per il progetto my_bevy_game di sopra):

Terminal window
cargo install wasm-bindgen-cli
cargo build --release --target wasm32-unknown-unknown
wasm-bindgen --out-dir web --target web target/wasm32-unknown-unknown/release/my_bevy_game.wasm

Questo scrive un file .js e un file _bg.wasm nella cartella web; una piccola pagina HTML importa il file .js e avvia il gioco. La guida di installazione di Bevy suggerisce anche un profilo wasm-release con opt-level = "s" per rendere il download più piccolo. Le build web sono un ottimo modo per condividere i giochi delle game jam: si gioca con un clic, senza installare niente.

Spostare di una quantità fissa a ogni frame. x += 5.0 a ogni frame significa che il gioco va due volte più veloce a 120 FPS che a 60 FPS. Moltiplica per il delta time (x += speed * dt) oppure usa un passo fisso.

Seguire un vecchio tutorial di Bevy. Bevy cambia la sua API a ogni release, quindi un articolo scritto per la 0.12 spesso non compila con la 0.20. Controlla quale versione usa un tutorial, e preferisci gli esempi ufficiali per la tua versione. Ogni release ha anche una guida alla migrazione sul sito di Bevy.

Build di debug lente. Se il tuo gioco Bevy gira a pochi frame al secondo, probabilmente hai dimenticato le impostazioni opt-level in Cargo.toml.

Partire troppo in grande. Un GDR open world è un primo progetto difficile in qualsiasi linguaggio. Inizia con Pong, Snake, Breakout o Flappy Bird. Finiscilo, poi fai il prossimo un po’ più grande.

Ogni gioco ha delle schermate: un menu, il gioco vero e proprio, una schermata di pausa, il «game over». Crea un enum GameState con Menu, Playing, Paused e GameOver, e un enum Input con Enter, Escape e PlayerDied. Scrivi fn next_state(state: GameState, input: Input) -> GameState: Invio fa partire il gioco dal menu, Esc mette in pausa e toglie la pausa, morire fa finire la partita, e Invio sul «game over» torna al menu. Qualsiasi altro input non cambia niente. Passale una lista fissa di input e stampa ogni cambiamento. Suggerimento: fai match sulla tupla (state, input), e chiudi con (same, _) => same.

Mostra la soluzione
#[derive(Debug, Clone, Copy)]
enum GameState {
Menu,
Playing,
Paused,
GameOver,
}
#[derive(Debug, Clone, Copy)]
enum Input {
Enter,
Escape,
PlayerDied,
}
fn next_state(state: GameState, input: Input) -> GameState {
match (state, input) {
(GameState::Menu, Input::Enter) => GameState::Playing,
(GameState::Playing, Input::Escape) => GameState::Paused,
(GameState::Paused, Input::Escape) => GameState::Playing,
(GameState::Playing, Input::PlayerDied) => GameState::GameOver,
(GameState::GameOver, Input::Enter) => GameState::Menu,
// Qualsiasi altra cosa: ignora l'input e resta dove sei
(same, _) => same,
}
}
fn main() {
use Input::*; // così possiamo scrivere Enter invece di Input::Enter
let inputs = [Escape, Enter, Escape, PlayerDied, Escape, PlayerDied, Enter];
let mut state = GameState::Menu;
for input in inputs {
let new_state = next_state(state, input);
println!("{state:?} + {input:?} -> {new_state:?}");
state = new_state;
}
}

Output:

Menu + Escape -> Menu
Menu + Enter -> Playing
Playing + Escape -> Paused
Paused + PlayerDied -> Paused
Paused + Escape -> Playing
Playing + PlayerDied -> GameOver
GameOver + Enter -> Menu
▶ Provalo tu

Fare match su una tupla elenca tutti i cambiamenti ammessi in un solo posto, e l’ultimo ramo rende innocui tutti gli altri input. Un giocatore in pausa non può morire, perché (Paused, PlayerDied) finisce in (same, _). Bevy ha la stessa idea già integrata, e si chiama States.

  • Rust è una vera opzione per i giochi: «quasi pronto», con un engine solido (Bevy), librerie semplici (Macroquad, ggez), opzioni con editor (Fyrox, Godot con gdext) e tanti mattoni.
  • Ogni gioco esegue un ciclo: input, aggiornamento, disegno. Usa il delta time o un passo fisso, così la velocità non dipende dal frame rate.
  • L’ECS divide un gioco in entità (ID), componenti (dati semplici) e sistemi (funzioni che girano sulle entità con certi componenti).
  • Bevy è ECS da cima a fondo. Aggiungi le impostazioni di profilo opt-level, e usa dynamic_linking solo mentre sviluppi.
  • Macroquad è la partenza più rapida per il 2D: scrivi tu il ciclo e disegni con funzioni semplici.
  • Entrambi possono compilare per il browser con il target wasm32-unknown-unknown.

Verifica veloce

1. Nell'ECS, cos'è un'entità?

2. Perché un passo fisso fa comportare un gioco allo stesso modo su ogni computer?

3. Con quale impostazione di Bevy NON dovresti pubblicare il gioco finito?

4. Vuoi fare il tuo primo piccolo gioco 2D con tempi di build molto brevi. Quale crate suggerisce questa pagina?

Punteggio: 0 / 4

  • Are We Game Yet? (in inglese): la panoramica della community su engine di gioco in Rust, librerie per fisica, audio e rete, e giochi fatti con Rust.
  • Bevy Quick Start: Setup (in inglese): la guida ufficiale per installare Bevy, con le impostazioni per compilare in fretta e i profili per la release e per Wasm.
  • Game development in Rust with Macroquad (in inglese): un libro online gratuito che costruisce passo dopo passo uno sparatutto spaziale completo con Macroquad.
  • The godot-rust book (in inglese): come scrivere in Rust la logica di gioco per Godot 4 con gdext.
  • Game Loop — Game Programming Patterns (in inglese): una spiegazione amichevole e dettagliata di game loop, delta time e passo fisso.