Simple debounce module

I know there’s a bunch of debounce modules out there, I just wanted to try making something simple for my first community resource.

Usage:

--example preventing remote event spam in some server script
local remote = game.ReplicatedStorage.RemoteEvent

remote.OnServerEvent:Connect(function(player)
    local allowed = bouncer:check(player.Name)
    if not allowed then
        return
    end
--do something here
end
  1. Put module somewhere, I’m putting it in replicatedstorage so both client and server scripts can access it

  2. In script:

local db = require(game.ReplicatedStorage.debounceutilities)
  1. Get bouncer
local bouncer = db.new()

No parameters makes a bouncer that allows 1 check per id, no resetting
optional parameters:

  • Max requests/checks per id (id can be number or string)

  • resetduration (how many seconds until list is reset)

When resetting, all ids (keys) are removed from the bouncer’s list. This prevents memory leaks from new players joining and leaving clogging up the list.

If I want to allow every player to make 5 requests/calls/remote event fires every 30 seconds, I would set up the bouncer like so

local bouncer = db.new(5,30)

Use bouncer:check(id) method to see if that particular id is allowed through. Returns true or false.

if bouncer:check(id) then
--this id hasn't used up all their checks yet
else
--they didn't pass
end

Note: If using a bouncer to guard a remote event listener on a server script, create the bouncer outside the event connection so that all the different firings of the event reference the same bouncer with the same list

local bouncer = db.new(5,30)
...OnServerEvent:Connect(function(player)
    if not bouncer:check(player.UserId) then
        return
    end
   --if they got here they passed
end)
Old Version
--debounceutilities
--CarefreeCarrot 2024

local db = {}

	--STATIC MEMBERS------------
	db.log = {}
	db.__index = db
	----------------------------

	--MEMBER VARIABLES---------------------------
	db.maxrequestsperid = 1

	--false for no resetting
	--otherwise number in seconds since last reset that must pass for it to reset again
	db.resetduration = false

	--auto reset thread closes itself when specific debouncer has been inactive
	--for a while, starts again upon next call to debouncer
	db.resetthreadactive = false
	
	---------------------------------------------

	--------------STATIC MEMBER FUNCTIONS------------------------------------
	function db.new(maxreqperid, resetdur) --returns reference to new anonymous debouncer
		local debouncer = setmetatable({}, db)
		
		--MEMBER VARIABLE (placed here because lua doesn't deep copy tables when metatable templating)
		--reset completely erases id references from list table to prevent memory leaks
		--when players enter and leave over time
		debouncer.list = {}

		--if no params supplied, give default debouncer
		--default debouncer doesn't reset and maxes at 1 req per id
		if type(maxreqperid) == "number" then
			debouncer.maxrequestsperid = maxreqperid
		end
		if type(resetdur) == "number" then
			debouncer.resetduration = resetdur
		end
		return debouncer
	end

	--Gets reference to specific global key-binded debouncer if it already exists
	--Otherwise makes new debouncer and binds to key
	function db.getglobaldebouncer(key)
		if db.log[key] then
			return db.log[key]
		end
		local newdebouncer = db.new()
		db.log[key] = newdebouncer
		return newdebouncer
	end

	function db.destroyglobaldebouncer(key)
		if db.log[key] then
			db.log[key] = nil
		end
	end

	-------------------------------------------------------------------------

	--=================INDIVIDUAL DEBOUNCER OBJECT METHODS=============================
	

	function db:reset()
		for k,v in pairs(self.list) do
			self.list[k] = nil
		end
	end

	--If specific debouncer doesn't reset or reset thread is already active
	--then this method does nothing
	function db:spawnresetthread()
		if (not self.resetduration) or (self.resetthreadactive) then
			return
		end
		self.resetthreadactive = true
		task.defer(function()
			task.wait(self.resetduration)
			self:reset()
			self.resetthreadactive = false
		end)
	end

	function db:check(id)
		--if a reset occurs in the middle of a check, existing value for an id
		--does not get garbage collected as long as a singular reference exists
		--thus, check function creates a temporary reference
		local checks = self.list[id]
		local canproceed = false
		if checks == nil then
			self.list[id] = 0 --for edge cases
			checks = 0 --make into number so incrementation doesn't error
			canproceed = true
		elseif checks < self.maxrequestsperid then
			canproceed = true
		end
		checks = checks + 1

		--spawn reset thread to wait a specific duration of time, then reset
		--list for this specific debouncer object
		--or do nothing if resetting is disabled
		if self.list[id] ~= nil then --if was nil before, should have become 0
			self.list[id] = checks
		end
		self:spawnresetthread()
		return canproceed
	end

	--=======================================================================


return db
New Version
local dbu :DebounceUtilities = {}
	dbu.__index = dbu

	export type debouncer = {
		check			:(self, id :any) -> boolean,
	}

	type DebounceUtilities = {
		CreateAdmitter	:(AdmitUntilThisLimit :number, TimeOut :number) -> debouncer,
		CreateRejecter	:(RejectPastThisLimit :number, TimeOut :number) -> debouncer,
	}

	function dbu.CreateAdmitter(AdmitUntilThisLimit :number, TimeOut :number) :debouncer
		if not AdmitUntilThisLimit then AdmitUntilThisLimit = 1 end
		if not TimeOut then TimeOut = false end

		local NewDebouncer :debouncer = setmetatable({}, dbu)
		NewDebouncer.list = {}
		NewDebouncer.threshold = AdmitUntilThisLimit
		NewDebouncer.TimeOut = TimeOut
		NewDebouncer.ResetThreadActive = false
		NewDebouncer.invert = false
		if not TimeOut then NewDebouncer.ResetThreadActive = true end --Never reset if no timeout provided
		return NewDebouncer
	end

	function dbu.CreateRejecter(RejectPastThisLimit :number, TimeOut :number) :debouncer
		local NewDebouncer = dbu.CreateAdmitter(RejectPastThisLimit, TimeOut)
		NewDebouncer.invert = true
		return NewDebouncer
	end

	local function SpawnResetThread(db :debouncer)
		if db.ResetThreadActive then return end
		db.ResetThreadActive = true
		task.defer(function()
			task.wait(db.TimeOut)
			db.list = {}
			db.ResetThreadActive = false
		end)
	end

	function dbu:check(id :any) :boolean
		local checks = self.list[id]
		if not checks then checks = 0 end
		local pass = checks < self.threshold
		self.list[id] = checks + 1
		SpawnResetThread(self)
		if self.invert then return not pass end
		return pass
	end

return dbu
4 Likes