Lua Scripting

Debounce: why your trap takes the player's whole health at once

The Touched event fires several times in a row because of subtle physical collisions. Without debounce, a trap dealing 10 damage takes far more than 10 — and the same applies to sound, collectibles and cooldowns.

by Gustavo Cantino9 min readBeginner

Produced with AI from the sources cited below, and checked by automated verification against those same sources. No person read this text before publication. Editorial Policy

Typographic plate with the Lua Scripting category in large type, the beginner level below it, and a field of vertical lime green bars on a dark background.

You build a trap that takes 10 health. The player steps on it and dies instantly, the whole bar going to zero. The code is right, the number is right, and the result is wrong.

It is almost everybody's first encounter with the problem debounce solves, and the cause is not in your script — it is in the physics.

The problem

The official definition is direct: debounce is the technique that stops a function running too many times or an input firing repeatedly.

The example the documentation uses is exactly the trap: a part that deals 10 damage when touched. The naive implementation connects a function to Touched and subtracts 10 from the Humanoid's health. It reads well, it looks right.

And then comes the sentence that explains the mystery: testing shows that the Touched event fires several times in rapid succession, because of subtle physical collisions.

That is not an engine bug. A character standing on a part does not stay still — it oscillates by fractions of a stud, each leg touches at different moments, the collision resolves and re-forms. Each of those micro-contacts is a Touched. Your 10 damage becomes 10 times the number of contacts, and the number of contacts is not predictable.

The way out, in the documentation itself, is a debounce system that imposes a cooldown period on the damage, by means of an instance attribute.

Step by step

The pattern always has the same shape, in three steps: check whether it is already cooling down, mark that it is, do the work, and unmark after the time.

1. Choose where to keep the state. The documentation uses an instance attribute, and the choice has a non-obvious advantage: an attribute appears in the Explorer during testing. You can watch the debounce state changing on screen, with no print at all.

2. Set the time. The trap example uses a reset time of one second. There is no universal number — the right time is the one that makes the damage happen once per "step", and that depends on your game.

3. Apply it to three families of case. The documentation walks through three, and they cover almost everything:

Collision and damage. The trap case. The attribute goes to true, the damage happens, the time passes, the attribute goes back to false.

Sound. Debounce also serves sound effects — a sound when two objects collide, a sound when a button is clicked. The reason is mechanical: calling the sound's Play() restarts playback from the beginning of the track. Without debounce, the sound restarts on every trigger and turns into a click.

And here the documentation gives a more elegant solution than an attribute: to avoid overlap, the debounce uses the Sound object's IsPlaying property. The state already exists — there is no need to invent another.

Collectible with a cooldown. Games often have collectibles in the 3D world, like medkits and ammo packs. If the collectible should remain in the world to be picked up again, it needs a cooldown before it reactivates.

As with collision detection, the state is managed by an instance attribute. The health collectible example uses five seconds of cooldown and gives back 25 health. And it adds something worth copying: during the cooldown the part becomes semi-transparent, which makes the state visible to the player.

4. Keep the state visible. The collectible example does something worth copying in almost every case: besides blocking, it shows. A semi-transparent part during the cooldown answers, with no text at all, the question the player would ask — "why did that not work?".

That separates two problems that look alike on screen. Without visual feedback, a collectible on cooldown and a broken collectible are indistinguishable to whoever is playing, and the player concludes the second. With feedback, they wait.

The same principle applies to weapon reloads, a door that is closing and a button already pressed: the state has to be legible from the outside, not only true on the inside.

The artifact

The module below separates two things the official example keeps together, and the separation is a game decision, not an implementation one.

The example keeps the state in an attribute on the part. That solves the collectible case perfectly: the object is what becomes unavailable. But applied to a trap, it produces wrong behaviour as soon as two people step on it — while one player is cooling down, the other takes no damage at all, because the cooldown belongs to the part.

So: perPart for when the object becomes unavailable — a collectible, a lever, a door. perPlayer for when what is limited is the person — damage, shots, actions.

The example's third use, the sound, deliberately does not use the module: IsPlaying is already the source of truth, and creating an attribute alongside it would mean keeping two states for one thing.

One point of vocabulary is worth recording, because it confuses anyone searching for this: "debounce" comes from electronics, from the mechanical noise of a contact that bounces as it closes. The parallel is exact — physical collision in Roblox produces the same kind of noise, and the solution is the same: ignore whatever arrives inside a time window after the first valid signal.

Common mistakes

A per-part debounce on a trap. It is the mistake described above, and it is treacherous because a single-person test passes. You test alone in Studio, it works, you publish. With two players, the trap becomes a lottery.

A blocking task.wait inside the handler. The example's pattern uses a wait inside the connected function, and that is acceptable there. But in code that does more, waiting inside the handler holds that whole execution — and since each Touched runs on its own thread, the result is a pile-up of waiting threads. The artifact uses task.delay to unmark, which holds nothing.

Forgetting to unmark when the part disappears. If the part is destroyed during the cooldown, the function that was going to unmark still runs. With an attribute, writing to a destroyed part is a problem; with a table indexed by player, the state hangs around forever. Both fixes are in the artifact — checking Parent before writing, and clearing on PlayerRemoving.

Using debounce where the problem is something else. If the collectible should disappear when picked up, there is no debounce at all: the object is destroyed, and Touched stops firing because there is no part any more. Debounce is for what remains.

A single variable for everything. A local debounce = false at the top of a script serving several objects makes them all share the same state. It is the same scope mistake in different clothes — and harder to see, because the variable looks local.

There is also an architectural decision worth taking early: where the debounce lives. Scattered through the scripts, each object has its own variant, and the third one will already be wrong. In a module, like the artifact's, the rule is a single one and the scope choice is explicit at the call site — whoever reads the code sees whether it limits the part or the person, without having to work it out.

Next step

The cooldown concept extends beyond what the documentation uses as an example, and it says so itself: the same concept serves other cases, such as limiting how often the player fires rockets.

That sentence is the bridge to the pattern's most important use: it stops being a physics fix and becomes a game rule. Weapon cooldown, the interval between abilities, a limit on how many times a chest can be opened — it is all debounce under another name, and it is all balancing.

When debounce becomes a game rule, two things change. The time becomes a number adjusted often, so it should live somewhere easy to change — an attribute, a configuration module — and not be written inside the function. And the state has to be visible to the player: a cooldown the player cannot see is a cooldown they read as the game not responding. The semi-transparent part in the official example is the simplest version of that, and it works.

The exercise for today: look through your code for every Touched that touches health, currency or inventory. Each one without debounce is a random multiplier waiting for two players in the same place.

Sources

How we correct mistakes

Ready-to-use artifact

Reusable debounce, with per-part and per-player scope (ReplicatedStorage or ServerStorage)
lua
-- ModuleScript "Debounce"
--
-- The official example keeps the state in an ATTRIBUTE on the part, and
-- that solves the case of one trap. It stops solving it the moment two
-- people step on the same trap: the attribute belongs to the PART, so
-- while one player is cooling down, the other takes no damage at all.
--
-- This module separates the two scopes, because the choice between them
-- is a GAME decision, not an implementation one:
--
--   perPart   -> the whole part goes on cooldown. A collectible, a lever,
--                a door: the object is what becomes unavailable.
--   perPlayer -> each player has their own cooldown. A trap, damage,
--                a shot: what is limited is the person.

local Debounce = {}

-- ── PER PART scope (the official example's) ─────────────────────────
-- It really does use an attribute: it is visible in the Explorer during
-- Play, which makes the state inspectable with no prints at all.
function Debounce.perPart(part, key, seconds)
	if part:GetAttribute(key) then
		return false
	end
	part:SetAttribute(key, true)
	task.delay(seconds, function()
		if part.Parent then
			part:SetAttribute(key, false)
		end
	end)
	return true
end

-- ── PER PLAYER scope ────────────────────────────────────────────────
local last = {}

function Debounce.perPlayer(player, key, seconds)
	local now = os.clock()
	last[player] = last[player] or {}
	local previous = last[player][key]
	if previous and (now - previous) < seconds then
		return false
	end
	last[player][key] = now
	return true
end

game:GetService("Players").PlayerRemoving:Connect(function(player)
	last[player] = nil
end)

return Debounce

--[[
═══════════════════════════════════════════════════════════════════════
THE THREE USES, with the right scope for each
═══════════════════════════════════════════════════════════════════════

-- 1. TRAP: PER PLAYER. Two players standing on it take damage
--    independently, which is the behaviour the player expects.
local Players = game:GetService("Players")
local trap = script.Parent

trap.Touched:Connect(function(otherPart)
    local humanoid = otherPart.Parent:FindFirstChildWhichIsA("Humanoid")
    if not humanoid or humanoid.Health <= 0 then return end
    local player = Players:GetPlayerFromCharacter(otherPart.Parent)
    if not player then return end

    if Debounce.perPlayer(player, "trap_" .. trap:GetDebugId(), 1) then
        humanoid.Health -= 10
    end
end)

-- 2. COLLECTIBLE WITH A COOLDOWN: PER PART, with the state VISIBLE.
local kit = script.Parent
kit.Anchored = true
kit.CanCollide = false

kit.Touched:Connect(function(otherPart)
    local humanoid = otherPart.Parent:FindFirstChildWhichIsA("Humanoid")
    if not humanoid then return end

    if Debounce.perPart(kit, "CoolingDown", 5) then
        humanoid.Health += 25
        kit.Transparency = 0.75
        task.delay(5, function()
            if kit.Parent then kit.Transparency = 0 end
        end)
    end
end)

-- 3. SOUND: does not need this module. The object already carries the
--    state, and IsPlaying is the source of truth -- inventing an
--    attribute alongside it would create two states for one thing.
local projectile = script.Parent
projectile.Touched:Connect(function()
    local sound = projectile:FindFirstChild("Impact")
    if sound and not sound.IsPlaying then
        sound:Play()
    end
end)
]]
In this article

Keep reading