It’s not really though. It’s a very specific thing and then a lot of pretenders who say “Mine is an ECS because I have things called entities and things called components.” Even JME devs used to claim (11+ years ago) that JME was an ECS because of Controls… thinking that controls were somehow components. (They aren’t.)
That’s right, usually camera is not a game object.
Input moves the game object (which is an entity)… camera may or may not be linked to that spatial… or could be an overhead camera… or could be stationary and the world moves underneath it.
The useful thing about using a prebuilt ECS library is that you get to scratch your head about why something is the way it is and try to realize that “why” is very critical to what makes an ECS. If you stay pure like Zay-ES you get a lot of cool things for free like lock-free multithreading, networking, persistence, etc… Technically, Zay-ES could even wrap a massively distributed architecture where systems live on different servers (but nothing implements that today).
Entity Systems in a Nutshell
Entity systems ORIGINALLY started in the realm of pure data-oriented design. It was an answer to console platforms that had a streaming memory design where memory locality was literally the most important thing.
In this kind of architecture, you have to group data together where you need it instead of spread out all over memory in different objects. Let’s take a simple example for comparison.
Regular game object with simple physics pseudo-code:
class GameObject {
vec3 pos;
vec3 velocity;
void integrate( timeStep ) {
pos = pos + velocity * timeStep;
}
}
Then in classic design, something would iterate over all of the GameObjects and call intetgrate() jumping all over the heap.
In a purely data-oriented you would flatten all of the game objects into one big buffer:
o1.pos.x, o1.pos.y, o1.pos.z, o1.vel.x, o1.vel.y, o1.vel.z
o2.pos.x, o2.pos.y, o2.pos.z, o2.vel.x, o2.vel.y, o2.vel.z
…
o[n].pos.x, o[n].pos.y, o[n].pos.z, o[n].vel.x, o[n].vel.y, o[n].vel.z
Then a physics routine would stream that RAM, perform the pos + velocity * timeStep operation (maybe even with SIMD type of instructions) and then write the result to some output buffer. That output buffer might be the input to some other code and so on.
An illustrative example is to over-decompose physics into an acceleration and a velocity pass. Let’s imagine a case where some objects have acceleration and some don’t.
One step would flatten all of the acceleration-having game objects into one big buffer with acceleration and velocity elements, do one big pass to calculate the new velocity, then output that information to a new velocity buffer.
The next step would flatten all of the game objects into a pos and (possibly updated) velocity buffer and loop over that like my original example.
So those are the roots of these ideas and where a lot of the “rules” come from… because it turns out that if you decompose your logic this way: data together, logic separate, then you can get some design benefits even if you aren’t constrained by streaming.
Some benefits of following the “rules”:
- logic code can be simple and dedicated to its task (many times even reusable across games)
- logic code is inherently parallel. In my above example, it’s possible to run the velocity update and position update simultaneously. If your list of “game objects” is super super large, you could even start the next update pass before the previous one had finished and nothing bad happens.
- the assembly process leads to a kind of “duck typing” where the behavior of objects is not defined by a rigid class hierarchy but by a combination of the data they contain. If it has position and velocity then the physics integrator is interested. If it has position and a map icon then the map view is interested. If it has position and a model then the regular scene is interested and so on.
- adding new systems rarely affects the operation of other systems. For example, if I decide I want some objects to decay after a certain amount of time then I can add decay information to the object and a decay system will delete them when they expire. None of the other systems need to care about that. Objects will just disappear underneath them at some point… but since they conceptually get a new set of data every time then they don’t care.
In an ECS, the components are just the pieces of data… typed in such a way that you can easily tell the difference between Position(x,y,z), Velocity(x,y,z), and Acceleration(x,y,z). An entity is just a collection of those components. The systems generally don’t deal with full entities… they deal with the set of components that they are interested in, and then only the entities that actually have those components.
Sometimes as a thought experiment, it can be beneficial to think of an entity system kind of like an SQL database and the logic operates on result sets. (This is where it clicked for me 11 years ago.) The physics system would perform a query for all of the things it wants: position, rotation, linear velocity, linear acceleration. The result set would contain everything that had those parts and the system would loop over that result set and integrate it, then write the new values back out to the database. The data elements in the result set do not change while this is happening… when it’s done, on the next update pass, the system makes the query again and starts over with fresh data.
Obviously that’s not efficient (from a certain perspective) but is a useful thought experiment. (And maybe it is the most efficient if you have billions of objects and don’t care about timely updates but I digress.)
A lot of entity systems will invert this control for you. You register your “system” as some formal thing (probably even implement a System interface), you tell the library what components that system cares about, then the library calls you once per frame with the latest data. It’s very rigid.
Zay-ES takes a slightly different approach by hiding the query+assemble under an EntitySet. This can kind of be thought of as a persistent query that can be repeated when you want. (Like if the SQL ResultSet let you ‘refresh’ it by rerunning the query that created it.)
EntitySet physicsObjects = entityData.findEntities(Position.class, Acceleration.class, Velocity.class);
...
entityObjects.applyChanges(); // reissue the "query", ie: get the latest data
for( Entity e : physicObjects ) {
// do the integration, set the new components back to the entity
}
In practice, Zay-ES is not really reissuing a query but doing clever caching under the covers…and as long the system code follows “the rules”, changes are applied automatically. (And if nothing has changed then applyChanges() is a no-op.)
But because each EntitySet is its own view, you could technically have a separate thread handle each different system/EntitySet and nothing bad happens.
It’s trivially possible to implement the more rigid “implement a system” style design on top of Zay-ES but it’s also really nice that sometimes you can just grab a set of entities, look at them, and throw them away. (Zay-ES also has the added benefit of being able to tell you what changed about the EntitySet membership.) If Zay-ES had chosen the rigid design then it would not be so easy to go the other way.
I’m going to stop here so all of that can sink in and foster new questions.
I also highly recommend looking at the Asteroid Panic example (https://github.com/jMonkeyEngine-Contributions/zay-es/tree/master/examples/AsteroidPanic) I already posted with the above description in mind. Hopefully things will start to make more sense. (Arguably: Asteroids is the simplest game possible to make and so the hope was that implementing it with an ECS would be as illustrative as possible. It might feel like it doesn’t improve over a pure-game object design but that’s a) beside the point of this example, and b) ignores how much easier it would be to add additional features.)