Разработка игр на Rust: Bevy, Macroquad и другие
Многие начинают учить Rust, потому что хотят сделать игру. Отличная причина! Играм нужны скорость, аккуратная работа с памятью и код, который не падает посреди битвы с боссом, — а это как раз то, в чём Rust силён.
На этой странице — честная картина: что уже делают на Rust, какой движок или библиотеку выбрать и две идеи, которые используются почти в каждой игре: игровой цикл (game loop) и ECS. А потом ты увидишь первую программу на Bevy и на Macroquad.
Какие примеры где запускаются? Игровой цикл, крошечный мир ECS и упражнение — это обычный Rust, поэтому у них есть кнопка «Попробуй сам» для Rust Playground. Bevy и Macroquad открывают окно, так что эти примеры — для твоего компьютера, через cargo run. Пример на Macroquad был скомпилирован (в том числе для веба), пока писалась эта страница; пример на Bevy следует официальным примерам Bevy.
Честно о разработке игр на Rust
Заголовок раздела «Честно о разработке игр на Rust»Сайт сообщества 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, если хочешь расставлять всё мышкой в редакторе.
Игровой цикл
Заголовок раздела «Игровой цикл»Почти любая игра — это один большой цикл, который повторяется снова и снова, пока ты не выйдешь:
- Прочитать ввод (клавиши, мышь, геймпад).
- Обновить игру: подвинуть объекты, проверить столкновения, посчитать очки.
- Нарисовать новую картинку на экране.
Один проход цикла называется кадром (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, И Velocityfn 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; } }}
// Ещё одна система: печатает каждую сущность, у которой есть Positionfn 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
Заголовок раздела «Первое приложение на Bevy»Bevy (на английском) — самый известный движок на Rust, и в нём всё построено на ECS. Версия 0.20 вышла в октябре 2026 года. Bevy нужна последняя стабильная версия Rust, так что сначала обновись, а потом создай проект:
rustup updatecargo new my_bevy_gamecd my_bevy_gamecargo 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 И Transformfn 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
Заголовок раздела «Как ускорить компиляцию (и работу) Bevy»Руководство 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 с твоей программой при каждом изменении:
cargo run --features bevy/dynamic_linkingДва предупреждения из руководства Bevy: в Windows dynamic_linking работает только вместе с настройками opt-level выше. И не выпускай игру с этой feature: игре понадобится лишний файл библиотеки рядом с ней. Собирай релиз без неё. Ещё руководство показывает, как подключить более быстрый компоновщик (lld или mold) — в Linux это очень помогает.
Окно на Macroquad
Заголовок раздела «Окно на Macroquad»Macroquad (на английском) ощущается совсем иначе. Ни ECS, ни построителя приложения: игровой цикл ты пишешь сам, а рисуешь простыми функциями вроде draw_circle. Зависимостей у него очень мало, поэтому сборка с нуля занимает секунды, а не минуты.
cargo new move_the_ballcd move_the_ballcargo 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).
Игры в вебе (WebAssembly)
Заголовок раздела «Игры в вебе (WebAssembly)»И Macroquad, и Bevy умеют собирать игру для браузера в виде WebAssembly. Если Wasm для тебя в новинку, сначала прочитай Rust и WebAssembly. Начинается всё всегда с добавления цели Wasm:
rustup target add wasm32-unknown-unknownС Macroquad это очень просто. Одна команда собирает файл .wasm:
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 выше):
cargo install wasm-bindgen-clicargo build --release --target wasm32-unknown-unknownwasm-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. Доделай, а следующую игру сделай чуть больше.
Практика
Заголовок раздела «Практика»1. Состояния игры через перечисление
Заголовок раздела «1. Состояния игры через перечисление»В каждой игре есть экраны: меню, сама игра, пауза, «игра окончена». Сделай перечисление 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 -> MenuMenu + Enter -> PlayingPlaying + Escape -> PausedPaused + PlayerDied -> PausedPaused + Escape -> PlayingPlaying + PlayerDied -> GameOverGameOver + Enter -> Menumatch по кортежу собирает все допустимые переходы в одном месте, а последняя ветка делает любой другой ввод безвредным. Игрок на паузе не может погибнуть, потому что (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 (на английском): понятное и подробное объяснение игровых циклов, дельты времени и фиксированного шага.