How would i implement a modular status effect system?

I want to achieve a status effect system make it work on players, and NPCS.
E.X: take damage of 5 every 2 second for 10 second,
E.X 2: increased movement speed by 10 for 30 seconds

My issue is understanding how to track each humanoid and their status effects.

Im thinking about using a table to store each humanoid, but after that im just confused on how to add them, remove them, or track their status effects.

3 Likes

I highly recommend ECS. However, on a side note you could achieve the something with OOP as long as you use components, one module script only does one thing with a single responsibility principle.

Here are some code examples to illustrate

Entity creation of player with pure data

local npcEntity = world:Entity()
npcEntity :Set(HumanoidComponent(Instance.new("Humanoid")))
npcEntity:Set(PoisonComponent(5))
--You can add infinite amounts of components hence modular
npcEntity:Set(StunComponent(10))
npcEntity:Set(SpeedBuffComponent({value = 5, duration = 30}))

local entityDictionary = {}
entityDictionary[npcModel] = npcEntity --keep track of model --> entity
--hit detection -->part-->model-->dictionary --> Entity object
--remember to delete after model is destroyed, ancestor is set to nil should be good enough

System detects and tracks components, ex: status effect every frame doing poison damage, one module script called PoisonSystem.lua, easily find and track this system in charge of only doing one thing

PoisonSystem module script
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Components = require(script.Parent.Parent.Components)
local PoisonComponent = Components.PoisonComponent
local HealthComponent = Components.HealthComponent

local ECS = require(ReplicatedStorage.Shared.ECSFolder.ECS)

local World, System, Query, Component = ECS.World, ECS.System, ECS.Query, ECS.Component

--only thing that matters is if an entity is poisoned and has health
--transform is kinda equal to RunService heartbeat
local PoisonDamageSystem = System("transform", 1, Query.All(PoisonComponent, HealthComponent))

function PoisonDamageSystem:Update(Time)
    for i, entity in self:Result():Iterator() do
        local health = entity[HealthComponent].value
local poisonDamageRate = entity[PosionComponent].value
        entity:Set(HealthComponent(health - poisonDamageRate*))
    end
end

return PoisonDamageSystem

Another system is in charge of purely poison visual effects

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

local Components = require(script.Parent.Parent.Components)

local ECS = require(ReplicatedStorage.Shared.ECSFolder.ECS)

local World, System, Query, Component = ECS.World, ECS.System, ECS.Query, ECS.Component

local PoisonVisualEffects = System("transform", 1, Query.All(PoisonComponent, ModelComponent))

--When a new entity is created with a char model, and poisoned
function PoisonVisualEffects:OnEnter(Time, entity)
    local model = entity[ModelComponent].value
    
    --add particles to model
end

return PoisonVisualEffects

And you add drop on demand components.

npcEntity:Unset(PoisonComponent)
6 Likes

Hey there! Thanks for the answer, looks perfect for the thing im trying to do :slight_smile: .
I do have a question though, where do i put the “Entity creation of player with pure data”? Is it supposed to be a normal script, or a module? I am not too good in understanding these kind of systems so please bear with me :sweat_smile:

That’s up to you to decide, it could basically be anything hence why it’s difficult.

However usually you will have many models/instances/npcs, so I follow a factory approach with any model added through collection services being added with an entity.

This factory is located in a single starter player script at the moment.

local vehicleEntityFactory = world:Entity()
local function setupVehicleEntity(vehicleEntity, modelInstance)
    vehicleEntity:Set(ModelComponent(modelInstance))
    vehicleEntity:Set(VehicleSeatComponent())
    vehicleEntity:Set(WorldComponent(world))
    -- vehicleEntity:Set(HealthComponent())
end
vehicleEntityFactory:Set(CollectionServiceTagComponent({"Vehicle", setupVehicleEntity}))
vehicleEntityFactory:Set(WorldComponent(world))
CollectionClassSystem
local CollectionService = game:GetService("CollectionService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local ECS = _G.ECS

local Components = require(script.Parent.Parent.Components)
local CollectionServiceTagComponent = Components.CollectionServiceTagComponent
local WorldComponent = Components.WorldComponent

local World, System, Query, Component = ECS.World, ECS.System, ECS.Query,
                                        ECS.Component

local CollectionClassSystem = System("transform", 10, Query.All(
                                         CollectionServiceTagComponent,
                                         WorldComponent))

local function createAndSetupEntity(world, setupEntityFunction, instance)
    local entity = world:Entity()
    setupEntityFunction(entity, instance)

    return entity
end

local function setupCollectionClassInstance(tag, setupEntityFunction, world)

    local entities = {}
    CollectionService:GetInstanceAddedSignal(tag):Connect(function(instance)
        entities[instance] = createAndSetupEntity(world, setupEntityFunction, instance)
    end)

    local instances = CollectionService:GetTagged(tag)

    for _, instance in pairs(instances) do
        entities[instance] = createAndSetupEntity(world, setupEntityFunction, instance)
    end

    CollectionService:GetInstanceRemovedSignal(tag):Connect(function(instance)
        local entity = entities[instance]
        world:Remove(entity)
    end)
end

function CollectionClassSystem:OnEnter(Time, entity)
    local csData = entity[CollectionServiceTagComponent]
    local world = entity[WorldComponent].value
    local tag = csData.Tag
    local components = csData.Components
    setupCollectionClassInstance(tag, components, world)
    return true
end

return CollectionClassSystem
2 Likes

Here is an example that allows you to apply status effects any time you apply a tag to a Humanoid that matches a dictionary in StatusEffectsList. You can reference the Humanoid’s specific table in Humanoids to track which statuses are currently running. This is quite a bit more basic than using a module system like ECS.

This example uses a debounce as to not apply multiple of the same status effect at the same time. If you want stacking status effects, you’d have to remove the debounce and make the :GetInstanceRemovedSignal(i) call ProcessStatusEffects() with specifying the exact status effect that triggered the event instead of doing all available statuses.

local CollectionService = game:GetService("CollectionService")
local HumanCount = 0
local Humanoids = {} --Format: ["Human1"] = {Humanoid Instance, {StatusEffectsTable}}

local StatusEffectsList = {
	["Poison"] = {
		["TickTime"] = 2,
		["Damage"] = 5,
		["Length"] = 10,
		["Processing"] = false, --Debounce
	},
	["MovementPenalty"] = {
		["IncreaseIncrement"] = -10,
		["Length"] = 10,
		["Processing"] = false, --Debounce
	},
	["MovementBoost"] = {
		["IncreaseIncrement"] = 10,
		["Length"] = 10,
		["Processing"] = false, --Debounce
	},
}

function UpdateHumanTable(Human) --Puts the Human into the Humanoid table with the corresponding Status Effects. Returns the table.
	if Human then
		if Human:IsA("Humanoid") then
			
			--Dupe check
			local ExistingHumanTable = nil
			if Human:GetAttribute("IndexName") then
				ExistingHumanTable = Humanoids[Human:GetAttribute("IndexName")]
			end
			for i,v in pairs (Humanoids) do 
				if table.find(v,Human) then --Makes sure the Humanoid isn't already in one of the subtables in case the attribute check fails for some reason.
					ExistingHumanTable = v
				end
			end
			--Dupe check
			
			if ExistingHumanTable == nil then --Create a table if there isn't one already.
				HumanCount += 1
				local IndexName = "Human" ..tostring(HumanCount)
				Human:SetAttribute("IndexName", IndexName) --Add an attribute so we know that it's already in the table so it can find it during the dupe check.
				local FetchedEffects = {}
				for i,v in ipairs (CollectionService:GetTags(Human)) do 
					if StatusEffectsList[v] then
						FetchedEffects[v] = StatusEffectsList[v] --Inserts the StatusEffects into the temporary table which will go into the specific Humanoid table
					end
				end
				Humanoids[IndexName] = {Human, FetchedEffects}
				return Humanoids[IndexName]
			else --Update an existing table
				for i,v in pairs (CollectionService:GetTags(Human)) do 
					if StatusEffectsList[v] then
						if not ExistingHumanTable[2][v] then --If there already isn't an effect in the table,
							ExistingHumanTable[2][v] = StatusEffectsList[v] --Inserts the StatusEffects into the pre-existing table specific to the Humanoid
						end
					end
				end
				for i,v in pairs (ExistingHumanTable[2]) do
					if not table.find(CollectionService:GetTags(Human),i) then --If the player does not have a specific status effect tag,
						v = nil --Removes the status from the table
					end
				end
				return ExistingHumanTable
			end
		end
	end
end

function TagHuman(Human, Tags) 
	if Human then
		if typeof(Tags) == "table" then
			for i,v in ipairs (Tags) do
				if typeof(v) == "string" then
					CollectionService:AddTag(Human,v)
				end
			end
		elseif typeof(Tags) == "string" then
			CollectionService:AddTag(Human,Tags)
		end
	end
end

function UntagHuman(Human, Tags)
	if Human then
		if typeof(Tags) == "table" then
			for i,v in ipairs (Tags) do
				if typeof(v) == "string" then
					CollectionService:RemoveTag(Human,v)
				end
			end
		elseif typeof(Tags) == "string" then
			CollectionService:RemoveTag(Human,Tags)
		end
	end
end

function ProcessStatusEffects(HumanTable) --HumanTable Format: {Humanoid Instance, {StatusEffectsTable}}
	if typeof(HumanTable) == "table" then
		local HumanoidInstance  = HumanTable[1]
		local StatusEffects = HumanTable[2]
		if not HumanoidInstance or typeof(StatusEffects) ~= "table" then warn("Humanoid/Status Effects missing") return end
		for i,v in pairs (StatusEffects) do 
			if typeof(v) == "table" then 
				if v["Processing"] == false or v["Processing"] == nil then --Checks the debounce
					v["Processing"] = true --Sets the debounce
					if i == "Poison" then --Apply the effects here by checking the type of status effect (i).
						local TimeRan = 0
						local MaxTime = v["Length"]
						task.spawn(function()
							while TimeRan < MaxTime do
								TimeRan +=1
								task.wait(1)
							end
						end)
						while v do
							if TimeRan < MaxTime then
								if HumanoidInstance then
									HumanoidInstance.Health -= v["Damage"]
									print("Damaging humanoid...")
								end
							else
								break
							end
							task.wait(v["TickTime"])
						end
						UntagHuman(HumanoidInstance, i)
					elseif i ==  "MovementPenalty" or i == "MovementBoost" then
						HumanoidInstance.WalkSpeed += v["IncreaseIncrement"]
						print("Humanoid's speed adjusted!")
						wait (v["Length"])
						if HumanoidInstance then
							HumanoidInstance.WalkSpeed -= v["IncreaseIncrement"]
						end
						print("Humanoid speed back to normal.")
						UntagHuman(HumanoidInstance, i)
					end
				end
			end
		end
	else
		warn("Missing or improperly formatted humanoid table.")
	end
end

for i,v in pairs (StatusEffectsList) do
	CollectionService:GetInstanceAddedSignal(i):Connect(function(Object)
		if Object:IsA("Humanoid") then
			local FetchedHumanTable = UpdateHumanTable(Object)
			task.spawn(function()
				ProcessStatusEffects(FetchedHumanTable)
			end)
		end
	end)
	CollectionService:GetInstanceRemovedSignal(i):Connect(function(Object)
		if Object:IsA("Humanoid") then
			local FetchedHumanTable = UpdateHumanTable(Object)
			task.spawn(function()
				ProcessStatusEffects(FetchedHumanTable)
			end)
		end
	end)
end


--Now you just need to tag a humanoid whenever you want to apply a status effect. You can use the TagHuman function from above or the CollectionService:AddTag() function which achieves the same thing.
local TestHuman = game.Workspace.Robloxian.Humanoid
wait(1)
TagHuman(TestHuman,{"Poison", "MovementPenalty"}) --Will apply poison damage every 2 seconds for a total length of 10 seconds and a movement status effect that decreases walk speed.
wait(1)
TagHuman(TestHuman, "Poison") --This won't do anything since there's a debounce in place.
4 Likes

When making a System for the client and server. Do I have to make a world for both the client and Server? Additionally, do I have to load the systems in their respective worlds separately?

Yes they both each have a world client and server.

Yes as some of it is shared, some of it is not.

For example damage system should be on server and not the client. In the end it is up to you.