ModifierManager | A stat modifier system with type safety, stacking rules, and client sync

ModifierManager

A type-safe stat modifier system for Roblox games.

Manage buffs, debuffs, equipment bonuses, and any numeric stat modification with
stacking rules and client synchronization.

GitHub ¡ Documentation ¡ Wally ¡ Roblox Model ¡ Example Place


Features

  • Three modifier types: Additive, Multiplicative, and Override.
  • Four stacking rules: Stack, Replace, Highest, Refresh. This lets you control how different modifiers from the same source interact.
  • Automatic expiration: You can assign durations for modifiers, or no duration at all. Modifiers with durations clean themselves up efficiently.
  • Client-server sync: PlayerManager batches stat updates and syncs to clients automatically.
  • Two manager types: EntityManager for NPCs and objects, PlayerManager for players.
  • Tag system: Tag modifiers for bulk queries and removal ("buff", "debuff", "equipment").
  • Change signals: Subscribe to stat changes for UI updates or game logic.
  • Type-safe: Luau strict mode with exported types.

UI Example from the uncopylocked test place:


Quick Start

local ModifierManager = require(ReplicatedStorage.ModifierManager)
local playerStats = ModifierManager.PlayerManager.new()

playerStats:SetBase(player, "Combat.Health", 100)
playerStats:SetBase(player, "Movement.Speed", 16)

playerStats:AddModifier({
    player = player,
    path = "Movement.Speed",
    value = 1.5,
    type = "Multiplicative",
    source = "SpeedBoost",
    duration = 10,
})

local speed = playerStats:Get(player, "Movement.Speed") -- 24

On the client, a ClientStatReader receives synced stats and reacts to changes:

local reader = ModifierManager.ClientStatReader.new()

reader:OnChanged("Movement.Speed", function(newSpeed)
    humanoid.WalkSpeed = newSpeed
end)

See the Best Practices page for a full setup with data-driven config, sync wiring, and a movement system example.


Modifier Examples

Equipment That Replaces On Swap

playerStats:AddModifier({
    player = player,
    path = "Combat.Damage",
    value = 25,
    type = "Additive",
    source = "EquippedWeapon",
    stackingRule = "Replace", --auto-removes previous weapon bonus
    tags = { "equipment", "weapon" },
})

Stun Override

playerStats:AddModifier({
    player = player,
    path = "Movement.Speed",
    value = 0,
    type = "Override", --ignores all other speed modifiers
    source = "Stun",
    priority = 200,
    duration = 2,
    tags = { "debuff", "cc" },
})

Bulk Removal By Tag

--Cleanse: remove all debuffs from every stat at once
playerStats:RemoveAllByTag(player, "debuff")

How Calculation Works

Modifiers apply in a deterministic order:

1.  Base value                         100
2.  + Additive modifiers         100 + 20 + 10 = 130
3.  × Multiplicative modifiers   130 × 1.2 × 1.1 = 171.6
4.  Override (if present)         Uses highest-priority Override instead
5.  Clamps & rounding            Final bounds applied

This means a +20 Additive buff and a 1.5× Multiplicative buff always combine the same way regardless of what order they were added.


Stacking Rules

Control what happens when a modifier from the same source is applied again:

Rule What Happens Use Case
nil Accumulates freely Default, no source checking
"Stack" Accumulates (checks source) Rage stacks, poison ticks
"Replace" Removes old, adds new Equipment swaps, aura changes
"Highest" Keeps only the strongest Shield buffs, damage reduction
"Refresh" Updates value & resets timer Refreshable buffs on reapply

Choosing a Manager

Manager Key Type Side Use Case
EntityManager string Server NPCs, objects, world entities
PlayerManager Player Server Player stats with automatic client sync
ClientStatReader — Client Read-only access to synced stats

EntityManager and PlayerManager can only be created on the server. Use ClientStatReader on the client.


Installation

Wally

[dependencies]
ModifierManager = "estrogenie/modifier-manager@1.0.0"

Roblox Model

Get it from the Creator Store

GitHub

Clone or download from GitHub.


License

MIT, use it however you want.

Tags

modifiermanager, modifier, modifiers, stat, stats, buff, buffs, debuff, debuffs, stat system, stat manager,
modifier system, buff system, debuff system, stacking, stacking rules, additive, multiplicative, override,
equipment bonus, status effect, stat modification, stat modifier, player stats, entity stats, client sync,
server sync, replication, type-safe, luau, wally, open source, rpg, mmorpg, combat system, walkspeed, jump
power, health, damage, defense, tags, expiration, duration, temporary buff, permanent buff, crowd control,
stun, slow, speed boost, clamp, rounding, signals, onchanged, modifier manager roblox, roblox module, roblox
library, community resource

18 Likes

What happened to just

local speed = movementSpeed * 1.5

Are we for real?

What is this, Tiktok??


I also don’t get how the client-server works? Do you make everything on server and then the client just gets… remotes?? What’s the point of that? Why not just do it on the client for best player experience?

2 Likes

your questions are fair.

if you only ever have very few stats to modify then yeah movementSpeed * 1.5 is all you need and you should definitely do that.

This library exists for when your game has dozens-hundreds of stats being affected by dozens-hundreds of sources simultaneously. However, a simple movementSpeed * 1.5 doesn’t handle:

  • A speed potion (+50%), heavy armor (x0.8) and a stun (sets speed to 0) are all active at once. what’s the final speed? What happens when the stun expires in 2 seconds? What about when the potion expires 8 seconds later? Which modifiers do you remove and are you recalculating correctly each time?

  • An ability cleanses all debuffs from a player. If you use raw math you need to track every debuff variable across every stat and manually undo each one. With tags from the module it’s one simple call.

  • Also to clarify the client-server sync: the module isn’t supposed to make your game systems like movement or combat fully server sided since obviously that would feel horrible for the player experience. All it does is keep a single source of truth for all values you can easily modify on the server. With the module the server says ‘your speed is 24’ and syncs that number to the client as the client applies it locally. everything would still feel instant and responsive.

The ClientStatReader also lets you react to changes for the UI such as showing buff icons or displaying tooltips like “Speed: 24 (base 16, +50% speed potion)” without the client needing to know any of the logic.

To be clear, if your game has a few stats and straightforward modifiers then raw math is totally fine. I made this module months ago for a game which has hundreds of talents with hundreds of modifiers across many systems with countless stats and the module worked wonderfully for that use case like RPGs, MMO’s, ability-based games where you have equipment, skill trees, buffs, debuffs, and status effects all stacking on the same stats.

4 Likes
local speed = movementSpeed
if <has speed potion> then
    speed *= 1.5
end
if <has heavy armor> then
    speed *= 0.8
end
if <stunned> then
    speed = 0
end

Not if you code your games correctly

I still don’t understand. Is it literally just sending a remote to the client? How does the server communicate to the client? There are just way too many unanswered questions when I could just detect speed potions or heavy armor on the client.

1 Like

the complexity is exactly in the parts you put in angle brackets: tracking what’s active, what’s expired, what stacks, and recalculating when any of those change. with 3 modifiers you can eyeball it but with 40 buffs, debuffs, equipment slots, and talents all touching overlapping stats the if-chain approach means every system in your game needs to know about every other system.

“not if you code your games correctly” sure, and the correct way to code it at a scale is a modifier system. that’s what this is for.

for your sync question: yes, it’s basically just refiring a remote with the computed value. the module doesn’t create the remote for you but you provide a callback via onSyncRequired and hook it up to whatever networking you already have. the module is just telling you when something changed and what the new value is. yes you can do it on the client directly but then the client is the authority of its own stats.

I don’t think you understand, you have simple use cases.

For me who is looking into making a Moba, this is extremely useful, as you can have many items and effects all working at the same time.

This is also really good for RPGs.

You are just not the target audience

2 Likes

How incredibly disorganized does your code have to be in order for you, the developer, to not know how to track whether a player has a potion effect??

Just use a function …

Effects.Apply(subject, stat, modifier) -> id
Effect.Modify(id, modifier) -> id
Effects.Remove(id)

No need for all of these types metatables bs and indirection

How does this relate to any of my points at all? My point was that, if you did not code your game in some sloppily taped-together way, you do not have to manually undo each debuff variable. Which is true.

I never mentioned in that quote that this is an incorrect solution to managing your games.

I understand that I am not the target audience. That is because I am smart enough to make my own systems that do not use OOP and aren’t held together by hopes and dreams.

I have been in several situations where I needed to manage modifiers of some kind. You wanna know what I did? I multiplied the base by the modifier(s). Revolutionary, right?


Please stop interpreting my “you do not need this specific modifier system” as a “you do not need any modifier system whatsoever and you should always use raw multipliers”.

your alternative is literally a modifier system, just one that doesn’t handle things like stacking rules, expiration, type ordering, tag-based removal, or sync. that’s fine if you don’t need those things but it’s the gap that this module fills.

metatables aren’t just ‘hopes and dreams’ as you say. they’re standard luau for sharing behavior between related types without duplicating code. the module uses them so EntityManager and PlayerManager can share a base implementation. that’s avoiding copy pasting 400 lines, not what you said. even I hate overuse of OOP in roblox so we agree on some fundamentals here.

i think we actually agree more than you think because you don’t need this for simple cases and i’ve said that multiple times now the module exists for complex cases and if you don’t hit those then great

2 Likes

the alternative is psuedocode (that absolutely could do all of those things?)

metatables are a terrible afterthought of a feature that should have been excluded from op2 compliation like getfenv and setfenv

you see a lot of moving parts and then you wrap it in objects and call it complexity and then you lose a thousand CPU cycles. stoop

1 Like

In what case would I need every feature your module offers? Because literally every situation I’ve been in I’ve only had to just multiply a number.

yes, any pseudocode can do anything. thats like saying a napkin sketch could be a house. the point is his pseudocode doesnt do those things and that actually implementing them is the hard part. if it were trivial he’d show it instead of handwaving. the module is meant to do those things

and on performance, the module caches results with a dirty flag and only recalculates when a modifier changes so not every frame. i bet i could still work on performance though.

i listed those cases already, RPGs, MMOs, ability-based games with overlapping buffs/debuffs/equipment/talents on the same stats. if you don’t hit that complexity then the module isn’t for you and that’s fine, it doesn’t have to be for everyone.

don’t be intentionally dense. luau is low entropy. that is why i already knew :down_arrow:

without ever looking at the source.

1 Like

Sounds nice, I will take a look into it once I have some time available. I struggled a bit writing the modifiers for a roguelite I was working on, and this could fit into it.

Thanks!

1 Like

this is great actually, thanks for making it!

1 Like

why doesn’t the effect apply when i add it?, it only works on the next packet

my eventsgameplayservice module :

for _, player: Player in GetPlayers() do
						local character = player.Character
						if not character then continue end
						
						local humanoid = character:FindFirstChildOfClass("Humanoid")
						if not humanoid then continue end
						
						humanoid.RootPart.Anchored = false

						B_StatsService.playerStats:AddModifier({
							player = player,
							path = "Movement.Speed",
							value = 0,
							type = "Override", --ignores all other speed modifiers
							source = "Stun",
							priority = 200,
							duration = 18.5,
							stackingRule = "Refresh",
							tags = { "debuff", "cc" },
						})
						
						local speed = B_StatsService.playerStats:Get(player, "Movement.Speed") -- 24
						humanoid.WalkSpeed = B_StatsService.playerStats:Get(player, "Movement.Speed")

						
						warn("SET SPEED STUNNED")
						
						character:PivotTo(BeginningPart.CFrame)
						
					end

My RunningServiceModule :

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local PacketNetwork = require("@game/ReplicatedStorage/Modules/crucial/PacketNetwork")
local ModifierManager = require("@game/ReplicatedStorage/Modules/crucial/ModifierManager")
local B_StatsService = require("@game/ReplicatedStorage/Services/B_StatsService")


local service = {}

function service.init(self)
	self.container = script
	self.RunPacket = PacketNetwork("SetRunning", PacketNetwork.Boolean8)
	--self.playerStats = ModifierManager.PlayerManager.new()
	print(self.container.Name .. " initialized")
end

function service.start(self)

	-- Set base walk speed for each player on join
	Players.PlayerAdded:Connect(function(player)
		player.CharacterAdded:Connect(function()
			warn("BASE SPEED SET")
			B_StatsService.playerStats:SetBase(player, "Movement.Speed", game.StarterPlayer.CharacterWalkSpeed)
		end)
	end)

	-- Listen for client telling us they started/stopped running
	self.RunPacket.OnServerEvent:Connect(function(player, isRunning)
		local character = player.Character
		if not character then return end
		local humanoid = character:FindFirstChildWhichIsA("Humanoid")
		if not humanoid then return end

		if isRunning then
			B_StatsService.playerStats:AddModifier({
				player = player,
				path = "Movement.Speed",
				value = 25,
				type = "Override",
				source = "Running",
				stackingRule = "Replace",
				tags = { "movement", "running" },
				priority = 50,
			})
		else
			B_StatsService.playerStats:RemoveAllByTag(player, "running")
		end

		-- Apply the resolved speed to the humanoid
		humanoid.WalkSpeed = B_StatsService.playerStats:Get(player, "Movement.Speed")
	end)

	print(self.container.Name .. " started")
end

function service.onUpdate(self, dt: number)
end

return service

MY runningcontroller module :

local ReplicatedFirst = game:GetService("ReplicatedFirst")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local userInput = game:GetService("UserInputService")
local RunService = game:GetService("RunService")
local Players = game:GetService("Players")
local PacketNetwork = require("@game/ReplicatedStorage/Modules/crucial/PacketNetwork")
local ModifierManager = require("@game/ReplicatedStorage/Modules/crucial/ModifierManager")


local LocalPlayer = Players.LocalPlayer
local Controller = {}

function Controller.init(self)
	_G.STAMINA = 100
	self.container = script
	self.maxStamina = 100
	self.running = false
	self.crouching = false
	self.stamina = self.maxStamina
	self.regenTime = time()
	self.lastRunState = nil  -- avoid spamming identical packets

	self.RunPacket = PacketNetwork("SetRunning", PacketNetwork.Boolean8)

	self.statReader = ModifierManager.ClientStatReader.new()
	self.statReader:OnChanged("Movement.Speed", function(newSpeed)
		local character = LocalPlayer.Character
		if not character then return end
		local humanoid = character:FindFirstChildWhichIsA("Humanoid")
		if humanoid then
			humanoid.WalkSpeed = newSpeed
		end
	end)

	print(self.container.Name .. " initialized")
end

function Controller.start(self)

	self.OnCharacterAdded = function(character)
		self.humanoid = character:FindFirstChildWhichIsA("Humanoid")
			or character:WaitForChild("Humanoid")
	end

	LocalPlayer.CharacterAdded:Connect(self.OnCharacterAdded)
	local character = LocalPlayer.Character
	if character then self.OnCharacterAdded(character) end

	local function setRunning(state: boolean)
		if self.lastRunState == state then return end
		self.lastRunState = state
		self.RunPacket:Fire(state)
	end

	self.inputBegan = function(input: InputObject, chatting)
		if chatting then return end
		if input.KeyCode == Enum.KeyCode.LeftShift then
			self.running = true
		end
		if input.KeyCode == Enum.KeyCode.LeftControl then
			self.running = false
		end
	end

	self.inputEnded = function(input: InputObject)
		if input.KeyCode == Enum.KeyCode.LeftShift then
			self.running = false
		end
	end

	self.update = function(dt)
		if not self.humanoid then return end

		local canRun = self.humanoid.MoveDirection.Magnitude > 0
			and self.running
			and not self.crouching
			and self.stamina > 0

		if canRun then
			setRunning(true)
			self.regenTime = time()
			self.stamina -= 0.5
		else
			setRunning(false)
			if self.stamina < self.maxStamina then
				if (time() - self.regenTime) > 5 then
					self.stamina = math.clamp(self.stamina + 15 * dt, 0, self.maxStamina)
				end
			end
		end

		_G.STAMINA = self.stamina
	end

	self.onHeartbeat = RunService.Heartbeat:Connect(self.update)
	self.onUserInput = userInput.InputBegan:Connect(self.inputBegan)
	self.onUserInputEnded = userInput.InputEnded:Connect(self.inputEnded)

	warn("started " .. self.container.Name)
end

function Controller.onUpdate(self, dt: number)
end

return Controller

Or is it possible to do OnChanged in the server too?

now that i think about it , the onChanged in the client is not firing at all

Edit : I think i figured it out, i needed to hook it up myself, right?

local PacketNetwork  = require("@game/ReplicatedStorage/Modules/crucial/PacketNetwork")
local ModifierManager = require("@game/ReplicatedStorage/Modules/crucial/ModifierManager")

local Controller = {}

Controller.statReader = nil

function Controller.init(self)
	self.container     = script
	self.statReader    = ModifierManager.ClientStatReader.new()
	self.StatSyncPacket = PacketNetwork("StatSync", PacketNetwork.StringLong, PacketNetwork.Any)

	self.StatSyncPacket.OnClientEvent:Connect(function(statPath, syncData)
		self.statReader:ProcessSync(statPath, syncData)
	end)

	Controller.statReader = self.statReader

	print(self.container.Name .. " initialized")
end

function Controller.start(self)
	print(self.container.Name .. " started")
end

function Controller.onUpdate(self, dt: number)
end

return Controller

the module doesn’t send remotes for you, you only wired the client half being ProcessSync but you also need the server half.

playerStats.onSyncRequired = function(player, statPath, syncData)
	if not player.Parent then return end
	StatSyncRemote:FireClient(player, statPath, syncData)
end

without that the client OnChanged never fires

also the client reader lags ~0.1s cause the sync is batched, so dont drive WalkSpeed off of it. just use server side playerStats:OnChanged(player, "Movement.Speed", fn) instead cause it fires instantly.

and make sure OnChanged and ProcessSync are on the same ClientStatReader instance and not a new one per module

1 Like

I tried doing this, but the OnChanged in the server is not firing

local ModifierManager = require("@game/ReplicatedStorage/Modules/crucial/ModifierManager")
local Players = game:GetService("Players")

local service = {}
service.playerStats = nil

function service.init(self)
	self.container  = script
	self.playerStats = ModifierManager.PlayerManager.new()
	self.networking = require("@game/ReplicatedStorage/Modules/Network")

	print(self.container.Name .. " initialized")
end

function service.start(self)
	self.StatSyncPacket = self.networking.StatSync

	self.playerStats.onSyncRequired = function(player, statPath, syncData)
		if not player.Parent then return end
		self.StatSyncPacket:FireClient(player, statPath, syncData)
	end


	self.setupPlayer = function(player: Player)
		warn("[Stats] OnChanged Hooked to "..player.Name)
		task.wait()
		self.playerStats:OnChanged(player, "Movement.Speed", function(newSpeed)
			warn("found1")
			local char = player.Character
			if not char then return end
			warn("found2")
			local hum = char:FindFirstChildWhichIsA("Humanoid")
			if hum then
				warn("found3")
				hum.WalkSpeed = newSpeed
			end
		end)
		
	end

	Players.PlayerAdded:Connect(self.setupPlayer)
	for _, player in Players:GetPlayers() do
		self.setupPlayer(player)
	end

	print(self.container.Name .. " started")
end

function service.onUpdate(self, dt: number)
end

return service

RunningService :

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players           = game:GetService("Players")

local B_StatsService   = require("@game/ReplicatedStorage/Services/B_StatsService")

local service = {}

local function onCharacterAdded(player: Player, character: Model)
	local humanoid = character:FindFirstChildWhichIsA("Humanoid")
		or character:WaitForChild("Humanoid", 5)

	if not humanoid then
		warn("[MovementService] No Humanoid found for", player.Name)
		return
	end

	warn("[MovementService] Base speed set for", player.Name)
	B_StatsService.playerStats:SetBase(
		player,
		"Movement.Speed",
		game.StarterPlayer.CharacterWalkSpeed
	)
end

local function onPlayerAdded(player: Player)
	player.CharacterAdded:Connect(function(character)
		onCharacterAdded(player, character)
	end)

	if player.Character then
		onCharacterAdded(player, player.Character)
	end
end

function service.init(self)
	self.container = script
	--local A_EventsService = require("@game/TextChatService/A_EventsService")

	self.networking = require("@game/ReplicatedStorage/Modules/Network")

	print(self.container.Name .. " initialized")
end

function service.start(self)

	Players.PlayerAdded:Connect(onPlayerAdded)

	for _, player in Players:GetPlayers() do
		task.spawn(onPlayerAdded, player)
	end
	
	self.RunPacket  = self.networking.SetRunning
	self.RunPacket.OnServerEvent:Connect(function(player: Player, isRunning: boolean)
		local character = player.Character
		if not character then return end

		local humanoid = character:FindFirstChildWhichIsA("Humanoid")
		if not humanoid then return end

		if isRunning then
			B_StatsService.playerStats:AddModifier({
				player       = player,
				path         = "Movement.Speed",
				value        = 26,
				type         = "Override",
				source       = "Running",
				stackingRule = "Replace",
				tags         = { "movement", "running" },
				priority     = 50,
			})
			warn("Running on")
		else
			B_StatsService.playerStats:RemoveAllByTag(player, "running")
			warn("Running off")
		end
	end)

	print(self.container.Name .. " started")
end

function service.onUpdate(self, dt: number)
end

return service

Edit : i found a fix, but i needed to modify BaseModifierManager and PlayerManager

In BaseModifierManager SetBase add

	if not self.stacks[stackKey] then
		self.stacks[stackKey] = {}
	end
	if not self.signals[stackKey] then  
		self.signals[stackKey] = {}
	end
	if not self.tagIndex[stackKey] then
		self.tagIndex[stackKey] = {}
	end

And then in PLayermanager GetStack, the same thing

if not self.stacks[player] then
		self.stacks[player] = {}
	end
	if not self.signals[player] then 
		self.signals[player] = {}
	end
	if not self.tagIndex[player] then
		self.tagIndex[player] = {}
	end

1 Like

so it looks like it was an actual bug, i fixed it (hopefully) and published to the model, git, and wally.
basically if you hooked OnChanged before setting the base stat it would wipe your connection so it never fired.

1 Like

I have a suggestion, You should consider doing something similar, but instead of Modifiers, States