Light's Dynamically Grouped Streams
Outdated
This page is a little outdated to Light's current process. The core ideas are still here, but the query function is a bit more optimized now due to some fundamental changes.
Light utilizes message batching to keep the library's data overhead low. Because messages are batched until the end of the frame, there comes to be an issue with broadcasting the same data to multiple players. If we want to be able to send things to individual groups of people, we have to have distinct buffers to separate those people from one another.
A Simple Scenario
We want to send some data to a couple of our players.
To batch this data for each player, we could store separate buffers for each player, and copy over the data we already serialized into each target's send buffer. Let's lay out send buffers like this:
Copying
Now, we run the first line: send({plr1, plr2}, 1234)
buffer-copy-example | |
---|---|
Now, the second: send({plr1}, 4321)
buffer-copy-example | |
---|---|
When we step replication, each player will get their respective buffer as-is. Each player got their messages in order as expected. This copying behavior is essentially what Blink does. However, copying can grow costly for your memory usage and add some real overhead when you continue to do it for a large number (or all) of your players.
A Smarter Way
What ByteNet does to combat this fact is to create a separate "stream" for events fired to everyone, and individual streams for each player. Then, on the replication step, send the global stream with :FireAllClients() first. This creates an undocumented event ordering discrepancy, because events fired to all users will always be recieved first. ByteNet also has bad performance firing to a specific list of players, because there is no singular stream it can write to.
An (Even) Smarter Way
Here's where Light comes in. Light implements a system which batches together streams based on the composition of their users. Now, you get something like this for your buffers:
pseudocode-send-buffer-with-records | |
---|---|
Notice how {plr1, plr2} gets a buffer to be written to. We base the lookup for each stream based on its composition of player records, meaning any event sent to plr1 and plr2 will be written to a single buffer. These groups need to be created when you send a message to that group for the first time, and can add a bit of overhead there. For the fruits of our labor, when we step replication, instead of sending each player exclusively one buffer, we simply send a list of them, saving tons of memory:
This creates the same ordering discrepancy as bytenet, but on a larger scale. Ordering of a message is only guaranteed
per-stream, and streams are grouped based on who they were sending to. In practice, this has little impact on how you
develop. But, if it's an issue you can always use light.send()
and utilize the copying behavior anyways.
Queries
Well, we can save on memory by grouping together calls like this without copying. But what about actually finding the buffers we're writing to? That's non-trivial, because there's an exponentially high number of possible combinations. Not to mention, these groups can't permute / have different orders. They must be based solely on what players do and do not exist in a stream.
The first step is to make sure our streams know who's listening, and that we have a list of streams that can be queried. Generating these lists involve a million billion edge cases I won't dive deep into here, but the gist is to give us an array to iterate.
querying_streams.luau | |
---|---|
Now, we could iterate through each stream and continue if
table.find()
doesn't leave you with an exact
match for each player. But that takes a lot of time if we create lots of streams, so let's add a new property to
type Stream
for the number of subscribed users, so we can filter out streams without the right number:
querying_streams.luau | |
---|---|
With the number of subscribed users for a stream saved, we should also change subscribed_users
to be a set, so we can
check if someone is subscribed to a stream in O(1) time. Now, we can make our query function:
This is great, now finding a stream is ~O(n*s) where "n" is the number of players we're querying for, and "s" is the
number of streams of the same number of subscribed users. Not too shabby! Though, we want it to be really quick to find
our streams which we've already created. We'll add a new data set local player_queryable
to let the player
know which streams it is already listening to, so we can iterate that instead of all of the streams. If no matching
streams are found in the first player's dataset, we need to make a new stream.
This is still ~O(n*s), but "s" is typically going to be a smaller number, since it's specific to one player. This is (more or less) how Light's broadcasts work. Light also maintains a "global" stream and a generated set of the "localized" streams belonging to only a single player, so they can be accessed very quickly.