A function for RunService that fires a function on a fixed time step

RunService:BindToTimeStep(name, priority, step, function)
(I couldn’t put this in the title because Discourse kept telling me it wasn’t descriptive enough)

Say I want to have something happen every .1 seconds. What I could do now is bind a function to Stepped or Heartbeat:

local lastDone = elapsedTime();
local overflow = 0;
local step = .1;
Heartbeat:connect(function()
    local t = elapsedTime();
    local dt = t - lastDone;

    if (dt >= step - overflow) then
        lastDone = t;
        overflow = dt - step;
        dostuff();
    end
end);

This is pretty complicated and wasteful since a lot of extra Lua has to be run. It could also cause problems if Heartbeat’s time step is greater than .1 seconds, which would have to be accommodated for using more Lua calculations every time the function is called.

I could also do a loop with wait(.1)'s but after a while the extra wait time will add up to a few seconds. This also causes issues when I want two different things to happen at two different time steps. Of course, coroutines could be used but that makes it incredibly ugly and probably wasteful.

BindToTimeStep would call the given function at every given time step as closely as possible by accounting for time overflow like in the example code above. It would also call the function multiple times at once if the overflow exceeds some multiple of the time step. For example, if the time step is .1 and the function was last run at t = 0 and the client’s next heartbeat is at t = .25, then it would run the function twice.

The main purpose is to eliminate a lot of unnecessary slow Lua from a method I’m guessing a lot of games do (or probably should do instead of using loops or a variable time step).

What do you need to do at such a long interval like 0.1 seconds that needs to be perfectly and exactly on time? Generally the only cases that kind of syncing is necessary is stuff that’s being constantly updated every frame/every couple of frames, or servers that handle precise measurements of time.

I’m not a fan of this idea. If you really want it, you can already implement it in Lua without having additional wait time. Something like this will execute something every 0.1 seconds or catch up if it falls behind. (not recommended because writing code that has to catch up could be considered bad practice if you run it this often and continuously (it could spiral out of control if you have a badly performing machine))

function waitUntil(t)
   return (tick() >= t) or wait(t - tick())
end

local target = tick() + 0.1

while waitUntil(target) do
   -- do stuff
   target = target + 0.1
end

Another solution could be using a secondary loop to flag down the Heartbeat connection and tell it when to do what you want. This is also nice because you could swap out the ‘while’ loop for some event-triggered functionality as well:

local doSomething = false

heartbeat:connect(function()
	if (doSomething) then
		doSomething = false
		-- Blah
	end
end)

while (true) do
	wait(0.1)
	doSomething = true
end

But that doesn’t solve OP’s issues:

  • It won’t run again if it’s behind on schedule
  • The wait(0.1) will actually wait for >= 0.1 (can be more), so this will go out of sync like OP mentioned.

This is the case I mentioned where the extra time waited adds up. If for each call, there’s an additional .01 seconds of wait time (what I’m getting in studio right now), within 10 iterations there will be a whole time step missed. That’s not including the time between setting doSomething to true and the next heartbeat.

The point isn’t to make doing something on a time step perfectly on time. That’s just impossible to do. The point is to prevent extra wait time from adding up into something significant in the long run. It’s easy to do that in Lua but it involves a lot of unnecessary overhead.

Try something like the code I posted? I don’t think there is any significant overhead. (At least, you shouldn’t have to optimise for it I don’t think.) People often underestimate how many statements of some type you can execute per second.

That’s not entirely correct. Sure, over time your wasted time goes up, but it’s not like it affects anything. Instead of waiting 0.1 seconds, you’re just waiting 0.11 seconds. Example:

wait(0.1) => wait(0.11) wait(0.11) wait(0.11) wait(0.11)

It’d be a problem if it did this:
wait(0.1) => wait(0.11) wait(0.12) wait(0.13) wait(0.14) … wait(999)

but that’s not what’s happening.

The method you posted will work. It could get annoying though when it comes to stopping and starting, where Bind and Unbind functions would make it more simple. It could also get ugly when the stuff that needs to be done takes time.

What I mean by adding up is when the cumulative additional wait time becomes something that contains another time step.

For example, if do wait(.1); doStuff(); ten times and have .01 seconds of extra wait time for each, it will add up to .1 seconds of additional wait time, so doStuff will only be called 10 times when it should be called 11 times.

Why does it matter? For example, I loop through my save queue for datastore stuff once every 5 seconds, but if it takes 5.1 or even 6 seconds it’s no big deal. The same thing with a timer GUI – I loop through every second, but if there’s a slight delay before the timer is updated, it’s not really a big deal. What are you doing that being called 10 times instead of 11 matters? Even though it’s only called 10 times, the time it takes to update again is really small at a 0.1 second interval.

I use a damped oscillator to implement a recoil effect for guns. The acceleration is a function of the displacement. In short, if it overshoots in one direction, it will overshoot in the opposite direction even more and spiral out of control into infinity. Displacement for each step is calculating using the time elapsed from the previous step. There’s a certain threshold for the elapsed time (a little over 1/30) that, if exceed, causes overshooting. So if someone lags out for a bit the elapsed time goes above the threshold due to extra wait time, then the oscillator will spiral out of control. That’s why it’s important to break down the time elapsed intro smaller time steps that are below the threshold and execute the acceleration/velocity/displacement calculations once at each time step.

Solution: Instead of doing

local lastUpdate = tick()
while wait(0.1) do
    doStuff(lastUpdate)
    lastUpdate = tick()
end

do

local start = tick()
while wait(0.1) do
    doStuff(tick()-start) --doStuff would be a function of delta t (time elapsed) where you could find the oscillation for any t value
end

What I tried to convey is that if tick()-start is above a certain threshold, the oscillation calculations overshoot and go out of control. That’s why delta t must be fixed at a value under the threshold and doStuff must be called multiple times when tick()-start contains multiple delta t’s.

You’re not calculating oscillation right then. If you take a look at the leaked copy of Phantom Forces (I doubt Stylist Studios cares at this point given how much it’s been distributed and how much they’ve updated the game since), take a look at AxisAngle’s spring module. It does exactly what you’re looking to do – they use it for recoil, arm sway, and everything else that oscillates in their game. I’m not telling you to copy and paste it into your game, but you can get an idea of how to improve your current approach with it. If you’re not a fan of looking inside a leaked game, I’m pretty sure @Quenty has something pretty similar somewhere in Nevermore Engine.

It’s not worth it. I counted 4 assignments, 2 calls, 3 subtractions, and a comparison in your code. Your processor can do all that roughly 30 billion times per second. Any perceived speed gains will be placebo.

If your heartbeat is running at less than 10 Hz, you have way more serious issues to be worried about than getting Roblox’s engineers to offload 5 or 6 lines of code from Lua to C.

You should focus on correctness instead so you can fix bugs like that.

1 Like

Why not make things complex?

Use a wait() loop, but instead of a fixed time interval, the interval can be the output of a PID system that tracks the time intervals after each iteration. You’ll want a rolling I instead of cumulative though; I’ve never had any good come out of cumulative I.

1 Like