I solved this problem in many different ways in the past. Unfortunately there is no silver bullet here. Every architecture has advantages and drawbacks. So I am going to describe several approaches here so you can choose which one is most appropriate for the game you want to create.
First of all, such mechanics usually requires that a modifiable character attribute (like maxHp, attackSpeed or even exotic attributes like perAttackLifeStealChance) is more than just a
float member variable of your character. It usually requires that you store all the modifiable attributes in an array. That way you can generalize the code which calculates them. You don't use
character.attackSpeed, you use the function
ATTACK_SPEED being a constant representing the position of the attack speed in the character attribute array.
This function might or might not cache its results. This is an optimization question which depends on how computationally expensive this function gets and how often it is called. But be weary of premature optimization here! You won't know yet if your attribute handling is going to become a bottleneck. So you should prioritize readability and maintainability over performance until you have profiled it with real gameplay and the data shows that you have to optimize.
OK, but how do we implement
Calculate everything in one place
Have the GetAttribute function go through every single system in the game which could modify attributes and apply it to the return value. Character perks, equipment, area effects, temporary effects, you name it.
This has the advantage that it is pretty easy to find bugs. If an attribute comes out on some nonsensical value, you know exactly where you have to set your debugger breakpoint.
But on the other hand, it creates a very tight coupling between this function and everything which changes an attribute. When you have a lot of attribute modifying features, this function could turn out pretty gargantuan and with dependencies on a lot of different game systems.
Register and unregister modifiers
OK, so let's make this more generic. Make the stat calculation system unaware of which systems can cause stat modifiers. Any other system can call
character.AddAttributeModifier to add an attribute modifier and then
character.RemoveAttributeModifier to remove it again. The
character.GetAttribute calculation function now only needs to go through all the modifiers and apply them. It doesn't need to concern itself with where that modifier comes from.
This has the advantage that the attribute calculation function becomes very simple. You can hide the complexity of exotic conditional modifiers in those systems where they belong. It might also have performance advantages, because the function no longer needs to look into modifiers from systems which currently don't modify anything. (But again: be weary of premature optimization here).
But the disadvantage is that this architecture is very prone to bugs resulting from systems not unregistering modifiers when they should be. Have you heard of exploits in games where repeating some unlikely sequence of actions allows the player to raise a character attribute to unlimited values? Instructions like "use temporary buff, quicksave, change area, quickload, die before the buff runs out, respawn, and suddenly the temporary buff is permanent and can be cast again and it stacks"? Whenever I read about something like that I suspect that they are using this architecture and have a system which forgets to unregister some temporary attribute modifier in some edge-case they didn't think about.
Apply modifiers every game tick
OK, so we need to make sure that something which isn't active anymore no longer affects the character attributes. How can we make sure of that? We could insist that each game system which modifies attributes modifies them again for every game tick. That modifier is then active for only the next game tick. If the system wants to maintain that modifier for longer, it has to keep posting it every tick.
This architecture could be realized by maintaining 3 values for every attribute:
- base value
- current value
- modification value
Every system can increase or decrease the modification value for each stat. But no system is allowed to ever change the base or current value. They are only allowed to read those.
Then you would have a separate attribute recalculation system which would run every tick and for each attribute:
modification back to 0
This architecture relies on the premise that every system gets executed exactly once between two calls to the attribute recalculation system. But is this actually true in your architecture? There might be systems which would benefit from doing only occasional updates. So forcing them to run every tick just to maintain their modifiers might become restricting for your architecture. You also have that "spooky action at a distance" problem which comes with shared access to global values. If you suddenly end up with a ridiculous modification value in your attribute system update, you might have to go on a wild goose chase to find out which system set that value and why.