Lightweight timer

Hello there!
I made this lightweight timer module as a substitution for the wait function.
idk if it’s useful for you guys and if it’s performant enough.
That’s why I decided to share it with you guys. Let me know your feedback, it’s appreciated!

local RunService = game:GetService("RunService")

local UpdateEvent = RunService.Heartbeat
local Tasks = {}

local function UpdateTasks()
	local now = os.clock()

	for index, task in ipairs(Tasks) do	
		if task.Timeout <= now then
			table.remove(Tasks, index).Callback()
		end
	end
end

local function AddTask(callback, duration)
	local startTime = os.clock()

	if not callback then
		return
	end

	table.insert(Tasks, {
		Timeout = startTime + (duration or 0),
		Callback = callback,
	})
end

UpdateEvent:Connect(UpdateTasks)
return AddTask

It’s usage is simple.

local Timer = require(script.Timer)

local function HelloWorld()
	print("5 seconds have passed!")
end

-- Timer(Callback, WaitTime)
Timer(HelloWorld, 5)
1 Like

is this any better then delay()?

2 Likes

It should be so. it’s written in pure lua and it’s not using the wait function which means it doesn’t compete with roblox ressources as much (task scheduler). but correct me if am wrong :smiley:

Otherwise I have a lot of features to add in my mind but I tried it to keep it as brief as possible for maximum performance and precision hopefully.

I would personally rename this variable so it is more readable

1 Like

oh yeaa that’s true, I fixed it.
Otherwise I have 2 ways of implementing such timer function.
The tasks table method that you can see here. or binding every callback to it’s own renderstepped event which I guess it might defeat the whole purpose :thinking:
That’s why I chose the table method for now

I’d prefer that Heartbeat gets used in all cases, the frequency is sufficient enough for both the client and server to use it accurately. I have a strong distaste towards using RenderStepped for virtually anything unless it’s something that takes no time to run and there’s a genuine use case for having it. I only use RenderStepped if I have to, otherwise I will always pick Stepped or Heartbeat.

The other bone I have to pick is that you’re disguising a function as a table for some reason but the Timer table itself doesn’t actually have any members. If this is the case then you can just return a function when the module is required instead. It should be relatively easy to change over as well.

local UpdateEvent = game:GetService("RunService").Heartbeat

local tasks = {}

local function addTask(callback, duration)
    -- The body of the __call metamethod goes here
end

local function updateTasks()
    -- The body of UpdateTasks() goes here
end

UpdateEvent:Connect(updateTasks)

return addTask
1 Like

that’s some strong points right there. Thanks, I’ll edit it now!

That easily cutted off the metatable entirely and the Timer table. making it faster and more optimized :smiley:

And for using the heartbeat event instead. I agree with you. I checked the wiki and it said that the use of renderstep is discouraged if using heartbeat is possible!

I wonder if this code can be optimized any further :thinking:

There’s a bug in your code:

for index, task in ipairs(Tasks) do	
	if task.Timeout <= now then
		table.remove(Tasks, index).Callback()		
    end
end

You’re modifying the table while iterating over it, without properly dealing with how items shift around when you make changes. If two sequential tasks expire at the same time, the second one will not be visited and thus not removed, causing it to run at least for one update too many. E.g.

local t = {1, 2, 3, 4}
for k, v in pairs(t) do
    if k >= 2 then
        table.remove(t, k)
    end
end 
print(table.concat(t, ", "))

prints 1, 3.

You can fix it by visiting elements in reverse, e.g.

local t = {1, 2, 3, 4}
for i = #t, 1, -1 do
    local v = t[i]
    if v >= 2 then
        table.remove(t, i)
    end
end 
print(table.concat(t, ", "))

prints 1.

1 Like

I know this is just an example but you shouldn’t use one letter variables, it results in messy code

1 Like

This is not really a valid way of calling AddTask, so it should probably just throw an error instead of mysteriously returning and doing nothing. In general, I prefer code that I use to do type checking and throw errors when it’s used incorrectly.

Accessing these variables from the global scope can be really slow. Make them local by adding this at the top of your script:

local insert, remove, require, ipairs = table.insert, table.remove, require, ipairs

https://www.lua.org/gems/sample.pdf (lua performance tips)

If I were to use a timer library in my own work I’d expect more features. Check out this for inspiration: Timer.Elapsed Event (System.Timers) | Microsoft Learn

1 Like

Thanks man, I didn’t even thought about that!
That could’ve easily ruined someone’s day not knowing what in the world is happening with their code lmao

I tried to keep the module as concise as possible but sure. I’ll do :grinning_face_with_smiling_eyes:

I’ve read somewhere that this optimization is no longer useful and roblox luaua already does that automatically, but I might be wrong?

Thanks to you guys @ThanksRoBama, @colbert2677, @D0RYU.
I was able to improve this module even more and learn some things along the way!
Let me know, if you have any further feedback :smiley_cat:
Module:

local Timer = {}
local RunService = game:GetService("RunService")

local UpdateEvent = RunService.Heartbeat
local UpdateEventConnection = nil
local Tasks = {}

local RandomGenerator = Random.new()

-- DEBUGGER
local DEBUG = true -- RunService:IsStudio()
local DebugStats = nil
local DebugPrecision = 4
local Seperator = string.rep("-", 30)

-- Returns void, Update the stats.
local function UpdateStats(elapsedTime, expectedDuration)
	local margin = elapsedTime - expectedDuration

	if margin > DebugStats.Max then
		DebugStats.Max = margin
	end

	if margin < DebugStats.Min then
		DebugStats.Min = margin
	end

	if DebugStats.Avg then
		DebugStats.Avg = (DebugStats.Avg + margin)/2
	else
		DebugStats.Avg = elapsedTime
	end

	DebugStats.Rng = DebugStats.Max - DebugStats.Min
end

-- Returns void. Update the queued tasks, calling and deleting them if they're expired.
local function UpdateTasks()
	local now = os.clock()
	
	for index = #Tasks, 1, -1 do
		local task = Tasks[index]
		if task.Timeout <= now then
			local elapsedTime = now - task.StartTime
			table.remove(Tasks, index).Callback(elapsedTime, table.unpack(task.Params))
			if DEBUG then
				local expectedDuration = task.Timeout - task.StartTime
				UpdateStats(elapsedTime, expectedDuration)
			end
		end
	end
end

-- Returns the 'id' of the newly created task,
-- Call the function 'callback' after the specified 'duration' passing the extra parameters '...'.
function Timer.Delay(callback, duration, ...)
	local startTime = os.clock()
	duration = duration or 0
	local typeOfCallback = typeof(callback)
	
	if typeOfCallback ~= "function" then
		error("Callback is expected to be a function, got '"..typeOfCallback.."'")
	end
	
	local id = #Tasks + 1

	table.insert(Tasks, id, {
		StartTime = startTime,
		Timeout = startTime + duration,
		Callback = callback,
		Params = {...}
	})
	
	return id
end

function Timer.SetUpdateEvent(event)
	if not (typeof(event) == "RBXScriptSignal") then
		error("the returned event should be a roblox event signal.")
	end
	
	if UpdateEventConnection then
		UpdateEventConnection:Disconnect()
	end
	
	UpdateEventConnection = event:Connect(UpdateTasks)
end

-- Returns the internal tasks table.
function Timer.GetTasks()
	return Tasks
end

-- Returns a task through the specified 'id',
-- beware, it might return another task or nil if the specified task have expired.
function Timer.GetTask(id)
	return Tasks[id]
end

-- Returns the 'number' rounded to the nearest multiplier of 'factor',
-- Returns the 'number' itself if the 'factor' is zero.
function Timer.Round(number, factor)
	if factor == 0 then
		return number
	end

	return math.round(number/factor)*factor
end

-- Returns the 'number' with the specified 'precision' (decimal places) or less.
function Timer.Decimals(number, precision)
	precision = precision or DebugPrecision
	return Timer.Round(number, math.pow(10, -precision))
end

-- Returns a random number between 'min' and 'max', with the specified 'precision'.
function Timer.Random(min, max, precision)
	local factor = math.pow(10, precision)
	return RandomGenerator:NextInteger(min*factor, max*factor)/factor
end

-- Returns a table containing the stats of the timer measured through previous calls,
-- Beware, this will return an empty table if DEBUG is false and not being enabled before.
function Timer.GetStats()
	return DebugStats
end

-- Returns void, Print the stats returned by "Timer.GetStats" in a suitable format.
function Timer.PrintStats()
	local stats = Timer.GetStats()
	print(Seperator)
	print("TIMER STATS:")
	for stat, value in pairs(stats) do
		print("\t"..stat..": "..Timer.Decimals(value))
	end
	print(Seperator)
end

-- Returns void, Resets the "DebugStats" for a fresh debug start.
function Timer.ResetStats()
	DebugStats = {
		Min = math.huge, -- Minumum
		Max = 0, -- Max
		Avg = nil, -- Average
		Rng = math.huge, -- Range
	}
end

-- Returns DebugStats, Enable debugging stats and reset them.
function Timer.EnableDebug()
	if not DEBUG then
		Timer.ResetStats()
		DEBUG = true
	end
	return DebugStats
end

-- Returns DebugStats, Disable debugging stats,
-- The returned table won't change itself unless if enabled debugging again or done manually.
function Timer.DisableDebug()
	DEBUG = false
	return DebugStats
end

-- Returns Timer, Initialize the module.
local function Initialize()
	Timer.ResetStats()
	Timer.SetUpdateEvent(UpdateEvent)
	return Timer
end

return Initialize()

Demo:

local Timer = require(script.Timer)

local function HelloWorld(elapsedTime, expectedDuration)
	print("Elapsed:", Timer.Decimals(elapsedTime, 3))
	print("Expected:", Timer.Decimals(expectedDuration, 3))
	print(string.rep("-", 30))
end

-- Stress test
for i = 0,1000, 1 do
	-- Create a timer delay call to the function HelloWorld with a random delay (5 -> 10) seconds
	local expectedDuration = Timer.Random(5, 10, 2)
	-- Timer.Delay(Callback, Duration, CallbackParams...)
	Timer.Delay(HelloWorld, expectedDuration, expectedDuration)
end

-- Print Stats after 11 seconds
Timer.Delay(Timer.PrintStats, 11)

Major changes:

  • Fixed the bug which happens when executing two tasks in one frame by looping through the table in reverse.

  • Now the callback functions get called along with the elapsed time and the parameters specified.

  • Added some getters for internal variables.

  • Added debugging functions so you can measure the performance of the timer in your game with one function call.

  • Added various math functions that might prove helpful in debugging or using the module.

  • Added the ability to change the update event.

image

PS: I avoided OOP to keep it simple, lightweight and performant.

If you think it’s good enough now. Let me know so I I can move it over to community ressources :smile_cat:

Published module:

IIRC Luau already optimises these calls so they shouldn’t be as slow as if you do them from a regular Lua environment. I’m not particularly sure how applicable this feedback is to Roblox development like outside Lua development. Just wanted to point out that it’s not necessary to do this.

I can’t recall my source at the moment right now though so it’s a disregardable comment but maybe someone does know the specifics on this.

1 Like

Below are the results of benchmarking local variables vs the normal functions:

(Image from boatbomber’s Benchmarker Plugin - Compare function speeds with graphs, percentiles, and more!) Note: There are two profiler rows per function, one is the total and the second is split into two to show the profiler overhead (it’s incredibly small but I though I should include it for the sake of accuracy).

As you can see, the differences are so negligible that sometimes the normal method actually performed better than the localised equivalent although this can vary slightly.

Edit: On the left you can see the averages and percentiles of the time, they are averages and percentiles of the function run 200 times - not just once.

Code and settings used:

Settings:

  • Call amount: 1000 (multiplied by 200 in the code, so each function was called 200,000 times)
  • Graph points: 100
  • Baseline zero: true
  • Graph outliers: true
local insert, remove, require2, ipairs2 = table.insert, table.remove, require, ipairs
math.randomseed(tick())

return {

	ParameterGenerator = function()
		local Table = table.create(1000)
		
		for Index = 1, 1000 do
			Table[Index] = math.random(100) / 10
		end
		
		return Table, math.random(1000), math.random(100) / 10
	end;

	Functions = {
		["local insert"] = function(Profiler, Table, Position, RandomNumber)
			Profiler.Begin("local insert")
			for Index = 1, 200 do
				insert(Table, Position, RandomNumber)
			end
			Profiler.End()
		end;

		["insert"] = function(Profiler, Table, Position, RandomNumber)
			Profiler.Begin("insert")
			for Index = 1, 200 do
				table.insert(Table, Position, RandomNumber)
			end
			Profiler.End()
		end;
		
		["local remove"] = function(Profiler, Table, Position, RandomNumber)
			Profiler.Begin("local remove")
			for Index = 1, 200 do
				remove(Table, Position, RandomNumber)
			end
			Profiler.End()
		end;

		["remove"] = function(Profiler, Table, Position, RandomNumber)
			Profiler.Begin("remove")
			for Index = 1, 200 do
				table.remove(Table, Position, RandomNumber)
			end
			Profiler.End()
		end;
		
		["local ipairs"] = function(Profiler, Table)
			Profiler.Begin("local ipairs")
			for Index = 1, 200 do
				for Index, Value in ipairs2(Table) do
					
				end
			end
			Profiler.End()
		end;

		["ipairs"] = function(Profiler, Table)
			Profiler.Begin("ipairs")
			for Index = 1, 200 do
				for Index, Value in ipairs(Table) do
					
				end
			end
			Profiler.End()
		end;
	};

}
2 Likes

the vanilla one might be better because there could be some optimizations that can’t work when you use a localized version (indirect call) but that’s just an assumption tho

1 Like

It is very good to be honest and looks clean in my standard.

1 Like

Thank you, I appreciate your kind words :smiley:

i don’t understand this resource, why not do something like task.delay(5, func)??
also what are all of those other functions used for? this whole module seems pretty pointless