Help with Achieving a Loop with Precise Frequency

Hello all!

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

Thanks greatly in advance!

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

2 Likes

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

Seems like you need to rely on achieving a rate of 50 Hz.

I would also suggest using delta time like @Thedagz mentioned but specifically an accumulator to guarantee a code runs at a desired rate.

Similarly for time step for achieving reliable behavior over time for problems such as physics or maybe your case though I’m not entirely sure.

3 Likes

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.

1 Like

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

assuming the stuff run at 50Hz, 0.02 seconds, each iteration would have at most 0.02 seconds of being late in doing what it needs to do

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)
2 Likes

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.

Again, thanks to everyone who answered/commented!

3 Likes