I am programming an electrical system that players may use to create complex and precise contraptions and mechanisms. It is very important to have reliable and precise behavior due to how complex some players will make their contraptions.
And naturally, I have been having issues with making a While loop that yields precisely. I am aware wait() is incredibly unreliable and so I have been using task.wait(), which is a significant improvement, but still far too unacceptable, even with some code to try to account for inaccuracies (somewhere below).
For instance, say the while loop is set to run at 50 hertz (50 times a second), I get around 62% of the desired result when not using correction code (around 30-31 hertz) and I get around 86% of the desired result when correcting (around 43 hertz).
Two things to note:
1.) Ideal behavior is to be no more than 3 hertz away from the target frequency (so, for a target hertz of 50, anything between 47-53 is not bad). Anything more than 5 hertz away is unacceptable.
2.) My testing has been limited mostly to around 50 hertz, but other frequencies will be implemented and I would really like to avoid hacky solutions that only work for one or two hardcoded frequencies. Also, it seems higher frequencies are even more unreliable. Testing at 150 hertz averages around ~63 hertz! That’s not even half of the target result!
I am really stumped on this, perhaps I should do some RunService shenanigans with os.clock()?
Here is the code:
--This is for the circuit searching/powering loop. Uses bC.hertz()
bC.MainThread = ""
function bC.Start()
if not bC.canPower() then
local timesFPS = 0
bC.MainThread = coroutine.wrap(function()
--I would GREATLY like to avoid doing something like this hacky "constant" (it is not even a constant!)
local hackyConstant = 0.81967213114754098360655737704918 -- a very hacky, temporary multiplier. This does not work for values far other than 50.
local targetWait = (1 / bC.hertz()) * hackyConstant
local correctedWait = targetWait
local lastWait = targetWait
while bC.canPower() do
if bC.watts() > 0 then
--This is the main code to check for a valid circuit and then power it!
--TODO add above!
end
--TODO add correction function to account for the time needed to run the real, actual code above!
--This block attempts to correct for any spikes or drops due to Heartbeat.
timesFPS = timesFPS + 1
if lastWait ~= targetWait then
correctedWait = targetWait + (targetWait - lastWait)
end
lastWait = task.wait(correctedWait)
end
end)
bC.setPower(true)
bC.MainThread()
--Checking to make sure fires mostly correctly.
local debugTime = task.wait(1)
bC.Stop()
print("Fired "..timesFPS.." times in "..debugTime.." seconds.")
print("TargetWait is "..1 / bC.hertz())
end
end
Its not really possible to have accurate times regardless of what method you use, waits() and runservice will be different due to several factors like game FPS, and just simple floating issues. Instead you should use the time difference between each cycle(DeltaTime) to determine what happens, for example if the deltatime is greater than 2 cycles then you just scale whatever operation your doing by twice
Something like that could work, I suppose, but my concern is for user builds that require relatively precise timings. Sometimes having your electric components fire twice (or maybe more!) and the general unreliability of having it fluctuate that much is a concern for me.
That’s why I was attempting to make correction code (this block):
if lastWait ~= targetWait then
correctedWait = targetWait + (targetWait - lastWait)
end
lastWait = task.wait(correctedWait)
Basically, the idea was that if the last cycle was too slow or too fast, try adjusting the current cycle to be faster or slower to try to compensate and roughly get to a semi-reliable frequency
That honestly probably the best you could do, you could try using os.clock() but not sure if os.clock() has the precision needed for your usecase, or you maybe could try event based programming and rely on signals?
I don’t intend on using massive frequencies like say, gigahertz (1 billion times a second). Realistically, it is unlikely that the game will use frequencies higher than maybe 500 hertz at the highest end. 50-60 hertz would be by far the most common and as long as I can get 90% of the target frequency reliably, I think it would work fine for my use case. If I substantially lower the task.wait() time, I can sometimes get closer to the target, but it gets so unreliable and unpredictable.
Just keep in mind that its common for games to have a delta time of like 1 second long before it executes the next frame due to like lag and stuff. So its just better to code for that event rather then trying to create a accurate frequency
I would suggest using both Heartbeat and Render Stepped if possible to double the rate you can check. You can use os.clock or tick to check how much time has elapsed since the time between Heartbeat and RenderStepped isn’t going to be even.
Ah, yes, I see. So, that is what you guys meant with DeltaTime. That probably can reasonably meet my use case. If not, I will probably have to settle for inaccuracies and try to make a more broad timing/clock cycling functionality. Naturally, I expect some performance sacrificed for precision, but is there any significant performance impacts I should be aware of when doing this?
Players will be able to access the timing mechanisms and players are unpredictable. I need to know roughly how much I have to limit their ability to use these mechanics to avoid abuse
This would be ideal, yes, but the problem is that I can’t get it consistently at a target frequency (like 50 hertz). For instance, the task.wait(0.02) (aka 1 / 50) can yield all over the place. Sometimes it yields at the desired 0.02. Sometimes it yields at 0.04. Sometimes 0.05. I even got a few times around 0.1 (this would be closer to a measly 10 hertz!)
The problem is the unreliability and consistently reaching around the target point.
I will try the DeltaTime idea tomorrow, but I am leaving this unsolved until I get around to trying it.
Future viewers, feel free to suggest other ideas as well.
I don’t know about task.wait(), but I believe wait() could not go any faster than 0.03 seconds. (I think wait() was equivalent to wait(0.03).) I would check to see if task.wait() has a similar 0.03 second minimum.
I believe task.wait() does not have such a minimum as it is the same as RunService.Heartbeat:Wait(), meaning if no number is passed to it, it should execute on the next Heartbeat.
The problem is each Heartbeat varies greatly in time elapsed, albeit nowhere near as bad as normal wait()
You can get perfect 50 hz average by keeping a running count of the progressed time (which should be accurate over longer times) like so:
--Testing code
local dts = {}
local t_prev
function test_timing()
local t = tick()
if t_prev then
local dt = t - t_prev
table.insert(dts, dt)
end
t_prev = t
if #dts >= 100 then
local avg = 0
for _, dt in ipairs(dts) do
avg += dt
end
avg = avg / #dts
print(1 / avg) --Should be pretty close to 50
dts = {}
t_prev = nil
end
end
function update(dt)
local t0 = tick()
local t = tick()
if t - t0 > dt then
warn("Update is too slow to run at " .. dt .. " hz.")
end
end
local dt_cumulative = 0
RunS.Heartbeat:Connect(function(dt)
dt_cumulative += dt
while dt_cumulative >= freq do --Don't run all the way to 0, it's nicer to just have constant timesteps. Try it if you want tho.
dt_cumulative -= freq
test_timing()
update(freq)
end
end)
The solution presented by @ThanksRoBama, @dthecoolest, and @Thedagz seems to be working quite well so far from the tests I have done. Frequency is often 98% within the target range, which is very good so far.
For those viewing this, the code I implemented is as follows:
The main code:
function bC.Start()
if not bC.canPower() then
local targetWait = (1 / bC.hertz())
local timesFPS = 0
bC.setPower(true)
local function mainThread()
if bC.watts() > 0 then
--This is the main code to check for a valid circuit and then power it!
end
--TODO add correction function to account for the time needed to run the real, actual code above!
timesFPS = timesFPS + 1
end
local function killMainThread()
if not bC.canPower() then
return true
else
return false
end
end
util.DeltaLoop.Start(mainThread,killMainThread,targetWait) -- this is the most important part, using an additional ModuleScript named "Utility"
--Checking to make sure fires mostly correctly.
local debugTime = task.wait(1)
bC.Stop()
print("Fired "..timesFPS.." times in "..debugTime.." seconds.")
print("TargetWait is "..1 / bC.hertz())
end
end
The “Utility” ModuleScript (“util” references this module)
function utilities.DeltaLoop.Start(payload,condition,frequency)
local deltaTimeTotal = 0
utilities.DeltaLoop.CurrentLoop = game:GetService("RunService").Heartbeat:Connect(function(dt)
deltaTimeTotal += dt
if condition() then
utilities.DeltaLoop.CurrentLoop:Disconnect()
utilities.DeltaLoop.CurrentLoop = ""
return
end
while deltaTimeTotal >= frequency do
deltaTimeTotal -= frequency
payload()
end
end)
end
If you’d like to point out flaws in the above code blocks, feel free to as well.