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.
Goal Description
This proposes allowing multiple
EffectStacksto 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:
MovementBoostwith an Intensity of4010Intensity and sets the Duration to8secondsWith effect stacking:
MovementBooststack with an intensity of4010and a duration of8secondsThis means temporary effects no longer have to overwrite persistent effects.
Plugin use case
Consider two plugins:
10Movement Boost while a condition is active5Movement Boost, because of a special roleWith the current system, both plugins have to repeatedly set the same effect and can end up fighting over its value.
With effect stacking:
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
EffectStackclass representing one independent contribution to an effect.This supports both static and dynamic stacks.
For example, a plugin could create a normal static stack:3
or a dynamically calculated stack:
new EffectStack(() => IsRunning? (byte)10 : (byte)0);CanBeRemovedwould 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.MaxIntensityEach
EffectStackhas its ownMaxIntensity.MaxIntensitydefaults to255.StatusEffectBase'sMaxIntensityremains the hard upper limit for the final effect intensity.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)
StatusEffectBasecould keep track of its active stacks and calculate the final intensity from them.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:
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 suppliedAction/Funcinside atry/catchand returns the default value if the callback throws.Example
A plugin could keep a reference to its own stack:
Later, the plugin can remove only its own contribution:
Other stacks affecting
MovementBoostwould 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:
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: