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: