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
- 1. Why would I even want to do ping compensation?
- 2. How NOT to create a synced Cooldown
- 3. Overengineering fallacy
- 2A. Hypothetically following the improper scenario
- 4. How to create a synced Cooldown!
- 5. Summary & afterword
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:
â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 đ
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 â
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!
- It automatically accounts for ping. Again, ROBLOX does this for you.
- 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!
- 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).
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:
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