§ How to · with Picoo
How to make a cooldown system in Roblox
By Sametcan Tasgiran, Founder & Developer·Published ·Updated
A cooldown is the cheapest anti-exploit in Roblox and the one most often written where it cannot work: on the client.
If the timer lives in a LocalScript, it is a suggestion. The pattern below keeps the timer where the player cannot reach it, and still feels instant.
Per-player, per-ability
A table keyed by player, then by ability id — one player's cooldown never blocks another's.
os.clock, not tick()
Monotonic and unaffected by clock changes, so a system time adjustment cannot skip a cooldown.
Visible countdown
The client draws a sweeping veil and a countdown, purely as display — the server holds the truth.
Flood guard
A hard floor between casts (100ms) catches remote spam before the per-ability check even runs.
Resource cost
Cooldown plus mana: the second constraint that stops burst abuse when several abilities are off cooldown at once.
Denied feedback
A refused cast tells the client why, so the UI can flash instead of appearing to ignore the input.
Files Picoo ships for this prompt
3 files · 422 lines · ~45s · 2 credit
AbilityConfig
cooldown / cost per ability, in one table — the only place you re-balance.
53 lines
AbilityServer
Per-player timestamps, flood guard, resource check, and the actual effects.
255 lines
AbilityClient
Keybinds and the cooldown sweep. Display only.
114 lines
Sample output: ServerScriptService.AbilityServer
local cooldowns: { [Player]: { [string]: number } } = {}
local lastCast: { [Player]: number } = {}
castEvent.OnServerEvent:Connect(function(plr: Player, id: unknown)
if typeof(id) ~= "string" then return end
-- Flood guard: no legitimate client casts twice within 100 ms.
local now = os.clock()
if lastCast[plr] and now - lastCast[plr] < 0.1 then return end
lastCast[plr] = now
local def = Config.byId(id)
if not def then return end
-- The cooldown itself. os.clock() is monotonic — a player changing their
-- system clock cannot skip it, which tick()-based cooldowns allow.
local cds = cooldowns[plr]
local readyAt = cds and cds[id]
if readyAt and now < readyAt then return end
if (resource[plr] or 0) < def.cost then
stateEvent:FireClient(plr, { kind = "denied", id = id, reason = "resource" })
return
end
cds[id] = now + def.cooldown
resource[plr] -= def.cost
stateEvent:FireClient(plr, { kind = "cast", id = id, cooldown = def.cooldown })
end)Building a cooldown system in Roblox
Almost every Roblox cooldown tutorial shows a boolean in a LocalScript. It works when you test it, because you are not attacking your own game. The moment someone does, the timer they are supposed to obey turns out to live on their machine — and firing the RemoteEvent directly skips it entirely.
The fix is not complicated, it is just placed correctly: the server keeps a table of per-player, per-ability timestamps and refuses anything that arrives early. The client still draws a countdown, because a hotbar with no visible recharge feels broken — but that countdown is presentation. If the client shows the ability as ready a moment too soon, the worst case is one refused cast.
Two details matter more than they look. Use os.clock() rather than tick(): os.clock is monotonic, so a player changing their system clock cannot pull a stored ready-time into the past. And put a flood guard in front of everything — a hard 100ms floor between casts from one player — so remote spam is dropped before it reaches the per-ability logic at all.
Cooldown alone is also rarely enough. When several abilities come off cooldown together you get a burst that no single timer constrains, which is why the ability system pairs it with a resource cost: mana bounds the total, cooldown bounds the repeat. A refused cast reports its reason back, so the UI can flash the bar red instead of appearing to ignore the keypress — silence reads as a bug, and a player who thinks the game is broken stops playing before they complain.
See more on the Luau generator, the game builder, or browse the full blog.
Frequently asked
What is the difference between a debounce and a cooldown?+
A debounce stops the same event firing twice for one action — .Touched raising 5-30 times for a single contact, for example. A cooldown is a gameplay rule: this ability may be used once every N seconds. They look like the same `if busy then return end` shape, but a debounce guards correctness while a cooldown guards balance, and only one of them is worth defending against a player.
Why not just handle the cooldown in the LocalScript?+
Because the LocalScript belongs to the player. Exploiters run their own code in that context and fire your RemoteEvent directly — the client's timer is not consulted. Keep it in the client for the visible countdown, but the server has to keep its own.
os.clock() or tick()?+
os.clock(). It is monotonic — it counts forward regardless of the system clock. tick() follows wall time, so changing the machine clock can make a stored `readyAt` already in the past. os.time() has the same problem at second resolution.
How do I show the countdown without trusting the client?+
Send the cooldown length once, when you approve the cast, and let the client animate locally from it. If the client lies to itself and shows the ability as ready early, its next cast is simply refused — a UI that is wrong for 200ms costs nothing, a server that is wrong costs balance.
Does this work for tools and clicks too?+
Yes — the shape is identical. Key the table by player and by whatever the action is (tool name, button id), and check it in the server handler for that RemoteEvent. Anything a player can spam needs one.
Related Picoo prompts
an ability system with cooldowns in Roblox
If the cooldown lives on the client, it is a suggestion. Every cooldown, cost and damage number here is enforced on the server.
How to prevent exploits in Roblox
4 files · 160 lines · 44 seconds · 1 credit. Drop-in guards + a one-prompt audit of your existing scripts.
Roblox combat system
5 files · 140 lines · 42 seconds · 1 credit. Drop into your place and press Play.