CooldownObject | Structured Cooldown System with Client Replication

By @epicalyepik

This module was created to provide a simple, structured, and signal-based cooldown system with optional client replication.

Module | Test Place

This is my first open source resource, so if you have any suggestions, I’d love to hear them

Highlighted features

  • Thread-safe implementation
  • Automatic cooldown expiration
  • Signals for cooldown Started / Updated / Ended
  • Client-side replication support
  • Easy cooldown checking
  • Support for default cooldown durations
  • Duration multiplier
  • Automatic signal cleanup after cooldown ends

Cooldown Object (Server)

Key — unique string identifier for a cooldown (e.g. ability name).

CooldownObjectModule.new(Player : Player?)

Creates a new cooldown object. The player variable is completely optional, and it’s only used for client communication

Recommended usage: One CooldownObject per Player or NonPlayer entity.

CooldownObject.Defaults

You can set this to a custom table with this format:

CooldownObject.Defaults = {
[Key]: Duration(number)
}
Code Example
	Object.Defaults = {
		["Slap"] = 2
	}
	Object:set("Slap")
	--Slap will be on cooldown for 2 seconds
CooldownObject(Key) / CooldownObject:get(Key)

Both do the same; they return True if there’s a cooldown active for Key
get(Key) avoids metatable call overhead and is slightly faster

CooldownObject:set(Key, Duration?)

Puts the key in cooldown for Duration or Default duration (if previously set)
You can also overwrite active cooldowns with this

Special values:

  • Duration = 0 → clears immediately
  • Duration = -1 → infinite cooldown

CooldownObject:SignalGet(Key)

Returns cooldown Ended signal for key or nil if key isn’t in cooldown

CooldownObject:InfoGet(Key)

Returns the cooldown information:

{StartTime: number, Length: number, Ended: Signal}

or nil if key isn’t in cooldown

CooldownObject:SetMultiplier(number)

Sets the cooldown object’s Duration multiplier, and the next cooldowns will have their duration multiplied.

CooldownObject:GetMultiplier()

Returns the current duration multiplier

CooldownObject:Destroy()

Deletes all cooldowns without firing signals and disconnects all internal connections.

Client (Inside CooldownObject module)
Client.CooldownStarted

Signal fired when a cooldown starts

Argument given
{
Key: string,
Length: number,
StartTime: number,
Ended: Signal -- fired when cooldown ends
Updated: Signal -- fired when cooldown is overwritten, it also sends as an argument an updated version of this table.
}

Client.GetMultiplier()

Returns the current cooldown duration multiplier

Client(Key) | Client.get(Key)

Returns true if Key is in cooldown

Code examples

Server

local cooldowns = CooldownObject.new(player)

cooldowns:set("Dash", 3)

if cooldowns("Dash") then
    print("Dash is on cooldown")
end

Client

CooldownObjectClient.CooldownStarted:Connect(function(Cooldown)
	print("Cooldown started with key "..Cooldown.Key.." length "..Cooldown.Length.." at: "..Cooldown.StartTime)
	
	Cooldown.Ended:Once(function()
		print("Cooldown "..Cooldown.Key.." ended")
	end)
	
	Cooldown.Updated:Once(function()
		print("Cooldown "..Cooldown.Key.." overwritten")
	end)
end)

And that’s about it. Please let me know how you like the system. I’ll be updating it occasionally if I find the time.

11 Likes

hey, @epicalyepik. I found your module really interesting, you should make a github page to make the configuration easier :happy2: !

1 Like

A Technical Review

I’m dropping by to give this thread a much-needed bump because, after integrating this into a high-scale modular tool system, I can safely say this is one of the most lightweight and “developer-friendly” cooldown libraries on the DevForum.

:hammer_and_wrench: What makes this stand out (Technical Perspective):

  • Metamethod UX: The implementation of the __call metamethod is a stroke of genius. Being able to check a cooldown status by simply calling the object (if cd("SkillName") then) instead of chaining methods makes the code significantly cleaner and more readable in complex logic trees.
  • Dynamic Scaling: The _SpeedMultiplier is a lifesaver for RPGs or any game with “Haste” or “Slow” mechanics. Being able to globally or locally scale cooldowns without re-calculating every task.wait or os.clock manually is a huge time-saver.
  • Lightweight Signals: The custom Signal class included is efficient and handles the .Ended event perfectly, allowing for seamless sequencing of abilities (e.g., triggering a “Combo Finisher” exactly when a cooldown ends).

:light_bulb: Implementation Note & Edge Cases:

During my stress tests in a Client-First Execution architecture (where the client handles prediction before server reconciliation), I ran into the common Remote event invocation discarded warning.

For those using highly modular initializers where the Server might fire before the Client is fully “ready” to listen:

  1. This isn’t an issue with the library itself, but rather a timing/initialization race condition.
  2. In my specific case, since I handle local prediction and server-side validation independently, I opted to silence the server-to-client firing line:
    -- Custom tweak for pre-emptive client architectures:
    -- Event:FireClient(self._player, {Payload})
    

This kept the server-side state perfectly synced without cluttering the output with discarding warnings.

:chart_increasing: Verdict:

If you are tired of manually managing os.clock() tables and want a robust, OOP-based solution that supports Luau type-checking out of the box, use this.

Huge thanks to @epicalyepik for the contribution. This deserves way more eyes!

3 Likes

Thanks a lot for the detailed review and for putting it through stress tests, really glad to hear it held up well in a larger system.

And yeah, that remote invocation warning is definitely more of an initialization timing thing than anything related to the library itself. Appreciate you mentioning it and sharing your setup!

1 Like