How Can I Efficiently Manage Multiple Cooldown Types in One Module?

I want to manage multiple different types of ability cooldowns inside a single cooldown module. Some abilities have:

  • Multiple charges (e.g. 3 uses before cooldown),
  • A duration before cooldown starts (like a temporary buff),
  • Or a standard single-use cooldown.

I’m trying to build a clean and efficient system that supports all of these variations without making the module bloated or inconsistent.

My current cooldown module works for basic charge-based cooldowns, but I’m running into trouble trying to expand it to support more complex cooldown behavior, such as duration-based cooldowns or abilities with mixed logic.

Here’s the current cooldown module I’m working with:

--Services
local RunService = game:GetService("RunService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

--Assets
local PlayerGUIRemote = ReplicatedStorage.Assets.Remotes.GUI

--Script

local Cooldown = {}

Cooldown.__index = Cooldown

function Cooldown.new(parent,cooldownTime,maxCharges)
	local self = setmetatable({}, Cooldown)
	
	self.CooldownTime = cooldownTime
	self.Parent  = parent
	self._isOnCooldown = false
	self._currentTime = nil
	self.StartTime = nil
	self.DeltaTime = nil
	self.TimeRemaining = nil
	self.Debounce = false
	self.MaxCharges = maxCharges or 1
	self.Charge = maxCharges
	
	return self
end

function Cooldown:Start()
	
	self.Charge -=1
	PlayerGUIRemote:FireClient(self.Parent.Player,"Charge",self.Charge)
	
	if self._isOnCooldown == false then
		
		self._isOnCooldown = true
		
		task.spawn(function()
			while self.Charge < self.MaxCharges do
				self.StartTime = os.clock()	
				repeat
					task.wait()
					self.DeltaTime = os.clock() - self.StartTime
					self.TimeRemaining = math.max(0,(self.CooldownTime - self.DeltaTime))
					PlayerGUIRemote:FireClient(self.Parent.Player,"Time",self.TimeRemaining)	
				until self.TimeRemaining <= 0 
				self:Refresh()
				PlayerGUIRemote:FireClient(self.Parent.Player,"Charge",self.Charge)
			end
			self._isOnCooldown = false
			
		end)
	end	
end

function Cooldown:Reduce(cooldownType,number)
	
	if self._isOnCooldown == false then print("Notoncooldown") return end
	
	if cooldownType == "Flat" then
		
		self.StartTime = self.StartTime - number
		print("CooldownReducedBy"..number)
		
	elseif cooldownType == "Percent" then
		
		self.StartTime = self.StartTime - (self.TimeRemaining * (number/100))
		print("CooldownReducedBy "..number.."%")
			
	end	
end

function Cooldown:Refresh()
	
	self.Charge += 1
	print("ChargeRefreshed")
	self._currentTime = nil
	self.StartTime = nil
	self.DeltaTime = nil
	self.TimeRemaining = nil
	
end

return Cooldown

Here is how I currently Use it in a server script:

--Services
local Replicated = game:GetService('ReplicatedStorage')
local UIS = game:GetService('UserInputService')
local TweenService = game:GetService("TweenService")
local RunService = game:GetService("RunService")

--Modules
local MockP = require(Replicated.Modules.Shared.MockPart)
local Raycast = require(Replicated.Modules.Shared.Raycast)
local Bezier = require(Replicated.Modules.Shared.Bezier)
local AbilityManager = require(Replicated.Modules.Combat.AbilityManager)
local PlayerManager = require(Replicated.Modules.Combat.PlayerManager)

--Remotes
local CharacterTwoRemote = Replicated.Characters.CharacterTwo.Remotes.CharacterTwoRemote
local FXRemote = Replicated.Assets.Remotes.FX

--Body

CharacterTwoRemote.OnServerEvent:Connect(function(player,abiltyType,...)
	
	--PlayerInitialize
	
	if PlayerManager:GetPlayer(player) == nil then
		PlayerManager:InitializePlayer(player,"CharacterTwo")
	end

	--Body
	
	local Player = PlayerManager:GetPlayer(player)
	local PlayerAbilities = Player.Abilities
	
	if abiltyType == "AA" then

		FXRemote:FireAllClients("CharacterTwo","AA",player,...)
		
	elseif abiltyType == "Shift" then
		
		if PlayerAbilities.Shift.Cooldown.Debounce == true or PlayerAbilities.Shift.Cooldown.Charge <= 0 then return end
		
		PlayerAbilities.Shift.Cooldown.Debounce = true
		PlayerAbilities.Shift.Cooldown:Start()
		FXRemote:FireAllClients("CharacterTwo","Shift",player,...)
		
		task.wait(0.3)
		
		PlayerAbilities.Shift.Cooldown.Debounce = false
		
		
	elseif abiltyType == "Skill" then

		FXRemote:FireAllClients("CharacterTwo","Skill",player,...)	
		
	elseif abiltyType == "Ultimate" then

		FXRemote:FireAllClients("CharacterTwo","Ultimate",player,...)	
		
	end
end)

And here is my ability module:

--Services
local Replicated = game:GetService("ReplicatedStorage")

--Modules
local Cooldown = require(Replicated.Modules.Combat.CooldownManager)

--Assets
local Characters = Replicated.Characters

--Module

local Ability = {}


function Ability.new(player,abilityType,cooldownTime,maxCharges,cooldownType)
	local self  = setmetatable({}, Ability)
	self.AbilityType = abilityType
	self.Cooldown = Cooldown.new(self,cooldownTime,maxCharges,cooldownType)
	self.Player = player
	self.Duration = 10
	self.isActive = false


	return self
end




function Ability.InitalizePlayer(player,character)
	
	local CharacterAbilities = Characters[character].Abilities
	
	local self  = setmetatable({}, Ability)
	
	self.AA = Ability.new(player,"AA",CharacterAbilities.AA.Stats.Cooldown.Value,CharacterAbilities.AA.Stats.MaxCharges.Value)
	self.Shift = Ability.new(player,"Shift",CharacterAbilities.Shift.Stats.Cooldown.Value,CharacterAbilities.Shift.Stats.MaxCharges.Value,"Duration")
	self.Skill = Ability.new(player,"Skill",CharacterAbilities.Skill.Stats.Cooldown.Value,CharacterAbilities.Skill.Stats.MaxCharges.Value,"Charge")
	self.Ultimate = Ability.new(player,"Ultimate",CharacterAbilities.Ultimate.Stats.Cooldown.Value,CharacterAbilities.Ultimate.Stats.MaxCharges.Value)
		
	return self
end



return Ability

I was also wondering — would it be a good design decision to store things like CooldownTime, DurationTime, InternalDebounce, and MaxCharges inside a folder in ReplicatedStorage instead of hardcoding those values into scripts?

Would this be a more scalable or modular approach for managing different character abilities and their configurations? Or would it just lead to unnecessary replication/network clutter? I’m thinking this could also allow easier character customization without changing scripts directly.

If anyone has any general tips on:

  • Better naming conventions for variables like _isOnCooldown, DeltaTime, etc.
  • Lua OOP structuring and keeping the module lean/organized
  • Syntax or readability improvements
    That would be very helpful as well!

It seems you’re overcomplicating your cooldown module with charges (something that rather should be in it’s own separate handling module along with the abilities).

Here’s a cooldown module I wrote a while back, feel free to compare: Simple and Free | Cooldown/Debounce data handling module - #20 by Luacathy

Code
local Cooldown = {}
local CooldownData = {}

function Cooldown.Add(tableName : string, valueName : string, cooldown : number, callback : thread, ...)
	if tonumber(cooldown) ~= nil then
		if CooldownData[tableName] then
			table.insert(CooldownData[tableName], valueName)
			CooldownData[tableName][valueName] = {["StartTime"] = os.clock(), ["Time"] = cooldown, ["TimeLeft"] = cooldown, ["Function"] = callback, ["Arguments"] = {...}}
			return true
		else
			CooldownData[tableName] = {[valueName] = {["StartTime"] = os.clock(), ["Time"] = cooldown, ["TimeLeft"] = cooldown, ["Function"] = callback, ["Arguments"] = {...}}}

			return true
		end
	end
	return false
end

function Cooldown.Check(tableName : string, valueName : string)
	if CooldownData[tableName] then
		if CooldownData[tableName][valueName] then
			local timeLeft = CooldownData[tableName][valueName]["TimeLeft"]
			return false, timeLeft
		else
			return true
		end
	else
		return true
	end
end

local NewUpdateThread = coroutine.wrap(function()
	local RunService = game:GetService("RunService")

	while RunService.Heartbeat:Wait() do
		for TableOrder,Table in next, CooldownData do
			for TableName,TableValues in pairs(Table) do
				if TableValues["StartTime"] and TableValues["Time"] and TableValues["TimeLeft"] then
					local currentTime = os.clock()
					local timePassed = currentTime - TableValues["StartTime"]
					local timeLeft = TableValues["Time"] - timePassed
					if TableValues["Time"] > timePassed then
						TableValues["TimeLeft"] = timeLeft
					else
						TableValues["TimeLeft"] = 0

						pcall(function()
							if TableValues["Function"] then
								if TableValues["Arguments"] then
									TableValues["Function"](table.unpack(TableValues["Arguments"]))
								else 
									TableValues["Function"]()
								end
							end
						end)

						CooldownData[TableOrder][TableName] = nil

						if #CooldownData[TableOrder] == 0 then
							CooldownData[TableOrder] = nil
						end
					end
				end
			end
		end
	end
end)

NewUpdateThread()

return Cooldown
1 Like

I’ve made some adjustments to it as I was currently looking at it, fixed a major issue with only being able to pass a single argument into a callback function and a minor optimization with if TableValues["Time"] > timePassed then instead of if TableValues["Time"] >= timePassed then.

1 Like

So would you make another module for Charges and then make that a part of the Ability Table?

Well that depends, the way I see modules - they are bricks. A brick can be connected to a single other brick or a few or a couple or a dozen or a ton of other bricks. And it’s honestly up to you to decide how you’d want to approach creating and combining those bricks to run your game. The more bricks you have the messier it gets but coincidently the easier it would be to add new features or edits or fixing bugs later on. Think about it this way, if you have this one huge brick that runs your game it would be a pain trying to find, edit, change or add something to it. That’s why you usually try to find the balance between the two. Hopefully, this extended metaphor has in some way helped to guide you in answering your question.

1 Like

Oh, and also the less project specific you make the brick the more likely that you’d find some use for it in your other projects!

1 Like