1
\$\begingroup\$

I'm writing a 2D roguelike that uses an Entity Component System and I'm having trouble deciding between the traditional OOP approach versus a data-driven approach to implementing buffs and debuffs. For the purposes of this question, I've created two examples (Option A and Option B) to demonstrate the differences.

Option A is the data-driven approach where the data and functionality / behavior for the buffs are separated and we maintain a mapping from buff name to corresponding execute function to link them up.

Meanwhile, Option B just uses the standard OOP approach where buff state and behavior is combined into a single subclass.

Option A:

// Buffs Data File:
let buffs = {
  regeneration: {
    name: "Regeneration",
    description: "Provides some amount of passive regen",
    applyRate: 1000
  },
  snare: {
    name: "Snare",
    description: "Slows the entity by some amount",
    duration: 3000,
    textureName: "items",
    textureFrame = { x: 64, y: 384, w: 32, h: 32 }
  },
  fleeing: {
    name: "Fleeing",
    description: "This unit is running for its life",
    duration: 1000,
    applyRate: 1000
  }
};


// Buffs System
function executeFleeing(scene: Scene, receiver: Entity) {
  let physics = receiver.get(CT.Physics);
  if (!physics) { return; }

  physics.maxSpeed.buffed *= .5;
}

function executeSnare(scene: Scene, receiver: Entity) {
  let physics = receiver.get(CT.Physics);
  if (!physics) { return; }

  physics.maxSpeed.buffed *= 10;
}

function executeRegeneration(scene: Scene, entity: Entity) {
  let health = entity.get(CT.Health);
  if (!health) { return; }

  health.add(5);
}

let mapping = {
  Fleeing: executeFleeing,
  Snare: executeSnare,
  Regeneration: executeRegeneration
};

execute() {
  for (let entity of this.scene.query(CT.Buffs)) {      
    let buffs = entity.get(CT.Buffs);

    for (let b of buffs.list) {
      let buffName = b[0];
      let buff = b[1];

      if (!buff.isActive()) { buffs.list.delete(buffName); continue; }

      if (!buff.elapsed()) { continue; }
      
      buffs.get(buffName).lastTick = performance.now();

      mapping[buffName](this.scene, entity);
    }
  }
}

Option B:

// Date files
class RegenerationBuff extends Buff {
  constructor() {
    super();
    
    this.name = "Regeneration";
    this.description = "Provides some amount of passive regen";

    this.applyRate = 1000;
  }

  execute(scene: Scene, entity: Entity) {
    let health = entity.get(CT.Health);
    if (!health) { return; }

    health.add(5);
  }
}

class SnareBuff extends Buff {
  constructor() {
    super();
    
    this.name = "Snare";
    this.description = "Slows the entity by some amount";

    this.duration = 3000;
    
    this.textureName = "items";
    this.textureFrame = new Frame(64, 384, 32, 32);
  }

  execute(scene: Scene, receiver: Entity) {
    let physics = receiver.get(CT.Physics);
    if (!physics) { return; }

    physics.maxSpeed.buffed *= 10;
  }
}

class RegenerationBuff extends Buff {
  constructor() {
    super();
    
    this.name = "Regeneration";
    this.description = "Provides some amount of passive regen";

    this.applyRate = 1000;
  }

  execute(scene: Scene, entity: Entity) {
    let health = entity.get(CT.Health);
    if (!health) { return; }

    health.add(5);
  }
}


// Buffs System
execute() {
  for (let entity of this.scene.query(CT.Buffs)) {      
    let buffs = entity.get(CT.Buffs);

    for (let b of buffs.list) {
      let buffName = b[0];
      let buff = b[1];

      if (!buff.isActive()) { buffs.list.delete(buffName); continue; }

      if (!buff.elapsed()) { continue; }
      
      buff.lastTick = performance.now();

      buff.execute(this.scene, entity);
    }
  }
}

Option A seems to generally be the recommended paradigm of operation within an ECS (separate state and behavior), but I'm not sure if that would apply to non-components as well. In addition, Option A seems like it could be more performant since entities would only need to maintain a list of buff names rather than instantiate a new buff object every time we want to add one to an entity. This is because the data for the buffs already exists in the data-driven approach, so each entity doesn't need to duplicate the information.

However, Option B has the advantage in that it is conceptually simpler, and finding and editing a buff should be quicker, as all of the relevant information (data and behavior) is in a single place, in a single file. In addition, we don't have to maintain an object mapping that maps from buff name to buff execute function, as it is all contained within the object.

Which option is preferable? Which one would you choose?

\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

Option 1: TL;DR, just use classes, and only classes

If you don't care a whit about performance, go with the class-based version and forget the rest forever after, because it is far and away the easiest thing to model mentally and to code. There is little reason not use only classes at beginning of your project and until such time as performance becomes a problem in terms of simply developing the project further. At that time you will spend a week or two refactoring everything to suit a more performant approach to ECS.

Pros: you're able to quickly modify local members the moment you modify the methods that use them, and vice versa - this of course depends on language, IDE, etc. used, vs. with option 2, where you spend more time doing management between changed structs and their reliant functions.

Cons: This is incidental, and not necessarily the case in your code, but one reason classes don't play nicely with ECS is that ECS avoids inheritance entirely, focusing on composition for what it achieves. To use inheritance here is counter-intuitive because of its limitations in terms of permutation count.

Option 2: Harder to conceptualise, but performant

Let's clarify first that your initial approach is much closer to what is often referred to as pure ECS. It is the most C-like / Assembly-like way of laying out data.

There has been a lot of flak on this site in the past for that approach, for reasons I'm still not entirely clear on - perhaps others see it as premature optimisation, but there were entire generations of programmers before us who saw things very differently.

Cons:

  • have to pass the this parameter (or multiple additional parameters if you separate this into its component members) which requires more management to keep your struct and your functions that use that struct, in sync.

Pros:

  • easier to plan out what the smallest possible data model should look like, keeping functionality and secondary / temporary variables completely separate.
  • close to the metal and allows for the strongest possible optimisations.

Elaborating on this last point: OOP was intended as an organisational tool. Such programming-time and compile-time concerns should never have negatively affected run-time performance, yet they did. These runtime costs include:

  1. Vtables (at least when actually using inheritance) requiring additional JMPs
  2. Small headers at the beginning of class instances creating admittedly minor data overhead
  3. Every class instance that references another class instance has to JMP to that location, which, if not in cache already, will require a callout to main memory.

Re this last point, let's go into more detail. Consider memory layout if we have a tree of objects (class instances) in OOP, bearing in mind that this is how most applications are structured:

    A
   / \
  B   B
 / \ / \
C   CC  C

The layout (at best!) will be as follows :

A x1 @ location W

B x2 @ location X

C x2 @ location Y

C x2 @ location Z

...Although it is much more likely, e.g. in the case you do not put B's and C's into arrays, that each individual object can be allocated in a different memory location, all over main memory (OK, in practice there may be mitigating factors such as a the allocator used, but sometimes you don't have control over that). This non-contiguity matters for objects such as vertices, particles, entities and components of which there can be (tens of) thousands. That's a lot of jumping around in memory if you implement all of these as classes!

Now consider this memory layout (array of Vertex structs, note repetition is as per vertex count):

[ (x|y|z|normal|colour)(x|y|z|normal|colour)(x|y|z|normal|colour) ... ]

Or this layout (struct of arrays of Vertex members, again as per vertex count):

[ xxxyyyzzznnnccc ]

Here we have compacted the data into one large contiguous run in main memory, for optimal cache performance. With classes alone, allocation cannot work like this.

Option 3: Use classes where you can afford to

I think it is fair to say that the original designers of OOP, coming from a C background themselves, did not foresee a time when programmers would become so naive as to use classes for absolutely everything. They knew the value of simpler data layouts and would have advocated for them.

A hybrid or balanced approach is what most of us use to get the best of both worlds.

  • Use classes at the top levels of your application structure.
  • Use compact array-of-struct or struct-of-array only where objects number in the hundreds or thousands.

Personal experience

After about a year or two of practice, I did not find it particularly taxing to consider things in terms of option 2, above, and for anyone serious about developing games, simulations, graphics engines or scientific tools, I would recommend teaching yourself option 2. Otherwise, rather keep things simple and focus on your game design and logic.

\$\endgroup\$
3
  • \$\begingroup\$ So, considering this is Javascript and I don't have much control over the memory layout, I assume you would also agree to go with the class-based approach then? (By the way, thanks for the amazing reply!) \$\endgroup\$ Commented Jun 21, 2021 at 19:46
  • \$\begingroup\$ @RyanPeschel Hi Ryan, you're welcome! and thanks for your positive reception. In today's Javascript it is possible to do Option 2 using TypedArray. Specifically, see this section which outlines how to access complex data types in an otherwise "raw" buffer using views. Notice that this is not all as neatly wrapped up as classes, nor as simple as using array-of-struct in C, but it is possible. \$\endgroup\$
    – Engineer
    Commented Jun 21, 2021 at 20:36
  • \$\begingroup\$ @Engineer There is a Javascript ECS framework that does just that. \$\endgroup\$ Commented Jun 21, 2021 at 23:37

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .