Add task.deferOnce

There are times where I have an update function that is fired whenever some event is fired. An example is a health bar, I might have something like this

local Humanoid: Humanoid = SomeHumanoid

Humanoid:GetPropertyChangedSignal("Health"):Connect(function()
    UpdateHealthbar(Humanoid)
end)

The problem is if the humanoid has their health changed many times in a single frame, for example a shotgun blast with many individual projectiles, the UpdateHealthbar function will be fired more times than needed.

If I could instead use a function like task.deferOnce, the passed function will only be fired once per frame

Humanoid:GetPropertyChangedSignal("Health"):Connect(function()
    task.deferOnce(UpdateHealthbar, Humanoid)
end)
Example implementation
local QueuedDefers = {}

local function FireDeferOnce(Callback)

    -- Get callback data
    local CallbackData = QueuedDefers[Callback]

    if (CallbackData) then

        -- Remove callback from queue
        QueuedDefers[Callback] = nil

        -- Fire callback with arguments if they exist
        if (CallbackData.Arguments) then
            return Callback(table.unpack(CallbackData.Arguments))
        end

        -- Fire callback without any arguments
        return Callback()
    else
        -- Callback wasnt in queue for whatever reason
    end
end

local function DeferOnce(Callback, ...)

    -- Get arguments as array to pass to callback
    local Arguments = if (select("#", ...) > 0) then {...} else nil

    -- Defer if not already queued
    if (not QueuedDefers[Callback]) then
        task.defer(FireDeferOnce, Callback)
    end

    -- Add/update callback
    QueuedDefers[Callback] = {Arguments = Arguments}
end

function UpdateHealthbar(Key)
    print("Updating healthbar", Key)
end

while true do

    -- Simulate healthbar being updated many times per frame
    for Key = 1, math.random(5, 20) do
        DeferOnce(UpdateHealthbar, Key)
    end

    -- Make it per frame
    task.wait()
end
2 Likes

Functions have a :Once() Signal

4 Likes

True, signals do have a Once() option, though it is not exactly a solution in the proposed situation.

The OP is using Connect() to catch all health changes which may come in small steps.

Defered or not defered signal behaviour, either way the function is going to be called for each change, for example -1, -5, -2, -4. UpdateHealthbar will be called four times. If the changes happened during the same frame, the proposed method would pile the callbacks up and only call the latest queued one (for -4).

I cannot really tell how the engineering team will decide on adding such a function, since many additions involve trade-offs (is it worth implementing for the gained benefits?). Anyhow, it does represent a solution in particular scenarios.


Update 2023-01-07; responses to replies

@Razorter the idea was not to listen for the signal once, but to queue all the signals and only respond to the latest one received in the single frame (see the OP’s example implementation).


@Rocky28447 that of course works but not as much in favour of performance. I’ve seen the term ‘polling’ used around the forum in this context, and it refers to repetitive sampling of the status of something, instead of going for event based responses, which are not as rapid. The logic of proposed behaviour would be to 1. respond to events when they occur and 2. if the events are received multiple times in the same frame, respond to the latest signal.

Good idea! If something like this was to be implemented, this really makes more sense. I suppose it would even have slightly smaller overhead.

3 Likes

Correct me if I’m wrong but RBXScriptSignal:Once(fn) does not call the function multiple times if it is fired multiple times. There would be no point to :Once() if it fired multiple times.

1 Like

if i recall correctly you can solve this problem by changing workspace.SignalBehavior to deferred

While it’s not impossible to create something like this, this is definitely a nice to have sort of thing that would definitely improve performance. I support.


See the below.

But this leads me to believe we could just have a different connection option. Why not RBXScriptSignal:OncePerFrame or something like that?

1 Like

Just… Check the health… Once per frame…? And compare it against the value it had… on the last frame…?

Am I missing something here? What’s wrong with RunService.Heartbeat?

While I understand the reasoning of having this function be placed under the task library–and I do concede it seems alluringly appropriate, I would prefer an implementation as suggested by @SubtotalAnt8185. Despite being decidedly more limited in its utility, I believe this strict segmentation would work to better enforce the code’s intention. Otherwise, I fear having these wayward snippets could, in problematic contexts, quickly become a debugging nightmare, given the circumstantially unintuitive nature of their execution. Though I have in equal parts optimism and concern for this feature, I’ll refrain from commenting on its apparent implementation implications–as needless to reaffirm, from a developer’s perspective, such discussion would ultimately boil down to nothing more than nonconstructive speculation.

1 Like

Can’t this already be done by using task.cancel on the thread returned by task.defer?

Unlikely that we could make a function like this because it sets up a huge trap. For example, this code does not do what you want (thanks to functions without any upvalues being treated differently as an optimization):

task.deferOnce(function()
    foo(bar)
end)
task.deferOnce(function()
    foo(bar)
end) --> Calls foo twice

task.deferOnce(function()
    bar()
end)
task.deferOnce(function()
    bar()
end) --> Calls bar once

It’s likely too easy for less experienced users to get that pattern wrong for it to be a good API. It’s also unclear how it would work with arguments:

task.deferOnce(foo, bar)
task.deferOnce(foo, baz) --> Called with bar or baz at end of frame?

A good way to handle this if you want it would be with a Lua module exposing a higher order function wrapping the target call:

local fooOncePerFrame = deferOncePerFrame(foo)
fooOncePerFrame()
fooOncePerFrame() --> calls foo once
4 Likes

Agreed, OncePerFrame feels more optimal. However I don’t think this development pattern is anywhere near popularized enough to warrant this engine feature. A module could easily handle this with a small LoC.

1 Like

Use a debounce system like this:

local globalid = 0

local function call()
    globalid += 1
    local localid = globalid

    task.defer(function()
        if globalid == localid then
            -- code here
        end
    end)
end

The code will only run if it hasn’t attempted to run at least one resumption cycle later.