Перейти к содержимому

Разработка игр на Rust: Bevy, Macroquad и другие

Многие начинают учить Rust, потому что хотят сделать игру. Отличная причина! Играм нужны скорость, аккуратная работа с памятью и код, который не падает посреди битвы с боссом, — а это как раз то, в чём Rust силён.

На этой странице — честная картина: что уже делают на Rust, какой движок или библиотеку выбрать и две идеи, которые используются почти в каждой игре: игровой цикл (game loop) и ECS. А потом ты увидишь первую программу на Bevy и на Macroquad.

Какие примеры где запускаются? Игровой цикл, крошечный мир ECS и упражнение — это обычный Rust, поэтому у них есть кнопка «Попробуй сам» для Rust Playground. Bevy и Macroquad открывают окно, так что эти примеры — для твоего компьютера, через cargo run. Пример на Macroquad был скомпилирован (в том числе для веба), пока писалась эта страница; пример на Bevy следует официальным примерам Bevy.

Сайт сообщества Are We Game Yet? (на английском) следит за игровой экосистемой Rust. На собственный вопрос он отвечает так: «Почти. Кубики у нас есть, клей приноси свой». Справедливое описание.

Что хорошо уже сегодня:

  • Настоящие игры выходят. Tiny Glade — уютная игра про строительство замков в Steam — и Veloren — воксельная RPG с открытым кодом — обе числятся на Are We Game Yet как игры на Rust.
  • Сильный движок. У Bevy большое активное сообщество и новый выпуск каждые несколько месяцев.
  • Много строительных блоков. Are We Game Yet перечисляет крейты для физики, звука, ввода, сети, интерфейса и многого другого.
  • Игра может работать в вебе. Rust компилируется в WebAssembly, поэтому многие игры на Rust запускаются и в браузере.

Что пока сложно:

  • «Unity для Rust» ещё нет. Большинство движков на Rust ориентированы на код. Визуальные редакторы существуют (он есть у Fyrox, а Godot умеет работать с Rust), но они моложе больших коммерческих инструментов.
  • Всё меняется. Bevy ещё не дошёл до 1.0, и в каждом выпуске есть несовместимые изменения. Прошлогоднее руководство может уже не скомпилироваться.
  • Время компиляции. Большим движкам на первую сборку нужны минуты. Ниже ты узнаешь, как с этим справляться.

Если цель — выпустить коммерческую 3D-игру в следующем году с командой художников, Unity, Unreal или Godot надёжнее. А если хочешь учиться, делать 2D-игры, игры для геймджемов, инструменты или симуляции — или тебе просто нравится Rust, — это очень весёлое место. 🦀

Инструменты бывают двух видов. Движок (engine) даёт целую структуру: как устроены игровые объекты, сцены и ресурсы. Библиотека (или фреймворк) даёт окно, рисование, звук и ввод, а остальное ты организуешь сам.

Крейт Что это Для чего хорош Что нужно знать
bevy (0.20) Полноценный 2D- и 3D-движок, построенный на ECS Проекты побольше, 3D, понять, как устроены современные движки Самый известный движок на Rust. Несовместимые изменения в каждом выпуске; долгая первая сборка
macroquad (0.4) Простая 2D-библиотека, вдохновлённая raylib Первая игра, геймджемы, веб-игры Очень мало зависимостей, собирается быстро. Ты вызываешь функции рисования в цикле
ggez (0.10) Лёгкий 2D-фреймворк, вдохновлённый LÖVE 2D-игры с понятной структурой Ты реализуешь трейт EventHandler с update и draw
fyrox (1.0) 2D- и 3D-движок с редактором сцен Тем, кому нужен редактор, как в Godot или Unity Дошёл до версии 1.0 в 2026 году. Сообщество меньше, чем у Bevy
raylib (6.0) Привязки Rust к библиотеке raylib на C Тем, кто знает raylib по C Нужны CMake и инструменты сборки C, потому что он собирает библиотеку на C (см. FFI)
godot (0.5, «gdext») Привязки Rust к движку Godot 4 Работать в редакторе Godot, а игровую логику писать на Rust Ты пишешь классы на Rust, которые Godot загружает как GDExtension

Документация у всех этих инструментов на английском. Простое правило: для первой 2D-игры начни с Macroquad. Бери Bevy, когда нужен полноценный движок и ты готов освоить ECS. Выбирай Godot с gdext или Fyrox, если хочешь расставлять всё мышкой в редакторе.

Почти любая игра — это один большой цикл, который повторяется снова и снова, пока ты не выйдешь:

  1. Прочитать ввод (клавиши, мышь, геймпад).
  2. Обновить игру: подвинуть объекты, проверить столкновения, посчитать очки.
  3. Нарисовать новую картинку на экране.

Один проход цикла называется кадром (frame). При 60 кадрах в секунду (FPS) на каждый кадр приходится около 16 миллисекунд. Но кадры длятся по-разному. Компьютер может быть занят, или в каком-то кадре нужно загрузить файл.

Если игра двигает игрока «на 2 единицы каждый кадр», на быстром компьютере она идёт быстрее, а на медленном — медленнее. Популярное решение — фиксированный шаг времени (fixed timestep): игровая логика всегда продвигается равными шагами (здесь по 10 мс), а в каждом кадре выполняется столько шагов, сколько помещается в прошедшее время. Остаток ждёт в накопителе (accumulator) до следующего кадра.

const STEP_MS: u32 = 10; // игровая логика всегда продвигается на 10 мс за раз
const SPEED: i32 = 2; // игрок сдвигается на 2 единицы за одно обновление
fn main() {
// Сколько на самом деле длился каждый кадр. Настоящая игра замеряет это по часам.
let frame_times = [16, 16, 45, 5, 16, 33];
let mut accumulator = 0; // время, которое прошло, но ещё не просчитано
let mut position = 0;
for (frame, elapsed) in frame_times.into_iter().enumerate() {
accumulator += elapsed;
// Выполняем столько фиксированных шагов, сколько помещается во время
let mut updates = 0;
while accumulator >= STEP_MS {
position += SPEED; // update(): игровая логика
accumulator -= STEP_MS;
updates += 1;
}
// render(): рисуем один раз за кадр, что бы ни случилось
println!("кадр {frame}: {elapsed:>2} мс, обновлений: {updates}, игрок на {position:>2}, остаток {accumulator} мс");
}
}

Вывод:

кадр 0: 16 мс, обновлений: 1, игрок на 2, остаток 6 мс
кадр 1: 16 мс, обновлений: 2, игрок на 6, остаток 2 мс
кадр 2: 45 мс, обновлений: 4, игрок на 14, остаток 7 мс
кадр 3: 5 мс, обновлений: 1, игрок на 16, остаток 2 мс
кадр 4: 16 мс, обновлений: 1, игрок на 18, остаток 8 мс
кадр 5: 33 мс, обновлений: 4, игрок на 26, остаток 1 мс
▶ Попробуй сам

Посмотри на кадр 2: он был медленным (45 мс), поэтому игра выполнила 4 обновления, чтобы догнать время. Всего прошла 131 мс, и игра выполнила 13 обновлений по 10 мс. Игрок оказывается на 26, как бы время ни делилось на кадры. Благодаря этому физика стабильна и одинакова на любом компьютере, что важно ещё и для повторов и сетевых игр. Есть одна опасность: если кадр очень медленный, игра выполняет много обновлений, чтобы догнать время, и из-за этого следующий кадр тоже получается медленным. Поэтому настоящие циклы ещё и ограничивают число обновлений за кадр (игровые часы Bevy, например, по умолчанию продвигаются не больше чем на 250 мс за кадр).

Другой распространённый способ — дельта времени (delta time): ты измеряешь, сколько секунд прошло с прошлого кадра, и умножаешь на это скорости (x += speed * dt). Так проще, и именно так сделаны примеры на Bevy и Macroquad ниже. Многие движки используют оба способа: дельту времени для анимации и фиксированный шаг для физики.

Что такое ECS: сущности, компоненты, системы

Заголовок раздела «Что такое ECS: сущности, компоненты, системы»

Во многих языках ты сделал бы класс Player, класс Enemy, наследующий от Character, и так далее. Это быстро запутывается: а как быть с врагом, которого ещё и можно подобрать? Большинство игровых движков на Rust вместо этого используют ECS (Entity Component System — «сущность, компонент, система»):

  • Сущность (entity) — это просто ID, вроде числа. Своих данных и поведения у неё нет.
  • Компонент (component) — это кусочек простых данных, прикреплённый к сущности: Position, Velocity, Health. В Rust это обычные структуры или перечисления.
  • Система (system) — это функция, которая работает со всеми сущностями, у которых есть определённые компоненты. Например, система движения работает со всем, у чего есть и Position, и Velocity.

У дерева есть Position, но нет Velocity, поэтому система движения его пропускает. Хочешь, чтобы дерево двигалось? Дай ему Velocity. Никакой класс менять не нужно. Данные и поведение остаются раздельными, и это к тому же хорошо сочетается с правилами заимствования в Rust.

Вот крошечный мир ECS на чистом Rust. Каждый тип компонентов живёт в своём векторе, а ID сущности — это индекс. None значит «у этой сущности нет такого компонента» (см. Option).

struct Position { x: i32, y: i32 }
struct Velocity { dx: i32, dy: i32 }
// Сущность — это просто ID: индекс в каждом списке компонентов
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
}
}
// Система: работает со всеми сущностями, у которых есть И Position, И 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;
}
}
}
// Ещё одна система: печатает каждую сущность, у которой есть Position
fn print_system(world: &World, tick: u32) {
println!("такт {tick}:");
for (id, pos) in world.positions.iter().enumerate() {
if let Some(pos) = pos {
println!(" #{id} {:<6} в ({}, {})", world.names[id], pos.x, pos.y);
}
}
}
fn main() {
let mut world = World::default();
world.spawn("игрок", Some(Position { x: 0, y: 0 }), Some(Velocity { dx: 1, dy: 0 }));
world.spawn("дерево", Some(Position { x: 5, y: 2 }), None); // без Velocity: никогда не двигается
world.spawn("стрела", 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);
}
}

Вывод:

такт 1:
#0 игрок в (1, 0)
#1 дерево в (5, 2)
#2 стрела в (3, 2)
такт 2:
#0 игрок в (2, 0)
#1 дерево в (5, 2)
#2 стрела в (6, 1)
▶ Попробуй сам

Дерево остаётся в (5, 2), потому что у него нет Velocity. Никто не писал «деревья не двигаются» — это просто следует из данных. Настоящие библиотеки ECS гораздо быстрее и умнее (они хранят компоненты так, чтобы процессор читал их быстро, и выполняют системы параллельно), но идея та же.

Bevy (на английском) — самый известный движок на Rust, и в нём всё построено на ECS. Версия 0.20 вышла в октябре 2026 года. Bevy нужна последняя стабильная версия Rust, так что сначала обновись, а потом создай проект:

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

В Linux понадобятся ещё несколько системных библиотек (звук, ввод, графика). Руководство по установке Bevy (на английском) перечисляет их для каждого дистрибутива. В Windows нужны Visual Studio C++ Build Tools, а в macOS — инструменты командной строки Xcode.

Эта программа основана на официальном примере Bevy 2d/move_sprite. Она рисует оранжевый квадрат, который скользит влево и вправо. (Официальный пример загружает картинку; здесь вместо неё простой цветной квадрат, чтобы не нужны были файлы изображений.)

use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins) // окно, отрисовка, ввод, звук...
.add_systems(Startup, setup) // выполняется один раз при запуске
.add_systems(Update, sprite_movement) // выполняется каждый кадр
.run();
}
// Компонент: в какую сторону движется эта сущность
#[derive(Component)]
enum Direction {
Left,
Right,
}
fn setup(mut commands: Commands) {
commands.spawn(Camera2d);
// Создаём одну сущность с тремя компонентами
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,
));
}
// Система: работает с каждой сущностью, у которой есть Direction И 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;
}
}
}

Сравни с крошечным миром ECS выше. #[derive(Component)] превращает обычное перечисление в компонент. commands.spawn((...)) создаёт сущность с кортежем компонентов. А Query<(&mut Direction, &mut Transform)> просит у Bevy «все сущности, у которых есть и то и другое» — точно как наша movement_system делала с помощью zip. time.delta_secs() — это дельта времени из раздела про игровой цикл, поэтому квадрат движется со скоростью 150 пикселей в секунду на любом компьютере.

Запусти через cargo run. Первая сборка компилирует сотни крейтов и может занять несколько минут, так что наберись терпения. Потом пересборки идут намного быстрее.

Руководство Bevy советует две вещи. Во-первых, добавь это в Cargo.toml. Bevy и остальные зависимости будут оптимизированы, а твой собственный код по-прежнему будет быстро компилироваться:

[dependencies]
bevy = "0.20"
# Немного оптимизации в dev-профиле.
[profile.dev]
opt-level = 1
# Много оптимизации в dev-профиле для зависимостей.
[profile.dev.package."*"]
opt-level = 3

Без этого отладочная сборка игры может работать очень медленно: низкая частота кадров и долгие загрузки. Зависимости компилируются один раз, так что за оптимизацию ты платишь только при первой сборке. (Профили объясняются в уроке Производительность.)

Во-вторых, пока ведёшь разработку, включи feature dynamic_linking. Тогда Bevy собирается как отдельная разделяемая библиотека, и Cargo не приходится заново компоновать весь Bevy с твоей программой при каждом изменении:

Terminal window
cargo run --features bevy/dynamic_linking

Два предупреждения из руководства Bevy: в Windows dynamic_linking работает только вместе с настройками opt-level выше. И не выпускай игру с этой feature: игре понадобится лишний файл библиотеки рядом с ней. Собирай релиз без неё. Ещё руководство показывает, как подключить более быстрый компоновщик (lld или mold) — в Linux это очень помогает.

Macroquad (на английском) ощущается совсем иначе. Ни ECS, ни построителя приложения: игровой цикл ты пишешь сам, а рисуешь простыми функциями вроде draw_circle. Зависимостей у него очень мало, поэтому сборка с нуля занимает секунды, а не минуты.

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

Эта маленькая игра показывает мяч, который ты двигаешь стрелками:

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; // пикселей в секунду
loop {
// 1. Ввод: стрелки двигают игрока
let dt = get_frame_time(); // секунд с прошлого кадра
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. Не даём игроку уйти за край экрана
player.x = player.x.clamp(0.0, screen_width());
player.y = player.y.clamp(0.0, screen_height());
// 3. Рисуем всё для этого кадра
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
}
}

Здесь видны три шага игрового цикла: ввод, обновление, рисование. #[macroquad::main("Move the Ball")] открывает окно с этим заголовком. main объявлен как async, но ни Tokio, ни знания async не нужно: next_frame().await просто значит «покажи этот кадр и дождись следующего». Надпись на экране («стрелки — двигаться, Esc — выход») мы оставили на английском, как в проверенном коде. В Linux README Macroquad просит сначала установить несколько системных пакетов (pkg-config libx11-dev libxi-dev libgl1-mesa-dev libasound2-dev в Ubuntu).

И Macroquad, и Bevy умеют собирать игру для браузера в виде WebAssembly. Если Wasm для тебя в новинку, сначала прочитай Rust и WebAssembly. Начинается всё всегда с добавления цели Wasm:

Terminal window
rustup target add wasm32-unknown-unknown

С Macroquad это очень просто. Одна команда собирает файл .wasm:

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

Файл окажется в target/wasm32-unknown-unknown/release/move_the_ball.wasm (для примера выше он весил около 600 КБ). В README Macroquad есть короткий index.html, который загружает его с помощью небольшого JavaScript-файла mq_js_bundle.js. Положи оба файла в одну папку и раздай её любым локальным веб-сервером, например basic-http-server . (установка: cargo install basic-http-server). Двойной щелчок по HTML-файлу обычно не работает: браузеры не дают загрузить файл .wasm прямо с диска.

Bevy использует wasm-bindgen, чтобы создать связующий JavaScript-код. Эти шаги взяты из README с примерами Bevy (для проекта my_bevy_game выше):

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

Команда записывает в папку web файлы .js и _bg.wasm; маленькая HTML-страница импортирует .js и запускает игру. Руководство Bevy ещё советует профиль wasm-release с opt-level = "s", чтобы скачивать приходилось меньше. Веб-сборки — отличный способ показать игру с геймджема: люди играют в один клик, ничего не устанавливая.

Двигать на фиксированное расстояние каждый кадр. x += 5.0 в каждом кадре значит, что при 120 FPS игра идёт вдвое быстрее, чем при 60 FPS. Умножай на дельту времени (x += speed * dt) или используй фиксированный шаг.

Следовать старому руководству по Bevy. Bevy меняет API в каждом выпуске, так что статья для 0.12 часто не скомпилируется с 0.20. Проверяй, какую версию использует руководство, и отдавай предпочтение официальным примерам для своей версии. К каждому выпуску на сайте Bevy есть руководство по миграции.

Медленные отладочные сборки. Если игра на Bevy выдаёт несколько кадров в секунду, ты, скорее всего, забыл про настройки opt-level в Cargo.toml.

Начать со слишком большого. RPG с открытым миром — тяжёлый первый проект на любом языке. Начни с Pong, «Змейки», Breakout или Flappy Bird. Доделай, а следующую игру сделай чуть больше.

В каждой игре есть экраны: меню, сама игра, пауза, «игра окончена». Сделай перечисление GameState с вариантами Menu, Playing, Paused и GameOver и перечисление Input с Enter, Escape и PlayerDied. Напиши fn next_state(state: GameState, input: Input) -> GameState: Enter в меню запускает игру, Escape ставит на паузу и снимает с неё, гибель игрока заканчивает игру, а Enter на экране «игра окончена» возвращает в меню. Любой другой ввод ничего не меняет. Скорми функции фиксированный список вводов и печатай каждое изменение. Подсказка: сделай match по кортежу (state, input) и закончи веткой (same, _) => same.

Показать решение
#[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,
// Всё остальное: игнорируем ввод и остаёмся, где были
(same, _) => same,
}
}
fn main() {
use Input::*; // позволяет писать Enter вместо 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;
}
}

Вывод:

Menu + Escape -> Menu
Menu + Enter -> Playing
Playing + Escape -> Paused
Paused + PlayerDied -> Paused
Paused + Escape -> Playing
Playing + PlayerDied -> GameOver
GameOver + Enter -> Menu
▶ Попробуй сам

match по кортежу собирает все допустимые переходы в одном месте, а последняя ветка делает любой другой ввод безвредным. Игрок на паузе не может погибнуть, потому что (Paused, PlayerDied) попадает в (same, _). В Bevy та же идея встроена и называется States.

  • Rust — реальный вариант для игр: «почти готово», с сильным движком (Bevy), простыми библиотеками (Macroquad, ggez), вариантами с редактором (Fyrox, Godot с gdext) и множеством строительных блоков.
  • Каждая игра крутит цикл: ввод, обновление, рисование. Используй дельту времени или фиксированный шаг, чтобы скорость не зависела от частоты кадров.
  • ECS делит игру на сущности (ID), компоненты (простые данные) и системы (функции, которые работают с сущностями, у которых есть определённые компоненты).
  • Bevy построен на ECS сверху донизу. Добавь настройки профиля opt-level, а dynamic_linking используй только во время разработки.
  • Macroquad — самый быстрый старт для 2D: цикл ты пишешь сам, а рисуешь простыми функциями.
  • Оба умеют собирать игру для браузера с целью wasm32-unknown-unknown.

Проверь себя

1. Что такое сущность в ECS?

2. Почему с фиксированным шагом времени игра ведёт себя одинаково на любом компьютере?

3. С какой настройкой Bevy НЕ стоит выпускать готовую игру?

4. Ты хочешь сделать первую маленькую 2D-игру с очень быстрой сборкой. Какой крейт советует эта страница?

Результат: 0 / 4

  • Are We Game Yet? (на английском): обзор от сообщества — игровые движки на Rust, библиотеки для физики, звука и сети и игры, сделанные на Rust.
  • Bevy Quick Start: Setup (на английском): официальное руководство по установке Bevy, настройкам для быстрой компиляции и профилям для релиза и Wasm.
  • Game development in Rust with Macroquad (на английском): бесплатная онлайн-книга, в которой шаг за шагом делается полноценный космический шутер на Macroquad.
  • The godot-rust book (на английском): как писать игровую логику для Godot 4 на Rust с gdext.
  • Game Loop — Game Programming Patterns (на английском): понятное и подробное объяснение игровых циклов, дельты времени и фиксированного шага.