Using ECS to Approach Turn-based RPGs
Making a highly flexible turn-based RPG system is pretty complicated. Even Enterbrain with their RPG Maker series had a hard time creating a system that was open-ended to extend and develop, but still simple enough for trivial implementations.
In most RPGs, action or turn-based, team-building and execution are the key strategic elements of the game. The ability to learn and capacitate the different effects of different skills and talents is what makes people "good" at them (or that they follow a guide where someone already did that for them). To make room for this player expression, developers need to design and implement creative effects, while also balancing them so that there is no one solution that puts it all to shame. This is exaggerated in gacha games where variety is what sells, and unsurprisingly, it is gacha games that get the most negativity about balancing.
It is open-ended problems where logic must remain highly extendable that ECS solutions are particularly good at, so RPGs and ECS are an obvious match. The concepts here apply to ECS in general, but for demonstration I will use Rust and an ECS called legion
(clickable).
Abstraction
To begin, we need to first parameterize the different entities in our world. Well, there's going to be "actors" on the field, which can be allies or enemies, and all have the common trait of having a name, a health pool and some other resource, like mana or SP. The components are going to look like this:
#[derive(Clone, Debug, PartialEq)]
pub struct Name(String);
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Alignment {
Enemy,
Ally,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Health {
hp: i32,
max_hp: i32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SkillPoints {
sp: i32,
max_sp: i32,
}
If you are looking to implement this exact example, please note I have omitted some trait implementations for the sake of simplicity.
We can conclude that any enemy with the components Name, Alignment, Health, SkillPoints
are an actor, but it makes sense for an enemy to not have SP, so Name, Alignment, Health
can also be valid.
let actors: &[Entity] = world.extend(vec![
(
Name::from("Friend"),
Alignment::Ally,
Health { hp: 100, max_hp: 100 },
SkillPoints { sp: 25, max_sp: 25 },
),
(
Name::from("Foe"),
Alignment::Enemy,
Health { hp: 65, max_hp: 65 },
SkillPoints { sp: 0, max_sp: 0 }, // this also makes sense
),
]);
The world is now populated by our data, but it does not have any logic. It is impossible for our "Friend" to attack either the "Foe" or "Dreadful Foe." Let's add an attack that deals 75 flat damage to our "Friend's" repertoire of moves.
We need a new set of systems to resolve the effect of such an action. In the ECS of my choice, mutable changes to the world like adding entities can only happen between phases or schedules. This is important, so for my effect resolution phase, I create my own schedule to handle it.
We also need a way of communicating all the different effects of an action between systems that might care about it. We could use a rudimentary event system as a sort of pipeline, but we also want to modify the effects between systems. We will declare a schema for battle effects, aptly named "Effect," and add any extra logic and data to it through extra components.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Effect {
pub target: Entity,
}
// this is a marker component so filters can be applied
// at the ECS level.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct EffectApplied;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Damage {
pub amount: i32,
}
Then, when our user actually chooses to use our skill, we will create a new "Effect" with the "Effect" and "Damage" components, and thus begins this effect's journey through the pipeline.
// in one of our game logic systems...
let effect = world.push((
Effect { target: foe },
Damage { amount: 75 },
));
The natural result of an effect with a "Damage" component is that it takes away some of the health of the enemy. Time to write our first system describing this logic, and it's a very simple system.
#[system]
#[read_component(Effect)]
#[read_component(Damage)]
#[write_component(Health)]
pub fn apply_damage_effect(
world: &mut SubWorld,
commands: &mut CommandBuffer,
) {
let (effect_world, mut actor_world) = world.split::<(&Effect, &Damage)>();
let mut damage_effects = Query::<(Entity, &Effect, &Damage)>::new()
// this filter is important! so we do not rerun applied effects.
.filter(!component::<EffectApplied>());
let mut actors = Query::<&mut Health>::new();
for (effect_id, effect, damage) in damage_effects.iter(&effect_world) {
let final_damage = damage.amount;
// apply damage
if let Ok(health) = actors.get_mut(&mut actor_world, effect.target) {
health.hp -= final_damage;
}
// mark effect as completed
commands.add_component(*effect_id, EffectApplied);
}
}
// in our main game loop...
let mut effect_phase = Schedule::builder()
// add the new system from above to our effect phase
.add_system(apply_damage_effect_system())
.build();
// later, evaluate the effects on our game world
effect_phase.execute(&mut world, &mut resources);
We can see the spoils of our effort by logging the state of the world. This is all in a white-on-black console, but one can already imagine how you might integrate this into your game with assets and UI from the components alone.
Foe - Enemy
HP: 65 / 65
SP: 0 / 0
Performing attack for 75 damage...
Foe - Enemy
HP: -10 / 65
SP: 0 / 0
UI Response
The effects do not get cleaned up after the effect phase is over, they only get marked with an EffectApplied
component. This is very important as you both might want to use the results of these effects for later, and you definitely want to give your player feedback on their actions, e.g. damage indicators. Since our current scenario is very simple, the numbers displayed on damage indicators are the same as the value of the Damage
component.
If you want UI response in general, including damage indicators, you should also make sure that they line up with your animations. If we give each Effect
a number, the animator can control UI response by sending events to play the results of an effect back by that number.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Effect {
pub target: Entity,
pub seq: i32, // new! the animation software can send events for
// a custom solution to play these back.
}
This number doesn't necessarily have to be unique. It might make more sense to the system that you want to break up effects into two entities, one that does damage and one that conditionally gives a debuff or does supplemental damage where it's important that the damage calculation takes it as a separate instance of damage.
After you no longer need your "Effect(s)," absolutely remember to clean up after yourself. Using entities for data storage over the duration of a single frame is powerful, but it is very prone to memory leaks.
#[system]
#[read_component(EffectApplied)]
pub fn cleanup_effects(
world: &mut SubWorld,
commands: &mut CommandBuffer,
) {
let mut to_cleanup = Query::<Entity>::new()
.filter(component::<EffectApplied>());
for effect_id in to_cleanup.iter(world) {
commands.remove(*effect_id);
}
}
Stats
Almost all RPGs have a plethora of stats beyond "Max HP" that enrich gameplay decisions in a unique way. If your game has a defense stat and a magic defense stat, it is important to take the actions that maximize your damage, i.e. using magic skills on an enemy with high defense but low magic defense. We shall now investigate how we can go about involving our stat system in our ECS solution.
// new component that stores all the stats!
// this is very rudimentary and your stat system will look different,
// especially if you have a dense status effect design, but accessing
// the final stats of a friend or foe should be as easy as accessing
// a field or simple function from your ECS.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Stats {
pub max_hp: i32,
pub atk: i32,
pub def: i32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Effect {
pub target: Entity,
pub source: Entity, // new! it is relevant to track sources now
pub seq: i32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Health {
pub hp: i32,
// `max_hp` moved to `Stats`.
//pub max_hp: i32,
}
let actors: &[Entity] = world.extend(vec![
(
Name::from("Friend"),
Health { hp: 100 },
// ...
Stats { max_hp: 100, atk: 75, def: 50 },
),
(
Name::from("Foe"),
Health { hp: 65 },
// ...
Stats { max_hp: 65, atk: 40, def: 20 },
),
]);
Instead of our primal attack skill dealing a flat 75 damage, we now want it to scale based on our attack. Naturally, since this is our basic attack skill, it should be 100% of our attack stat.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Attack {
pub scale: f32,
}
let effect = world.push((
Effect { target: foe, source: friend },
Attack { scale: 1.0 }, // new `Attack` component!
Damage { amount: 0 }, // note our `Damage` component stays here, instead
// with an uninitialized value
));
Our enemy, "Foe" also now has a defense stat. Damage formulas are a decision highly based in game design and balancing, but for this example, I will be using the very rudimentary formula of Damage - DEF = Final Damage
.
Now, the stream of logic for calculating damage is no longer a two-node sequence, it now looks like this:
We will start with the first part of the diagram: coverting the posted 100% of ATK to an actual damage number. This only modifies the "Damage" component of our effect, and uses the "Attack" component and "Stats" component of the source to calculate the damage before defense is factored in.
#[system]
#[read_component(Effect)]
#[read_component(Attack)]
#[read_component(Stats)]
#[write_component(Damage)]
pub fn resolve_attack_damage_effect(
world: &mut SubWorld,
) {
let (mut effect_world, actor_world) = world.split::<(&Effect, &Attack, &mut Damage)>();
let mut damage_effects = Query::<(&Effect, &Attack, &mut Damage)>::new()
.filter(!component::<EffectApplied>());
let mut actors = Query::<&Stats>::new();
for (effect, attack, damage) in damage_effects.iter_mut(&mut effect_world) {
// get source's atk stat
let Ok(source_stats) = actors.get(&actor_world, effect.source) else {
continue;
};
// resolve damage effect (do floating point conversions)
let result = attack.scale * source_stats.atk as f32;
damage.amount = result as i32;
}
}
The next step in our damage calculation involves reducing the incoming damage by an amount determined by the defender's defense stat. We need to make sure this system is run after the system that converts the posted 100% of ATK to damage, but otherwise this system is also rather trivial.
#[system]
#[read_component(Effect)]
#[read_component(Stats)]
#[write_component(Damage)]
pub fn apply_defense_effect(
world: &mut SubWorld,
) {
let (mut effect_world, actor_world) = world.split::<(&Effect, &mut Damage)>();
let mut damage_effects = Query::<(&Effect, &mut Damage)>::new()
.filter(!component::<EffectApplied>());
let mut actors = Query::<&Stats>::new();
for (effect, damage) in damage_effects.iter_mut(&mut effect_world) {
// get targets's def stat
let Ok(target_stats) = actors.get(&actor_world, effect.target) else {
continue;
};
// reduce incoming damage by the def stat
// NOTE: we do not want our damage to heal the enemy! if the result
// is negative, reduce to 0 instead.
damage.amount = std::cmp::max(damage.amount - target_stats.def, 0);
}
}
Modify our schedule to include the new systems, making sure that we place our systems in the correct order. Incorrect order of execution will make or break an ECS solution, especially in a solution where ECS is used in the middle of a frame as a sort of scratchpad. Tread carefully!
let mut effect_phase = Schedule::builder()
.add_system(resolve_attack_damage_effect_system()) // new!
.add_system(apply_defense_effect_system()) // new! AFTER ABOVE!!!!
.add_system(apply_damage_effect_system())
.build();
Now, our foe has the constitution to survive our friend's initial attack!
Foe - Enemy
HP: 65 / 65
SP: 0 / 0
Performing attack for 100% of ATK as damage...
Foe - Enemy
HP: 10 / 65
SP: 0 / 0
Multi-hit Attacks
It is also often in RPGs (especially Pokémon) that multi-hit attacks are different from single hit attacks in fundamental ways. Even in this rough, simple system, against an enemy with a modest amount of defense, a skill that performs a single hit for 100% ATK will do more damage than a skill that performs three hits for 50% ATK each, even though it deals a combined 150% ATK.
// this is "one" multi hit attack
// notice how the only new code written is this snippet! this is ECS zen
// and if you make it here you are doing it right.
let multi_hit_effects = world.extend(vec![
(
Effect { target: foe, source: friend },
Attack { scale: 0.5 },
Damage { amount: 0 },
),
(
Effect { target: foe, source: friend },
Attack { scale: 0.5 },
Damage { amount: 0 },
),
(
Effect { target: foe, source: friend },
Attack { scale: 0.5 },
Damage { amount: 0 },
),
]);
// for keen rustaceans, you can make this code significantly shorter:
let multi_hit_effects = world.extend(
(0..3)
.map(|_| (
Effect { target: foe, source: friend },
Attack { scale: 0.5 },
Damage { amount: 0 },
))
);
Foe - Enemy
HP: 65 / 65
SP: 0 / 0
Performing three attacks for 50% of ATK as damage each...
Foe - Enemy
HP: 14 / 65
SP: 0 / 0
Because this RPG system is so open-ended, some properties of your game may emerge even without your awareness and intuition. While this can prove detrimental occasionally, most players (specifically nerds) will thank you for the amount of player expression in your game. And you didn't even explicitly write it yourself!
Healing
Almost every RPG in existence has a method of healing your party members mid battle. In a turn-based RPG, the main modus of healing is through items or SP. Whatever resource it may use, you can apply the same principles as the damage system to create your healing system. Even if your game has complicated healing mechanics like healing reduction or critical heals, you can draw parallels from the basic "modify x
based on y
" principles above. This is left as an exercise to the reader.
Shields
An easy strategic element to implement in your game is a shield. Give your players or enemies the option to use their turn to throw up a shield instead of dealing damage, and things get interesting. Now you have an effective form of damage control that isn't killing the enemy or healing!
Some shields absorb a posted amount of damage before breaking. Other shields absorb a number of "hits," the definition of which depends on critical game design decisions. I will be demonstrating the latter in this experiment.
An important detail to consider is whether or not additional properties of a move also get absorbed with the hit. For example, if a party member uses a skill that does damage and applies a status effect against a foe with a shield, does that foe still receive the status effect? The answer totally depends on balancing decisions! The most interesting part of it is there are valid, easy ways to opt out of both methods for specific, more powerful shields and skills.
For this example, I will be using the latter interpretation: shields nullify supplementary effects of attacks. However, to balance this, I also decided that if the enemy uses a multi-hit move, each hit uses up a layer of shield.
It is also worth considering if the shield is a status effect or not, whether it is affected by skills that dispel buffs. Status effects are out of the scope of this post, so I will make "Shield" a very basic component that is added to actors with a shield.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Shield {
pub layers: i32,
}
// new enemy, Dreadful Foe starts with 1 layer of shield.
// with not so dreadful defense (same as Foe)...
let dreadful_foe = world.push((
Name::from("Dreadful Foe"),
Alignment::Enemy,
Health { hp: 200 },
SkillPoints { sp: 25, max_sp: 25 },
Stats { max_hp: 200, atk: 90, def: 20 },
Shield { layers: 1 },
));
The simple solution for this logic is if an entity targeted with an "Effect" has a layer of shield, deduct one layer of shield and then nullify the effect. "Nullify" can look like a lot of things, but I think it helps to at least keep the effect around for post-processing of the result, so I will just prematurely add an "EffectApplied" component to the effect instead of outright removing it from the world. If you want to provide feedback to your player that their hit was blocked, you might want to set another marker component or flag telling your UI systems about that.
#[system]
#[read_component(Effect)]
#[write_component(Shield)]
pub fn shield_nullify(
world: &mut SubWorld,
commands: &mut CommandBuffer,
) {
let (effect_world, mut actor_world) = world.split::<&Effect>();
let mut effects = Query::<(Entity, &Effect)>::new()
.filter(!component::<EffectApplied>());
let mut actors = Query::<&mut Shield>::new();
for (effect_id, effect) in effects.iter(&effect_world) {
// check if the target has a shield
let Ok(shield) = actors.get_mut(&mut actor_world, effect.target) else {
continue;
};
if shield.layers > 0 {
// if they do, reduce by one and perform shield nullification
shield.layers -= 1;
commands.add_component(*effect_id, EffectApplied);
}
}
}
let mut effect_phase = Schedule::builder()
.add_system(resolve_attack_damage_effect_system())
.add_system(apply_defense_effect_system())
.add_system(shield_nullify_system())
.flush() // we made changes (added "EffectApplied") so we need to flush
// our command buffers
.add_system(apply_damage_effect_system())
.build();
That should do it all. You can place the shield nullification system anywhere as long as it happens before any non-idempotent effects happen (hp gets reduced, status effects get applied, etc). However, I recommend placing them as late as possible so you don't have to move the system around in the future. It isn't that much of a hassle either way though, so feel free to place it anywhere that feels right.
Now when our friend attempts to attack our brooding foe with his basic attack, he is met with sadness:
Dreadful Foe - Enemy
HP: 200 / 200
SP: 25 / 25
Performing one attack for 100% of ATK as damage...
Dreadful Foe - Enemy
HP: 200 / 200
SP: 25 / 25
However, if our friend is resourceful, he might use his multi-hit attack to expend the shield before dishing out a modest amount of damage that's higher than zero.
Dreadful Foe - Enemy
HP: 200 / 200
SP: 25 / 25
Performing three attacks for 50% of ATK as damage each...
Dreadful Foe - Enemy
HP: 166 / 200
SP: 25 / 25
The first hit of the multi-hit attack gets absorbed, but subsequent hits are converted into damage, even if the foe's health isn't reduced as much as one big strike without a shield.
This logic can work for any conditionals you have. Target %HP below a certain threshold can be represented by a "TargetHPBelow" component with the percentage specified in the body, and this works similarly for many conditionals. If you only want the conditional to apply for some effects, you can split the effect into two entities. This may break the shield implementation a little bit, but with a simple association, the shield can absorb both effects.
Supplemental Damage
I want to close this write up with a deceivingly simple design idea: I want a skill that does an extra bit of damage based on the amount of damage an attack deals to an enemy. Let's say, a skill that does 100% ATK as damage, and an extra hit of 80% of the damage dealt. The extra hit is also subject to defense.
// you probably get the drill now... new property, new component...
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SupplementalDamage {
pub scale: f32,
}
let supp_effect = world.push((
Effect { target: foe, source: friend },
Attack { scale: 1.0 },
Damage { amount: 0 },
SupplementalDamage { scale: 0.8 },
));
There's nothing stopping a system birthing another "Effect," as long as the changes are applied so that subsequent systems can get this new data. This system is now one of the shorter ones, despite the complicated definition!
#[system]
#[read_component(Effect)]
#[read_component(Damage)]
#[read_component(SupplementalDamage)]
pub fn create_supplementary_damage_effect(
world: &mut SubWorld,
commands: &mut CommandBuffer,
) {
let mut supp_effects = Query::<(&Effect, &Damage, &SupplementalDamage)>::new()
.filter(!component::<EffectApplied>());
for (effect, damage, supp_damage) in supp_effects.iter(world) {
let damage = damage.amount as f32 * supp_damage.scale;
// create new effects for supplementary damage
commands.push((
// same targeting properties
effect.clone(),
// override damage, omitting "Attack" component
Damage { amount: damage as i32 },
));
}
}
So then, you go back to your schedule, astutely remembering that you need to flush your command buffers after the system... and...
let mut effect_phase = Schedule::builder()
.add_system(resolve_attack_damage_effect_system())
.add_system(apply_defense_effect_system())
.add_system(shield_nullify_system())
.flush()
.add_system(create_supplementary_damage_effect_system())
.add_system(apply_damage_effect_system())
.flush()
// ...?
.build();
You immediately notice a problem. If you don't notice it now, that's fine, you will notice it during testing: The supplemental damage never actually gets applied.
Foe - Enemy
HP: 65 / 65
SP: 0 / 0
Performing an attack for 150% of ATK as damage, plus an additional 40% of the damage dealt...
Foe - Enemy
HP: 10 / 65
SP: 0 / 0
If we have an attack dealing 75 damage, after DEF it is now 55 damage. This part is correct, but 80% of 55 is 44, so we should be expecting an extra 24 damage after factoring in defense. Clearly, this hasn't worked!
If you have direct control over your schedule and choose to run it again, you may notice that it actually does work as intended if you run the schedule twice.
Foe - Enemy
HP: 65 / 65
SP: 0 / 0
Performing an attack for 150% of ATK as damage, plus an additional 40% of the damage dealt...
Foe - Enemy
HP: 10 / 65
SP: 0 / 0
Running schedule again (for no reason :D)...
Foe - Enemy
HP: -14 / 65
SP: 0 / 0
This is an obvious but common error when effects are circularly dependent. For the supplementary damage system to run, it must run after all the damage calculation systems run to get the final damage number. However, the supplementary damage system then creates another damage "Effect," but the damage "Effect" doesn't have a chance to get processed as the supplementary damage system was placed after the damage systems that process it.
A tempting solution is to simply apply the damage to the foe in the supplementary damage system, but this violates all the principles of ECS that make it work. Sure, you can just subtract the HP of the subject, but you also have to rewrite the defense calculation because I explicitly wanted that included. Not to mention the dillion other damage modifiers your programmers added that are now being ignored.
The ECS version of that solution (and probably the only good one) is just rerunning the systems until all effects have applied. Keep in mind this opens a new gateway for infinite-loop bugs to come crawling in, but right now this is the best solution I have that still upholds ECS principles.
loop {
effect_phase.execute(&mut world, &mut resources);
// check for any unapplied effects
let mut query = Query::<Entity>::new()
.filter(component::<Effect>() & !component::<EffectApplied>());
if query.iter(&world).count() > 0 {
println!("Running schedule again to resolve chained effects...");
} else {
break;
}
}
// finished processing effects!
Foe - Enemy
HP: 65 / 65
SP: 0 / 0
Performing an attack for 150% of ATK as damage, plus an additional 40% of the damage dealt...
Running schedule again to resolve chained effects...
Foe - Enemy
HP: -14 / 65
SP: 0 / 0
This works but is a bummer. It doesn't exactly throw a sandbag into performance, since the chances that the scheduler gets to parallelize systems in a configuration like this are slim, but it's still unsatisfyingly incomplete. If you have a better idea, you can send me an email about it @ frostu8@uw.edu
.
Conclusion
With these principles in mind, you can make a highly extendable RPG system that is complete and can create surprising and interesting combinations! I believe these are the most rewarding kinds of turn-based RPG systems, but I am not an RPG nerd nor someone qualified to speak to the masses about quality RPG games.
There are many ways to integrate this into your game: if your engine is an ECS, you can integrate these systems directly into your game, provided your ECS has the scheduling flexibility required by the schedule loop idea. If your engine isn't an ECS, there are lots of ways to integrate a system like this into your engine. Unity has its own Entity-Component System implementation, but I also recommend MoonTools.ECS for a more lightweight and simple experience. For Godot, there's Godex, but I have no personal accounts of this implementation.
Other engines also have their own ECS implementations, whether they are official, unofficial or the engine is the ECS implementation. The power of ECS is worth researching for complex implementations like this, so get learning, because there is a lot to learn.