Danube Dev Log pt 1
Introduction
I've never really done game development. Probably part of that is that I don't play a lot of them either. My "Games Of The Year" post from 2023 features [[https://cohost.org/stillinbeta/post/4039802-p-now-i-play-vide][six games], which was every game I played.
But I have had an idea kicking around in my head for a while. Every year or so I take a look at the Rust ecosystem and see if it's feasible to make my game yet. The last two attempts involved fyrox and amethyst] (now seemingly defunct). For this attempt I decided to go back to one I tried years ago and found immature: [[https://bevyengine.org/][bevy.
The Idea
The primary inspiration for this game (and the name) come from 2001: A Space Odyssey. But I've also seen similar sequences in The Outer Wilds, Elite: Dangerous, and even Sayonara Wild Hearts.
The basic idea is this: A structure is spinning in space, and your ship is approaching it. You need to match your velocity with the structure, especially angular velocity/roll.
This obviously needs to be a 3D game, but not a (technically) complicated one. At these scales I can effectively ignore gravity, and there's no body interaction physics. All we need to do is detect collisions and throw up a game over.
It's that last point that's proved a stumbling block in the past, but this time I made it work!
Bevy
Bevy is a native Rust game framework. It's possible to use engines like Godot with Rust, but I wanted the native experience. My project, my rules! Rust or bust!
Bevy is based on an "Entity Component System", which seems to like "Model View Controller" for video games. It seems to be a very trendy architecture, but I have no idea if it's good. This is my first game! Let's try it out.
Getting started
/Note that I will be assuming a little bit of Rust familiarity. Please get ahold of me with any questions!
Other languages I've used had enough boilerplate that you needed something like cargo-generate to start a project. Bevy is much more traditional. You add it to your Cargo.toml:
[dependencies] bevy = "0.14" # make sure this is the latest version
And stick an invocation in main
:
use bevy::prelude::*; fn main() { App::new().add_plugins(DefaultPlugins).run(); }
the prelude concept is controversial, but it's sure good for iterating
cargo run
and a empty window pops up. Not bad! What else can we do?
When the Entity has Components
A lot of things are written in Rust, but not everything feels like they're written for Rust. Bevy is definitely the latter. Let's spawn in a cube and make it rotate to show you what I mean.
Now, I could dig out Blender to draw a cube, but that's overkill. Bevy will let us build one easily:
let cube = Cuboid::from_length(1.0);
This builds us a mesh, which gives a shape. But that shape needs to exist in the world. For that, we'll use Pbr.
fn spawn_cube(mut commands: Commands) { let cube = Cuboid::from_length(1.0); let pbr = PbrBundle { mesh: cube, ..default() }; commands.spawn(pbr); }
And tell Bevy to run this:
fn main() { App::new() .add_systems(Startup, spawn_cube) .run(); }
But this doesn't quite work.
$ cargo run Compiling danube-example v0.1.0 (/home/ellie/Projects/danube/danube-example) error[E0308]: mismatched types --> src/main.rs:14:15 | 14 | mesh: cube, | ^^^^ expected `Handle<Mesh>`, found `Cuboid` | = note: expected enum `bevy::prelude::Handle<bevy::prelude::Mesh>` found struct `bevy::prelude::Cuboid`
This is our first lesson of the ECS methodology: Individual components (for that's what the mesh
attribute of ~PbrBundle
is)
tend to be as small as possible.
So this one, instead of storing a copy of a mesh that might be spawned hundreds of times, wants a "handle" to one.
To fix this, we'll need to add an Asset.
And that'll show off one of Bevy's party tricks: what's a system
actually?
Systemetise me Cap'n
Let's look at the call signature for add_systems
.
pub fn add_systems<M>( &mut self, schedule: impl ScheduleLabel, systems: impl IntoSystemConfigs<M>, ) -> &mut App
ScheduleLabel
we can worry about later, but what's IntoSystemConfigs
?
it's complicated. The upside is that the arguments of a system function can take
any or all of a large number of parameters.
Right now we're just taking commands, but we want access to Mesh assets too.
So those just go in the function signature:
fn spawn_cube(mut commands: Commands, mut meshes: ResMut<Assets<Mesh>>)
ResMut
means that this is a Resource, which is basically a global singleton.
the Mut
means its mutable, so we mark its variable as mutable.
Then we just add our cube to the meshes:
let mesh = meshes.add(cube); let pbr = PbrBundle { mesh: mesh, ..default() };
And it compiles! …but we can't see anything. Worry not, we just need to add a camera!
fn add_camera(mut commands: Commands) { commands.spawn(Camera3dBundle { transform: Transform::from_xyz(5.0, 5.0, 5.0).looking_at(Vec3::ZERO, Dir3::Y), ..default() }); }
The transform
line means "position this camera at 5,5,5 in the space, and consider Y
to be up.
The indespensible diagram of the coordinate system.
(I am constantly making the hand gesture while programming).
cargo run again
and we get this!
Spin Spin Spin from the tableside
Let's make our cube spin!
First, we're going to make what we call a "marker struct."
#[derive(Component) struct OurCube;
In most languages there'd be little point to defining an empty struct like this - it doesn't contain any information! But in Rust, despite having a size of zero bytes, empty structs are very useful.
First, we'll mark our Pbr
struct with our struct:
commands.spawn((pbr, OurCube));
Bevy lets any tuple of components turn into an entity. Handy!
We want to make another system that spins our cube around. So we're going to write our first Query. Take a look:
fn spin_cube(mut query: Query<&mut Transform, With<OurCube>>, time: Res<Time>) {
Query
is, in my opinion, one of the coolest uses of the Rust type system I've seen in a while.
with the same expression you can declare what you're looking for and what shape it should take.
We've requested every Transform
that's associated with an OurCube
.
We also know that we want Transform to be mutable.
And Bevy is smart enough to make sure nobody else will use this Transform
while I've got a mutable reference to it.
Pretty slick.
We grabbed time as well, because we need to know how much to spin the cube.
We'll use Time::delta_seconds
for this:
let rotation_rate = std::f32::consts::FRAC_PI_2; let rotation = time.delta_seconds() * rotation_rate;
(Rotation is described in radians, so π/2 is a quarter rotation/second, or 15 RPM)
Then, we simply get our matches and apply the rotation!
for mut transform in query.iter_mut() { transform.rotate_axis(Dir3::Y, rotation); }
Add our system to the app, making sure we specify Update
instead of Startup
:
.add_systems(Update, spin_cube)
Conclusion
Now, I fully admit to being a type system sicko; I wrote a lot of Haskell before I started doing Rust. But to me, the way Bevy uses ECS and the Rust type system together is just… beautiful.
Think about it: <Res<T>
is basically a HashMap where the keys are types.
The polymorphism of the systems
is great: near complete freedom on parameters, strict guard rails for invalid queries.
The using of marker types reminds me a bit of how Python sometimes uses object()
as a sentinel type, if None is expected to be a valid input:
_default = object() def f(val, param=_default): if param is _default: ...
But it's even nicer, because you can still attach methods to the marker type if you want!
Next Time
We made a cube rotate, but what about our docking bay door? Surely that won't be much harder? Or require an entire new piece of software??