Welcome to the Summer of ECS 2023. In this series we are going to learn about ECS and end with a working library.
Entity Component System, or ECS, is a frequently used pattern in games. ECS is used to help developers manage the behaviors and data of all objects. If we start at the beginning we can better understand how ECS came to exist. Once we understand that then we can build an ECS library in future episode. First up: What is the best way to share code between two objects?
Inheritence vs Composition
What is the best way to share behaviors between two things? A programming debate that has been going for years. There’s two primary concepts to this: Inheritence and Composition.
Inheritence
Inheritence allows an object to take on behaviors from a parent. A lot of languages will use Class to represent types of Objects. Classes can extend from other classes to share behaviors and data.
class Pet {
string name;
Position position;
void Walk(Position walkTo);
}
class Cat {
void Purr();
}
Cat myPet = new Cat("Kitty");
myPet.Walk({x: 5, y: 10});
myPet.Purr();
One problem with inheritance is representing how complex the world really is. What happens when we want a Pet that can also act as a store? We would need a StorePet that extends both Pet and Store. But both Store and Pet have a position, so how do we deal with the collision? Many languages do not support multiple inheritence to avoid these problems. In these cases a StorePet may have to re-implement what it means to be a Store or a Pet.
Composition
Composition gives us a different way to share behaviors to objects. We can create a class to hold everything about one behavior. Starting with our inital example of a Cat we can add some behaviors.
class Moveable {
Position position;
void Walk(Position walkTo);
}
class Cat {
// Data unique to cats
string name;
// Behaviors unique to cats
void Purr();
// Shared behavior that cats have
Moveable mover;
}
Cat myPet = new Cat("Kitty");
myPet.mover.Walk({x: 5, y: 10});
myPet.Purr();
We can attach any behaviors we would like to our cat.
We could even attach a Shop behavior and suddenly have a ShopCat.
There is the awkwardness that we either need to expose mover or
we need to add another Walk() method to Cat that calls the mover.
There’s a number of questions that start to arise with this style like:
- What happens if Dog uses a different behavior called
moveror calls it’s Moveable something else? - How we can generically call
Walk()on anything that has a Moveable behavior? - How do we not duplicate code? Less code == less maintenance
Let’s see how the ECS pattern helps us organize these behaviors to solve these problems.
Enter The Super Object
For a first attempt at reducing the pain of composition:
what if we have just one super class that all of our objects use?
The Super Object! For any game devs you’ve probably seen GameObject.
We can add every behavior possible to our cool new super object as optionals.
A Cat then is just something that happens to have specific behaviors.
class Moveable {
Position position;
void Walk(Position walkTo);
}
class Nameable {
string name;
}
class Purrable {
void Purr();
}
class Object {
optional<Moveable> mover;
optional<Nameable> namer;
optional<Purrable> purrer;
}
In just a few lines of code we have solved all of our problems, right? We can easily move every object if we wanted to. We could also select one object and make it purr if it supports purr. We could define something as a Cat based simply on what behaviors it has.
bool isCat(Object maybeCat) {
return maybeCat.mover.exists() && maybeCat.namer.exists() && maybeCat.purrer.exists();
}
Object storeCat = new Object();
storeCat.addMover(startingPosition);
storeCat.addNamer("Jake Clawson");
storeCat.addPurrer();
storeCat.addStore(startingItems);
isCat(storeCat) // yes!
storeCat.getMoverOrFail().Walk(newPosition);
This pattern is still very common around the world and even in video games. The Unity3d developers are hiding, cheering, or both right now.
But wait, why isn’t the ending music of the episode playing? Game engines are in a constant quest for performance and features. Gamers want more things on screen, more types of actions, and more frames per second. If you have 100 behaviors the Object class becomes very large in memory. We could make the behaviors point to some other place in memory to shrink our Object.
class Object {
optional<Moveable*> mover;
optional<Nameable*> namer;
optional<Purrable*> purrer;
}
We gained lower memory use and faster iteration over object lists.
But we’ve introduced a level of indirection each time we want to access the mover.
That makes access slightly slower.
That decrease may be unnoticable for your game. You may be really happy with the Object pattern and want to stop reading. That is a correct choice. Your ability to build your idea is what is important. But maybe squeezing every last bit of performance is exactly what you like doing. Maybe you need to, because you want 12,000 archers on the screen.
There’s another performance problem I want to touch on: CPU caches.
Side Quest: Memory
We all know hard drives and RAM. Turns out accessing data from these is quite slow.
- Reading from a hard drive may be 10ms. We only have 16ms to render a frame and keep 60 frames per second.
- So we have SSDs, but those are still 0.15ms. We need to go through 12,000 archers.
- Up next we have RAM. At 0.0001ms we’ve can see 12,000 data points in 1.2ms. That’s pretty good! But one archer is a ton of data points. Even in 2D, x, y, facing angle, health, ammo, what image do we draw. Crap we are back to > 6ms.
- Enter CPU caches. L2 is about 20x faster than RAM. L1 cache is another 14x faster than that. Problem is the CPU cache is super tiny. L1 being like kilobytes. L2 being like megabytes.
Working our data around these caches mean we can do a lot of things at the same time. Some really smart people started to notice something about algorithms in games: interactions between entities rarely need to access every behavior of an object. Collisions really only need to know the position and how much space an object occupies. If we get all of the data Collisions need close together, then we could fit a lot of Collideables in cache.
Entities & Components
One interesting observation is that we can just turn our behaviors into separate lists.
Instead of one list of all objects, we have many lists. One for each behavior.
In fact while we are at it let’s just make a name for those bits of data: Component.
List<optional<Moveables>> moveables;
List<optional<Purrables>> purrables;
for (Moveable m : moveables) {
m.Walk(newLocation)
}
Now all we need is a way to line everything up so that one object occupies a known space.
We could just simply say the first object is always the first slot of every list.
Then adding a Component to an object is just a matter of turning the slot on.
We can even keep up our trend of naming things. Let’s call it an Entity.
typedef int EntityID;
EntityID nextEntity;
EntityID addObject() {
EntityID myID = nextEntity;
nextEntity++;
moveables.add(None);
purrables.add(None);
}
void addMoveable(EntityID entity, Moveable data) {
moveables[entity] = data;
}
Moveable* getMoveable(EntityID entity) {
return &moveables[entity];
}
Something we lost though is the context of all the pieces of that object. Calling Walk is simple and isn’t a big deal. But what about checking a collision? We need to access two Components.
Systems
We can define plain methods that access whatever data they need.
After all, each EntityID lines up neatly. The method can create all the context it needs.
We can even
Time to go 3 for 3 on naming things, here comes System.
void Collisions() {
for (var [size, entity] : sizeables) {
optional<Position> position = positionables[entity];
if (!size.exists() || !position.exists()) {
// We can't check the collision of something that has no size or position.
continue;
}
// Now we have all of the context needed to check this Entity for collisions
}
}
We can define all kinds of systems to perform the behaviors we need. We can even share these systems and components with other games.
Where Next?
We now have all the parts that make up ECS. Entities were objects, but have become just an index in a bunch of lists. Components were parent classes and interfaces, but now are arrays of data for entities that use them. Systems were functions on our objects, but now are separate methods that can access data.
There’s more optimizations we can do. There’s an API we need to build to make ECS easy to use. We created a list of Purrables and we have 1 CatStore compared to the 12,000 archers. That’s 12,000 purrables worth of memory wasted. The code we have is relying on globals and coding ECS feels awkward for those not used to it.
Sadly the end of the episode music has started right as the final boss is revealed. Everything beyond this point is implementation details. What we need next is a Terrible Idea. Next time let’s build an ECS library together. It will be terrible fun.