As you know, I’m making a game using the bevy game engine.
tl;dr
Bevy has two main ways of lumping code into units: plugins and bundles. I’ve been architecting my project like this: Separate crates for major features which expose a single plugin composed of various, smaller plugins. Sub-plugs are individual features which are integrated into the crate-plugs, which then get composed together into the main app. Feature flags help keep the main app flexible, allowing me to do things like build the same app with or without the inspector. Components are declared within sub-plugs; bundles and complex systems are integrated as their own files. Breaking it up like this keeps code simple and follows the single-responsibility principle.
$ tree crates/inspector crates/player src
crates/inspector
├── Cargo.toml
└── src
├── gizmos
│ ├── mod.rs
│ └── player_cam.rs
├── inspector_cam
│ ├── bundle.rs
│ └── mod.rs
├── lib.rs
├── state
│ ├── inspector_state.rs
│ ├── mod.rs
│ └── ui_state.rs
└── tabs
├── assets.rs
├── game_view.rs
├── inspector.rs
├── mod.rs
└── resources.rs
crates/player
├── Cargo.toml
└── src
├── cam
│ ├── bundle.rs
│ ├── driver.rs
│ └── mod.rs
├── controls
│ └── mod.rs
├── lib.rs
└── player
├── bundle.rs
└── mod.rs
src
├── inspector.rs
└── main.rs
12 directories, 24 files
Crate dependencies
The crate dependency structure is pretty straight-forward. The more general-purpose crates are higher on the tree, including bevy and general libraries, while the inspector (being essentially an integration library) is placed right below the main application. I had gone back and forth on this structure, sometimes adding feature flags to higher-level plugin crates for inspector features, but this is generally unnecessary and clutters up the build process.1 The plugin hierarchy is more interesting, and plays into the overall project structure.
Plugin hierarchy
It’s pretty clear that every file should hold only one “thing” in it, but with a flat hierarchy like we have with components that’s a bit hard to accomplish! We need some way to understand how to group things together. That’s where bundles come in. They’re just collections of components. We can create a “prefab” by creating a function that returns impl Bundle
, typically with a unique marker component for easy access. This is exactly what I did. I’m using a full struct instead of a free function for better encapsulation, in case I need to define systems that relate to the specific bundle.
#[derive(Component, Default, Debug)]
pub struct Player;
pub struct PlayerBundle;
impl PlayerBundle {
#[allow(clippy::new_ret_no_self)]
pub fn new<M: Material>(
transform: Transform,
mesh: Handle<Mesh>,
material: Handle<M>,
) -> impl Bundle {
(
Name::new("Player"),
Player,
transform,
Mesh3d(mesh),
MeshMaterial3d(material),
RigidBody::Dynamic,
Collider::capsule(0.5, 1.),
TnuaController::default(),
TnuaAvian3dSensorShape(Collider::cylinder(0.49, 0.)),
LockedAxes::ROTATION_LOCKED,
SpawnAroundTracker,
)
}
}
But not everything we make is prefab-ish; the power of the ECS lies in the flexibility of components. So, we need a way to organize our components and systems. This is exactly what plugins do for us. Conveniently, plugins can be nested. So, each crate contains a major feature which is relegated to its own plugin. Within the crate, each submodule contains its own plugin. In the end, the exposed plugin should be just a collection of other plugins - a plugin bundle, if you will. Similarly to my bundle structs, I define my systems within the submodule plugins for better encapsulation.
pub mod prelude {
pub use crate::cam::*;
pub use crate::controls::*;
pub use crate::player::*;
}
use prelude::*;
pub struct PlayerPlugin;
impl Plugin for PlayerPlugin {
fn build(&self, app: &mut App) {
app.add_plugins((IntegrationPlugin, PlayerCamPlugin, ControlsPlugin));
}
}
Splitting up my dependencies in this way lets me compose the main application from various pieces, rather than mucking my way through interwoven dependencies.
Of course, some amount of interdependency is going to happen. Plugin composition and exposed components, etc., allow us to cross-breed our plugins. The important part is that this happens at the top-level.
I’m sure I’m missing a lot, and that a lot of this is fairly obvious - but hey, the fact that it feels obvious now is the sign of good design, right?
Footnotes
-
It may be necessary in the future to add custom inspector widgets, which would be gated behind a feature flag, but that generally doesn’t seem necessary. Perhaps these would be better as their own plugins, anyways. ↩