Replicating Instances to Only Some Players

What do I need to know before reading?

Beginner knowledge of Luau is expected. You should understand the difference between a server and clients, and what “memory” is (to simplify for the purposes of this tutorial, memory is all the data in a live game, like player characters, script code, GUIs, smooth terrain, and so on). Important to note is that server memory is separate from the memory of any client (clients won’t have anything not replicated to them in their memory, like anything in ServerScriptService, and vice versa)…

Why would I want to do this?

Sometimes, only some players need to store Instances on their client. For example, say you have a game full of 30 players, and each player has their own house. Only the outside of the house is shown in the neighborhood, but when a player enters, they are teleported to the inside of the house which is stored somewhere else.

Normally, if I want to enter my house, the insides would have to be stored on every single player’s client, even if those other players weren’t inside it. This is an unnecessary waste of memory, and conserving memory is especially important on mobile devices. We want only the players inside that house to store the insides in memory.

This technique is only useful for replicating Instances you want the client to store in the Workspace service. For smooth terrain, use ReadVoxels and WriteVoxels. While it would be possible to serialize data

Why not just use StreamingEnabled?

StreamingEnabled is made for general use cases. Advanced developers can take advantage of their intimate knowledge of their own game’s mechanics to better optimize the game for both performance and user experience than can StreamingEnabled.

Hack replication.

For historical reasons (once upon a time, clients shared memory with the server. As Roblox changed this, they wanted to make it easy for beginner developers to script new GUIs, and adapt existing ones.), there is something special about each Player’s Player.PlayerGui Instance, which is where the players’ GUIs are stored. The server can add Instances to Player.PlayerGui and it will replicate only to that player, but no one else. Changes made client-side to anything the server creates there will not be replicated to the server.

Why not just use ReplicatedStorage instead of Player.PlayerGui?

You could use ReplicatedStorage as the temporary storage location instead of Player.PlayerGui. This would be simpler, but we’d be wasting everyone’s bandwith, CPU, and memory resources, even if this is temporary. This would be an awful solution.

Want to skip the explanation?

You can grab a copy of the finished place!

Create the replication target.

The replication target is the special location we’ll use to replicate Instances to the client. Insert a RemoteEvent into ReplicatedStorage named “HandlePlayer”, and a new Script named “PlayerImmigration” with this code into ServerScriptService:

local Services = {
    Players = game:GetService("Players"),
}

local function CreateReplicationTarget(Player)
    local PlayerGui = Player:WaitForChild("PlayerGui")
    -- We have to use a ScreenGui for this. Any other Instance will be destroyed and remade when the player dies, which would break our code.
    local ReplicationTarget = Instance.new("ScreenGui")
    ReplicationTarget.Name = "ReplicationTarget"
    ReplicationTarget.ResetOnSpawn = false
    ReplicationTarget.Parent = PlayerGui
end

Services.Players.PlayerAdded:Connect(CreateReplicationTarget)

Receive the replication.

Add a LocalScript named “ClientEntry” inside StarterPlayer.StarterPlayerScripts with this code:

local Imports = {
    ChunkReceiver = require(script.ChunkReceiver),
}

Imports.ChunkReceiver.Main()

Add a ModuleScript named “ChunkReceiver” with this code, placing it inside “ClientEntry”:

local ChunkReceiver = {}

local Services = {
    Players = game:GetService("Players"),
}

local Player = Services.Players.LocalPlayer
local PlayerGui = Player:WaitForChild("PlayerGui")

local ReplicationTarget = PlayerGui:WaitForChild("ReplicationTarget")

local OrphanedClones = {}

local function HandleReplication(Object)
    local ExpectedDescendantAmount = tonumber(Object.Name)

    while #Object:GetDescendants() < ExpectedDescendantAmount do
        Object.DescendantAdded:Wait()
    end 

    local ObjectClone = Object:Clone()
    -- Storing an Instance as a key in a table will prevent it from being garbage collected, so we don't need to parent it yet.
    OrphanedClones[Object] = ObjectClone
end

function ChunkReceiver.Main()
    -- Instance.ChildAdded won't pick up children added before the listener was connected.
    -- There is no guarantee our listener will connect before we receive a replication.
    local PreviousReplications = ReplicationTarget:GetChildren()

    for _, Replication in pairs(PreviousReplications) do
        HandleReplication(Replication)
    end

    ReplicationTarget.ChildAdded:Connect(HandleReplication)
end

return ChunkReceiver

“Chunk” refers in this tutorial to the Instance we’re replicating, which will typically be a Model. We’re building a generic system here, so we don’t want to unnecessarily couple the chunk sending and receiving process with the usage of the chunk in other code.

Inform the server when the client has fully loaded the chunk.

When an Instance is dynamically created (during a live game, as opposed to the statically created Instances in your Studio’s game hierarchy) and replicated to the client, there is no guarantee that it will be fully replicated once it is available to the client. Because of this, the client will need to inform the server of this.

Insert a RemoteEvent named “ChunkReceived” into ReplicatedStorage. Replace the code of ChunkReceiver with this:

local ChunkReceiver = {}

local Services = {
    Players = game:GetService("Players"),
    ReplicatedStorage = game:GetService("ReplicatedStorage"),
}

local Player = Services.Players.LocalPlayer
local PlayerGui = Player:WaitForChild("PlayerGui")

local ReplicationTarget = PlayerGui:WaitForChild("ReplicationTarget")

local OrphanedClones = {}

local function HandleReplication(Object)
    local ExpectedDescendantAmount = tonumber(Object.Name)

    while #Object:GetDescendants() < ExpectedDescendantAmount do
        Object.DescendantAdded:Wait()
    end 

    local ObjectClone = Object:Clone()
    -- Storing an Instance as a key in a table will prevent it from being garbage collected, so we don't need to parent it yet.
    OrphanedClones[Object] = ObjectClone

    Services.ReplicatedStorage.ChunkReceived:FireServer(Object)
end

function ChunkReceiver.Main()
    -- Instance.ChildAdded won't pick up children added before the listener was connected.
    -- There is no guarantee our listener will connect before we receive a replication.
    local PreviousReplications = ReplicationTarget:GetChildren()

    for _, Replication in pairs(PreviousReplications) do
        HandleReplication(Replication)
    end

    ReplicationTarget.ChildAdded:Connect(HandleReplication)
end

return ChunkReceiver

Add a Script named “ServerEntry” into ServerScriptService with this code:

local Imports = {
    ChunkSender = require(script.ChunkSender),
}

Imports.ChunkSender.Main()

Now, add a ModuleScript named “ChunkSender” inside “ServerEntry” with this code:

local ChunkSender = {}

local Services = {
    Players = game:GetService("Players"),
    ReplicatedStorage = game:GetService("ReplicatedStorage"),
}

local PendingCompletersDatas = {}

local function ClearPendingReplications(Player)
    -- Since any replicating Instances will be in Player.PlayerGui, they will be removed when the player leaves. We just need to remove any leftover references to free them for garbage collection.
    PendingCompletersDatas[Player.UserId] = nil
end

local function CallCompletionCallback(Player, ObjectClone)
    -- Validate the client input.
    if typeof(ObjectClone) ~= "Instance" then
        return
    end

    local PendingCompletersData = PendingCompletersDatas[Player.UserId]

    if PendingCompletersData == nil then
        return
    end

    local PendingCompleter = PendingCompletersData[ObjectClone]

    if PendingCompleter == nil then
        return
    end

    PendingCompleter(Player, ObjectClone)
    ObjectClone:Destroy()
end

function ChunkSender.Main()
    Services.Players.PlayerRemoving:Connect(ClearPendingReplications)
    Services.ReplicatedStorage.ChunkReceived.OnServerEvent:Connect(CallCompletionCallback)
end

function ChunkSender.Send(Player, Object, Completer)
    -- In our code, this will be guaranteed to exist. In your code, make sure it is too.
    local ReplicationTarget = Player.PlayerGui.ReplicationTarget

    local PendingCompletersData = PendingCompletersDatas[Player.UserId]

    if PendingCompletersData == nil then
        PendingCompletersData = {}
        PendingCompletersDatas[Player.UserId] = PendingCompletersData
    end

    local ObjectClone = Object:Clone()
    -- Setting the name to this is important! It's how the client will know the number of descendants to expect.
    ObjectClone.Name = #ObjectClone:GetDescendants()
    ObjectClone.Parent = ReplicationTarget

    -- Completer is a function that will be called once the client fully loads the clone of Object.
    PendingCompletersData[ObjectClone] = Completer

    -- This is part of our decoupling effort. Let the code calling this function handle mapping of the object to its clone.
    return ObjectClone
end

return ChunkSender

Replace the code of “ChunkReceiver” with this:

local ChunkReceiver = {}

local Services = {
    Players = game:GetService("Players"),
    ReplicatedStorage = game:GetService("ReplicatedStorage"),
}

local Player = Services.Players.LocalPlayer
local PlayerGui = Player:WaitForChild("PlayerGui")

local ReplicationTarget = PlayerGui:WaitForChild("ReplicationTarget")

local OrphanedClones = {}

local function HandleReplication(Object)
    local ExpectedDescendantAmount = tonumber(Object.Name)

    while #Object:GetDescendants() < ExpectedDescendantAmount do
        Object.DescendantAdded:Wait()
    end 

    local ObjectClone = Object:Clone()
    -- Storing an Instance as a key in a table will prevent it from being garbage collected, so we don't need to parent it yet.
    OrphanedClones[Object] = ObjectClone

    Services.ReplicatedStorage.ChunkReceived:FireServer(Object)
end

function ChunkReceiver.Main()
    -- Instance.ChildAdded won't pick up children added before the listener was connected.
    -- There is no guarantee our listener will connect before we receive a replication.
    local PreviousReplications = ReplicationTarget:GetChildren()

    for _, Replication in pairs(PreviousReplications) do
        HandleReplication(Replication)
    end

    ReplicationTarget.ChildAdded:Connect(HandleReplication)
end

function ChunkReceiver.ClaimClone(Object)
    local Clone = OrphanedClones[Object]
    -- Now Object will be completely free for garbage collection.
    OrphanedClones[Object] = nil

    return Clone
end

return ChunkReceiver

We added the ChunkReceiver.ClaimClone function, which will allow client-side code to claim ownership over the chunk we replicated. Again, the point of this is to decouple our chunk replication code from the code using the chunk (for example, the code that teleports the player to the newly replicated house).

Let’s clarify something confusing.

The object clone on the server-side will be viewed by the client as the original object. The client’s clone is actually a clone of a clone of the original object! Don’t let the variable identifiers confuse you.

Let’s test this.

Insert a RemoteEvent named “RecognizeHouse” into ReplicatedStorage, and a Model named “House” into “PlayerImmigration”. This will be the chunk we’re going to replicate. Feel free to put anything you want inside it. Now, replace the code of “PlayerImmigration” with this:

local Services = {
    Players = game:GetService("Players"),
    ReplicatedStorage = game:GetService("ReplicatedStorage"),
    ServerScript = game:GetService("ServerScriptService"),
}

local Imports = {
    ChunkSender = require(Services.ServerScript.ServerEntry.ChunkSender),
}

local House = script.House

local HandledPlayers = {}

local function HandleNewHouse(Player, HouseClone)
    print("Replicated a house to " .. Player.Name .. "!")
    -- This is where we'd place code to tell the client what to do with what we just replicated to it. For example, we might tell the client that this model it received is a house. We'll simplify it here.
    Services.ReplicatedStorage.RecognizeHouse:FireClient(Player, HouseClone)
end

local function CreateReplicationTarget(Player)
    local PlayerGui = Player:WaitForChild("PlayerGui")
    -- We have to use a ScreenGui for this. Any other Instance will be destroyed and remade when the player dies, which would break our code.
    local ReplicationTarget = Instance.new("ScreenGui")
    ReplicationTarget.Name = "ReplicationTarget"
    ReplicationTarget.ResetOnSpawn = false
    ReplicationTarget.Parent = PlayerGui
end

local function HandlePlayer(Player)
    if HandledPlayers[Player] ~= nil then
        return
    end
    
    HandledPlayers[Player] = true

    Imports.ChunkSender.Send(Player, House, HandleNewHouse)
end

local function UnhandlePlayer(Player)
    HandledPlayers[Player] = nil
end

Services.Players.PlayerAdded:Connect(CreateReplicationTarget)
Services.ReplicatedStorage.HandlePlayer.OnServerEvent:Connect(HandlePlayer)
Services.Players.PlayerRemoving:Connect(UnhandlePlayer)

Make a ModuleScript named “HouseManager” in “ClientEntry” with this code:

local HouseManager = {}

local Services = {
    Players = game:GetService("Players"),
    ReplicatedStorage = game:GetService("ReplicatedStorage"),
    Workspace = game:GetService("Workspace"),
}

local Imports = {
    ChunkReceiver = require(Services.Players.LocalPlayer.PlayerScripts.ClientEntry.ChunkReceiver),
}

local function RecognizeHouse(OriginalHouse)
    local House = Imports.ChunkReceiver.ClaimClone(OriginalHouse)
    House.Parent = Services.Workspace
    -- Now, HouseManager takes responsibility for the model that was replicated, and the chunk loading process is complete.
    print("We've claimed a house.")
end

-- With our architecture, we've made sure that this listener will connect before a house is replicated. Keep this in mind with your own code.
function HouseManager.Main()
    Services.ReplicatedStorage.RecognizeHouse.OnClientEvent:Connect(RecognizeHouse)
end

return HouseManager

Replace the code of “ClientEntry” with this:

local Services = {
    ReplicatedStorage = game:GetService("ReplicatedStorage"),
}

local Imports = {
    ChunkReceiver = require(script.ChunkReceiver),
    HouseManager = require(script.HouseManager),
}

Imports.ChunkReceiver.Main()
Imports.HouseManager.Main()

Services.ReplicatedStorage.HandlePlayer:FireServer()

You can test this in Studio by hitting “Play”, and alternate between the server and client view. The server won’t see the house model in Workspace, but the client will!

To unload a chunk, the client would simply destroy the copy of it.

Why clone the replicated object, instead of just parenting it away and destroying the server’s version?

Even after reparenting the replicated Instance, because changes made on the client in Player.PlayerGui do not replicate to the server, the server still sees the Instance as not having moved. Destroying it server-side will result in the destruction of it client-side, even after reparenting client-side.

This seems like an awful hack, even though it is useful.

That’s true. We’re wasting CPU resources to clone the object client-side that would be unnecessary if Roblox implemented this functionality properly. This thread is made partially to illustrate the problem. It’s important to express what developers need through the Developer Forums. Consider supporting this thread by @Crazyman32 if it would help you:

45 Likes

PlayerScripts is intended for local-scripts. You really shouldn’t put code in GUIs. Additionally, it’s bad practice to put GUIs from the server, onto the client. (From my understanding, it’s deprecated and only remains for legacy purposes)

May I ask why you’re making a dictionary of services rather than just variables? Wouldn’t a dictionary just use up more memory and clock time?

Ditto above


Minor criticisms aside, this is a very good resources and I myself even learnt one or two things from this topic. :+1:

2 Likes

Thanks for the feedback.

I never suggested putting code into GUIs.

I’m aware of good and bad practice. There are times where it makes sense to break the rules. This is one of them, where the only way to accomplish what I’m suggesting is through this hack. I would never suggest creating actual GUIs on the server, the only reason I use a ScreenGui here is because it’s the only instance that we can stop from being destroyed every time the player respawns.

I care more for what I see as readable code than small optimizations. The memory usage is absolutely negligible. You want to focus on optimizing away potentially dozens or hundreds of megabytes (as the tutorial attempts), not worry about the few kilobytes a table or two takes up. As for clock time, anytime it’d be significant, I’d just localize the member I’m accessing. Nobody else has to do this, though!

(For example, what would we call the Players service? Just “Players” and we could confuse it with an array of players. “PlayersService” and now we have an inconsistency where we’d write “LightingService” but not “ReplicatedStorageService” or “StarterGuiService”. It’s fine to disagree with this.)

7 Likes

Roblox’s own style guide (FTW) suggests you just put them at the top.

Referencing your imports in a dictionary is very rouge and (in my opinion) just make your code overall slower and harder to read.

If you’re constantly using a service, those extra milliseconds will add up.

Any sane programmer would use camelCase for variables and singleton functions (sometimes), and PascalCase for imports (like services) and class methods/variables.

1 Like

Sure, they have a style guide for internal use. This isn’t compulsory to follow.

Worrying about microoptimization is a waste of time in my eyes.
Function calls have overhead, and we could do away with many of them in general, but usually their overhead is small and as there’s a readability benefit, we choose not to often. This obsession with every CPU cycle isn’t so useful, instead, we should focus on the critical areas of our code to optimize, such as the one place we have 3,000 table accesses.

These sorts of statements are problematic. You can’t make blank statements about what any “sane” programmer would do. I see this attitude a lot in relatively new programmers to Roblox, who don’t have much experience with other languages. It’s about an obsession over dogmatic but unquestioned rules. Personally, I dislike differentiating types of identifiers based on casing, and do so generally by naming or tables.

The attitude of this kind of feedback, speaking in general, seems to be about oneupmanship. While good practice should be encouraged and criticism of bad practice is valid, I don’t think there’s a very good argument that I’ve done anything wrong.

In the initial feedback, I was criticized for inserting something into PlayerGui, which indicates that you didn’t actually read any of the tutorial and missed the premise of what it was about.

I didn’t mean to suggest that anyone should code in my style, I just don’t believe that it would cause confusion with readers, or makes the code less readable.

Still, I appreciate the respectful feedback, even if I don’t think it’s valuable.

7 Likes

I agree with the nitpick, services should really be variables, not table members. Table access in Lua is slow, and sometimes you need to access services during “intensive” code. Like executing something frame-by-frame inside a loop, in which it would be bad to write Services.RunService.Heartbeat.Wait(), when you could just access RunService directly, or better yet, Heartbeat as a variable. It’s actually more readable to me (and obviously faster).

However, it’s ultimately relatively trivial, especially so for something like this, and it boils down to personal preference. But I would never encourage anyone to put these sorts of things in a table when there’s no need (and doing so goes against common practice). It also introduces inconsistency when you do need to micro-optimise some intensive code, and then you end up with variables floating outside of the tables you had before.

Right. When we say slow, though, we mean on the order of thousands of table accesses per millisecond on slow hardware, up to a hundred thousand or two hundred thousand on non-mobile good hardware.

You bring up a great point, that accessing them during intensive code is where the problem lies. Of course, most of our code is not runtime intensive. We can estimate or benchmark where this is, and focus efforts there.

In this particular code, the vast majority of the CPU expenditure in real-world use is going to be spent cloning the Instance, and will grow linearly with the number of descendants, so I don’t concern myself with microoptimization here.

4 Likes

This is a super useful guide and I might use it. Thanks for sharing!

I wonder if this is how a lot of popular games with housing replicate the houses to individual clients. This kind of problem has frustrated me a lot. Maybe other people do just use StreamingEnabled to solve this, although I don’t fully trust it.

4 Likes

I definitely learned many things from this guide, just by reading your code. Thank you very much for sharing this with us!