Tutorial
This guide walks through setting up ModifierManager in a Roblox game.
Getting ModifierManager
From GitHub
Download the ModifierManager folder from the repository and place it in ReplicatedStorage.
File Structure
ReplicatedStorage/
└── ModifierManager/
├── init.luau
├── Types.luau
├── Calculator.luau
├── BaseModifierManager.luau
├── EntityManager.luau
├── PlayerManager.luau
├── ClientStatReader.luau
└── Packages/
├── Signal.luau
└── Trove.luau
Basic Usage
ModifierManager runs on the server. The example below shows a basic player stat setup with client sync.
Server Script
-- ServerScriptService/PlayerStats.server.luau
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ModifierManager = require(ReplicatedStorage.ModifierManager)
-- Create manager
local playerStats = ModifierManager.PlayerManager.new()
-- Remotes for sync
local statSyncEvent = ReplicatedStorage.Remotes.Events.StatSync
local getStatSyncDataFunction = ReplicatedStorage.Remotes.Functions.GetStatSyncData
-- Live sync: fires whenever a stat changes
playerStats.onSyncRequired = function(player, statPath, syncData)
if not player.Parent then
return
end
statSyncEvent:FireClient(player, statPath, syncData)
end
-- Initial sync: client requests all stats on load
getStatSyncDataFunction.OnServerInvoke = function(player: Player)
return playerStats:GetAllSyncData(player)
end
-- Initialize player stats
local function setupPlayer(player: Player)
playerStats:SetBase(player, "Combat.Health", 100)
playerStats:SetBase(player, "Combat.MaxHealth", 100)
playerStats:SetBase(player, "Combat.Damage", 10)
playerStats:SetBase(player, "Movement.Speed", 16)
playerStats:SetClamps(player, "Combat.Health", 0, nil) -- Min 0, no max
playerStats:SetClamps(player, "Movement.Speed", 0, 50) -- 0 to 50
end
Players.PlayerAdded:Connect(setupPlayer)
for _, player in Players:GetPlayers() do
task.spawn(setupPlayer, player)
end
Client Script
-- StarterPlayerScripts/StatsClient.client.luau
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ModifierManager = require(ReplicatedStorage.ModifierManager)
local reader = ModifierManager.ClientStatReader.new()
local statSyncEvent = ReplicatedStorage.Remotes.Events.StatSync
local getStatSyncDataFunction = ReplicatedStorage.Remotes.Functions.GetStatSyncData
-- Receive live syncs from server
statSyncEvent.OnClientEvent:Connect(function(statPath, syncData)
reader:ProcessSync(statPath, syncData)
end)
-- Fetch all current stats on load
local initialData = getStatSyncDataFunction:InvokeServer()
if initialData then
reader:ProcessBulkSync(initialData)
end
-- React to changes
reader:OnChanged("Combat.Health", function(newHealth)
print("Health:", newHealth)
end)
-- Read stats anytime
print("Current speed:", reader:Get("Movement.Speed"))
Adding Modifiers
Modifiers change stat values. They can be temporary or permanent.
Temporary Buff
-- Speed boost that lasts 10 seconds
playerStats:AddModifier({
player = player,
path = "Movement.Speed",
value = 1.5,
type = "Multiplicative", -- 50% faster
source = "SpeedBoost",
duration = 10,
})
Permanent Equipment Bonus
-- Weapon damage bonus (no duration = permanent)
playerStats:AddModifier({
player = player,
path = "Combat.Damage",
value = 25,
type = "Additive",
source = "EquippedSword",
tags = { "equipment", "weapon" },
})
Debuff with Override
-- Stun: force speed to 0
playerStats:AddModifier({
player = player,
path = "Movement.Speed",
value = 0,
type = "Override",
source = "Stun",
duration = 2,
priority = 200, -- Higher priority overrides win
})
Stacking Rules
Control how modifiers from the same source interact.
Stack (Default)
Multiple modifiers accumulate:
-- Each hit adds more damage
playerStats:AddModifier({
player = player,
path = "Combat.Damage",
value = 5,
type = "Additive",
source = "RageStack",
stackingRule = "Stack",
})
-- Call 3 times = +15 damage total
Replace
New modifier removes old ones from same source:
-- Only one weapon equipped at a time
playerStats:AddModifier({
player = player,
path = "Combat.Damage",
value = newWeaponDamage,
type = "Additive",
source = "EquippedWeapon",
stackingRule = "Replace",
})
Highest
Only keeps the highest value:
-- Only strongest shield matters
playerStats:AddModifier({
player = player,
path = "Combat.Defense",
value = shieldValue,
type = "Additive",
source = "Shield",
stackingRule = "Highest",
})
Refresh
Updates existing modifier's value and resets duration:
-- Reapplying refreshes the timer
playerStats:AddModifier({
player = player,
path = "Combat.Damage",
value = 1.2,
type = "Multiplicative",
source = "Enrage",
stackingRule = "Refresh",
duration = 5,
})
Removing Modifiers
By ID
local modId = playerStats:AddModifier({ ... })
-- Later
playerStats:RemoveModifierById(player, "Combat.Damage", modId)
By Source
-- Remove all modifiers from "EquippedWeapon" on one stat
playerStats:RemoveBySource(player, "Combat.Damage", "EquippedWeapon")
-- Remove from ALL stats
playerStats:RemoveAllBySource(player, "EquippedWeapon")
By Tag
-- Remove all buffs
playerStats:RemoveAllByTag(player, "buff")
-- Remove all debuffs
playerStats:RemoveAllByTag(player, "debuff")
EntityManager for NPCs
Use EntityManager for non-player entities with string keys:
local entityStats = ModifierManager.EntityManager.new()
-- Setup enemy
entityStats:SetBase("goblin_1", "Combat.Health", 50)
entityStats:SetBase("goblin_1", "Combat.Damage", 5)
-- Apply poison
entityStats:AddModifier({
entity = "goblin_1",
path = "Combat.Health",
value = -10,
type = "Additive",
source = "Poison",
duration = 5,
})
-- Cleanup when enemy dies
entityStats:CleanupEntity("goblin_1")
Listening to Changes
React when stats change:
-- Server
playerStats:OnChanged(player, "Combat.Health", function(newHealth)
if newHealth <= 0 then
-- Player died
end
end)
-- Client
reader:OnChanged("Combat.Health", function(newHealth)
HealthBar.Size = UDim2.new(newHealth / maxHealth, 0, 1, 0)
end)
Calculation Example
Given:
- Base: 100
- Additive modifiers: +20, +10
- Multiplicative modifiers: 1.2, 1.1
Calculation:
- Sum additives:
100 + 20 + 10 = 130 - Multiply:
130 * 1.2 * 1.1 = 171.6
If an Override modifier exists with the highest priority, it replaces the result entirely.
Edge Cases
A few things to keep in mind:
- Reading a stat that doesn't exist returns
0— no errors are thrown. SetClampsandSetDecimalPlacesrequire the stat to exist first. CallSetBasebefore using them, or you'll get an error.- Stat paths must be in
"Category.StatName"format. A path like"Health"or"A.B.C"will error. EntityManagerandPlayerManagercan only be created on the server. Attempting to call.new()on the client will error.
-- This is safe — returns 0
local health = entityStats:Get("nonexistent", "Combat.Health")
-- This will error — stat doesn't exist yet
entityStats:SetClamps("enemy_1", "Combat.Health", 0, 100)
-- Do this instead
entityStats:SetBase("enemy_1", "Combat.Health", 50)
entityStats:SetClamps("enemy_1", "Combat.Health", 0, 100)
Next Steps
- Best Practices - Data-driven config, recommended sync patterns, and a full movement system example
- API Reference - Complete method documentation and type definitions