§ 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