0
\$\begingroup\$

I'm restructuring my model of entities, components and systems, where entities are:

const createEntity = (name, components) => ({
    id: genUniqueId(),
    name: name,
    components: components
});

And I came across a question, and would like to hear someone's experience about it. The question is what are the advantages and disadvantages of each of the following implementation approaches:

1 - Implement all entities in a single global object, and traverse this object by applying the systems in each entity, example:

const entities = {};
for(let key in entities)
    for(let system of systems)
       system(entities[key]);

2 - Create several global variables for each group that the systems serve, example:

const players = {};
const monsters = {};
const clouds = {};

for(let key in players)
       systems['input'](players[key]);

for(let key in monsters)
       systems['simpleAI'](monsters[key]);

for(let key in clouds)
       systems['wind'](clouds[key]);

3 - Create local variables within the systems to store the entities, and functions for each system to add and remove entities from their internal objects, example:

const windSystem = () => {
    const clouds = {};
    for(let key in clouds)
       ...
};

const addEntityInWindSystem = () => {...};
const delEntityInWindSystem = () => {...};

My intention with the question is just to look for other solutions or even understandings about the cons and advantages that each implementation can bring me during development.

\$\endgroup\$
  • \$\begingroup\$ One con is that this isn't a true ECS in that systems can't overlap. Suppose you have a system that processes entities with movement. That should process every entity with movement, not just clouds or monsters or players. You're missing the component part of the ECS. \$\endgroup\$ – clabe45 Sep 17 '18 at 19:10
  • \$\begingroup\$ Also, what does the redefined clouds constant do in windSystem? I can't remember, but I think that will raise a duplicate declaration error, because you already have it defined in the global scope. \$\endgroup\$ – clabe45 Sep 17 '18 at 19:12
  • 1
    \$\begingroup\$ @clabe45 There are 3 different implementations, not 3 parts of the same implementation. I agree that this would get away from the ECS, only the first one would be ECS, but then what other ways do I have to do? \$\endgroup\$ – PerduGames Sep 17 '18 at 19:50
  • \$\begingroup\$ Sorry I don't understand your question. \$\endgroup\$ – clabe45 Sep 17 '18 at 19:57
1
\$\begingroup\$

Sander's answer mentioned families, and I wanted to add some info:

  • Families group entities together based on component criterion.
  • Usually, a system has one family that forms its entity list.
  • Entities can be added to the system/family when they are added to the game engine, and when the system itself is added to the game engine.

Here's a basic working implementation: (I normally don't write code for people, but this is best illustrated in a working example)

class Engine {
  constructor() {
    this.systems = [];
    this.entities = [];
  }

  process() {
    this.systems.forEach(system => { system.process(system.family.getEntities()) });
  }
}

class System {
  constructor(family) {
    this.family = family;
  }

  process() {}
}

class Family {
  constructor(engine, all=[], one=[], none=[]) {
    this.all = all;   // entity must have all of these components
    this.one = one;   // entity must have at least one of these
    this.none = none; // entity must have none of these
    this.engine = engine;
  }

  matches(entity) {
    let entityComponents = entity.components.map(comp => comp.constructor);  // get constructors
    let allPassed = this.all.every(comp => entityComponents.includes(comp));
    let onePassed = !this.some || this.one.some(comp => entityComponents.includes(comp)); // if there are no 'one's, it passed (you don't need this with Array.every)
    let nonePassed = entityComponents.every(comp => !this.one.includes(comp));
    return allPassed && onePassed && nonePassed;
  }

  getEntities() {
    return this.engine.entities.filter(entity => this.matches(entity));
  }
}

class Entity {
  constructor() {
    this.components = [];
    this.id = Entity.nextId;
    Entity.nextId++;
  }
  getComponent(constr) {
    for (let i=0; i<this.components.length; i++) {
      if (this.components[i].constructor === constr) return this.components[i];
    }
    return null;
  }
  toString() { return `Entity-${this.id}`; }
}
Entity.nextId = 1;

// --- sample usage ---
// no need to subclass a `Component` class, because you need to call `super`, and it's pretty useless to have to do for every component type
class HealthComponent {
  constructor(initial) {
    this.health = initial;
  }
}

class HealthSystem extends System {
  constructor(engine) {
    super(new Family(engine, [HealthComponent]));
  }

  process(entities) {
    entities.forEach(entity => {
      if (entity.getComponent(HealthComponent).health <= 0) console.log(entity + " died");
    });
  }
}

let engine = new Engine();
engine.systems.push(new HealthSystem(engine));

let deadEntity = new Entity();
deadEntity.components.push(new HealthComponent(0));
engine.entities.push(deadEntity);

let aliveEntity = new Entity();
deadEntity.components.push(new HealthComponent(1));
engine.entities.push(aliveEntity);

engine.process();

This needs to be optimized so that a family's matched entity list is only updated when it needs to, not every time it is queried. You need to update each family when either A) an entity is added to or removed from the engine or B) an entity gains or loses a component. I would store a cached list of matched entities in each family, and, when either A or B occurs, update each list.

| improve this answer | |
\$\endgroup\$
  • \$\begingroup\$ I don't understand the utility of match(), why do I need it? all, one, none? why not just compare if one is the same as the other? \$\endgroup\$ – PerduGames Sep 24 '18 at 23:37
  • \$\begingroup\$ @PerduGames What exactly do you mean compare if one is the same as the other? You need to be able to know whether certain entities match certain families or not so you can select them and use them as input for systems. \$\endgroup\$ – clabe45 Sep 24 '18 at 23:41
  • \$\begingroup\$ Is that I can not see an example for the application of "one" or "none", you only use "all" in the example. \$\endgroup\$ – PerduGames Sep 26 '18 at 18:24
  • \$\begingroup\$ @PerduGames "One" can be used when a family/system collects entities that have componentA or componentB, etc. For instance, a farming system might require that its components have either a vegetable component or a plant component. "None" is used to negate criteria. Example: Select all entities that have age but don't have immortality. \$\endgroup\$ – clabe45 Sep 26 '18 at 21:28
  • \$\begingroup\$ I understand, I'm trying to pass this to my code, which is utlizando bit mask: stackoverflow.com/questions/52539147/… \$\endgroup\$ – PerduGames Sep 27 '18 at 14:35
2
\$\begingroup\$

Neither of the three approaches are typical. The first approach could be considered a "naive" approach as it works, but is not very efficient because you'd need to check for every entity if it matches the system.

What an ECS framework typically does is organize entities in groups that are based on the components that they have. The groups are then matched to the systems based on a system signature, which lists which components the system is interested in.

Lets say I have set of entities A with components [Position, Velocity]. I have another set B [Position, Velocity, Mass]. These two sets would be stored in different groups, and systems are matched against these groups. A system with a [Position, Mass] signature would only be matched against group B. A system with [Position, Velocity] would be matched against both.

You could represent this with something like this:

const A = [{entity: 0, components: {position: {x:1, y:2}, velocity: 5}}, ...]
const B = [{entity: 10, components: {position: {x:3, y:4}, velocity: 5, mass: 8}}, ...]

The groups here are "A" and "B". The system is "move". Note that entities are an id, similar to a primary key in a database table. You can then match your systems against these groups any way you like.

| improve this answer | |
\$\endgroup\$
  • \$\begingroup\$ But implementation 2 is exactly that, I just did not name it with "A" and "B", I preferred "clouds" and "monsters", but they would be groups also composed with equal components. And how loop it be for them, the way I do in implementation 2? and how would you add and remove those entities from the groups when needed? \$\endgroup\$ – PerduGames Sep 18 '18 at 19:14
  • \$\begingroup\$ The essential part to understand is that these groups (families) are usually stored as criterion, not an actual list of entities. That way, when entities are added to the game engine, they can be added to systems, and when systems themselves are added to the game engine, they can collect all the entities that match the criterion. \$\endgroup\$ – clabe45 Sep 18 '18 at 19:31
  • \$\begingroup\$ @clabe45 It would look like this: My entities const myEntity = {id: 2256469, name: 'myName', components: {...}}, and my groups being const A = [2256469, ...]; const B = [2256469, ...]; and my systems thus const move = (?) => {?}; how would I construct the loop to traverse the matrix A or matrix B, since the objects can leave one group or another dynamically, they(matrix A and B) will have different sizes. And yet, how would I call my system in the main loop? \$\endgroup\$ – PerduGames Sep 18 '18 at 19:46
  • \$\begingroup\$ @PerduGames: In a simple approach you run all systems sequentially. Then for each system, you iterate over the matching groups. Then for each group you iterate over its entities. If you have systems that modify / move entities, you'll want to order the execution of your system in such a way that you get correct output. Some frameworks offer help by letting you specify system dependencies, or synchronization points. \$\endgroup\$ – Sander Mertens Sep 18 '18 at 20:30
  • \$\begingroup\$ @PerduGames see my answer \$\endgroup\$ – clabe45 Sep 18 '18 at 21:29

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.