Tutorial · Scripting foundations

How to use a debounce in Roblox

You make a part that gives 10 coins when touched, step on it once, and get 80 coins. That is not a bug in your maths: Touched fires again for every body part that touches, and again as the character moves. A debounce is the fix, a small check that ignores repeats for a moment. This guide explains why it happens, then builds the three kinds of debounce you will use everywhere: one for the whole part, one per player, and a cooldown module that also protects RemoteEvents from spam.

Updated 5 November 20266 min readBeginnerLuauWritten by

In short

Keep a variable that says "busy". At the top of the handler, return if it is set; otherwise set it, do the work, wait, and clear it. For something each player can use, keep a table of the last time each player used it, lastUse[player] = os.clock(), and return when os.clock() - lastUse[player] is below the cooldown. Find the player from the touching part with Players:GetPlayerFromCharacter(hit.Parent).

  • A single debounce for the whole part blocks everyone else too. For rewards, debounce per player.
  • Timestamps (os.clock) are simpler and safer than task.wait inside the handler.
  • Any RemoteEvent a player can fire needs a cooldown on the server: a cheater can fire it thousands of times.

Get the script

Touched debounce script with a cooldown per player

A Script inside a part that gives coins on touch, once every few seconds per player, however many body parts touch it.

Script in the CoinPad part named CoinPad
-- Script inside a Part named CoinPad.
-- Gives coins on touch, at most once every COOLDOWN_SECONDS per player.
local Players = game:GetService("Players")

local pad = script.Parent :: BasePart

local COOLDOWN_SECONDS = 5
local COINS = 10

local lastPaid: { [Player]: number } = {}

pad.Touched:Connect(function(hit: BasePart)
	local character = hit.Parent
	local player = if character and character:IsA("Model") then Players:GetPlayerFromCharacter(character) else nil
	if not player then
		return -- an NPC or a loose part
	end
	local now = os.clock()
	if now - (lastPaid[player] or 0) < COOLDOWN_SECONDS then
		return -- this player was paid recently
	end
	lastPaid[player] = now

	local leaderstats = player:FindFirstChild("leaderstats")
	local coins = leaderstats and leaderstats:FindFirstChild("Coins")
	if coins and coins:IsA("IntValue") then
		coins.Value += COINS
	end
end)

Players.PlayerRemoving:Connect(function(player: Player)
	lastPaid[player] = nil -- do not keep players who left
end)

RoCode builds it in the place you have open in Roblox Studio, on Windows or macOS with a desktop browser; on a phone the button sends the prompt and the link to your computer. Free to start, with free credits every day. More Roblox Studio scripts

Where to put it

Explorer: the Script inside the pad
Workspace
  CoinPad Part new
    CoinPad Script new

Settings you can change

COOLDOWN_SECONDSIn the script
How long one player waits before the pad pays them again. Default: 5.
COINSIn the script
Coins per touch. Default: 10.

Coins go into a Coins IntValue in leaderstats, which the leaderboard guide creates.

What a cheater can and cannot doThe cooldown is kept on the server per player, so touching faster or with more body parts never pays more.

Type-checked with luau-lsp strict against Roblox's API definitions on 5 November 2026. Not playtested in Studio yet: press Play and watch the Output window before you publish.

Prompt for RoCode

Make the CoinPad part give 10 coins to a player who touches it, at most once every 5 seconds per player, on the server, using leaderstats Coins, and clean up when players leave.

On this page
  1. Get the script
  2. Why it happens
  3. 1. Simple debounce
  4. 2. Per player
  5. 3. Cooldown module
  6. 4. Remotes
  7. Common mistakes
  8. Common errors
  9. With RoCode
  10. Questions
  11. Sources

Why Touched fires so many times

A character is made of many parts: feet, legs, the torso, accessories. Touched fires once for each part that starts touching, and again when a part stops and starts touching as the character walks or bobs. Roblox's own debounce page puts it simply: Touched can fire several times in quick succession for what looks like one contact. A handler with no debounce runs for every one of them.

Script in a Part in Workspace named TouchCounter
-- Script inside a Part. Step on it once and count the prints.
local pad = script.Parent :: BasePart

pad.Touched:Connect(function(hit: BasePart)
	print("Touched by", hit.Name)
end)

Press Play and walk onto the part: Output shows LeftFoot, RightFoot, LeftLowerLeg and more, all in one step. That is what a debounce filters.

Step 1: The classic debounce

The simplest version uses one true or false for the whole part. While it is true, every touch is ignored. This suits things that should happen once for everyone, such as a trap that fires and resets.

Script in a Part in Workspace named Trap
-- Script inside a Part named Trap.
-- Drops a ball when anyone steps on it, then waits 3 seconds before it can fire again.
local trap = script.Parent :: BasePart

local RESET_SECONDS = 3

local busy = false

trap.Touched:Connect(function(hit: BasePart)
	if busy then
		return
	end
	local humanoid = hit.Parent and hit.Parent:FindFirstChildOfClass("Humanoid")
	if not humanoid then
		return -- not a character: do not start the cooldown for a loose part
	end
	busy = true

	local ball = Instance.new("Part")
	ball.Shape = Enum.PartType.Ball
	ball.Size = Vector3.new(4, 4, 4)
	ball.Position = trap.Position + Vector3.new(0, 30, 0)
	ball.Parent = workspace
	game:GetService("Debris"):AddItem(ball, 5)

	task.wait(RESET_SECONDS)
	busy = false
end)

Set busy before anything that waits, and check for a character first, so a falling brick does not lock the trap. Debris:AddItem removes the ball after 5 seconds without another wait in your code.

Step 2: A cooldown per player

For rewards, one player's cooldown must not block another. Keep a table keyed by player with the time each one last got paid. os.clock() returns seconds with high precision, so the check is one subtraction and the handler never waits.

Script in the CoinPad part named CoinPad
-- Script inside a Part named CoinPad.
-- Gives coins on touch, at most once every COOLDOWN_SECONDS per player.
local Players = game:GetService("Players")

local pad = script.Parent :: BasePart

local COOLDOWN_SECONDS = 5
local COINS = 10

local lastPaid: { [Player]: number } = {}

pad.Touched:Connect(function(hit: BasePart)
	local character = hit.Parent
	local player = if character and character:IsA("Model") then Players:GetPlayerFromCharacter(character) else nil
	if not player then
		return -- an NPC or a loose part
	end
	local now = os.clock()
	if now - (lastPaid[player] or 0) < COOLDOWN_SECONDS then
		return -- this player was paid recently
	end
	lastPaid[player] = now

	local leaderstats = player:FindFirstChild("leaderstats")
	local coins = leaderstats and leaderstats:FindFirstChild("Coins")
	if coins and coins:IsA("IntValue") then
		coins.Value += COINS
	end
end)

Players.PlayerRemoving:Connect(function(player: Player)
	lastPaid[player] = nil -- do not keep players who left
end)

Removing players on PlayerRemoving stops the table from growing for the whole life of the server. If the pad should pay only once per visit, never clear lastPaid[player] until they leave, or use a set: if paid[player] then return end.

Step 3: A cooldown module for everything

Pads, abilities, shop buttons and remotes all need the same check. Put it in one ModuleScript and each script asks it: "may this player do this now?"

ModuleScript in ServerScriptService named Cooldown
-- ModuleScript in ServerScriptService named Cooldown.
-- Cooldown.try(player, "Dash", 2) returns true at most once every 2 seconds for that player and action.
local Players = game:GetService("Players")

local Cooldown = {}

local lastUse: { [Player]: { [string]: number } } = {}

function Cooldown.try(player: Player, action: string, seconds: number): boolean
	local now = os.clock()
	local actions = lastUse[player]
	if not actions then
		actions = {}
		lastUse[player] = actions
	end
	local last = actions[action]
	if last and now - last < seconds then
		return false
	end
	actions[action] = now
	return true
end

function Cooldown.remaining(player: Player, action: string, seconds: number): number
	local actions = lastUse[player]
	local last = actions and actions[action]
	if not last then
		return 0
	end
	return math.max(seconds - (os.clock() - last), 0)
end

Players.PlayerRemoving:Connect(function(player: Player)
	lastUse[player] = nil
end)

return Cooldown

Every server Script that requires it shares the same table, because a ModuleScript runs once per server. So a cooldown set by your pad script is seen by your shop script, which is what you want for an action name they share.

Step 4: Debounce RemoteEvents on the server

A cooldown in a LocalScript only stops honest players. A cheater can call FireServer directly as often as they like, so every remote that does something needs its own check on the server. With the module:

Script in ServerScriptService named DashServer
-- Script in ServerScriptService named DashServer.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")

type CooldownModule = {
	try: (Player, string, number) -> boolean,
	remaining: (Player, string, number) -> number,
}
local Cooldown = require(ServerScriptService:WaitForChild("Cooldown")) :: CooldownModule

local DASH_COOLDOWN = 2
local dashRemote = ReplicatedStorage:WaitForChild("Dash") :: RemoteEvent

dashRemote.OnServerEvent:Connect(function(player: Player)
	if not Cooldown.try(player, "Dash", DASH_COOLDOWN) then
		return -- too soon: ignore silently
	end
	local character = player.Character
	local root = character and character:FindFirstChild("HumanoidRootPart")
	if root and root:IsA("BasePart") then
		root.AssemblyLinearVelocity += root.CFrame.LookVector * 60
	end
end)

Keep a matching cooldown in the LocalScript too, so the button greys out and honest players see why nothing happened; the server one is the one that counts. Ignoring early requests silently is fine. Kicking for them is not: lag can bunch up two honest presses. The RemoteEvents guide covers the other checks a remote needs.

Common debounce mistakes

What goes wrong, and the fix
MistakeWhat happensFix
Setting busy = true after a waitSeveral touches get through before it is set.Set it first, then do the work.
One busy for a reward padWhile one player is paid, nobody else can be.Use a cooldown per player.
Starting the cooldown for any partA rolling brick locks the pad for everyone.Check for a character or player first.
An error before busy = falseThe part stays locked forever.Use timestamps, which need no reset, or wrap the work in pcall.
Cooldown only in the LocalScriptCheaters fire the remote as fast as they like.Check it on the server.
Never clearing the tableIt grows with every player who ever joined.Clear entries on PlayerRemoving.

Common errors and fixes

Open the Output window (Window menu, then Output) before you press Play.

It still fires several times

The debounce check runs after something slow, or each touch is a different player. Put the check at the very top of the handler, and print the player's name to see who is triggering it.

It works once and never again

The handler set busy = true and then errored or returned before setting it back. Look for a red line in Output, or switch to the timestamp version in Step 2, which never gets stuck.

attempt to index nil with leaderstats

The touching part was not a player's character, so player is nil. Return early when GetPlayerFromCharacter gives nil. See attempt to index nil.

Build it with RoCode

If you would rather not fix every pad by hand, RoCode can do it in the place you have open. RoCode is an independent AI agent for Roblox Studio, not made by Roblox: you describe the change in a web chat, and its Studio plugin edits the scripts in your Explorer.

You type

My coin pads pay out many times per step. Add a 5 second cooldown per player to every Touched reward script in my game, kept on the server, and add a server cooldown to my Dash RemoteEvent.

RoCode does
  • Can find every Touched handler and remote with Search Scripts before it changes anything
  • Edits the scripts in place and is told to compile-check each one with Check Script
  • Sets an undo point before and after each batch of changes, so Ctrl+Z steps back one batch at a time

With plugin 3.9.15 or newer RoCode can control a test character that walks onto pads, but it checks only what it chose to test, so step on each pad yourself too. How RoCode connects to Studio.

Questions

What is a debounce in Roblox?

A check that ignores an event for a short time after it last ran, so one touch or one click does the action once. Usually a boolean or a timestamp.

Why does Touched fire multiple times?

Each body part that touches fires it, and parts touch again as the character moves. A single step can fire it ten times or more.

How do I make a cooldown in Roblox?

Save os.clock() when the action runs, and next time return early if less than the cooldown has passed. Keep one saved time per player for things each player uses.

Should I use task.wait in a debounce?

It works for the simple version, but timestamps are better: the handler never pauses, nothing can get stuck locked, and you can tell the player how long is left.

Sources and further reading

How the code was checked: every script on this page passes Luau's strict type checker against Roblox's API definitions (luau-lsp, 5 November 2026). A type check catches misspelt APIs and wrong types, not game logic, so playtest in Studio before you publish.

Try RoCode on your own place.

Free every day, no card required. Install the plugin, describe the feature, and review what lands in Studio.