How to write cooldowns a client can't reset
Cooldowns kept per character, per argument or timed with a client's clock don't hold. How to key them by Player and measure time on the server.
What it looks like
The server has a cooldown, but it's keyed by or measured with something the client controls, so the client can start it over whenever it likes. Common shapes are a cooldown kept per character, per argument the client sends, or worked out from a time the client sends.
Luau
local lastUse: { [Model]: number } = {} remote.OnServerEvent:Connect(function(player) local character = player.Character if character and os.clock() - (lastUse[character] or 0) >= 5 then lastUse[character] = os.clock() end end)
How to detect it
- For each cooldown, find the table key and the clock.
- The key should be the
Playeror theirUserId. A key from the client, such as an item name, a tool or any other argument, lets them use a new key each time. A character key resets whenever they respawn, which players can usually do from the menu. - The clock should be the server's own
os.clock()oros.time(). A time sent by the client is whatever they say it is. - Check where the cooldown entry is cleared. Clearing it on respawn, on unequip or when a value the client sends changes gives the client a reset button.
- Cooldowns that should last longer than a session, such as daily rewards, belong in saved data. That's Reward claims the server doesn't track in server-validation.
What an exploiter could do
The cooldown doesn't hold, so the remote can be fired as fast as the client likes. Whatever the cooldown was protecting against comes back: lag from repeated effects or new parts, or a reward meant to be slow paid out over and over.
How to fix it
- Key every cooldown by
Player, and clear it inPlayers.PlayerRemoving. - Measure time on the server with
os.clock(). Never use a time the client sends. - If one player's actions need separate cooldowns, like one per ability, key by player first and then by a server-checked ability name.
Vulnerable example
Vulnerable
local ReplicatedStorage = game:GetService("ReplicatedStorage") local cooldown = 5 local lastBlast: { [Model]: number } = {} ReplicatedStorage.Remotes.Shockwave.OnServerEvent:Connect(function(player: Player) local character = player.Character local root = character and character:FindFirstChild("HumanoidRootPart") if not character or not root or not root:IsA("BasePart") then return end local now = os.clock() if now - (lastBlast[character] or 0) < cooldown then return end lastBlast[character] = now local blast = Instance.new("Explosion") blast.BlastPressure = 0 blast.DestroyJointRadiusPercent = 0 blast.Position = root.Position blast.Parent = workspace end)
Fixed example
Fixed
local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local cooldown = 5 local lastBlast: { [Player]: number } = {} ReplicatedStorage.Remotes.Shockwave.OnServerEvent:Connect(function(player: Player) local character = player.Character local root = character and character:FindFirstChild("HumanoidRootPart") if not root or not root:IsA("BasePart") then return end local now = os.clock() if now - (lastBlast[player] or 0) < cooldown then return end lastBlast[player] = now local blast = Instance.new("Explosion") blast.BlastPressure = 0 blast.DestroyJointRadiusPercent = 0 blast.Position = root.Position blast.Parent = workspace end) Players.PlayerRemoving:Connect(function(player) lastBlast[player] = nil end)
Common false positives
- Cooldowns that are meant to reset on respawn, like an ability that recharges when you die, are fine when respawning itself is slow or limited. Check how quickly a player can respawn.
- Per-ability cooldowns keyed by player and an allowlisted ability name are fine.
- A client timestamp used only to order messages, like the newest position wins, isn't a cooldown.