Custom Calculators in Forge enable developers to implement complex, dynamic calculations for effect modifiers. These calculators provide a powerful way to create game-specific logic that goes beyond the built-in modifier types.
At the foundation of the system is the CustomCalculator abstract class:
public abstract class CustomCalculator
{
public List<AttributeCaptureDefinition> AttributesToCapture { get; } = [];
public Dictionary<StringKey, object> CustomCueParameters { get; } = [];
protected static int CaptureAttributeMagnitude(
AttributeCaptureDefinition capturedAttribute,
Effect effect,
IForgeEntity? target,
EffectEvaluatedData? effectEvaluatedData,
AttributeCalculationType calculationType = AttributeCalculationType.CurrentValue,
int finalChannel = 0);
// Additional implementation...
}This base class provides:
- Management of attribute captures for accessing attribute values during calculations.
- Custom parameters that can be passed to cues when effects are applied.
- Helper methods for retrieving attribute values from targets or sources.
Forge offers two primary calculator types that inherit from CustomCalculator:
- CustomModifierMagnitudeCalculator: For modifying a single attribute with complex logic. Returns a single float value.
- CustomExecution: For modifying multiple attributes in a coordinated way. Returns an array of
ModifierEvaluatedData.
Most custom calculator methods, such as CalculateBaseMagnitude and EvaluateExecution, take an EffectEvaluatedData? effectEvaluatedData parameter. This object encapsulates all the computed and contextual data for an effect application at that moment.
Why is EffectEvaluatedData important?
- It caches and provides all the results and inputs computed for the current effect application, including attribute values, modifiers, levels, stacks, durations, and more.
- It is the central place for storing and re-accessing the current state of the applied effect, which is crucial for accurate and consistent logic when recalculating due to non-snapshot attribute dependencies or stack/level changes.
- When any non-snapshot property (like a non-snapshotted AttributeCaptureDefinition) changes in the game, Forge will re-evaluate the effect and update its
EffectEvaluatedDataaccordingly.
Pass effectEvaluatedData to all helper and custom methods that need contextual evaluation data, so they always use the freshest relevant information and respect the proper state and dependencies of the effect system. This ensures correct operation and optimal performance in both realtime and turn-based games.
The AttributeCaptureDefinition struct is central to retrieving attribute values from the Attributes system:
public readonly struct AttributeCaptureDefinition(
StringKey attribute,
AttributeCaptureSource source,
bool snapshot = true)
{
public StringKey Attribute { get; }
public AttributeCaptureSource Source { get; }
public bool Snapshot { get; }
public readonly bool TryGetAttribute(IForgeEntity? source, [NotNullWhen(true)] out EntityAttribute? attribute);
}Parameters:
attribute: The key of the attribute to capture (e.g., "CombatAttributeSet.CurrentHealth").source: Where to capture from—eitherAttributeCaptureSource.Source(the effect owner) orAttributeCaptureSource.Target(the effect target).snapshot: If true, captures the value once when the effect is applied; if false, continuously updates when the source attribute changes.
To use attribute capture properly:
- Define attribute capture definitions as properties in your calculator class.
- Register them in the
AttributesToCapturelist in the constructor. - Use
CaptureAttributeMagnitudeto safely retrieve values during calculation.
// Example of proper attribute capture setup
public class MyCalculator : CustomModifierMagnitudeCalculator
{
// 1. Define attribute captures as properties
public AttributeCaptureDefinition SourceHealth { get; }
public AttributeCaptureDefinition TargetArmor { get; }
public MyCalculator()
{
// 2. Initialize attribute definitions
SourceHealth = new AttributeCaptureDefinition(
"CombatAttributeSet.CurrentHealth",
AttributeCaptureSource.Source,
snapshot: false);
TargetArmor = new AttributeCaptureDefinition(
"DefenseAttributeSet.Armor",
AttributeCaptureSource.Target,
snapshot: true);
// 3. Register them for capture
AttributesToCapture.Add(SourceHealth);
AttributesToCapture.Add(TargetArmor);
}
public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData)
{
// 4. Use CaptureAttributeMagnitude to safely get values
int health = CaptureAttributeMagnitude(SourceHealth, effect, target, effectEvaluatedData);
int armor = CaptureAttributeMagnitude(TargetArmor, effect, target, effectEvaluatedData);
// Your calculation logic...
return health * (1.0f - (armor / 200.0f));
}
}The CaptureAttributeMagnitude method:
- Safely retrieves attribute values based on the capture definition.
- Returns 0 if the attribute doesn't exist (avoids null reference exceptions).
- Handles attribute lookup from the correct entity (source or target).
- Supports various calculation types to access different attribute properties:
AttributeCalculationType.CurrentValue: Gets the current total value of the attribute (default).AttributeCalculationType.BaseValue: Gets only the base value without modifiers.AttributeCalculationType.Modifier: Gets the total modifier value applied to the attribute.AttributeCalculationType.Overflow: Gets the overflow value (amount exceeding min/max bounds).AttributeCalculationType.ValidModifier: Gets the effective modifier value that isn't causing overflow.AttributeCalculationType.Min: Gets the attribute's minimum value.AttributeCalculationType.Max: Gets the attribute's maximum value.AttributeCalculationType.MagnitudeEvaluatedUpToChannel: Gets the value calculated up to a specific channel (requires finalChannel parameter).
Example with a specific calculation type:
// Get the valid modifier value (total modifier without overflow)
int validModifier = CaptureAttributeMagnitude(
StrengthAttribute,
effect,
target,
effectEvaluatedData,
AttributeCalculationType.ValidModifier);
// Get magnitude calculated up to a specific channel
int channelMagnitude = CaptureAttributeMagnitude(
StrengthAttribute,
effect,
target,
effectEvaluatedData,
AttributeCalculationType.MagnitudeEvaluatedUpToChannel,
finalChannel: 2);Even when capturing with snapshot, it's recommended to follow this pattern to ensure consistent and safe attribute access.
The ModifierEvaluatedData struct represents the complete data needed to apply a modifier to an attribute:
public readonly struct ModifierEvaluatedData
{
public EntityAttribute Attribute { get; }
public ModifierOperation ModifierOperation { get; }
public float Magnitude { get; }
public int Channel { get; }
public AttributeOverride? AttributeOverride { get; }
public ModifierEvaluatedData(
EntityAttribute attribute,
ModifierOperation modifierOperation,
float magnitude,
int channel)
{
// Implementation...
}
}This struct contains:
- Attribute: The target
EntityAttributeto be modified. - ModifierOperation: The operation type (
FlatBonus,PercentBonus, orOverride). - Magnitude: The calculated value to be applied.
- Channel: The attribute channel to apply the modifier to.
- AttributeOverride: Special override data (only used with
ModifierOperation.Override).
ModifierEvaluatedData is particularly important for CustomExecution implementers, as you'll be creating these objects directly to specify what attributes to modify and how.
To create a ModifierEvaluatedData instance in your custom execution:
// Direct creation of ModifierEvaluatedData
new ModifierEvaluatedData(
attributeInstance, // An EntityAttribute instance
ModifierOperation.FlatBonus,
calculatedValue, // Your calculated float value
0 // Channel to apply to
)The CustomCueParameters dictionary allows calculators to pass additional data to the Cues system:
public Dictionary<StringKey, object> CustomCueParameters { get; } = [];Custom cue parameters enable:
- Passing calculation results to visual/audio effects.
- Providing context about how the calculation was performed.
- Enabling dynamic cue behavior based on calculator results.
Usage:
- Add parameters in the constructor for default values.
- Update parameters during calculation with dynamic values.
- The parameters are automatically passed to the cues system.
public class DamageCalculator : CustomModifierMagnitudeCalculator
{
public AttributeCaptureDefinition AttackerStrength { get; }
public DamageCalculator()
{
AttackerStrength = new AttributeCaptureDefinition(
"StatAttributeSet.Strength",
AttributeCaptureSource.Source,
snapshot: true);
AttributesToCapture.Add(AttackerStrength);
// Set default cue parameters
CustomCueParameters.Add("cues.damage.type", "physical");
CustomCueParameters.Add("cues.attack.critical", false);
CustomCueParameters.Add("cues.damage.amount", 0);
}
public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData)
{
int strength = CaptureAttributeMagnitude(AttackerStrength, effect, target, effectEvaluatedData);
// Check for critical hit
bool isCritical = new Random().NextDouble() < 0.2;
float damage = strength * (isCritical ? 2.0f : 1.0f);
// Update cue parameters with dynamic values
CustomCueParameters["cues.attack.critical"] = isCritical;
CustomCueParameters["cues.damage.amount"] = damage;
return -damage; // Negative for damage
}
}These parameters can then be used by the cues system to spawn appropriate visual effects, play sounds, or trigger animations based on the calculation results.
Use this calculator type when you need a single modifier value that:
- Depends on multiple source attributes.
- Requires complex game-specific logic.
- Needs access to additional game state information.
The key distinction is that CustomModifierMagnitudeCalculator only returns a single float value, which is used to modify a single attribute specified in the effect's modifier.
To create a custom magnitude calculator:
public class MyDamageCalculator : CustomModifierMagnitudeCalculator
{
// Define which attributes to capture
public AttributeCaptureDefinition StrengthAttribute { get; }
public AttributeCaptureDefinition AgilityAttribute { get; }
public MyDamageCalculator()
{
// Capture source's Strength and Agility attributes (non-snapshot, will update)
StrengthAttribute = new AttributeCaptureDefinition(
"StatAttributeSet.Strength",
AttributeCaptureSource.Source,
snapshot: false);
AgilityAttribute = new AttributeCaptureDefinition(
"StatAttributeSet.Agility",
AttributeCaptureSource.Source,
snapshot: false);
// Register attributes for capture
AttributesToCapture.Add(StrengthAttribute);
AttributesToCapture.Add(AgilityAttribute);
// Add custom parameters for cues
CustomCueParameters.Add("cues.damage.type", "physical");
}
public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData)
{
// Get attribute values
int strength = CaptureAttributeMagnitude(StrengthAttribute, effect, target, effectEvaluatedData);
int agility = CaptureAttributeMagnitude(AgilityAttribute, effect, target, effectEvaluatedData);
// Custom calculation logic
float baseDamage = strength * 0.7f + agility * 0.3f;
// Add game-specific conditions
if (target.Tags.CombinedTags.HasTag("status.vulnerable"))
{
baseDamage *= 1.5f;
}
return baseDamage;
}
}Once defined, you can use your custom calculator in a modifier:
// Create an effect that applies calculated damage
var damageEffect = new EffectData(
"Physical Attack",
new DurationData(DurationType.Instant),
new[] {
new Modifier(
"CombatAttributeSet.CurrentHealth",
ModifierOperation.FlatBonus,
new ModifierMagnitude(
MagnitudeCalculationType.CustomCalculatorClass,
customCalculationBasedFloat: new CustomCalculationBasedFloat(
new MyDamageCalculator(), // Your custom calculator
new ScalableFloat(1.0f), // Coefficient
new ScalableFloat(0.0f), // PreMultiply additive
new ScalableFloat(-5.0f) // PostMultiply additive (negative for damage)
)
)
)
}
);When a CustomModifierMagnitudeCalculator is used:
- The effect system gathers all attributes specified in
AttributesToCapture. - When the effect is applied,
CalculateBaseMagnitude()is called. - The returned value is processed through the formula:
finalValue = (coefficient * (calculatedMagnitude + preMultiply)) + postMultiply - If a lookup curve is provided, the value is mapped through that curve.
- The final value is used as the magnitude for the modifier.
Use this calculator type when you need to:
- Modify multiple attributes with a single calculation.
- Create coordinated changes across different attributes.
- Implement complex game systems like combo effects or resource conversions.
Unlike CustomModifierMagnitudeCalculator, which returns a single float value to modify one attribute, CustomExecution returns an array of ModifierEvaluatedData objects that can modify multiple attributes with different operations and magnitudes. It can also modify attributes from different entities simultaneously (both the target and the source).
To create a custom execution calculator:
public class ManaDrainExecution : CustomExecution
{
// Define attributes to capture and modify
public AttributeCaptureDefinition TargetCurrentMana { get; }
public AttributeCaptureDefinition TargetMagicResist { get; }
public AttributeCaptureDefinition SourceIntelligence { get; }
public AttributeCaptureDefinition SourceCurrentMana { get; }
public ManaDrainExecution()
{
// Capture target mana and magic resistance
TargetCurrentMana = new AttributeCaptureDefinition(
"ResourceAttributeSet.CurrentMana",
AttributeCaptureSource.Target,
snapshot: false);
TargetMagicResist = new AttributeCaptureDefinition(
"ResistAttributeSet.MagicResistance",
AttributeCaptureSource.Target,
snapshot: false);
// Capture source's intelligence and mana
SourceIntelligence = new AttributeCaptureDefinition(
"StatAttributeSet.Intelligence",
AttributeCaptureSource.Source,
snapshot: false);
SourceCurrentMana = new AttributeCaptureDefinition(
"ResourceAttributeSet.CurrentMana",
AttributeCaptureSource.Source,
snapshot: false);
// Register attributes for capture
AttributesToCapture.Add(TargetCurrentMana);
AttributesToCapture.Add(TargetMagicResist);
AttributesToCapture.Add(SourceIntelligence);
AttributesToCapture.Add(SourceCurrentMana);
// Add custom parameters for cues
CustomCueParameters.Add("cues.spell.drain_amount", 0f);
CustomCueParameters.Add("cues.spell.transfer_amount", 0f);
}
public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData)
{
var results = new List<ModifierEvaluatedData>();
// Get attribute values
int targetMana = CaptureAttributeMagnitude(TargetCurrentMana, effect, target, effectEvaluatedData);
int magicResist = CaptureAttributeMagnitude(TargetMagicResist, effect, target, effectEvaluatedData);
int intelligence = CaptureAttributeMagnitude(SourceIntelligence, effect, target, effectEvaluatedData);
// Calculate mana drain amount (reduced by magic resistance)
float resistFactor = 1.0f - (magicResist / 200.0f); // 200 resist = 100% reduction
float drainAmount = intelligence * 0.5f * resistFactor;
// Cap the drain at the target's available mana
drainAmount = Math.Min(drainAmount, targetMana);
// Apply mana reduction to target if attribute exists
if (TargetCurrentMana.TryGetAttribute(target, out EntityAttribute? targetManaAttribute))
{
results.Add(new ModifierEvaluatedData(
targetManaAttribute,
ModifierOperation.FlatBonus,
-drainAmount, // Negative for drain
channel: 0
));
}
// Apply mana gain to source if attribute exists
if (SourceCurrentMana.TryGetAttribute(effect.Ownership.Source, out EntityAttribute? sourceManaAttribute))
{
results.Add(new ModifierEvaluatedData(
sourceManaAttribute,
ModifierOperation.FlatBonus,
drainAmount * 0.8f, // Transfer 80% of drained mana
channel: 0
));
}
// Update custom cue parameters with calculated values
CustomCueParameters["cues.spell.drain_amount"] = drainAmount;
CustomCueParameters["cues.spell.transfer_amount"] = drainAmount * 0.8f;
return results.ToArray();
}
}Once defined, you can use your custom execution in an effect:
// Create an effect that applies the mana drain
var manaDrainEffect = new EffectData(
"Mana Drain",
new DurationData(DurationType.Instant),
customExecutions: new[] {
new ManaDrainExecution()
}
);When a CustomExecution is used:
- The effect system gathers all attributes specified in
AttributesToCapture. - When the effect is applied,
EvaluateExecution()is called. - The method returns an array of
ModifierEvaluatedDataobjects. - Each modifier is applied to its target attribute with its specified operation, magnitude, and channel.
- Any custom cue parameters are passed to the cues system.
Custom calculators can integrate with other systems in your game:
public class QuestDamageCalculator : CustomModifierMagnitudeCalculator
{
private readonly QuestManager _questManager;
public AttributeCaptureDefinition BaseDamage { get; }
public QuestDamageCalculator(QuestManager questManager)
{
_questManager = questManager;
BaseDamage = new AttributeCaptureDefinition(
"CombatAttributeSet.BaseDamage",
AttributeCaptureSource.Source,
snapshot: true);
AttributesToCapture.Add(BaseDamage);
CustomCueParameters.Add("cues.quest.target_bonus", false);
}
public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData)
{
float baseDamage = CaptureAttributeMagnitude(BaseDamage, effect, target, effectEvaluatedData);
// Check if target is related to an active quest
if (target is IQuestTarget questTarget && _questManager.IsTargetForActiveQuest(questTarget.QuestTargetId))
{
baseDamage *= 1.5f; // 50% bonus damage to quest targets
CustomCueParameters["cues.quest.target_bonus"] = true;
}
return -baseDamage; // Negative for damage
}
}Custom executions can read from and write to external game systems, making them a powerful bridge between Forge's effect system and the rest of your game logic:
public class ComboAttackExecution : CustomExecution
{
private readonly ComboSystem _comboSystem;
public AttributeCaptureDefinition TargetHealth { get; }
public AttributeCaptureDefinition AttackerStrength { get; }
public ComboAttackExecution(ComboSystem comboSystem)
{
_comboSystem = comboSystem;
TargetHealth = new AttributeCaptureDefinition(
"CombatAttributeSet.CurrentHealth",
AttributeCaptureSource.Target,
snapshot: false);
AttackerStrength = new AttributeCaptureDefinition(
"StatAttributeSet.Strength",
AttributeCaptureSource.Source,
snapshot: true);
AttributesToCapture.Add(TargetHealth);
AttributesToCapture.Add(AttackerStrength);
CustomCueParameters.Add("cues.combat.combo_count", 0);
CustomCueParameters.Add("cues.combat.combo_damage", 0f);
}
public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData)
{
var results = new List<ModifierEvaluatedData>();
int strength = CaptureAttributeMagnitude(AttackerStrength, effect, target, effectEvaluatedData);
int comboCount = _comboSystem.GetCurrentComboCount(effect.Ownership.Owner);
// Calculate combo damage
float baseDamage = strength * 0.8f;
float comboDamage = baseDamage * (1 + comboCount * 0.2f);
// Apply health damage
if (TargetHealth.TryGetAttribute(target, out EntityAttribute? healthAttribute))
{
results.Add(new ModifierEvaluatedData(
healthAttribute,
ModifierOperation.FlatBonus,
-comboDamage, // Negative for damage
channel: 0
));
}
// Add combo to cue parameters
CustomCueParameters["cues.combat.combo_count"] = comboCount;
CustomCueParameters["cues.combat.combo_damage"] = comboDamage;
// Increment combo counter
_comboSystem.IncrementCombo(effect.Ownership.Owner);
return results.ToArray();
}
}Custom executions have access to both the effect and target parameters, which means they can raise events on any entity's EventManager. This is especially powerful when combined with periodic effects: the custom execution runs on every tick, and can fire an event each time to trigger other systems like ability activation.
public class PeriodicDamageWithEventExecution : CustomExecution
{
private readonly Tag _damageEventTag;
public AttributeCaptureDefinition TargetHealth { get; }
public PeriodicDamageWithEventExecution(Tag damageEventTag)
{
_damageEventTag = damageEventTag;
TargetHealth = new AttributeCaptureDefinition(
"CombatAttributeSet.CurrentHealth",
AttributeCaptureSource.Target,
snapshot: false);
AttributesToCapture.Add(TargetHealth);
}
public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData)
{
var results = new List<ModifierEvaluatedData>();
float damage = 10f;
if (TargetHealth.TryGetAttribute(target, out EntityAttribute? healthAttribute))
{
results.Add(new ModifierEvaluatedData(
healthAttribute,
ModifierOperation.FlatBonus,
-damage));
}
// Fire an event on the target. This can trigger event-driven abilities.
target.Events.Raise(new EventData
{
EventTags = _damageEventTag.GetSingleTagContainer()!,
Source = effect.Ownership.Owner,
Target = target,
EventMagnitude = damage,
});
return results.ToArray();
}
}This pattern is the recommended way to bridge periodic effects with event-triggered abilities. For example, a poison-over-time effect can fire a "damage.poison.tick" event on each periodic tick, which in turn activates a "Poison Resistance" ability on the target via AbilityTriggerData.ForEvent(poisonTickTag).
Custom activation context enables you to pass dynamic, strongly-typed data to your custom calculator or execution logic when applying an effect. This is especially useful for effects that depend on user input, situational gameplay, or real-time information beyond static effect definition.
To provide context data, use the generic ApplyEffect<TData> method from the EffectsManager:
var hitLocationData = new HitLocationData(HitZone.Head);
entity.EffectsManager.ApplyEffect(effect, hitLocationData);Inside your CustomModifierMagnitudeCalculator or CustomExecution, retrieve the context data using effectEvaluatedData.TryGetContextData<T>(out T? data):
Suppose you want a damage effect that doubles its damage when applied as a headshot. You could define the following context struct:
public readonly record struct HitLocationData(HitZone Zone);
public enum HitZone { Head, Torso, Arm, Leg }And a custom calculator like this:
public class HeadshotDamageCalculator : CustomModifierMagnitudeCalculator
{
public AttributeCaptureDefinition SourceWeaponDamage { get; }
public HeadshotDamageCalculator()
{
SourceWeaponDamage = new AttributeCaptureDefinition(
"WeaponAttributeSet.Damage", AttributeCaptureSource.Source, snapshot: true);
AttributesToCapture.Add(SourceWeaponDamage);
}
public override float CalculateBaseMagnitude(
Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData)
{
int damage = CaptureAttributeMagnitude(SourceWeaponDamage, effect, target, effectEvaluatedData);
// Default is single damage
float finalDamage = damage;
// Check activation context for hit location
if (effectEvaluatedData?.TryGetContextData(out HitLocationData? hitLocation)
&& hitLocation.Zone == HitZone.Head)
{
finalDamage *= 2.0f; // Double damage for headshots
CustomCueParameters["cues.damage.headshot"] = true;
}
else
{
CustomCueParameters["cues.damage.headshot"] = false;
}
return -finalDamage; // Negative for damage
}
}When applying the effect, specify the hit location:
// Apply the effect as a headshot
entity.EffectsManager.ApplyEffect(effect, new HitLocationData(HitZone.Head));
// Apply the effect as a torso shot
entity.EffectsManager.ApplyEffect(effect, new HitLocationData(HitZone.Torso));This pattern ensures that your custom logic can access dynamic activation information whenever the effect is applied, enabling rich scenario-driven gameplay without muddying your effect definitions.
When debugging issues with custom calculators:
- Log Input Values: Verify that attribute captures return expected values.
- Check Attribute Names: Ensure attribute keys match exactly what's in your game.
- Test with Simple Formulas: Start with simple calculations and build up complexity.
- Verify Processing Order: Remember that snapshots happen at effect application time.
- Test Edge Cases: Handle null values, zero values, and extremely large/small values.
-
Use CustomCalculationBasedFloat when:
- You need to modify a single attribute.
- Your calculation depends on multiple other attributes.
- You want the standard modifier framework to handle application.
-
Use CustomExecution when:
- You need to modify multiple attributes at once.
- You need coordinated changes across different attributes.
- You want complete control over how modifiers are created and applied.
-
Snapshot vs Live Updates:
snapshot: true: Captures the attribute value once when the effect is applied.snapshot: false: Continuously updates when the source attribute changes.
-
Source vs Target:
- Choose
AttributeCaptureSource.Sourcefor values from the effect owner. - Choose
AttributeCaptureSource.Targetfor values from the effect target.
- Choose
-
Error Handling:
- Always handle cases where attributes might not exist.
- Use
TryGetAttribute()to safely access attributes.
- Minimize Attribute Dependencies: Each non-snapshot attribute creates a dependency that triggers recalculations.
- Optimize Calculations: Custom calculators can run frequently, so keep calculations efficient.
- Cache Complex Values: If your calculator performs expensive operations, consider caching results.
- Batch Related Changes: Use
CustomExecutionto apply multiple changes at once instead of multiple effects.