§ 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 cast refused for lack of mana sends a "denied" message back, and the client flashes the mana bar red instead of appearing to ignore the input. An early cast is dropped; the slot's veil already shows the time left.

Files Picoo ships for this prompt

5 files · 419 lines · one recipe call

ReplicatedStorage.AbilityConfig

cooldown / cost per ability, in one table — the only place you re-balance.

52 lines

ServerScriptService.AbilityServer

Per-player timestamps, flood guard, resource check, and the actual effects.

254 lines

StarterGui.AbilityHUD (ScreenGui)

48 instances built in Edit mode: four ability slots with cooldown veils, and the mana bar.

instance

StarterGui.AbilityHUD.AbilityClient

Keybinds, taps and the cooldown sweep. The countdown is display only.

113 lines

ReplicatedStorage.AbilityRemotes

CastAbility (the client asks) and AbilityState (the server answers) RemoteEvents.

instance

Sample output: ServerScriptService.AbilityServer

-- ---------- the gate ----------
castEvent.OnServerEvent:Connect(function(plr: Player, id: unknown, dir: 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

	local char, hrp, hum = charOf(plr)
	if not (char and hrp and hum) then return end

	local cds = cooldowns[plr]
	if not cds then return end
	local readyAt = cds[id]
	if readyAt and now < readyAt then return end

	local have = resource[plr] or 0
	if have < def.cost then
		stateEvent:FireClient(plr, { kind = "denied", id = id, reason = "resource" })
		return
	end

	local aim: Vector3
	if typeof(dir) == "Vector3" and dir.Magnitude > 0.01 then
		aim = dir.Unit
	else
		aim = hrp.CFrame.LookVector
	end

	resource[plr] = have - def.cost
	cds[id] = now + def.cooldown
	stateEvent:FireClient(plr, { kind = "cast", id = id, cooldown = def.cooldown, power = def.power })
	stateEvent:FireClient(plr, { kind = "resource", value = resource[plr], max = MAX })

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. The files on this page are what Picoo's ability_system recipe generates when you ask for abilities with cooldowns: a registry, a server script that holds the timestamps, and a hotbar that draws the countdown.

Two details matter more than they look. Use os.clock() rather than tick(): os.clock is monotonic, so a clock adjustment on the server 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 cast refused for lack of mana reports that back, so the UI flashes 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 AI Luau script writer, the 3D model generator and the game builder, read how Picoo compares with ChatGPT, 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?+

The pattern does: key the table by player and by the action (tool name, button id), and check it in that RemoteEvent's server handler. The ability_system recipe applies it to the abilities in AbilityConfig; for a tool or a button, ask for the same check in a follow-up prompt. Anything a player can spam needs one.

Related Picoo prompts