Entity Component System
Up until now, most of the data that has been covered in this book has been about technical things that the game needs just to run, like input events, surfaces, textures and timing information. But beyond that is the data that defines RuggRogue as a game, such as the player, the map, monsters and items. This game-specific data is all managed by a crate named Shipyard, and this chapter is all about Shipyard and how RuggRogue uses it.
By its own description:
Shipyard is an Entity Component System focused on usability and speed.
Here, an entity is a lightweight ID whose role is to associate groups of components that hold the data describing the entity. The main benefit of this is that it avoids the "talking sword" problem that you'd run into with an object-oriented approach: if you have NPCs that you can talk to, and a sword you can pick up and swing, how do you represent a talking sword? In the object-oriented style of modelling game data, problems like this end up poking holes in the encapsulation the classes are supposed to have, and functionality drifts up the inheritance tree into a gigantic all-encompassing mega-class. Game data modelled with entities and components instead avoids both of those issues; see Catherine West's RustConf 2018 closing keynote (video and notes) for more information.
In a game built fully in the ECS-style, systems are just functions that manipulate groups of entities according to what components they have.
Shipyard 0.4
RuggRogue uses Shipyard 0.4, but at the time of writing it is not the most recent version of Shipyard, which is 0.5. So what gives? Well, 0.4 was the most up-to-date version of Shipyard when I started work on RuggRogue, and when 0.5 came out I ported the game over to it. Unfortunately, this broke the web build, so it had to be reverted. Therefore, RuggRogue uses Shipyard 0.4 and not 0.5.
In order to understand how RuggRogue reads and modifies its own game data, you'll need to understand the basics of Shipyard 0.4. This is the point where I would link to the Shipyard 0.4 User's Guide that existed when I started writing the game, except it was replaced wholesale when Shipyard 0.5 came out, which has a bunch of differences. I could build and host that old guide myself, but putting up documentation for an older version of somebody else's library with no indication that it's stale would be problematic. As such, most of this chapter is going to serve as a crash course on Shipyard 0.4, which should provide a foundation for understanding the code in RuggRogue that works with game data.
If you have Rust installed and you have the RuggRogue source code, you can peruse a detailed reference to Shipyard 0.4's API, along with all of the other crates used by RuggRogue, by typing cargo doc --open
.
Shipyard's source code also contains its user guide that can be built with mdBook, so you can check out older versions of its source code and run it through mdBook to read it.
The World
All data in Shipyard is stored in a World
that consists of:
- Entities: They're just IDs, but the world tracks which ones are still alive.
- Components: Associated with entities; Shipyard stores components of each unique type in separate storages.
- Uniques: Like components, but not associated with any entity; often called resources in other Rust ECS crates.
RuggRogue creates one as the very first thing in the main
function in the src/main.rs
file:
let world = World::new();
Every bit of data specific to RuggRogue as a game is stored in the world, such as the map, the player, monsters and items.
Uniques
As mentioned above, a unique is some data stored in the world that isn't associated with an entity like a component would be. They're not technically required in an ECS, but many Rust ECS crates provide something like them as a convenience. For example, here's how RuggRogue stores the current game seed:
pub struct GameSeed(u64);
let world = World::new();
let game_seed = std::env::args()
.nth(1)
.and_then(|arg| arg.as_str().parse().ok())
.unwrap_or_else(rand::random);
world.add_unique(GameSeed(game_seed)); // <-- adding a unique to the world
Since RuggRogue uses a single world to store all game data and passes it everywhere, uniques effectively act like global variables, without a lot of the incidental downsides of actual global variables.
Unique data is accessed by requesting a UniqueView
or UniqueViewMut
borrow out of the world with World::borrow
:
// immutable borrow of GameSeed unique
let game_seed = world.borrow::<UniqueView<GameSeed>>();
println!("{:?}", game_seed.0);
// mutable borrow of GameSeed unique
let game_seed = world.borrow::<UniqueViewMut<GameSeed>>();
game_seed.0 = 1234567890;
There is no way to remove or directly replace a unique in Shipyard 0.4. The ability to remove uniques was only added in Shipyard 0.5, so RuggRogue hacks around this limitation when it needs to.
Entity and Component Basics
An entity is a lightweight ID that's just a number in Shipyard's case. A component is some data associated with an entity. Each entity can have zero or one component of each type associated with it.
Entities are created with a special borrow of EntitiesViewMut
, like so:
// creating a empty entity with no components
let mut entities = world.borrow::<EntitiesViewMut>();
let entity_id = entities.add_entity((), ());
Entities are often made starting out with component data that is modified using ViewMut
:
struct Position {
x: i32,
y: i32,
}
struct Renderable {
ch: char,
}
let mut entities = world.borrow::<EntitiesViewMut>();
let mut positions = world.borrow::<ViewMut<Position>>();
let mut renderables = world.borrow::<ViewMut<Renderable>>();
// creating an entity with a Position component and a Renderable component
let entity_id = entities.add_entity(
(&mut positions, &mut renderables),
(
Position { x: 1, y: 2 },
Renderable { ch: '@' },
),
);
Deleting an entity requires clearing it out of every component storage, and thus requires the special AllStoragesViewMut
borrow:
let mut all_storages = world.borrow::<AllStoragesViewMut>();
all_storages.delete(entity_id_to_delete);
Components can be added to entities after creation with an immutable EntitiesView
borrow along with mutable ViewMut
component borrows of the relevant storages:
struct Name(String);
struct GivesExperience(u64);
let entities = world.borrow::<EntitiesView>();
let mut gives_experiences = world.borrow::<ViewMut<GivesExperience>>();
let mut names = world.borrow::<ViewMut<Name>>();
// adding Name and GivesExperience components to goblin_entity_id
entities.add_component(
(&mut gives_experiences, &mut names),
(
GivesExperience(20),
Name("Goblin".to_string()),
),
goblin_entity_id,
);
Components can be deleted from an entity on demand with just a mutable ViewMut
borrow on the relevant component storage:
let mut names = world.borrow::<ViewMut<Name>>();
names.delete(entity_id_to_make_nameless);
To check if an entity has a component, we can check if the View
of the component storage contains the entity ID:
struct Monster; // <-- empty tag struct
if world.borrow::<View<Monster>>().contains(entity_id) {
// entity_id has a Monster component
}
A component can be checked for and accessed via a View
or ViewMut
as well using Rust's if let
pattern matching syntax:
struct CombatStats {
hp: i32,
}
let mut combat_stats = world.borrow::<ViewMut>();
if let Ok(combat_stats) = (&mut combat_stats).try_get(entity_id) {
// entity_id has a CombatStats component, so do a bit of damage to it
combat_stats.hp -= 1;
}
Iterating Entities and Components
A common operation in RuggRogue is to iterate over all entities that have a certain set of components on them.
That can be achieved with the iter
function of the Shipyard::IntoIter
trait:
use Shipyard::IntoIter;
struct Name(String);
struct Position {
x: i32,
y: i32,
}
let names = world.borrow::<View<Name>>();
let positions = world.borrow::<View<Position>>();
// iterate over all entities that have both Name and Position components
for (name, pos) in (&names, &positions).iter() {
println!("{} is at ({},{})", name.0, pos.x, pos.y);
}
The entity IDs can be retrieved as well using the with_id
function from Shipyard::Shiperator
:
use Shipyard::IntoIter;
use Shipyard::Shiperator;
for (id, (name, pos)) in (&names, &positions).iter().with_id() {
// do something with id, name and pos
}
I believe Shipyard::IntoIter
and Shipyard::Shiperator
are no longer needed in Shipyard 0.5; consult its current documentation if you want to know more.
The EntityId
Entities are uniquely identified by the Shipyard::EntityId
type, which, as mentioned before, is just a number internally.
Since it's so lightweight, we can use it to model relationships between different entities.
For example, here's what equipping a player entity with weapon and armor entities might look like:
struct Equipment {
weapon: Option<EntityId>,
armor: Option<EntityId>,
}
struct AttackBonus(i32);
struct DefenseBonus(i32);
// create the player, weapon and armor entities
let (player_id, weapon_id, armor_id) = {
let mut entities = world.borrow::<EntitiesViewMut>();
let mut attack_bonuses = world.borrow::<ViewMut<AttackBonus>>();
let mut defense_bonuses = world.borrow::<ViewMut<DefenseBonus>>();
let mut equipments = world.borrow::<ViewMut<Equipment>>();
// Equipment component for the player
let player_id = entities.add_entity(
&mut equipments,
Equipment {
weapon: None,
armor: None,
},
);
// AttackBonus component for the weapon
let weapon_id = entities.add_entity(&mut attack_bonuses, AttackBonus(2));
// DefenseBonus component for the armor
let armor_id = entities.add_entity(&mut defense_bonuses, DefenseBonus(1));
(player_id, weapon_id, armor_id)
};
// later on...
{
let mut equipments = world.borrow::<ViewMut<Equipment>>();
// equip the player if they have an Equipment component
if let Ok(player_equip) = (&mut equipments).try_get(player_id) {
// put the weapon and armor in the player's Equipment component
player_equip.weapon = Some(weapon_id);
player_equip.armor = Some(armor_id);
}
}
This pretty much covers all of the ways that RuggRogue uses Shipyard to handle its own game data.
Conclusion
You should now have a general idea of how RuggRogue stores and accesses its data using Shipyard. Insofar as Rust ECS crates go, I'm so-so on Shipyard, since it came with a lot of functionality that I never used. I could use it for future projects, but I can just as easily see myself exploring other options or even cobbling my own data storage to suit my own needs.