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?