Stat system basics
Author(s): MonwilTags:
Work in progress!
This article is a work in progress! Some sections may be incomplete or need revising.
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, game calculates the value of each cached 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, game reuses those cached values whenever possible to avoid additional computation.
When 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 cache with the new value.
Adding custom stat modifiers⚓︎
MC_EVALUATE_CACHE callback runs right after a stat evaluation happens, and it provides the relevant player as well as a CacheFlag variable indicating which stat is updated. Modders can use this to inject their own stat modifiers on top of the vanilla ones.
Since previously applied stat changes are reset at the start of each stat evaluation, it's intended to 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 easily end up being unintentionally duplicated.
MC_EVALUATE_CACHE callback supports a CacheFlag optional param, which means that passing a value from CacheFlag enum as third argument to AddCallback will automatically make the provided function only run for the given cache flag. An alternative is checking if provided CacheFlag is equal to CacheFlag of 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 & CacheFlage.CACHE_SPEED == CacheFlage.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 operation as such effectively checks if a given bit is contained in the bit field, rather than checking if only the given bit is enabled. Thus allowing the condition to pass, even if other cache flags were also enabled in the bit field.
However, game always invokes the MC_EVALUATE_CACHE callback for each flag indiviudally, even if they're evaluated at the same time, which makes direct comparison also work perfectly fine in context of this particular callback.
Triggering cache evaluation⚓︎
When defining an item in items.xml file, one of the values that can be set is the item's cache
value. Multiple cache values can also be set, separated by a space.
Those are the stats the item is supposed to modify, and will be reevaluated whenever an item is obtained or lost (if it's a passive item or passivecache
is set to true), or when the item is used (if it's an active item).
Therefore, attaching a simple stat modifier to a passive item requires only setting the correct caches in items.xml and attaching a function to cache evaluation in order to apply the modifiers.
This however, isn't enough for every situation. Imagine an item working similarly to Money=Power, which applies a stat modifier dependent on external conditions (in this example, amount of coins the player has). This would require reevaluating stats not only when the item is picked up, but also whenever the coin count changes.
For such situations, API allows triggering stat evaluations at any moment using AddCacheFlags and EvaluateItems functions of EntityPlayer. As names might suggest, first of those functions specifies stats to be recalculated during next evaluation, while the second function actually triggers said evaluation.
Repentogon AddCacheFlags QoL
Repentogon adds an optional argument to AddCacheFlags, which can automatically trigger EvaluateItems right afterwards.
Applying stat modifiers outside of cache callback⚓︎
As mentioned before, stats are reset completely when evaluated. This combined with stat evaluation frequency being very inconsistent (that is, depending on situation they might either not happen for minutes or trigger multiple times every second) makes any changes not synced with them very inconsistent as well. Any conditional stat change should be done by storing state of that change one way or another (e.g. using CollectibleEffects or GetData) and then triggering a stat evaluation to apply it, with the stat modification itself being hooked to 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 soft Tears cap) or be apllied before specific vanilla multipliers (such as Soy Milk's damage decrease).
Unfortunately, API only allows interaction with the final stat values, thus consistency with vanilla is not possible without recreating the stat system.
Example code⚓︎
The following showcases how to work with stat cache 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 |
|