Broadcast queue

Note: When I originally wrote this module, I didn't know about BroadcastEventResultCode, which is really useful for just automatically retrying a broadcast when it gets throttled. All you have to do is this:

while Events.BroadcastToServer(...) == BroadcastEventResultCode.EXCEEDED_RATE_LIMIT do
	Task.Wait()
end

and it will automatically retry each frame until it doesn't encounter the rate limit exceeded result. If it works on the first try then it won't wait at all. I think this pattern should be used pretty much anywhere you ever use a broadcast, just so you know none are ever dropped.


Networked broadcasts currently have a limit of 10 per second. The client has a budget of 10 broadcasts per second to the server using BroadcastToServer, and the server has a budget of 10 broadcasts to use between BroadcastToPlayer and BroadcastToAllPlayers.

If you send too many broadcasts too fast, though, they are not queued, they just disappear. In order to make the most of the broadcast budget, I've made this module to queue broadcasts and send them as soon as possible.

local isServerContext = pcall(Game.IncreaseTeamScore, 0, 0)

local broadcastThreshold = 10

local function fastSpawn(f)
	local connection
	connection = Events.Connect("fastSpawn", function()
		connection:Disconnect()
		f()
	end)
	Events.Broadcast("fastSpawn")
end

local broadcastQueue = {}
local timeline = {}
local queueing = false

local function queueBroadcast(...)
	broadcastQueue[#broadcastQueue+1] = {...}
	if not queueing then
		queueing = true
		fastSpawn(function()
			while #broadcastQueue > 0 do
				while timeline[1] and os.clock() - timeline[1] >= 1 do
					table.remove(timeline, 1)
				end
				if #timeline == broadcastThreshold then
					repeat
						Task.Wait()
					until os.clock() - timeline[1] >= 1
				end
				local data = table.remove(broadcastQueue, 1)
				local method = Events.BroadcastToServer
				if isServerContext then
					if type(data[1]) == "string" then
						method = Events.BroadcastToAllPlayers
					else -- the first argument is a Player object
						method = Events.BroadcastToPlayer
					end
				end
				while method(table.unpack(data)) == BroadcastEventResultCode.EXCEEDED_RATE_LIMIT do
					Task.Wait() -- automatically retry each frame if the budget is unexpectedly low
				end
				timeline[#timeline+1] = os.clock()
			end
			queueing = false
		end)
	end
end

return queueBroadcast, broadcastQueue

edit: fixed a mistake where method was determined in the wrong place
edit 2: modified the context detection to work properly for networked scripts and single-player preview mode
edit 3: now checking the return value of the broadcast function and automatically retrying if it exceeds the threshold

This module can be used on the client and the server. It determines which context it is running in in order to determine whether to use BroadcastToServer. If the first argument is a Player object rather than a string, it automatically uses BroadcastToPlayer instead of BroadcastToAllPlayers.

2 things are returned, the first is the function queueBroadcast and the second is a table that contains the arguments of all of the broadcasts that are still in the queue in case you want to modify/cancel them before they're sent.

The first argument to queueBroadcast is the name of the event, unless you want to invoke BroadcastToPlayer, in which case the Player is the first argument and the event name is the second argument. The remaining arguments are sent through the broadcast and follow the same restrictions as the functions in the docs.

The way this works is, a table of timestamps of each broadcast is kept. Times that are 1 second or older are removed. If the number of timestamps in the table is equal to the threshold then Task.Wait() is called repeatedly until the oldest timestamp was 1 second ago, and then the next broadcast is sent. The loop only runs while there are broadcasts queued.

It's important that this module is the only thing in the game that uses broadcasts, otherwise it can't know the remaining broadcast budget and can exceed the limit if there is something else doing broadcasts while many are being queued. edit: Checking the return value allows for automatically retrying, so this will no longer fail when other things use the broadcast budget.

7 Likes

Hello Waffle, I found your code extremely useful and turned it into an API script to share. I tested it by running a ping/pong script with thousands of messages trying to overload it, and found this configuration led to the best timing without exceeding the limit.

I was also thinking it would be useful to take any broadcasts that can be bundled and unpackaged at the other end as a single broadcast, instead of multiple. Of course the size in bytes would have to be checked. I don't know if serialization or compression would be useful, but it may?

I do think the bulking of data that's going to the same destination might have an impact on things.

local API = {}
local isServerContext = pcall(Game.IncreaseTeamScore, 0, 0)
local broadcastThreshold = 5 -- cannot be higher than 5
local tickRate = 1 -- cannot be lower than 1
local function fastSpawn(f)

    local connection
    connection = Events.Connect("fastSpawn", function()
        connection:Disconnect()
        f()
    end)
    Events.Broadcast("fastSpawn")
end

local broadcastQueue = {}
local timeline = {}
local queueing = false

function API.queueBroadcast(...)
    broadcastQueue[#broadcastQueue+1] = {...}

    if not queueing then
        queueing = true
        fastSpawn(function()

            while #broadcastQueue > 0 do
                while timeline[1] and os.clock() - timeline[1] >= 1 do
                    table.remove(timeline, 1)
                end

                if #timeline == broadcastThreshold then
                    repeat                      
                        Task.Wait(0.1)
                    until os.clock() - timeline[1] >= tickRate
                end

                local data = table.remove(broadcastQueue, 1)
                local method = Events.BroadcastToServer

                if isServerContext then
                    if type(data[1]) == "string" then
                        method = Events.BroadcastToAllPlayers
                    else -- the first argument is a Player object
                        method = Events.BroadcastToPlayer
                    end
                end

                while method(table.unpack(data)) == BroadcastEventResultCode.EXCEEDED_RATE_LIMIT do
                    Task.Wait(0.1)
                end

                timeline[#timeline+1] = os.clock()
            end
            queueing = false
        end)
    end
end

return API