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)
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
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.
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
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
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
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
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, ", "))
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
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
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
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.
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
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.
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;
};
}
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
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