Using stacks to apply WalkSpeed/Stun in combat games?

TL;DR: What’s common practice for allowing multiple abilities to change properties like WalkSpeed or add temporary states like ‘Stun’ at the same time without interfering with one another?

In combat games its common to use tags, attributes, or single value instances to apply states such as ‘Stun’. The server binds logic to these states, ex. Stun applied → WalkSpeed = 0

If you were to try and apply a tag named ‘Stun’ from two places at once…
Ex. PlayerA and PlayerB both attack PlayerC and stun them at the same time-
The state’s logic collapses! You visibly see WalkSpeed get overridden.

If you wanted to modify WalkSpeed in multiple ability scripts you’d have to use a stack to store every value, then have the game take the average of all the modifiers whenever a modify is added or removed, THEN set the property using that evaluated average.
(For other properties you could do sum, min, max, oldest, most recent, etc.)

It makes sense to have a stack for properties like WalkSpeed, AND a stack for higher-level states like ‘Stun’.

This makes sense, especially if you want to be able to modify properties directly from multiple abilities (ex. use an ability to slow a player down, use an ability to speed yourself up) at the same time OR modify them through high-level states. Regardless, the same problem persists whether or not you’re modifying WalkSpeed or applying a temporary state like ‘Stun’- BOTH need stacks rather than tags/attributes.

My question is…

What is standard practice for wrapping properties like WalkSpeed and tags like ‘Stun’ so that you can modify/apply them from multiple abilities at the same time?

I thought about designing a module that lets me pass values into a stack created for an instance, but I don’t know if there’s a more intuitive design. What would you do?

Ex. States use various methods:

-- Methods
state:set(name: string, value: any) -- set a state's value
state:get(name: string): any? -- gets a states stored value
state:add(name: string, value: any): number -- add a value to a state's stack, returns an id (timestamp)
state:remove(name: string, timestamp: number) -- removes a value from a state's stack given a timestamp id
state:wait(name: string, value: any?, timeout: number?): boolean -- yields for a state's stored value to equal a value
-- returns true/false depending on if the value was reaches in the given (optional) timeout duration

-- Events
state.changed(name: string): RBXConnection -- listens for a state's stored value to change
state.added(name: string): (timestamp: number, value: any) -- fires when a value is added to a state's stack, takes a callback
state.removed(name: string) -- fires when a value is removed from a state's stack

Ex. Somewhere on the server:

local States = require(ServerStorage.States)
local character = workspace.PlayerA
local state = States(Character)

local function WalkSpeed() -- evaluate modifiers (take the average)
   if state:has("Stun") then state:set("WalkSpeed", 0) return end -- stun takes priority

   local sum, count = 0, 0
   state:foreach(function(timestamp, value: any)
      if typeof(value) ~= "number" then return end
      sum += 1
      count += value
   end)

   state:set("WalkSpeed", if count > 0 then sum else 16)
end
state.Added("WalkSpeed"):Connect(WalkSpeed)
state.Removed("WalkSpeed"):Connect(WalkSpeed)

--

state.changed("WalkSpeed"):Connect(function() -- set walkspeed from evaluated value
   character.Humanoid.WalkSpeed = state:get("WalkSpeed") or 16
end)

Ex. In your ability scipts:

state:add("WalkSpeed", 0) -- Walkspeed -> 0
state:add("WalkSpeed", 10) -- Walkspeed -> 5
state:add("WalkSpeed", 35) -- WalkSpeed -> 15

local timestamp: number = state:add("Stun") -- Walkspeed -> 0
task.wait(5)
state:remove("Stun", timestamp) -- Walkspeed -> 15

With this logic, you can see that modifying walkspeed in your abilities now averages out between multiple abilities, it also gets overriden by a higher-level state like stun. This allows us to write our state logic in one organized script on the server for every property/state.

An approach like this works, but its doesn’t seem as memorable or intuitive as I had hoped it could be.
You also unfortunately can’t see whats going on under the hood like you can with tags/attributes.

The problem you’re looking at is called race condition, and it pops up literally everywhere that involves computers and logistics. The way I handle it is to always have a base value whenever you need to stack changes on top of it. When the value needs to change again (adding/removing a stack), you start over and reevaluate it from the base. It’s tedious math, but it’s the only way to guarantee that race conditions do not happen, since there is a streamlined process for how the final value gets calculated.

Another problem you might run into is differentiating between additive and multiplicative stacks; one state might give you a flat +20 walkspeed, and another state might give you +10% walkspeed. The order of operations here are important, and the convention is to prioritize additive stacks first, and then multiplicative stacks come after.

You can also take inspiration from how Minecraft handles potion effects if you stack identical effects. Only the highest level stack is processed, but you still keep track of the superceded stacks. When the highest stack expires, you move onto the next highest stack that hasn’t expired yet.

Below is a snippet explaining how my project handles state changes:

export type ModifierType = {
	Name: string,
	DisplayName: string | (lvl: number) -> string,
	Description: string | (lvl: number) -> string,
	Flavor: Color3?, --color of the shape frame to adorn the displayed modifier; use red for debuffs and green for buffs
	Priority: number?,
	Hidden: boolean?, --if true, this modifier will not be replicated/shown to the player
	HiddenLevel: boolean?, --the level of the modifier will not be displayed (so as to use custom formatting instead)

	--[[
		Highest priority modifiers are evaluated first.
		By convention, multiplicative modifiers have NEGATIVE priority so they are always evaluated last.
		Identical modifiers can stack if they have different levels. Only the highest level one is evaluated.
		Modifiers expire when their expiry date is reached, expressed via workspace:GetServerTimeNow().
		
		When Eval() is called, the player's pstat and the level of the modifier is given.
		Pstat is passed by reference and is directly edited by the function.
		Eval() should NOT modify volatile pstats; that job should go to the OnProc() function.
	]]
	Eval: ((ps: psu.EntryData, lvl: number) -> ())?,

	--[[
		Optional callback when this modifier changes.
		Passed pstats might be missing at the time of call; check for its existence before using.
		"toggle" is a boolean iff the modifier was FIRST enabled or got disabled. It is nil otherwise.
		"upgrade" is a boolean iff the modifier changed levels; true/false if it leveled up/down. It is nil otherwise.
		"lvl" is the current level of the modifier at the time of the callback. It is nil if "toggle" is strictly false.
		"toggle" and "upgrade" are exclusive; only one of them is a boolean and the other must be nil.
		OnProc() will never be called if both "toggle" and "upgrade" is nil, since it means zero change.
		
		The difference between Eval() and OnProc() is that OnProc() is called strictly when the modifier changes,
		whereas Eval() is called arbitrarily whenever the API needs to update a Humanoid for whatever reason.
		
		The most common use-case of OnProc() is to provide instant buffs to the player when the modifier was added.
		For example, rage buff will always reset all combat stats whenever it is first added OR if it got upgraded.
	]]
	OnProc: ((hum: Humanoid, ps: psu.EntryData?, toggle: boolean?, upgrade: boolean?, lvl: number?) -> ())?,

	--[[
		For convenience; whenever this modifier is toggled, this VFX will be added/removed from the HRP of the Humanoid.
		The VFX will use a specific naming scheme so it can be detected and managed.
		When the modifier toggles off, the VFX will be removed.
		If VFXFadeTime is specified, it will fade out the VFX and schedule its deletion.
		Otherwise, the VFX will be immediately deleted.
		
		Alternatively, you can use the OnProc() callback to manually control VFX by checking for the "toggle" parameter.
		Create VFX if toggle == true, delete if toggle == false, do nothing otherwise.
	]]
	VFX: BasePart? | Attachment?,
	VFXFadeTime: number?,
}

export type Modifier = {
	self: ModifierType,

	--[[
		Table of expiration dates to stack modifier levels.
		Key is level, value is the expiration date as workspace:GetServerTimeNow()
		Only the highest level that's not yet expired will be used for evaluation.
	]]
	Expiry: {[number]: number?}, --stacked modifiers; 

	--flags to track stuff
	OnProcStarted: boolean?, --track OnProc() "toggle" param
	OnProcLastLevel: number?, --track OnProc() "upgrade" param
	VFXCalled: boolean?, --track API.ToggleVFX()
}

What you’re describing is the standard approach people are using.

Some things id change are maybe a more explicit API rather than the generic “add” and using handles instead of timestamps so IDs dont collide on the same frame