Event counting 101: The secret to a perfect door and more

:star2: This is a tutorial on event counters or event IDs. This concept is related to debounce or cooldowns. In this post, I’m going to give some examples of event counting and break the idea down.

Event counting is keeping track of how many times an event fires.
It’s often useful whenever there’s an intentional delay before some action triggered by an event.

Visualization [link to working demo]

Motivation

A great example is an automatically-closing door.

A clickable door

However, in this example, we still want players to be able to open and close the door rapidly.

Door swinging

Wait, can't I just...?

:x: Wrong method: It may seem easy enough to write something like the following (incomplete) code.

local function onMouseClick()
	-- open/shut door
	if closed then 
		openDoor() 
	else 
		shutDoor() 
	end
	closed = not closed

	-- wait some time and close the door
	task.wait(4)
	if not closed then
		shutDoor()
	end
end

detector.MouseClick:Connect(onMouseClick)

But this wouldn’t work! When players click too quickly, it displays unstable behavior. In the GIF below, the last three clicks should only open the door, but the door opens and closes rapidly.

Incorrect door swinging

Example solution [link]

I give my full solution to the example problem below. There’s also a working model here.

-- Services
local TweenService = game:GetService("TweenService")

-- Objects
local doorModel: Model = workspace.DoorModel
local sounds: Folder = doorModel.Sounds
local door: BasePart = doorModel.Door
local hinge: BasePart = doorModel.Hinge
local detector: ClickDetector = door.ClickDetector

-- Constants
local hingeClosedRotation = hinge.CFrame
local hingeOpenRotation = hinge.CFrame * CFrame.Angles(0, math.rad(90), 0)
local infoIn = TweenInfo.new(sounds.Open.TimeLength, Enum.EasingStyle.Cubic, Enum.EasingDirection.In)
local infoOut = TweenInfo.new(sounds.Close.TimeLength, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
local tweens = {
	Open = TweenService:Create(hinge, infoIn, { CFrame = hingeOpenRotation }),
	Close = TweenService:Create(hinge, infoOut, { CFrame = hingeClosedRotation }),
}
local DEBOUNCE_DELAY = 0.2
local DOOR_CLOSE_DELAY = 4

-- Variables
local doorAction = "Open"
local currentEventId = 0 -- event counter
local debounce = false

-- Functions
local function toggleDoor()
	tweens[doorAction]:Play()
	sounds[doorAction]:Play()
	doorAction = (doorAction == "Open") and "Close" or "Open"
end

local function autoShutDoor(eventId: number)
	if eventId == currentEventId and doorAction == "Close" then
		toggleDoor()
	end
end

local function onMouseClick()
	if debounce then return end
    debounce = true

    -- open/shut door
    toggleDoor()
    -- stamp the event with an id
    currentEventId += 1
    -- start shut door timer
    task.delay(DOOR_CLOSE_DELAY, autoShutDoor, currentEventId)

    task.wait(DEBOUNCE_DELAY)
    debounce = false
end

-- Events
detector.MouseClick:Connect(onMouseClick)

Why it works

Before getting into event IDs, it’s helpful to first understand debounce. Debounce essentially means the same thing as cooldown. There are a lot of great resources out there on that topic already, but I’ll give a quick refresher here.

What is debounce?

Debounce

To “debounce” something is to make sure it doesn’t happen “too much.” This is common when an event may fire many times though we only needed one, or in general when an event is faster than wanted.

Example

Let’s say the goal is to make a part get a little bit more invisible every time it’s touched.
Try putting this code in a Script inside a Part:

script.Parent.Touched:Connect(function()
	script.Parent.Transparency += 0.1
end)

But when the Halloween Cat touches the brick, it disappears a bit too quickly. :face_with_diagonal_mouth:

The brick disappears too quickly

The solution is to add a debounce!

local debounce = false -- create a debounce variable
script.Parent.Touched:Connect(function()
	if debounce then return end -- if debounced, then don't continue!
	debounce = true -- otherwise, start debouncing

	script.Parent.Transparency += 0.1

	task.wait(0.2) -- wait 0.2 seconds
	debounce = false -- stop debouncing
end)

The brick fades gradually

Well, it’s better anyway.

Debounce in terms of state machines

Note that debounce involves both a single global “state” and the events that request that state. Though an event may trigger many times, there’s just one state. The machine uses its state to figure out what needs to happen with the events.

In debounce, the behavior of the machine is like this:

State: The state reads either on :heavy_check_mark: or off :x:.

Procedure when an event triggers:

  • If the state is on :heavy_check_mark:, do nothing.
  • If the state is off :x:, turn the state on and do the action!

After event action:

  • Wait some time.
  • Turn the state back off :x:.

You could interchange the “on” and “off” states and it would make no difference.

Event counting

In event counting, the behavior of the machine is a bit different. There’s typically something that happens after a delay.

The key idea is that each thread gets its own “ID” and only the current thread ID allows the final action to occur.

Here’s an outline:

State: The state can take on an unlimited number of values (0, 1, 2, 3, …)!

Procedure when an event triggers:

  • Go to the next state (so 1 goes to 2 and 2 goes to 3, etc.).
  • Remember the new state by storing it in a local variable eventState.
  • Do main action.

After event action:

  • Wait some time.
  • Check if the current state equals eventState.
  • If they are equal :heavy_check_mark:, do another action!
  • If they are not equal :x:, do nothing.

So rather than being a machine with two configurations like debounce, event counters have potentially infinitely many configurations.

Step-by-step

Here’s how to count your events properly, step by step.

  1. Connect to any event.
    For example, create a Part. Then insert a ClickDetector into it, and also put this code in a Script inside the Part.
local part = script.Parent
local detector = part.ClickDetector

detector.MouseClick:Connect(function()
	print("Click detected!")
	task.wait(4)
	print("It's been four seconds since I detected some click.")
end)
  1. Create a state variable outside of the event function.
local state = 0
  1. Rewrite the event with the following additions:
    Add one to the state and store the result in a variable.
    Then after waiting, check if the state is equal to the variable.
    (Everything else can stay the same.)

Here’s the end result:

local part = script.Parent
local detector = part.ClickDetector
local state = 0

detector.MouseClick:Connect(function()
	state += 1 -- update state
	local eventState = state -- store state

	print("Click detected!")
	print("Number of clicks: " .. state)

	task.wait(4)

	-- check if states are equal before continuing
	if state ~= eventState then return end
	-- states were equal, so we can do an action:
	print("It's been four seconds since the *very last* click.")
end)

Voila! That’s an event counter.

I hope this helps! Feel free to ask for clarification on anything.

29 Likes

while this is technically a nice tutorial/suggestion, it’s kind of misplaced in the world of oop or just having reliable code

3 Likes
local debounce = false -- create a debounce variable
script.Parent.Touched:Connect(function()
	if debounce then return end -- if debounced, then don't continue!
	debounce = true -- otherwise, start debouncing

	script.Parent.Transparency += 0.1

	task.wait(0.2) -- wait 0.2 seconds
	debounce = false -- stop debouncing
end)

Sorry for having bad scripting skills since I am still a beginner learning things like debouncing. I have a question about this script.

Why would you put if debounce then return end -- if debounced, then don't continue! if the debounce has already been set to false near the end of the function? Since it says debounce = false -- stop debouncing

Increase the wait time by a significant amount and it becomes obvious

1 Like

Even though the code could be more reliable or clean, I think that this is a simple but useful resource that many people should check out before making debounce; doors sometimes get pretty annoying from game to game :smirk:

1 Like

Hi! To answer your question directly, the important thing is that return stops the function.

So if debounce then return end says, “If I’m currently debounced (i.e. cooling down), then don’t run any of the code below this, up to the end of the function. If I’m not debouncing, then run everything below this.”

That means that the snippet

	debounce = true -- otherwise, start debouncing

	script.Parent.Transparency += 0.1

	task.wait(0.2) -- wait 0.2 seconds
	debounce = false -- stop debouncing

will only run when the event triggers and not already debouncing.

Here’s a GUI visualization of debounce. Note that clicking has no effect while debounced.

Debounced button

Let me know if that clears things up.

Hey! I’ve read the post and have understood a little bit of event counter. Here are my questions:

  • What’s difference between debounce and event counter?
  • What are the benefits of event counter?
  • When do I use event counter
  • Are there occasions where using debounce is better than event counter?

I really hope you could clear these up! Would love more real-world problem examples in the future.

1 Like

Thanks for the feedback. The point of the post is to introduce debounce/event counting as a way of thinking about how to overcome common problems involving events. I presented this from a procedural, shared-state perspective for the sake of simplicity. In real applications, it may be better practice to encapsulate that state rather than let it hang freely as a variable in the script-level scope. In the docs article on debounce, a similar technique is used, except it uses GetAttribute/SetAttribute to store the debounce state.

So a more object-oriented approach for this is available. As a general idea, however, I think that this is a good approach to the class of problems it solves. If you want to modularize and/or improve on these ideas, please, feel free to! My code here is just for sake of example. And let me know if you think I’m missing something.

I was messing around with this concept and created cooldowns and multiple different actions depending on the state:

local clickDetector = game.Workspace.ClickPart.ClickDetector
local cooldownEnd = game.Workspace:GetServerTimeNow()
local cooldown = 1
local yieldCounter = 0
local eventCounter = 0
local eventsTable = {
	function ()
		print("This is action 1")
	end;
	function ()
		print("This is action 2")
	end;
	function ()
		print("This is action 3")
	end;
	function ()
		print("It's been two seconds since the *very last* click.")
	end,
}

local function YieldLastClick()
	local yieldState = yieldCounter
	local yieldEnd = game.Workspace:GetServerTimeNow() + 2
	while yieldState == yieldCounter do
		local currentTime = game.Workspace:GetServerTimeNow()
		if currentTime >= yieldEnd then
			eventsTable[4]()
			break
		end
		task.wait()
	end
end

local function ClickedPart(player)
	local currentTime = game.Workspace:GetServerTimeNow()
	if currentTime >= cooldownEnd then
		eventCounter += 1
		if eventCounter > 3 then eventCounter = 1 end
		cooldownEnd = currentTime + cooldown
		eventsTable[eventCounter]()
		yieldCounter += 1
		YieldLastClick()
	end
end

clickDetector.MouseClick:Connect(ClickedPart)

I haven’t had a problem with testing it, but it seems as though a race condition can very rarely occur. If a player clicks the click detector on the exact same frame the YieldLastClick believes the cooldown has ended from a previous click, what happens? I suppose I could avoid this entirely by offsetting the yieldEnd time by +/- a frame, but it seems kinda hacky to me.

Maybe I’m too busy worrying about something that won’t really matter which way it decides since the effect will not be noticeable. It will either look like the action was done before the player clicked again, or that the player reset the cooldown with their click. I suppose if I must have only one action at the end of the two seconds then I could simply set the yieldCounter to something else to guarantee only one action is used:

while yieldState == yieldCounter do
	local currentTime = game.Workspace:GetServerTimeNow()
	if currentTime >= yieldEnd then
		eventsTable[4]()
		--Will cancel the other thread if this one runs first
		--Will be canceled itself if the other runs first
		yieldCounter += 1
		break
	end
	task.wait()
end

Still seems hacky.

Great tutorial though, it got me thinking of different ways I could use it!

1 Like

Nice catch! This is a good question. I think the answer is it ends up being okay because of the priority of the task scheduler. Looking at the task scheduler priority included in this link:

The user input events are the first to occur. It appears that the specific tag is Render/PreRender/UpdateInput (see the tag table).

Whether you’re using the task library as in task.wait or using RunService as in BindToRenderStep, the events related to user input happen first. This means that as long as we immediately update the state within the function connected to the UI event, then a race should never occur because the condition will fail for proceeding scripts as soon as the state updates.

EDIT: I wrote out a way to test this but I realized it didn’t quite work. I’ll come back to it. In any case hopefully my explanation is clear.

2 Likes

Thank you. Debouncing makes a lot more sense now. However, would the return be needed for other things besides functions?

Let me first answer your questions, and then I’ll give another example of when I’ve used event counters.

Question: What’s the difference between debounce and event counter?

Answer: Debounce is temporarily turning something off whenever it gets activated.

Event counting is instead keeping track of how many times something activates to decide whether activation(s) should take effect.

Technically, debounce can be thought of as a restricted version of event counting where you only count one at a event at a time, every so often.

Question: What are the benefits of event counters?

Answer: Event counter techniques give a simple way to prevent instability or otherwise unwanted behavior in situations where an event may fire many times.

Question: When do I use an event counter?

Answer: Event counting as shown here is typically useful when it’s important to get the last time something fires in a burst of firings.

For example: If something needs to be done some time after a user stops clicking, but we want to let them be able to click as fast as they want, an event counter is useful. (As in the door example.)

But one might find other inventive places to use it, too!

Question: Are there occasions where using debounce is better than event counter?

Answer: They aren’t necessarily interchangeable; so yes, it’s much more common to use debounce, and you often don’t need more than that. But note that the door example I show uses both debounce (to prevent glitchy clicking) and event counting (to automatically shut the door after a few seconds).

Another real world example

My color wheel system lets you change colors of objects in real time, as shown below.

Warning: This has mild flashing colors.

Click to see the video...

In the frames of the video, the color usually exactly aligns across server and client:

But on some frames, there’s a bit of lag:

This isn’t an accident! This is an intentional safety feature of the color wheel.

This is because I have a server cooldown setting. The cooldown prevents clients from sending too many messages to the server as they drag around the color wheel.

:x: Wrong method. To implement a server cooldown, intuitively one might do something like this:

local lastServerUpdateTime = os.clock() -- keep track of the last time updated
local cooldownTime = 0.08 -- this is how long to wait between server messages
local function onColorChanged()
    -- always update client
     updateColorClient()

     -- check if enough time has passed on server
     local currentTime = os.clock()
     if currentTime - lastServerUpdateTime > cooldownTime then
        -- update server
        lastServerUpdateTime = currentTime
        updateColorServer()
     end
end

But this is wrong for a subtle reason. Can you see what it is?

Answer: What happens when the last message the client sends is during the server cooldown?
Then the client will update, but the server will never change to the right color!

:heavy_check_mark: Solution. We can fix this using an event counter.

local lastServerUpdateTime = 0 -- keep track of the last time updated
local cooldownTime = 0.1 -- this is how long to wait between server messages
local currentEventId = 0 -- this is the event counter

local function handleTicket(eventId: number)
    if eventId == currentEventId then
        -- update server
        lastServerUpdateTime = os.clock()
        updateColorServer()
    end
end

local function onColorChanged()
    -- always update client
     updateColorClient()

    -- count event
    currentEventId += 1
    local eventId = currentEventId

     -- check if enough time has passed on server
     local currentTime = os.clock()
     local timeSinceLastUpdate = currentTime - lastServerUpdateTime
     local timeUntilCooldownEnds = cooldownTime - timeSinceLastUpdate
     
     -- if timeUntilCooldownEnds is negative or 0, then update right away
     local timeLeft = math.max(0, timeUntilCooldownEnds)

     -- start timer if applicable, passing the event id as argument
     task.delay(timeLeft, handleTicket, eventId)
end

This way, the last change reaches the server. For an actual source code and working example, see my color wheel system.

2 Likes