Skip to content

[IDEA] Effect Stacking #451

Description

@xaze1

Goal Description

This proposes allowing multiple EffectStacks to contribute to the Intensity of the same effect.
The goal is to prevent temporary effects, items, roles, and plugins from overriding each other's Effect Intensity and Duration.

Example

Game use case

With the current effect system:

  • Player A gives Player B MovementBoost with an Intensity of 40
  • Player B eats a Yellow Candy, which adds 10 Intensity and sets the Duration to 8 seconds
  • After 8s, MovementBoost expires and is set to 0

The original 40 Intensity is lost, because the temporary effect overwrote the existing effect state.

With effect stacking:

  • Player A gives Player B a persistent MovementBoost stack with an intensity of 40
  • Player B eats a Yellow Candy, creating another stack with an intensity of 10 and a duration of 8 seconds
  • After 8s, the Yellow Candy stack expires and is removed from the Stack List

Player B now has 40 Movement Boost

This means temporary effects no longer have to overwrite persistent effects.

Plugin use case

Consider two plugins:

  • Plugin A gives a player a custom effect that provides 10 Movement Boost while a condition is active
  • Plugin B gives the same player 5 Movement Boost, because of a special role

With the current system, both plugins have to repeatedly set the same effect and can end up fighting over its value.

With effect stacking:

  • Plugin A owns one Movement Boost stack
  • Plugin B owns another Movement Boost stack

The final effect intensity is calculated by accumulating the active stacks in ascending MaxIntensity order, while respecting both each stack's limit and the effect's hard MaxIntensity limit.

When Plugin A's condition ends, Plugin A removes its own stack. Plugin B's stack remains unaffected.

This means plugins do not need to know about or restore the state created by other plugins.

Proposed API (Conceptual)

Introduce an EffectStack class representing one independent contribution to an effect.

public class EffectStack()
{
    public bool IsActive => Duration == 0f || TimeLeft > 0f;

    public byte MaxIntensity { get; set; } = byte.MaxValue;

    public int Intensity
    {
        get
        {
            if (_intensityCalc == null)
                return field;

            return _intensityCalc.InvokeSafely();
        }
        set;
    } = 1;

    public float Duration
    {
        get;
        set
        {
            field = value;
            TimeLeft = value;
        }
    } = 0;

    public float TimeLeft
    {
        get;
        set => field = Mathf.Max(0f, value);
    } = 0;

    public bool CanBeRemoved
    {
        get
        {
            if (_canBeRemovedCalc == null)
                return field;

            return _canBeRemovedCalc.InvokeSafely();
        }
        set;
    } = true;
    [CanBeNull] private readonly Func<int> _intensityCalc;
    [CanBeNull] private readonly Func<bool> _canBeRemovedCalc;

    public void RefreshTime(float deltaTime)
    {
        if (Duration == 0f)
            return;
        
        TimeLeft -= deltaTime;
    }

    public EffectStack([CanBeNull] Func<bool> canBeRemovedCalc = null) : this()
    {
        _canBeRemovedCalc = canBeRemovedCalc;
    }

    public EffectStack(Func<int> intensityCalc, [CanBeNull] Func<bool> canBeRemovedCalc = null) : this()
    {
        _intensityCalc = intensityCalc;
        _canBeRemovedCalc = canBeRemovedCalc;
    }
}

This supports both static and dynamic stacks.
For example, a plugin could create a normal static stack:3

new EffectStack 
{
    Intensity = 5
}

or a dynamically calculated stack:
new EffectStack(() => IsRunning? (byte)10 : (byte)0);
CanBeRemoved would allow certain stacks to survive calls that normally clear the effect. This can be useful for persistent contributions originating from custom roles, items, or plugins that should remain active until their owner explicitly removes them.

EffectStacks are not tied to a single effect and may be added to multiple effects. Removing a stack from one effect does not remove it from any other effect using the same stack.

MaxIntensity

Each EffectStack has its own MaxIntensity.

  • If no limit is specified, MaxIntensity defaults to 255.
  • The StatusEffectBase's MaxIntensity remains the hard upper limit for the final effect intensity.
  • Stacks are sorted by MaxIntensity, from lowest to highest, before their intensities are accumulated.

This allows stacks with smaller limits to contribute first, while stacks with no specific limit can contribute as much of the remaining intensity as possible, up to the effect's own MaxIntensity.

StatusEffectBase (Conceptual implementation)

StatusEffectBase could keep track of its active stacks and calculate the final intensity from them.

public abstract class StatusEffectBase : MonoBehaviour, IEquatable<StatusEffectBase>
{
    public List<EffectStack> Stacks { get; } = new();

    // Make the setter private so external code cannot directly override the calculated intensity.
    public byte Intensity
    {
      get => _intensity;
      private set
      {
        if (value > _intensity && !AllowEnabling)
          return;
        ForceIntensity(value);
      }
    }

    public bool IsEnabled
    {
      get => Intensity > 0;
      set
      {
        if (value == IsEnabled)
          return;
        if (value)
            ServerSetState(1);
        else
            ServerDisable();
      }
    }

    public float Duration { get; private set; } // Remove
    public float TimeLeft { get; set; } // Remove

    /// <summary> 
    /// Updates the Intensity of the Effect. 
    /// 
    /// Example: 
    /// Stacks = [ 
    ///     { Intensity = 10 }, 
    ///     { Intensity = 15, MaxIntensity = 20 }, 
    ///     { Intensity = 5 } 
    /// ] 
    /// 
    /// Intensity outcome: 10 -> 20 -> 25 
    ///</summary>
    private void UpdateIntensity()
    {
        int intensity = 0;
        Stacks.Sort((a, b) => a.MaxIntensity.CompareTo(b.MaxIntensity));
        foreach (var stack in Stacks)
        {
            if (stack.IsActive)
                intensity = Mathf.Min(intensity + stack.Intensity, Mathf.Min(stack.MaxIntensity, MaxIntensity));
        }
    
        intensity = Mathf.Max(intensity, 0);
        if (Intensity == intensity)
            return;

        Intensity = (byte)intensity;
    }

    [Server]
    public void ServerAddStack(EffectStack stack)
    {
        if (Stacks.Contains(stack))
            return;

        Stacks.Add(stack);
        UpdateIntensity();
    }

    [Server]
    public bool ServerRemoveStack(EffectStack stack)
    {
        var outcome = Stacks.Remove(stack);
        UpdateIntensity();
        return outcome;
    }

    [Server]
    public void ServerSetState(byte intensity, float duration = 0f)
    {
        if (!NetworkServer.active)
            return;

        DisableEffect();
        ServerAddStack(new EffectStack { Intensity = intensity, Duration = duration });
    }
  
     // Remove
     public void ServerChangeDuration(float duration, bool addDuration = false)
  
    // Make private
    private void ForceIntensity(byte newIntensity)

    // Replace StatusEffectBase.RefreshTime() with this in StatusEffectBase.Update()
    private void UpdateStacks()
    {
        for (int i = Stacks.Count - 1; i >= 0; i--)
        {
            var stack = Stacks[i];
            stack.RefreshTime(Time.deltaTime);
            
            if (stack.Duration == 0 || stack.TimeLeft > 0 || !stack.CanBeRemoved)
                continue;
            Stacks.RemoveAt(i);
        }
        
        UpdateIntensity();
    }

    // Gives back the return value of DisableEffect
    // If not connected to a Server, returns false
    [Server]
    public bool ServerDisable()

    protected virtual bool DisableEffect()
    {
        if (!NetworkServer.active || Stacks.Count == 0)
            return false;

        var hasLockedStacks = false;
        for (int i = Stacks.Count - 1; i >= 0; i--)
        {
            if (Stacks[i].CanBeRemoved)
                Stacks.RemoveAt(i);
            else
                hasLockedStacks = true;
        }
    
        if (hasLockedStacks)
        {
            UpdateIntensity();
            return false;
        }
        
        Intensity = 0;
        return true;
    }
}

The existing effect-level Duration and TimeLeft could then be replaced by the duration of individual stacks.

PlayerEffectsController (Conceptual API)

Add overloads which allow plugins to work with individual stacks:

public class  PlayerEffectsController : NetworkBehaviour
{
    [Server]
    public void EnableEffect<T>(EffectStack stack) where T : StatusEffectBase
    {
        if (NetworkServer.active)
        {
            if (!TryGetEffect<T>(out var effect)) 
              return;

            effect.ServerAddStack(stack);
            return;
        }
        Debug.LogWarning((object) "[Server] function 'T PlayerEffectsController::EnableEffect(System.Single,System.Boolean)' called when server was not active");
        return;
    }

    // Add return value of bool
    [Server]
    public bool DisableEffect<T>() where T : StatusEffectBase
    {
        if (NetworkServer.active)
        {
          if (!TryGetEffect<T>(out var effect) || !IsEnabled) 
              return false;
          
          return effect.ServerDisable();
        }
        Debug.LogWarning((object) "[Server] function 'T PlayerEffectsController::DisableEffect()' called when server was not active");
        return false;
    }

    // Overload method to remove a specific EffectStack
    [Server]
    public bool DisableEffect<T>(EffectStack stack) where T : StatusEffectBase
    {
        if (NetworkServer.active)
        {
          if (!TryGetEffect<T>(out var effect))
              return false;
          
          return effect.ServerRemoveStack(stack);
        }
        Debug.LogWarning((object) "[Server] function 'T PlayerEffectsController::DisableEffect()' called when server was not active");
        return false;
    }
}

The stack overload would let the creator of a stack remove exactly the contribution it owns without affecting other plugins or game systems.

Notes

With the proposed design, clients would continue using the synced Intensity, while the server uses Stacks to calculate and update that value.
This should keep the network-visible behavior relatively simple while moving the composition logic to the server.

I believe this could be implemented without extensive changes to the existing effect system.

As a possible secondary benefit, this architecture may also make server-side custom effects easier to implement, although that is not the primary goal of this proposal.

Additional implementation detail

  • InvokeSafely() calls the supplied Action/Func inside a try/catch and returns the default value if the callback throws.

Example

A plugin could keep a reference to its own stack:

private EffectStack _movementStack;
_movementStack = new EffectStack
{
    Intensity = 10
};
playerEffects.EnableEffect<MovementBoostEffect>(_movementStack);

Later, the plugin can remove only its own contribution:

playerEffects.DisableEffect<MovementBoostEffect>(_movementStack);

Other stacks affecting MovementBoost would remain untouched.

Expected behavior

The important semantic change would be:

Current behavior
Setting an effect changes the single shared intensity/duration of that effect.

Proposed behavior
Each source can contribute its own stack. The status effect derives its final intensity from all active stacks.

For additive effects:

Effect Intensity =
    Stack A Intensity
  + Stack B Intensity
  + Stack C Intensity

When one stack expires or is removed, only that stack's contribution disappears.

Benefits

This would make effects significantly easier for plugin developers to compose because plugins would no longer need to constantly re-apply values to prevent other systems from overwriting them.

It would also provide cleaner ownership:

  • A plugin owns the stacks it creates.
  • Temporary effects can expire independently.
  • Persistent effects can survive normal effect clearing.
  • Multiple independent systems can contribute to the same effect.
  • Removing one contribution does not require knowing or restoring the state created by another system.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ideaThis is an idea to doimportedImported to internal git

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions