
Building an RPG Stats System in ECS: Base Stats, Final Stats & Modifiers
Stats look simple at first.
A player might start with:
- 5 Attack
- 2 Defense
Then we equip a sword that gives:
- +5 Attack
So the player's attack becomes 10.
Easy.
But RPG stats rarely stay that simple.
Soon we may need to support:
- weapons
- armor
- accessories
- buffs
- debuffs
- status effects
- passive abilities
- temporary bonuses
- level progression
- critical chance
- resistances
If every feature modifies the same number directly, it becomes difficult to answer a surprisingly important question:
Where did this final stat value actually come from?
A clean ECS approach is to separate the values an entity starts with from the values the game calculates after modifiers.
In this guide, we'll build that architecture using:
BaseStatsFinalStatsEquipment- static item definitions
StatsSystem
The result is a stats pipeline that remains predictable as the game grows.
The Problem With One Stats Component
Imagine we start with one component:
1export class Stats {2 constructor() {3 this.attack = 54 this.defense = 25 }6}Then the player equips a sword.
We might write:
1stats.attack += 5That works.
But what happens when the sword is removed?
We need to remember to subtract 5.
1stats.attack -= 5Now imagine:
- equipping another weapon
- swapping armor
- applying a temporary buff
- removing a debuff
- dying
- loading a save
- recalculating after a level-up
Each feature now has permission to directly modify the same values.
That makes the current stat value depend on the entire history of previous changes.
Incremental Modification Can Drift
Suppose the player has:
Attack = 5
They equip a sword:
Attack = 10
They unequip it:
Attack = 5
So far, everything works.
But a bug causes the unequip code to run twice:
Attack = 0
The current value is now wrong because we modified the final number incrementally.
Another common problem is forgetting to remove a modifier at all.
This style makes correctness depend on every system perfectly undoing its previous changes.
There is a cleaner approach.
Recalculate From a Known Source
Instead of continually adding and subtracting from one mutable number, we can keep the original values separate.
For example:
Base Stats
- Attack: 5
- Defense: 2
Then calculate:
Final Stats
- Base Attack: 5
- Bronze Sword: +5
- Final Attack: 10
If the sword is removed, we simply recalculate from the base value.
There is nothing to undo.
The next calculation becomes:
- Base Attack: 5
- No weapon modifier
- Final Attack: 5
This approach is much easier to reason about.
BaseStats Stores the Starting Values
Create a component such as:
1export class BaseStats {2 constructor({3 attack = 5,4 defense = 2,5 } = {}) {6 this.attack = attack7 this.defense = defense8 }9}This component represents the entity's underlying stats before temporary or equipment-based modifiers are applied.
For a player, we might create:
1world.addComponent(2 player,3 new BaseStats({4 attack: 5,5 defense: 2,6 }),7)These numbers become our reliable starting point.
FinalStats Stores the Calculated Values
Next, create:
1export class FinalStats {2 constructor() {3 this.attack = 04 this.defense = 05 }6}FinalStats contains the values gameplay systems should usually use.
For example:
DamageSystemcan readFinalStats.attack- defense calculations can read
FinalStats.defense - UI can display the final values
The important distinction is:
BaseStats answers:
What stats does this entity naturally have?
FinalStats answers:
What are this entity's effective stats right now?
Why Store Derived Stats at All?
You could calculate final stats every time another system asks for them.
For example, DamageSystem could:
- read
BaseStats - inspect equipment
- inspect buffs
- inspect status effects
- calculate attack
But then every system that needs a stat may duplicate the same calculation.
A dedicated FinalStats component gives us a shared calculated result.
StatsSystem owns the calculation.
Other systems simply consume the result.
That gives us a useful responsibility boundary.
Equipment Is Separate State
Equipment should not live inside BaseStats.
It is its own piece of runtime state.
For example:
1export class Equipment {2 constructor() {3 this.weapon = null4 this.head = null5 this.body = null6 this.accessory = null7 }8}The component tells us what is currently equipped.
It does not calculate attack or defense.
That behaviour belongs in the stats pipeline.
Static Item Definitions Hold Modifiers
The sword itself can be described in a reusable item database.
For example:
1export const ItemDatabase = {2 bronzeSword: {3 name: 'Bronze Sword',4
5 modifiers: {6 attack: 5,7 },8 },9
10 leatherArmor: {11 name: 'Leather Armor',12
13 modifiers: {14 defense: 3,15 },16 },17}This data describes the type of item.
It is different from the player's runtime equipment state.
The Equipment component might only store:
1equipment.weapon = 'bronzeSword'StatsSystem can use that ID to find the modifiers.
The Stats Pipeline
The architecture now becomes:
BaseStats → starting values
Equipment → modifier sources
ItemDatabase → modifier definitions
StatsSystem → calculation
FinalStats → effective values
The player may have:
- Base Attack: 5
- Base Defense: 2
- Bronze Sword: +5 Attack
- Leather Armor: +3 Defense
StatsSystem calculates:
- Final Attack: 10
- Final Defense: 5
Other gameplay systems do not need to know how those numbers were produced.
Query the Required Components
StatsSystem only needs entities with:
BaseStatsFinalStatsEquipment
A query might look like:
1const entities = this.world.query([2 BaseStats,3 FinalStats,4 Equipment,5])This immediately communicates the system's requirements.
If an entity does not participate in equipment-based stat calculation, it does not need those components.
Start Each Calculation From Base Stats
The first step should be resetting final values from the base values.
For example:
1finalStats.attack =2 baseStats.attack3
4finalStats.defense =5 baseStats.defenseThis is one of the most important parts of the design.
Every recalculation begins from a known state.
We do not care what FinalStats.attack contained during the previous frame.
We overwrite it.
That prevents modifier drift.
Apply Equipped Item Modifiers
Now we can inspect equipment slots.
For example:
1const EQUIP_SLOTS = [2 'weapon',3 'head',4 'body',5 'accessory',6]Then:
1for (const slot of EQUIP_SLOTS) {2 const itemId =3 equipment[slot]4
5 if (!itemId) {6 continue7 }8
9 const itemDefinition =10 ItemDatabase[itemId]11
12 if (!itemDefinition?.modifiers) {13 continue14 }15
16 for (17 const [key, value]18 of Object.entries(19 itemDefinition.modifiers,20 )21 ) {22 if (23 typeof finalStats[key]24 === 'number'25 ) {26 finalStats[key] += value27 }28 }29}This gives us a generic modifier pipeline.
A weapon can modify attack.
Armor can modify defense.
An accessory could modify both.
The stats system does not need special code for every item.
Why Generic Modifier Keys Are Useful
Suppose our item definition contains:
1modifiers: {2 attack: 5,3}Later, another item might contain:
1modifiers: {2 defense: 4,3 criticalChance: 2,4}If FinalStats contains those properties, the same modifier loop can apply them.
That gives us a data-driven system.
Instead of writing:
1if (2 itemId === 'bronzeSword'3) {4 finalStats.attack += 55}the item itself describes its effect.
Avoid Hard-Coding Every Equipment Slot
You could write:
1applyItem(2 equipment.weapon,3)4
5applyItem(6 equipment.head,7)8
9applyItem(10 equipment.body,11)12
13applyItem(14 equipment.accessory,15)That works, but a slot array keeps the logic easier to extend:
1const EQUIP_SLOTS = [2 'weapon',3 'head',4 'body',5 'accessory',6]If we later add:
- gloves
- boots
- necklace
- ring
the loop can remain largely unchanged.
A Complete StatsSystem
A simple implementation might look like:
1import { System }2 from '../ecs/System.js'3
4import { BaseStats }5 from '../components/BaseStats.js'6
7import { FinalStats }8 from '../components/FinalStats.js'9
10import { Equipment }11 from '../components/Equipment.js'12
13import { ItemDatabase }14 from '../data/ItemDatabase.js'15
16const EQUIP_SLOTS = [17 'weapon',18 'head',19 'body',20 'accessory',21]22
23export class StatsSystem24 extends System {25 update() {26 const entities =27 this.world.query([28 BaseStats,29 FinalStats,30 Equipment,31 ])32
33 for (const entity of entities) {34 const baseStats =35 this.world.getComponent(36 entity,37 BaseStats,38 )39
40 const finalStats =41 this.world.getComponent(42 entity,43 FinalStats,44 )45
46 const equipment =47 this.world.getComponent(48 entity,49 Equipment,50 )51
52 finalStats.attack =53 baseStats.attack54
55 finalStats.defense =56 baseStats.defense57
58 for (59 const slot60 of EQUIP_SLOTS61 ) {62 const itemId =63 equipment[slot]64
65 if (!itemId) {66 continue67 }68
69 const itemDefinition =70 ItemDatabase[itemId]71
72 if (73 !itemDefinition74 ?.modifiers75 ) {76 continue77 }78
79 for (80 const [key, value]81 of Object.entries(82 itemDefinition.modifiers,83 )84 ) {85 if (86 typeof finalStats[key]87 === 'number'88 ) {89 finalStats[key] +=90 value91 }92 }93 }94 }95 }96}The important part is not the exact syntax.
It is the responsibility:
StatsSystem owns the calculation of effective stats.Example: Equipping a Bronze Sword
Imagine the player begins with:
- Attack: 5
- Defense: 2
Their equipment contains:
1equipment.weapon = nullSo the calculated result is:
- Final Attack: 5
- Final Defense: 2
Now equip:
1equipment.weapon =2 'bronzeSword'The item definition provides:
1modifiers: {2 attack: 5,3}On the next recalculation:
- Base Attack: 5
- Sword Modifier: +5
- Final Attack: 10
No direct mutation of BaseStats.attack was required.
Unequipping Becomes Simple
Now remove the weapon:
1equipment.weapon = nullWe do not write:
1finalStats.attack -= 5We simply recalculate.
The system starts from:
1finalStats.attack =2 baseStats.attackThen finds no weapon modifier.
The result naturally returns to 5.
This is one of the biggest benefits of derived stats.
Equipment Swapping Is Safer Too
Suppose the player replaces a Bronze Sword with a stronger weapon.
The old sword gives:
1attack: 5The new sword gives:
1attack: 12We do not need to:
- subtract the old sword
- remember its modifiers
- apply the new sword
We simply change:
1equipment.weapon =2 'steelSword'Then recalculate from the base.
The result becomes correct regardless of what was equipped previously.
FinalStats Becomes the Gameplay Interface
Once the system has calculated FinalStats, gameplay systems should usually consume those values rather than attempting to reconstruct them.
For example:
1const attackerStats =2 world.getComponent(3 attacker,4 FinalStats,5 )6
7const damage =8 attackerStats.attackDamageSystem does not need to understand:
- which sword is equipped
- what the sword's modifier is
- which armor set is active
- what the player's base attack was
It receives the final value it needs.
This keeps systems focused.
UI Should Read FinalStats Too
A stats UI can display:
1const stats =2 world.getComponent(3 player,4 FinalStats,5 )Then show:
- Attack: 10
- Defense: 5
The UI should not calculate those numbers itself.
That avoids a common problem where the UI and gameplay use different formulas.
FinalStats becomes the shared source of truth for effective stat values.
Notify UI When Stats Change
We do not necessarily want to rebuild the UI every frame if the values have not changed.
Before recalculating, we can remember the old values.
For example:
1const previousAttack =2 finalStats.attack3
4const previousDefense =5 finalStats.defenseAfter recalculation:
1const changed =2 previousAttack3 !== finalStats.attack ||4 previousDefense5 !== finalStats.defenseIf something changed, we can emit:
1this.world.events.emit(2 EVT_STATS_CHANGED,3 {4 entity,5 attack:6 finalStats.attack,7 defense:8 finalStats.defense,9 },10)Now UI or other systems can react only when necessary.
State and Events Have Different Jobs
FinalStats is persistent state.
EVT_STATS_CHANGED describes something that happened.
That means:
FinalStats
These are the current effective values.
EVT_STATS_CHANGED
The effective values just changed.
A UI system may use the event as a signal to refresh while still reading the actual values from the component.
Should Stats Recalculate Every Frame?
For a small game, recalculating a handful of stats every frame may be completely fine.
The implementation is simple and predictable.
As the game grows, you may prefer recalculating only when something changes.
Possible triggers include:
- equipment changed
- level changed
- buff added
- buff removed
- status effect changed
- passive ability changed
This becomes a trade-off between simplicity and optimization.
Dirty Flags Are Another Option
Instead of recalculating constantly, a component could contain a flag:
1statsDirty = trueWhen equipment changes:
1statsDirty = trueThen StatsSystem recalculates only dirty entities.
Afterward:
1statsDirty = falseThis can reduce unnecessary work.
But it also introduces another state that must be maintained correctly.
Start simple unless performance shows that optimization is needed.
Events Can Trigger Recalculation
Another approach is event-driven recalculation.
For example:
EVT_ITEM_EQUIPPEDEVT_ITEM_UNEQUIPPEDEVT_STATUS_EFFECT_CHANGED
could cause stats to become dirty or trigger recalculation.
This fits well when the project already has a structured Event Bus.
Again, the important thing is to keep one system responsible for calculating the result.
Adding More Stats
Our example only uses:
- attack
- defense
But the same design scales naturally.
BaseStats could eventually contain:
1export class BaseStats {2 constructor() {3 this.attack = 54 this.defense = 25 this.maxHealth = 1006 this.criticalChance = 57 this.moveSpeed = 58 }9}FinalStats can expose the corresponding calculated values.
Equipment modifiers can then remain data-driven.
Percentage Modifiers Need More Structure
Flat bonuses are easy:
1attack: 5But RPGs often need percentage modifiers too.
For example:
- +5 Attack
- +10% Attack
- -20% Move Speed
At that point, a more structured modifier format is useful.
For example:
1modifiers: [2 {3 stat: 'attack',4 type: 'flat',5 value: 5,6 },7
8 {9 stat: 'attack',10 type: 'percent',11 value: 0.1,12 },13]Then StatsSystem can control the order in which modifier types are applied.
Modifier Order Matters
Suppose:
- Base Attack = 100
- Flat Bonus = +20
- Percentage Bonus = +10%
Depending on the rules, we might calculate:
(100 + 20) × 1.10 = 132
or:
100 × 1.10 + 20 = 130
Neither formula is universally correct.
The important thing is that the rule belongs in one predictable location.
That is another reason a centralized StatsSystem is valuable.
Buffs Can Use the Same Pipeline
Equipment is only one source of modifiers.
A temporary buff might provide:
1{2 stat: 'attack',3 type: 'flat',4 value: 10,5}A debuff might provide:
1{2 stat: 'defense',3 type: 'percent',4 value: -0.2,5}StatsSystem can gather modifiers from multiple sources before producing the final values.
The architecture becomes:
BaseStats
- Equipment Modifiers
- Buff Modifiers
- Debuff Modifiers
- Passive Modifiers
= FinalStats
Status Effects Can Influence Stats
Suppose a status effect slows the player.
The status effect does not need to directly write:
1velocity.speed *= 0.5Instead, it could contribute a movement-speed modifier.
StatsSystem calculates the effective move speed.
Then movement-related systems consume that value.
This keeps temporary effects from directly modifying unrelated gameplay state.
BaseStats Can Change Too
Base stats do not have to remain permanently fixed.
Level progression might change:
1baseStats.attack += 2or a character respec may replace several base values.
That is fine.
The distinction is not:
BaseStats never changes.
The distinction is:
BaseStats represents the underlying value before calculated modifiers.
After the base value changes, StatsSystem recalculates FinalStats.
Don't Modify BaseStats When Equipping Items
One important rule is to avoid this:
1baseStats.attack += 5when equipping a sword.
Now the equipment modifier has become mixed into the underlying stat.
If the weapon is removed, we again have to undo the mutation.
Equipment should influence FinalStats, not permanently alter BaseStats.
FinalStats Should Usually Be Treated as Derived State
Other systems can read FinalStats.
But ideally, they should not arbitrarily write to it.
For example, avoid:
1finalStats.attack += 100inside an ability system.
Instead, that ability should create a modifier source.
Then StatsSystem remains responsible for calculating the actual final value.
This gives us one owner for derived-stat calculation.
One Owner Makes Debugging Easier
Suppose the player unexpectedly has:
Attack = 27
If many systems can directly modify attack, debugging means searching the entire codebase.
But if StatsSystem is the only place that writes FinalStats, we know exactly where to investigate.
We can inspect:
- Base Attack
- equipped items
- buffs
- debuffs
- passive modifiers
and reconstruct the calculation.
This becomes even more useful in complex RPG systems.
Show the Breakdown in Debug Tools
A future stats debugger could show:
Attack
- Base: 5
- Bronze Sword: +5
- Strength Buff: +3
- Poison Weakness: -1
- Final: 12
This is much easier to support when modifiers remain separate from the underlying value.
The same breakdown can eventually be shown in:
- character sheets
- item comparison UI
- tooltips
- developer inspectors
Equipment UI Can Display Modifiers Directly
Because modifiers live in the item definition, inventory UI can display them without calculating stats.
For example, selecting the Bronze Sword can show:
- Attack +5
The UI reads:
1itemDefinition.modifiersThe StatsSystem reads the same data when calculating FinalStats.
That gives both gameplay and presentation a shared definition.
Avoid Duplicating Item Modifier Logic
A common mistake is having:
- inventory UI interpret modifiers
- equipment logic interpret modifiers
- stats logic interpret modifiers
- combat logic interpret modifiers
Each system may eventually implement the rules differently.
Instead:
- item definitions describe modifiers
- StatsSystem applies them
- UI displays them
- gameplay consumes FinalStats
The responsibility remains clear.
What About Enemy Stats?
The same architecture can work for enemies.
An enemy might have:
1world.addComponent(2 enemy,3 new BaseStats({4 attack: 8,5 defense: 3,6 }),7)8
9world.addComponent(10 enemy,11 new FinalStats(),12)If that enemy does not use equipment, you can either:
- omit
Equipmentand use a different stats query - add an empty Equipment component
- generalize modifier sources later
The right choice depends on the game.
The important point is that BaseStats and FinalStats are not inherently player-specific.
Stats Systems Should Be Capability-Based
Avoid writing:
1if (entity === player) {2 // calculate stats3}Instead, query for the components that define participation in the stats pipeline.
For example:
1world.query([2 BaseStats,3 FinalStats,4 Equipment,5])Any compatible entity can then use the same system.
That keeps the design aligned with ECS composition.
Stats and Damage Should Remain Separate
StatsSystem calculates effective stats.
DamageSystem decides how those stats are used during damage resolution.
For example:
1const damage =2 Math.max(3 1,4 attackerStats.attack5 - targetStats.defense,6 )That calculation belongs to combat.
The stats system should not need to know:
- who attacked
- who was hit
- whether it was critical
- whether damage was blocked
Its job ends once the effective stats are ready.
System Order Can Matter
Suppose equipment changes during the current frame.
If combat immediately uses FinalStats, the pipeline should ensure stats are updated first.
For example:
- equipment change
- StatsSystem recalculation
- combat resolution
If combat happens before recalculation, it may use stale values.
This is where stats architecture connects to ECS system ordering.
Events Can Reduce Same-Frame Problems
Another approach is to make equipment changes produce an event or mark stats dirty.
Then the system pipeline can establish clearly when recalculation happens.
For example:
- Equip request
- Equipment updated
- Stats marked dirty
- Stats recalculated
EVT_STATS_CHANGED- UI refreshes
The exact design can vary.
What matters is that the timing is predictable.
Save Games Become Easier
A save file may need to store:
- BaseStats
- Equipment
- active buffs
- progression
It may not even need to store FinalStats.
When loading:
- restore the underlying state
- run StatsSystem
- rebuild FinalStats
Because final values are derived, they can be regenerated.
That reduces the chance of saved derived values becoming inconsistent with the source data.
Multiplayer Benefits From Derived Stats Too
In multiplayer, the authoritative simulation can calculate FinalStats.
Clients can receive:
- underlying equipment/state
- final values
- or both
The exact replication strategy depends on the game.
But having one deterministic calculation makes it easier to ensure different machines agree about how stats are produced.
A More Advanced Modifier Pipeline
As the RPG grows, modifiers may need metadata.
For example:
1{2 source: 'bronzeSword',3 stat: 'attack',4 type: 'flat',5 value: 5,6}A buff might be:
1{2 source: 'battleCry',3 stat: 'attack',4 type: 'percent',5 value: 0.15,6}Now we can:
- identify where a modifier came from
- remove modifiers by source
- display stat breakdowns
- control stacking rules
The simple modifier object can evolve without changing the overall architecture.
Modifier Stacking Rules
Eventually you may need rules such as:
- multiple flat modifiers stack
- percentage bonuses add together
- some buffs do not stack
- the strongest effect wins
- certain categories multiply separately
Those rules belong in the stats calculation layer.
For example, the system might process modifiers in phases:
- base value
- flat bonuses
- additive percentage bonuses
- multiplicative bonuses
- clamps or limits
The exact formula is game-specific.
The architecture remains the same.
Clamp Stats When Appropriate
Some stats may require limits.
For example:
1finalStats.criticalChance =2 Math.min(3 finalStats.criticalChance,4 100,5 )Movement speed might have a minimum.
Resistance might be capped.
Again, the centralized StatsSystem gives these rules one home rather than scattering them across every consumer.
Not Every Value Needs BaseStats and FinalStats
Do not automatically duplicate every number in the game.
For example, Health.current is usually runtime state rather than a derived stat.
Transform.position does not need:
- BasePosition
- FinalPosition
The base/final pattern is particularly useful when a value is calculated from several modifier sources.
Use it where it solves a real problem.
A Useful Stats Checklist
When designing an RPG stat, ask:
Is this an underlying character value?
Store it in BaseStats.
Is this the value after modifiers?
Expose it through FinalStats.
Is this an equipment bonus?
Keep it in the item definition.
Is this a temporary bonus?
Represent it as a modifier source such as a buff or status effect.
Who calculates the result?
StatsSystem.
Who consumes the result?
Combat, movement, UI, abilities, or other gameplay systems.
Who should directly write FinalStats?
Preferably only the stats calculation pipeline.
A Practical Mental Model
Think of your stats like a calculation graph:
Base Values
Attack: 5
Defense: 2
↓
Modifier Sources
Bronze Sword: +5 Attack
Leather Armor: +3 Defense
Buff: +10% Attack
↓
StatsSystem
Applies the game's modifier rules.
↓
FinalStats
Attack: effective value
Defense: effective value
↓
Consumers
DamageSystem
UI
Abilities
Movement
AI
The consumers do not need to know how the result was produced.
Why This Architecture Scales
The biggest benefit is that new modifier sources can be added without redesigning the entire feature.
Today:
- equipment
Tomorrow:
- buffs
- debuffs
- passive abilities
- skill trees
- food bonuses
- temporary zones
- party bonuses
- difficulty modifiers
All of them can feed into the same stat-calculation pipeline.
The final gameplay systems still read the same FinalStats component.
Conclusion
A clean RPG stats system benefits from separating source values from calculated values.
Instead of continually changing one mutable attack number, we keep:
BaseStatsfor underlying valuesEquipmentfor current equipment state- static item definitions for item modifiers
StatsSystemfor calculation rulesFinalStatsfor the effective result
That gives us a predictable pipeline:
Base Stats
- Modifiers
→ StatsSystem
→ Final Stats
Equipping an item does not permanently change the base value.
Unequipping does not require carefully subtracting an old modifier.
The system simply recalculates from a known source.
That makes the architecture easier to:
- debug
- save
- replicate
- extend
- display in UI
- test
- balance
As the game grows, the same design can support percentage modifiers, buffs, debuffs, passive abilities, status effects, stacking rules, and more advanced RPG calculations.
The key principle remains simple:
Keep the source data separate from the derived result, and give one system responsibility for calculating the final value.

Learn why ECS components should stay data-focused, how logic-heavy components create coupling, and why systems are a better place for gameplay behaviour.

Build a simple Entity Component System in JavaScript from scratch with entities, data-only components, queries, systems, and a working update loop.

Learn how to organize an ECS game engine with separate folders for core ECS code, components, systems, events, factories, data definitions, UI, and game-specific logic.