AR
AgentRuss
ECS RPG stats system showing BaseStats, equipment modifiers, and status bonuses combining into FinalStats.
AgentRuss Guide

Building an RPG Stats System in ECS: Base Stats, Final Stats & Modifiers

Written by
AgentRuss
Published

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:

  • BaseStats
  • FinalStats
  • Equipment
  • 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:

JavaScript
1export class Stats {
2 constructor() {
3 this.attack = 5
4 this.defense = 2
5 }
6}

Then the player equips a sword.

We might write:

JavaScript
1stats.attack += 5

That works.

But what happens when the sword is removed?

We need to remember to subtract 5.

JavaScript
1stats.attack -= 5

Now 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:

JavaScript
1export class BaseStats {
2 constructor({
3 attack = 5,
4 defense = 2,
5 } = {}) {
6 this.attack = attack
7 this.defense = defense
8 }
9}

This component represents the entity's underlying stats before temporary or equipment-based modifiers are applied.

For a player, we might create:

JavaScript
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:

JavaScript
1export class FinalStats {
2 constructor() {
3 this.attack = 0
4 this.defense = 0
5 }
6}

FinalStats contains the values gameplay systems should usually use.

For example:

  • DamageSystem can read FinalStats.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:

  1. read BaseStats
  2. inspect equipment
  3. inspect buffs
  4. inspect status effects
  5. 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:

JavaScript
1export class Equipment {
2 constructor() {
3 this.weapon = null
4 this.head = null
5 this.body = null
6 this.accessory = null
7 }
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:

JavaScript
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:

JavaScript
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:

  • BaseStats
  • FinalStats
  • Equipment

A query might look like:

JavaScript
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:

JavaScript
1finalStats.attack =
2 baseStats.attack
3
4finalStats.defense =
5 baseStats.defense

This 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:

JavaScript
1const EQUIP_SLOTS = [
2 'weapon',
3 'head',
4 'body',
5 'accessory',
6]

Then:

JavaScript
1for (const slot of EQUIP_SLOTS) {
2 const itemId =
3 equipment[slot]
4
5 if (!itemId) {
6 continue
7 }
8
9 const itemDefinition =
10 ItemDatabase[itemId]
11
12 if (!itemDefinition?.modifiers) {
13 continue
14 }
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] += value
27 }
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:

JavaScript
1modifiers: {
2 attack: 5,
3}

Later, another item might contain:

JavaScript
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:

JavaScript
1if (
2 itemId === 'bronzeSword'
3) {
4 finalStats.attack += 5
5}

the item itself describes its effect.


Avoid Hard-Coding Every Equipment Slot

You could write:

JavaScript
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:

JavaScript
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:

JavaScript
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 StatsSystem
24 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.attack
54
55 finalStats.defense =
56 baseStats.defense
57
58 for (
59 const slot
60 of EQUIP_SLOTS
61 ) {
62 const itemId =
63 equipment[slot]
64
65 if (!itemId) {
66 continue
67 }
68
69 const itemDefinition =
70 ItemDatabase[itemId]
71
72 if (
73 !itemDefinition
74 ?.modifiers
75 ) {
76 continue
77 }
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 value
91 }
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:

JavaScript
1equipment.weapon = null

So the calculated result is:

  • Final Attack: 5
  • Final Defense: 2

Now equip:

JavaScript
1equipment.weapon =
2 'bronzeSword'

The item definition provides:

JavaScript
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:

JavaScript
1equipment.weapon = null

We do not write:

JavaScript
1finalStats.attack -= 5

We simply recalculate.

The system starts from:

JavaScript
1finalStats.attack =
2 baseStats.attack

Then 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:

JavaScript
1attack: 5

The new sword gives:

JavaScript
1attack: 12

We do not need to:

  1. subtract the old sword
  2. remember its modifiers
  3. apply the new sword

We simply change:

JavaScript
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:

JavaScript
1const attackerStats =
2 world.getComponent(
3 attacker,
4 FinalStats,
5 )
6
7const damage =
8 attackerStats.attack

DamageSystem 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:

JavaScript
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:

JavaScript
1const previousAttack =
2 finalStats.attack
3
4const previousDefense =
5 finalStats.defense

After recalculation:

JavaScript
1const changed =
2 previousAttack
3 !== finalStats.attack ||
4 previousDefense
5 !== finalStats.defense

If something changed, we can emit:

JavaScript
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:

JavaScript
1statsDirty = true

When equipment changes:

JavaScript
1statsDirty = true

Then StatsSystem recalculates only dirty entities.

Afterward:

JavaScript
1statsDirty = false

This 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_EQUIPPED
  • EVT_ITEM_UNEQUIPPED
  • EVT_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:

JavaScript
1export class BaseStats {
2 constructor() {
3 this.attack = 5
4 this.defense = 2
5 this.maxHealth = 100
6 this.criticalChance = 5
7 this.moveSpeed = 5
8 }
9}

FinalStats can expose the corresponding calculated values.

Equipment modifiers can then remain data-driven.


Percentage Modifiers Need More Structure

Flat bonuses are easy:

JavaScript
1attack: 5

But 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:

JavaScript
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:

JavaScript
1{
2 stat: 'attack',
3 type: 'flat',
4 value: 10,
5}

A debuff might provide:

JavaScript
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:

JavaScript
1velocity.speed *= 0.5

Instead, 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:

JavaScript
1baseStats.attack += 2

or 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:

JavaScript
1baseStats.attack += 5

when 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:

JavaScript
1finalStats.attack += 100

inside 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:

JavaScript
1itemDefinition.modifiers

The 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:

JavaScript
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 Equipment and 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:

JavaScript
1if (entity === player) {
2 // calculate stats
3}

Instead, query for the components that define participation in the stats pipeline.

For example:

JavaScript
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:

JavaScript
1const damage =
2 Math.max(
3 1,
4 attackerStats.attack
5 - 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:

  1. equipment change
  2. StatsSystem recalculation
  3. 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:

  1. Equip request
  2. Equipment updated
  3. Stats marked dirty
  4. Stats recalculated
  5. EVT_STATS_CHANGED
  6. 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:

  1. restore the underlying state
  2. run StatsSystem
  3. 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:

JavaScript
1{
2 source: 'bronzeSword',
3 stat: 'attack',
4 type: 'flat',
5 value: 5,
6}

A buff might be:

JavaScript
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:

  1. base value
  2. flat bonuses
  3. additive percentage bonuses
  4. multiplicative bonuses
  5. 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:

JavaScript
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:

  • BaseStats for underlying values
  • Equipment for current equipment state
  • static item definitions for item modifiers
  • StatsSystem for calculation rules
  • FinalStats for 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.