/ Building A (Better) Game Engine

BA(B)GE: The "Ideal" Component System

An ECS (Entity-Component-System) framework is a desirable API design for game engines: It provides a structured way to create objects in the scene and dynamically compose those objects at runtime. It avoids the rigidity of an inheritance-based approach while opening up many new possibilities for code design and reuse. What I present here is, in my opinion, the ideal API for a component-based game engine in terms of ergonomics alone. This API makes it trivial to create, destroy, retrieve, and modify components while doing its best to get out of the developer's way.

It's also nearly impossible to implement properly in Rust due to the way Rust enforces safety at compile time. While this design can work in other languages and other engines, it's design fundamentally undermines the functionality that makes Rust so powerful.

In this post I'll discuss why this API seems so ideal and why these advantages are at odds with Rust's unique ownership system. The goal is not to present an objectively "ideal" framework for game creation, nor is this system necessarily perfect as entity-component frameworks go. The goal here is to demonstrate that the functionality needed to build a robust, user-friendly entity-component framework, at least as it has been presented in existing game engines like Unity, is at odds with idiomatic usage of Rust.

The Platonic Ideal

In the last article I provided an example of what an ideal component API would look like. Let's review that example and analyze what makes the API so appealing. First, making an entity and managing its components:

// Construct objects with `new()` like normal.
let mut entity = Entity::new();

// Assign components directly to the entity.
entity.assign(Transform::new()); 

// Get components directly from the entity.
let transform = entity.get::<Transform>();

// Directly access component members.
transform.position = Point::new(0.0, 0.0, 0.0); 

// Call methods on components and mutate them.
transform.translate(Vector3::new(1.0, 2.0, 3.0));

// Destroy components directly through the entity.
entity.destroy_component::<Transform>();

// Destroying the entity destroys all components and makes the entity unusable.
entity.destroy();

And implementing components and behaviors:

pub struct BulletComponent {
	velocity: Vector3,
	damage: f32,
}

impl Updatefor BulletComponent {
    fn update(&mut self) {
        // Retrieve the transform for the bullet and move it based on the velocity.
        let transform = self.entity.get::<Transform>();
        transform.translate(self.velocity * time::delta());

        // Slow down the bullet slightly over time.
        self.velocity *= 1.0 - (0.1 * time::delta());

        // Check for collisions and apply damage to the first one with a health
        // component. Destroy the bullet once it has collided.
        let collider = self.entity.get::<ColliderComponent>();
        for other_entity in collider.collisions() {
            let health = other_entity.get::<HealthComponent>()
            health.health -= self.damage;
            self.entity.destroy();
            break;
        }
    }
}

Seems pretty straightforward at first blush. Let's look at what about this API makes it so nice to use:

  • Construct objects using idiomatic practices. That means creating entities with Entity::new() and components like Transform with Transform::new(). Sticking to idiomatic practices for the language is ideal because it keeps the learning curve shallow and makes it easier for developers to "switch gears" between working with the engine and doing other things with the language.
  • Operate directly on the entity rather than passing it around as a parameter. For example entity.assign(Transform::new()) and entity.get::<Transform>(). This aligns with the way that developers tend to think about their game entities and makes for a fairly intuitive API.
  • Get references directly to components. Entity::get<T>() should return a &T, &mut T, or maybe (maybe) something like Ref<T>. This keeps type signatures simple and intuitive and avoids complicated type signatures.
  • Destroying an entity destroys its components. entity.destroy() should automatically handle cleanup of all component data associated with the entity. This is to keep the management burden on game code low.
  • Behaviors can be defined for components, meaning a behavior routine can be defined that takes the component as the self parameter. This makes it easy to write behaviors in terms of a specific component in an object-oriented fashion without having to manually iterate over all components of a given type.
  • Standalone behaviors can be defined, meaning a behavior routine can be defined that doesn't take a self parameter or whose self parameter isn't a component type. This allows more flexibility in behavior routines by not having to create a dummy entity and component in order to execute some behavior.
  • Pass Entity objects around to reference entities in the world. It's important to be able to easily reference specific entities from various pieces of code; Enemies need to know the player's entity so they can attack it, collisions needs to keep track of the pairs of entities that collided, etc. That's what the Entity type is for, so it ought to be possible to pass Entity objects around and copy them as necessary.

As always there are various schools of thought and preferences when it comes to things like engine design and API design. The design concerns I list above are what I've found to be most desirable when writing game code, but there are other ways of designing an ECS framework, even for Rust.

Where Things Fall Apart

This design would actually work pretty well in languages like C++ or C#, and is easy to implement in Rust if one isn't concerned with safety or keeping with idiomatic language practices. But 'safety' in Rust isn't just about avoiding crashes and memory corruption; Safety is robust error handling, it means not needing runtime checks because errors are caught at compile time, and it means pushing developers into the pit of success by providing an API that naturally leads them to do the right thing.

Unfortunately, the API as proposed above violates one of Rust's core safety principles: No shared mutable state. For our purposes "shared mutable state" refers to having a mutable reference to some data at the same time as having another mutable or immutable reference to that same data. In discussing the proposed API, "some data" is always a component. I won't delve deeply into the details of why shared mutable state is bad; That topic has been covered at length over the course of Rust's development. Suffice to say that allowing multiple mutable references to the same data makes it possible to introduce a host of different memory and logic errors into your code, and by making "sharing" and "mutation" mutually exclusive Rust makes sure that such bugs simply don't compile.

Under the current design it's trivial to write an example that results in multiple mutable references to the same component. Take a look at the following example:

pub fn make_position_same(first: Entity, second: Entity) {
	let first_transform = first.get::<Transform>();
	let second_transform = second.get::<Transform>();

	first_transform.position = second_transform.position;
}

In this example we take two entities, retrieve their Transform component, and set the first transform's position to be the same as the second's. As long as first and second are different that will work, but if they're the same entity then first_trans and second_trans will both be mutable references to the same Transform object. Interestingly, in this case passing the same entity in twice likely wouldn't cause a crash but it probably would indicate a bug in the game code; If the developer had two entities and expected them to both be moved to the same point after the function was called they'd be pretty surprised when neither of them moved.

Introduce Runtime Checks

One way we could adhere to Rust's rule of "no shared, mutable state" is to track ownership at runtime. Currently Entity::get() is declared like this:

pub fn get<T: Component>(&mut self) -> &mut T { ... }

Where it returns a mutable reference directly to the component. Instead we could change it to return a wrapper type that can track borrows:

pub fn get<T: Component>(&mut self) -> RefMut<T> { ... }

The RefMut type internally tracks that the component is borrowed and marks that the component is no longer borrowed once it goes out of scope. If a second call to get::<T>() is made while the component is already borrowed the engine will panic with an error message to the developer:

pub fn make_position_same(first: Entity, second: Entity) {
	let first_transform = first.get::<Transform>();
	let second_transform = second.get::<Transform>(); // Panics here if first == second.

	first_transform.position = second_transform.position;
}

While this method is successful in enforcing Rust's safety rules, it does so both at the cost of runtime performance and ergonomics: The cost of tracking borrows at runtime likely isn't large but it's still frustrating since Rust can already do that at compile time, and I can tell you from experience that writing gameplay code under those conditions is practically impossible.

Make The Engine Explicit

Under the above design components are borrowed from the entity using Entity::get(). Rust's ownership rules then naturally tie the borrow to the lifetime of the Entity object and ensures that the object isn't destroyed while the component is being borrowed. But the Entity object doesn't actually own the component data, the engine does. The reason Rust can't track ownership in this case is because Entity::get() has to be doing some trickery to get at the engine and retrieve the component. If we change things around such that game code has access to the engine we can get Rust to track ownership again:

pub fn make_position_same(engine: &mut Engine, first: Entity, second: Entity) {
	let first_transform = engine.get::<Transform>(first);

	// ERROR: Trying to borrow from the compiler while there is already
	// a mutable borrow for `first_transform`.
	let second_transform = engine.get::<Transform>(second);

	first_transform.position = second_transform.position;
}

In the new example we pass a reference to the engine into the function along with the two entities. Now when we want to retrieve the transforms we use the entities as keys, similar to accessing elements stored in a HashMap. Lo and behold, doing so allows the compiler to track how the borrows and it generates an error. We can fix this error by slightly reordering the function:

pub fn make_position_same(engine: &mut Engine, first: Entity, second: Entity) {
	let position = {
		let second_transform = engine.get::<Transform>(second);
		second_transform.position()
	};

	let first_transform = engine.get::<Transform>(first);
	first_transform.position = position;
}

Now Rust can tell that there's no conflict in the borrows, and even if first and second are the same entity there's no chance of error.

One downside to this change is now we have to pass the engine to every function that wants to access game state. Considering that almost all functions want to do so this becomes a massive pain. The other issue is that we lose the ability to have mutable references to multiple components at once. In fact, if we have a mutable reference to any component we can't have a reference to any other component. While this example was easily rewritten to compile under these constraints, more complex cases quickly become unwieldy. The problem stems from the fact that ownership is now tracked at a very course granularity; Borrowing any component requires that the entire engine be borrowed, even though we can trivially confirm by hand that it's safe to both mutably borrow two components of different types.

Make Entity A Handle

Implicit in the above examples is the assumption that Entity doesn't contain any component data, that it's just a numeric identifier used internally by the engine to determine which components are associated with each other. The advantage of this design is that Entity objects can easily be passed around in order to refer to entities in the world -- an enemy AI can have a member target: Entity to reference what player it should follow, or you could pass around an NPC's Entity in order for other parts of the game to reference that character. Treating Entity as a simple ID instead of a more complex "handle" type makes it much easier to reference entities from various parts of the code base.

That said, if we have Entity be a "handle" we could address the problem of borrows being too course. Rather than a cheap ID value, Entity would be a unique object like Box or Vec. Accessing an entity's components requires having unique access to the entity, either by owning it or by having a unique reference (i.e. &mut Entity). In the make_positions_same example we would take first and second by mutable reference:

pub fn make_position_same(first: &mut Entity, second: &mut Entity) {
	let first_transform = first.get_mut::<Transform>();
	let second_transform = second.get::<Transform>();

	first_transform.set_position(second_transform.position());
}

All the issues from the previous examples are now resolved: Rust will statically prevent the same entity from being passed in twice, and we can safely have references to both transforms at once. We also gain back the ability to borrow components directly from the Entity rather than having to go through the engine, which allows us to safely borrow multiple components at once.

But, just as before, this design has consequences. Under this design it's unclear how one would be able to reference entities from within game code. For example, let's look at a simple enemy AI that moves towards a target entity at a fixed speed:

#[derive(Component)]
pub struct Enemy {
	pub velocity: Vector3,
	pub target: Entity, // Now Enemy owns its target?
}

impl Update for Enemy {
	fn update(&mut self) {
		let transform = self.entity.get::<Transform>();
		let target_transform = self.target.get::<Transform>();

		let move_dir = target_transform.position() - transform.position();
		transform.translate(move_dir.normalized() * self.velocity * time::delta());
	}
}

Under the previous designs, where Entity was a weak reference, this example works. But if Entity is a handle type then Enemy has to own its target in order to have access to it. There's also the nasty question of how a component knows what entity it belongs to. Under the previous designs I've been assuming that the game engine is able secretly inject an entity member into all component types, but once Entity is a handle that has weird implications; Now all components "own" their entity and have mutable access to it, which entirely undermines the point of making Entity a handle.

Conclusion

So the proposed design either violates Rust's safety rules, needs to enforce safety at runtime at the cost of performance and usability, or needs to make other changes that negatively impact usability. I could continue down the rabbit hole of ways to tweak the ECS framework until it works, but I've looked down that road and there's no end in sight. Ultimately this analysis leads to the question of whether an ECS framework is an appropriate design pattern for a game engine, at least one written in Rust, to follow. Ideally the core of a game engine should be flexible enough to be useful for building a wide variety of games, and the tradeoffs discussed above are liable to make many games difficult to build. At this point it is not clear to me that a Rust game engine could provide a single ECS framework that is flexible enough to work well with many game types. Instead I think it'll be worthwhile for me to start investigating alternatives, as doing so will likely shed light on what makes for a good engine API.

Any comments or feedback can be made on Reddit

David LeGare

David is a game developer and systems programmer. He likes cats, he tolerates dogs, and he often remembers to dress himself before leaving the house.

Read More