Stat system basics
Author(s): MonwilTags:
Many stats in Isaac are handled using a cache system. Knowing how it works at a basic level is essential when implementing custom stat modifiers.
Introduction to stat cache⚓︎
When loading into a run, the game calculates the value of each player stat by evaluating every possible modifier (such as passive items, character's starting stats or pill effects), then saves the results in a cache. From then on, the game reuses those cached values whenever possible to avoid additional computation.
When the game detects that a specific stat might've gotten changed (e.g. picking up Sad Onion might change tears stat), it reruns the full calculation for this stat, and updates the cache with the new value.
"Cache" Definition
A cache in computer science is a temporary storage of data that is intended to be used later to avoid recomputing things that are costly to calculate. This can be something like a complicated math equation that takes a lot of time to complete. In our case, it's to avoid having to check and recalculate every stat given by items that the player has in their inventory.
Adding custom stat modifiers⚓︎
The MC_EVALUATE_CACHE
callback runs right after a stat evaluation happens, providing the relevant player and a CacheFlag
variable indicating which stat has updated. Modders can use this to calculate their own stat modifiers after the vanilla ones.
Since previously applied stat changes are reset at the start of each stat evaluation, you should reapply them again each time.
It's important to ensure that a stat is updated only if the CacheFlag
provided matches the stat in question. Otherwise, it might end up being unintentionally duplicated.
MC_EVALUATE_CACHE
supports an optional parameter for the CacheFlag
, meaning that passing a value from the CacheFlag
enum as the third argument to AddCallback
will automatically make the provided function only run for the given cache flag. An alternative to this is to check if the provided CacheFlag
value is equal to the CacheFlag
of the stat that's meant to be changed.
CacheFlag comparison
In some mods' code, you might run across CacheFlag
checks that use a bitwise &
operator. They look something like this:
if cacheFlags & CacheFlag.CACHE_SPEED == CacheFlag.CACHE_SPEED then
This is because cache flags are a bit field in order to allow storing multiple cache flags in one variable. Using bitwise operations on them checks if a given bit is contained in the bit field, rather than checking if only the given bit is enabled. This allows the condition to pass even if other cache flags were also enabled in the bit field.
However, the game always invokes the MC_EVALUATE_CACHE
callback for each flag individually, even if they're evaluated at the same time, which makes direct comparison also work perfectly fine in context of this particular callback.
The following if-statements are both equally valid ways of checking if the provided CacheFlag
variable is for CACHE_SPEED
.
1 2 3 |
|
Triggering cache evaluation⚓︎
When defining an item in your items.xml file, one of the properties that can be set is the item's cache
value. This property tells the game which caches to re-evaluate, with different cache names separated by a space.
Caches will be re-evaluated whenever an item is obtained or lost (passive items, or active items with passivecache
set to true
), or when the item is used (active items).
Attaching a simple stat modifier to a passive item only requires setting the correct caches in items.xml and a Lua function that actually applies the modifiers.
However, this isn't enough for every situation. Imagine an item working similarly to "Money = Power", which applies a stat modifier dependent on external conditions (the amount of coins the player has). This would require re-evaluating stats not only when the item is picked up, but also whenever the coin count changes.
For such situations, the API allows triggering stat evaluations at any moment using the AddCacheFlags and EvaluateItems functions of EntityPlayer. The former specifies stats to be recalculated during the next evaluation, while the latter actually triggers said evaluation.
Repentogon AddCacheFlags QoL
Repentogon adds an optional argument to AddCacheFlags which will automatically trigger EvaluateItems
for you.
Applying stat modifiers outside of the MC_EVALUATE_CACHE
callback⚓︎
As mentioned before, stats are reset completely when evaluated. This means that stat changes done outside of the MC_CACHE_EVALUATE
callback may be overwritten at any time. Any conditional stat change should be done by storing the state of that change (e.g. using CollectibleEffects
or GetData
) and then triggering a stat evaluation to apply it, with the stat modification itself being hooked into an MC_EVALUATE_CACHE
callback.
Advanced stat calculations⚓︎
Both Tears and Damage use a more elaborate, multi-step formula to calculate their values.
It might be desirable to make modded stat-ups be affected by those formulas (such as the 5.00 soft Tears cap), or to apply them before specific vanilla multipliers (such as the "Soy Milk" downwards damage multiplier).
Unfortunately, the API only allows interaction with stats after all vanilla calculations have been applied. This makes full consistency with vanilla impossible without completely recreating the stat system.
Example code⚓︎
The following examples showcase how to work with stat caches in practice. The mod adds a custom passive item, which gives +1 Luck for each bone heart owned, at the cost of losing a bit of speed.
items.xml
:
1 2 3 4 5 |
|
main.lua
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
|