
If you excuse the clickbaity title in this post I would like to introduce a few different perspectives on what Entity Component Systems (ECS) actually are. I will start with the more usual definitions and by the end of the article discuss how its approaches to encapsulation and abstraction in comparison to Object Oriented Programming (OOP) and how they may complement each other. Of course I will be mostly thinking about Bevy’s ECS framework, but most of this should apply to all of them. This post is not a comparison of the strengths and weaknesses of both design patterns, rather an attempt to point out how to think about larger ECS projects.
I’m gonna assume you have some understanding of Bevy and what Object Oriented Programming is about. I’ll refresh both of these, but only briefly.
Entities, Components and Systems
The whole idea about Entity Component System (ECS) is to split your code into (mostly) three abstractions: Entities (think “objects” or “instances”), Components (the parts the entities are composed of) and Systems (functions that operate on components, typically via queries). You may say at this point “wait, we have ECS at home! My structs/classes are entities and they consist of components, I can call my methods ‘systems’, does this make my programming language an ECS?”. To answer this we need to understand one key idea that ECS design brings to the table: Which components a particular entity is composed of, is not defined statically by the type system, but at runtime. Any system can add or remove components to an entity. For that reason it is not meaningful to ask what type an entity has, it will always be the same, but the way it is implemented will allow each instance to consist of different components. In Bevy in particular, an entity is defined by the Entity
type which is nothing more than an ID, but internally, that ID is associated with the various components.
A typical game example would be: Entities are things like the player character, enemies, goodies to collect. Components might be Health
which describes the health of player characters and enemies, Sprite
which describes the image to be displayed for entities, CollectEffect
which describes what happens when a player collects this entity, and Weapon
which describes how an enemy player deals damage to the player, etc. pp..
ECS is about Data Locality
One thing that you get in most ECS implementations, which by itself already makes them useful, is efficient component access. Instead of describing entities as sets of components, we can usually instead make a vector (or some other sort of array like structure) for each component type and then store at some other place the indices into these for all entities (in reality this is usually a bit more clever/complex but for our purposes this view is close enough). So instead of:
struct Entity {
health: Health,
sprite: Sprite,
...
}
let entities: Vec<Entity> = ...;
we have
type EntityID = ...;
// left out: Some way of mapping from EntityID to component indices, could be as simple as a Vec or SlotMap of indices to the various vectors below
let healths: Vec<Health> = ...;
let sprites: Vec<Sprite> = ...;
...
What this enables you is doing things like iterating over all the Sprite
s and draw them on the screen without needing to worry about all the other components. Furthermore, those sprites are all contiguous in memory so this iteration will be very cache friendly as modern architectures are very optimized for fast sequential reads. It also allows us to treat these components completely separately: While one system may be reading all the sprites to draw them, another one may evaluate the Health
component to determine if entities are dead, without requiring synchronization mechanisms like locks.
ECS is an in-memory Database
You can also view the ECS pattern as creating a large in-memory database with Component types being your columns and entities being your rows (and cells can be left empty or NULL
in SQL speak). ECS implementations like Bevy will then let you run queries on this table such as “give me all entities that have a Health
and a Sprite
but not a PlayerCharacter
“. This makes your code very data-oriented: Each of your systems defines (via queries) what data it needs to access and the ECS implementation will take care of delivering it. In Bevy this goes as far as automatically scheduling systems to run in parallel that can be guaranteed (by looking at their queries) to not write to the same components of the same entities concurrently. Check out the official Bevy examples for how this looks in practice.
The “opposite” of OOP
Okay now that we have the more popular views of ECS out of the way, let’s get back to the headline. How does this ECS pattern relate to good old Object-Oriented Programming?
OOP Recap
Wikipedia says on this:
“Object-oriented programming (OOP) is a programming paradigm based on the concept of objects.[1] Objects can contain data (called fields, attributes or properties) and have actions they can perform (called procedures or methods and implemented in code). In OOP, computer programs are designed by making them out of objects that interact with one another.”
There are, of course, many variations on this idea but in this post I’d like to focus on the most prominent style as it is used in programming languages like Java or C++. Generally, we take some data (fields, attributes) and bundle them together into an object. This relationship is defined at compile time via the objects type, which is also called the objects class. On top of this data, we define some methods that provide the interface to this data. That is: A unit of code is a class that defines some combination of data and an interface to it via functions. In Rust this may look like this:
struct Health {
value: u32,
}
impl Health {
pub fn get(&self) -> u32 {
self.value
}
pub fn is_alive(&self) -> bool {
self.value > 0
}
pub fn damage(&mut self, damage: u32) {
self.value.saturate_sub(damage);
}
}
Our private data value
is safely hidden to code outside of this module and we control access to it via the methods we define, this is often called data abstraction or encapsulation. OOP often goes a bit farther than this and allows classes to be used in place of other classes, this is called inheritance in OOP, and polymorphism in general. That is, a programmer can override methods frome some class in its own class such that the same calling code can end up using different methods. This could for example be implemented using a vtable, that is a table of pointers to the individual methods that can look different for every object. Rust does not directly support all of the C++ style inheritance features, but with dyn
traits it comes very close to C++ inheritance on empty base classes or Javas interfaces (and, in fact, uses a mechanism very close to vtables):
trait Enemy {
fn get_health(&self) -> u32;
fn damage(&mut self, damage: u32);
fn freeze(&mut self);
}
struct Golem { ... }
struct Skeleton { ... }
struct Goblin { ... }
impl Enemy for Golem {
fn get_health(&self) -> u32 { ... }
...
}
impl Enemy for Skeleton { ... }
impl Enemy for Goblin { ... }
Now as a Rust coder you know all this already, so why were we talking about this? Oh right, we wanted to remind ourselves what OOP is about. So in short:
Data is private.
At compile time, define what data goes together into an object and encapsulate it behind an interface of code (methods).
We can polymorphically treat different such methods the same by using inheritance.
Comparison to ECS
In ECS, we define functions (systems) to deal with our data and give it to the framework to schedule. In the general case, those function will only ever be called by the framework and can thus be considered “private”: In the general cause, outside the module/plugin/crate the system is defined in, nobody needs to be able to refer to it. If an “outside” user wants to interact with a system this usually happens via data: Systems operate on entities with a given combination of components, so by adding, removing or changing components of entities, we can trigger computation. In contrast to OOP there is no explicit method call involved, we just manipulate the data in the framework and it will call the system for us. The interface is not a function signature, it is knowing which data we need to manipulate in which way.
Entities (data) are dynamic compositions of components and thus entities that are processed in a single system can look very different in terms of what “extra” components they have that are not relevant to that system. In that sense, systems are “polymorphic” (by some stretch of terminology) in the sense that they can do the same thing with different “types” or entities.
To summarize:
Code is private.
At compile time, define what code should be called and encapsulate access to them behind an interface of data (entities).
We can "polymorphically" treat different entities the same by using queries.
OOP | ECS | |
“Private” part | Data (“fields”) | Code (“systems”) |
“Interface” part | Code (“methods”) | Data (“components”) |
“Polymorphism” (use different things in the same way) | via Code (vtable) | via Data (queries) |
Conclusion
I hope I could give you a quick refresher on the idea of ECS and illustrate how it can be seen as a way to think about the relationships and uses of code vs data in principle not unlike OOP but making very different decisions. I have glossed over various details of both sides such as Diamond Inheritance, Co-/Contravariance, Resources, Events, and many more in the hope to get a point across. Since the times of OOP hype are gone you often hear the mantra “composition over inheritance” which can be seen as a counter movement to the previous trend of solving as many problems as possible using vtables. ECS for sure is a pattern that takes this idea to the extreme in some sense, for better or worse.
You might be forgiven to say this derivation is a bit tongue-in-cheek and, if you set your mind to it, I have no doubt you can find examples that break our little analogy. Nevertheless, I find it surprisingly useful as a mental model when thinking about how to structure larger Bevy projects: If you’re struggling with how to do something cleanly in an ECS, coming from OOP, think of your entities and components as interfaces between different parts of code (systems), whereas in OOP it’s closer to the other way around. In production, this should have implications on how you treat changes to these aspects of your code in PRs, how you document you code and how you change version numbers. If your interface is components, then in semantic versioning you might need to bump your major version if the usage of your components change.
Of course within a single component, there is nothing wrong with having method that encapsulate access to the actual data, so you can actually introduce some code into the interface this way and bring back a fraction of the OOP idea. This would likely also make it easier to argue whether its usage patterns changed or not. Similarly, with custom QueryData and custom SystemParam’s you can have as interface code that accesses multiple components or even different queries.