How to actually sync Cooldowns between Server and Client

Introduction

I often see questions and problems regarding syncing cooldowns for the Server and the Client because developers want to achieve the most crisp and responsive gameplay in their experiences.

With the amount of info going around this topic and lack of a clear answer I hope this thread will clear the air around how to, and how NOT to handle Cooldowns.

Table of Contents

So before you dive in to the “How to”, first ask yourself:

1. Why would I even want to do ping compensation? 💡


Take a step back and remember which platform you’re developing on. ROBLOX isn’t a game to the likes of DOTA 2 or Valorant. While those games definitely use some form of ping compensation it’s because those engines are almost definitely built in a way that they require ping compensation. Basically, it’s a completely niche and specialized system of a game that’s been built to serve one exact purpose.


Now take ROBLOX, which has been engineered from the ground up to be a “one size fits all”. Creating “ping compensation” is not necessary since it’s not applicable to the engine. It doesn’t need ping compensation, and any attempt that you introduce would likely be more detrimental and resource intensive than just ignoring it and letting ROBLOX handle it for you.

And in general, why would you want to compensate for ping in a cooldown? To get more accurate countdown timers? That’s bad practice. As for why…

2. How NOT to create a synced Cooldown ❌


When researching Cooldowns you’ve definitely heard the terms os.time() and os.clock() thrown around a lot.
Or, something along the lines of “pinging the client with a os.clock(), subtracting it from unix and offsetting the Cooldown by the amount”.
Or maybe just using os.clock() as a general debounce, e.g.:

local t = os.clock() + 3

print(t > os.clock()) -- Has (old time + 3 seconds) passed current time? False

task.wait(4) -- Wait 4 seconds

print(t > os.clock()) -- Has (old time + 3 seconds) passed current time? True

While all of those, and variations thereof (tick(), os.time()) are okay ways to determine whether something has happened in a short amount of time or a certain amount of time has passed, it is not a good way to create a Cooldown for your ability/abilities.


Consider the following example:

local t = 0
tool.Activated:Connect(function() -- Tool activated, e.g. ability used/sword swinged/gun shot
	if t > os.clock() then
		return -- Do not run the function if on Cooldown
	end
	t = os.clock() + 3
	SomeAwesomeFunction()
end)

How would you convey this debounce/cooldown to your Player?

When asking this question in any given programming assistance text forum or direct message board, you would unanimously hear a choir:

“Remote events!”

No.
Contrary to the popular belief, common sense and years of advice on the platform, you explicitly do not want to use Remote Events in this instance.

Why would you not use Remote Events? Let’s update our pseudocode and review what our little system will do.

local RemoteEvent = game.ReplicatedStorage.RemoteEvent
local t = 0
tool.Activated:Connect(function() -- Tool activated, e.g. ability used/sword swinged/gun shot
	if t > os.clock() then
		return -- Do not run the function if on Cooldown
	end
	t = os.clock() + 3
	-- We have to notify our Player of the cooldown!
	RemoteEvent:FireClient(Player,{Argument = ["Cooldown"],["Duration"] = "3"})
	SomeAwesomeFunction()
end)

-- In a LocalScript:
local RemoteEvent = game.ReplicatedStorage:WaitForChild("RemoteEvent")

RemoteEvent.OnClientEvent:Connect(function(data)
	if data["Argument"] == "Cooldown" then -- (This Remote Event signal sends over a "Cooldown" identifier which helps us recognize which line of code to execute.)
		print(data["Duration"]) --> 3
	end
end)

All right! Now the client knows that there’s a Cooldown that will take three seconds to run out.
Now looking at this code your average programming brain would conclude:

:warning: “Hey, since I have a Remote that fires to the Client with the necessary info, this only means I now have to construct a Cooldown visual on the Client that displays the Duration!”

Let’s follow up this logic and update our pseudocode:

local RemoteEvent = game.ReplicatedStorage.RemoteEvent
local t = 0
tool.Activated:Connect(function() -- Tool activated, e.g. ability used/sword swinged/gun shot
	if t > os.clock() then
		return -- Do not run the function if on Cooldown
	end
	t = os.clock() + 3
	-- We have to notify our Player of the cooldown!
	RemoteEvent:FireClient(Player,{Argument = ["Cooldown"],["Duration"] = "3"})
	SomeAwesomeFunction()
end)

-- In a LocalScript:
local RemoteEvent = game.ReplicatedStorage:WaitForChild("RemoteEvent")

RemoteEvent.OnClientEvent:Connect(function(data)
	if data["Argument"] == "Cooldown" then -- (This Remote Event signal sends over a "Cooldown" identifier which helps us recognize which line of code to execute.)
		print(data["Duration"]) --> 3
		-- Let's create a visual cooldown on our screen!
		*creating a GUI, TextLabel*
		TextLabel.Text = data["Duration"]
		-- Well, this clearly won't count down, so we also have to add something to loop it:
		for i = data["Duration"],0,-1 do
			task.wait(1) -- Wait 1 second per iteration
			TextLabel.Text = tostring(i) -- Remember to convert number -> text
		end
	end
end)

Awesome! Now we have a Tool, with a Server cooldown - so it won’t be exploitable, and it sends a Remote to the Client that will construct it’s own Cooldown on their screen using the info provided!

Wait. My cooldown is out of sync to the server. Why is this?

The Client’s for loop / while true do / repeat until / … works locally on your machine and does not account for the Server’s actual runtime or the ping difference it took between those two events (Tool activated, Remote received).

Gotcha. So I just have to research ping compensation! Looks like I just have to offset the amount of time it took the remote to… oh…

Welcome to the “How to actually sync Cooldowns” thread! When researching Cooldowns you’ve definitely heard the terms os.time()…

Note

(You can follow this hypothetical scenario in 2A)


As you can see, you can very quickly pidgeonhole yourself into thinking a certain way. You automatically take the same road step by step because hey, you’ve always done it this way and that’s how it works, right?

Or maybe you didn’t have to trace the pseudocode and came to the answer immediately as soon as you read “RemoteEvent”.

Either way, you’ve come to the wrong conclusion. And this in it’s purest form is the…

3. Overengineering fallacy 📐


(Overengineering - Wikipedia)

Basically, you’ve reached the skill ceiling (congratulations!) of a well rounded programmer, but this caused you to lose the ability to see the world the same way as a non-programmer would (oof!). You’ve started automating everything you do so you cannot come to a different conclusion – and this builds incredibly bad habits, like the one above.
Our entire goal had a fascinatingly simple problem:

  • Create a Cooldown.
  • Display that Cooldown.

While we automatically expanded it to:

  • Create a Cooldown.
  • We have to display that cooldown somehow.
    → Use a Remote Event (general replication rule of thumb).
  • We display the Cooldown, but now it’s out of sync.
    → Create a lag compensation system/formula.

2A. Hypothetically following the improper scenario ❌


:warning: And that’s without even tackling the other issues that would arise from this system!

Here, let’s update our pseudocode once again:

-- LocalScript:
local RemoteEvent = game.ReplicatedStorage:WaitForChild("RemoteEvent")

RemoteEvent.OnClientEvent:Connect(function(data)
	if data["Argument"] == "Cooldown" then
		print(data["Duration"]) --> 3
		*creating a GUI, TextLabel*
		TextLabel.Text = data["Duration"]

		-- Alright, we have to fix our desync issue:
		-- Let's assume I already updated the Server to also send "os.clock".
		local ClientTime = os.clock() --> Get local client's os.clock
		print(ClientTime) -- e.g. 1234.95
		print(data["CurrentTime"]) --> e.g. 1234.90
		
		ClientTime = ClientTime - data["CurrentTime"] -- Calculate lag
		print(ClientTime) --> 0.05: The remote took 0.05 seconds

		local NewTime = data["Cooldown"] - ClientTime -- Compensate latency (3 - 0.05)

		for i = NewTime,0,-1 do -- Count down from Cooldown - lag compensation
			task.wait(1)
			TextLabel.Text = tostring(i)
		end
	end
end)

This is what lag compensation would look like. So we’ve basically fixed laggy cooldowns now! Right?

In this case, no amount of lag compensation will fix what happens after the lag is compensated. And a Player with a lossy connection might have regular ping spikes.
A consistent lag between the Tool Activated and Remote Received can easily be migitated, but if the Player lags after the Remote is received the Cooldown will still be out of sync, because the local Client will count down it’s compensated Cooldown to 0 while having a major lag spike.

This is a massive UX and UI problem, since you don’t ever want to have your Player see that they can use an ability, but not be able to use it.

Unless if you want to do lag compensation for every second that passes on the Client. Good luck on that front.

Also consider what would happen if you e.g. wanted to reset the Cooldown because someone has an ability to reduce your cooldowns, or you wanted to reward the Player for landing a hit by shortening the Cooldown, or any sort of manipulation on the Cooldown. That’s double the amount of work we’ve already done.

4. How to create a synced Cooldown! ✅


The single best way to sync cooldowns is to implement Server-Authoritative Cooldowns and no questions asked.

To get to the meat and bones, create an Int/NumberValue and put it somewhere that the Client can access.
Create a listener function:

ReplicatedStorage.Value.Changed:Connect(function(Value)
	print(Value)
end)

Set a Cooldown on the Server:
ReplicatedStorage.Value.Value = 5

Now count down the cooldown on the Server:

for i = ReplicatedStorage.Value.Value,0,-1 do
	task.wait(1)
	game.ReplicatedStorage.Value.Value = i
end

Voila! Now check your output:

5
4
...

No Remote Events, no ping compensation, no arguments. A clean and elegant .Changed Event and a single number.

But ping compensation! This won’t account for ping!

  1. It automatically accounts for ping. Again, ROBLOX does this for you.
  2. You do not want to create a separate Cooldown on the Client and on the Server because that’s what causes the desync in the first place!
  3. The Server should be the ultimate judge, jury and executioner about at which second the Cooldown currently is.

Won’t this impact performance? For loops on a Server? I heard it’s bad practice.

Yes, any loop in general on the Server will mostly be considered bad practice. It usually impacts performance.

However, the for loop in our pseudocode handles changing an IntValue’s Value every second. Nothing more resource intensive than a calculator that prints you the output of (2 + 2).


:white_check_mark: And for the best part, since you most likely will want to be using a progress bar for your Cooldown GUI!
Update your listener function:

local Highest = 0 -- Make sure we don't fill the whole bar every iteration
ReplicatedStorage.Value.Changed:Connect(function(Value)
	if Highest <= Value then -- if 5 <= 5/4/3/2/1/0, true only once
		Highest = Value
		ProgressBarFrame.Size = UDim2.new(1,0,1,0) -- Fill entire bar
	end
	-- Tween our progress bar:
	-- UDim2.new(1,0,1,0) -> Full bar, UDim2.new(Value / Highest, 0, 1, 0) -> 5/5 of Bar, 4/5 (0.8) of bar, etc.
	ProgressBarFrame:TweenSize(UDim2.new(Value / Highest,0,1,0),"Out","Linear",0.2,true)

	-- Alternatively just change a TextLabel:
	TextLabel.Text = tostring(Value) -> "5", "4", etc.
end)

By tracking these Cooldowns you can easily start/resume/stop each one individually, add to them or remove from them, or even turn them to 0 instantly!
This implementation is also incredibly API friendly, since changing the Cooldown is as simple as doing ShootCooldown.Value = 3.

As for consistent latency or lag spikes:
Square green progress bar, white background, snapping from 4 seconds to 1

The Progress Bar/Text will automatically “snap” to the next iteration, which means if the Cooldown was to be visualised 3 seconds late it would appear to be 3 seconds in without manually compensating for said latency.

5. Summary & afterword 👍


I hope this guide has taught you something new! Use this info however you will, and remember to always look at the bigger picture! If you have to create a very elaborate system to achieve something that sounds simple chances are you’re falling into the overengineering trap again.

Thank you for reading!

Plug

I’m using this system in my own PvP/PvE game (Link) and would appreciate it if you dropped in, gave it a go and left a like :sunglasses:

18 Likes

The problem with usage of Values and changing it on server is that you moving work from client to server:
Script way:
image
And this fires ONCE!
Ofc, client still need to make loop for animations, but it won’t affect other players, which will result in less lag.

But in your way, we creating loop on server, which will take up resources, and it won’t free them until cooldown ends. ALSO, your way triggers replication every time value got changed.

So, your tutorial is good, BUT until value part’s idea. It can be easier to someone to understand and make, but this will be costly…

3 Likes

Heres a better way that wont result in your game having a high recv usage, Im going to use workspace:GetServerTimeNow() instead of os.clock() because it returns server’s time even when used in the client, that way we can sync cooldown between server and client

-- Module script
local module = {}
module.Actions = {
    Cooldown = 1
}

module.SkillMoves = {
    Test = {Duration = 3}
}

-- Server script
local started = workspace:GetServerTimeNow()
remote:FireClient(player, {module.Actions.Cooldown, started, "Test"})

-- Client script
remote.OnClientEvent:Connect(function(content)
    local action = content[1]

    if action == module.Actions.Cooldown then
        local started, skillName = table.unpack(content, 2)
        local skillInformation = module.SkillMoves[skillName]

        local textlabel = ... -- create a textlabel to visualize cooldown

        local c
        c = RunService.heartbeat:Connect(function()
            local remaining = math.floor(skillInformation.Duration-(workspace:GetServerTimeNow()-started))
            textlabel.Text = `{skillName} {remaining}`

            if remaining <= 0 then
                c:Disconnect()
                c = nil
                textlabel:Destroy()
                textlabel = nil
            end
        end)
    end
end)

there might be some typos that would break the script because I wrote this in devforum

9 Likes

Setting a value from the server just replicates it as it is to the client, no interpolation and no syncing is done, So no this method doesn’t works, in fact it is worse to not use a remote event as you could easily serialize and compress all your data and send it through it, also you can use :GetServerTimeNow() for syncing.

Just no.

3 Likes

Property replication… Is literally just remotes made simple??

Sending a remote from server to client and changing a property from the server for it to replicate will both delay depending on ping.

3 Likes