I made a system like this for NPCs. it would probably be the same for players
Heres my code
This file sends data from the server to clients through unreliable remote events. You have to chunk the data because of the 1000 byte limit. While roblox has a constant 1/20 interval, you can get better performance with your own system if you go down to something like 1/16 which still looks good. Then you can also replicate slower for entities that are far away or when the number of entities exceeds a large amount. Past the maximum distance there is no replication at all.
My buffer sends one CFrame for each entity, which is
- Position : f32 x 3 = 12 bytes
- Orientation : u16 x 3 = 6 bytes
- Entity ID : u16 x 1 = 2 bytes
For total of 20 bytes
@native function ReplicationProcessor.update(deltaTime: number)
tickCounter += 1
local fromGeneratedPool =
World.components[ComponentTypes.ComponentNames.FromGeneratedBase]
:: World.ComponentPool<ComponentTypes.FromGeneratedBaseState>
local dense = fromGeneratedPool.dense :: {ComponentTypes.FromGeneratedBaseState}
local entityIds : {number} = fromGeneratedPool.entityIds
local enemyCount : number = #dense
local slowdown : number = 1
if enemyCount >= EnemyReplicationSettings.COUNT_ULTRA then
slowdown = EnemyReplicationSettings.SLOWDOWN_ULTRA
elseif enemyCount >= EnemyReplicationSettings.COUNT_HIGH then
slowdown = EnemyReplicationSettings.SLOWDOWN_HIGH
elseif enemyCount >= EnemyReplicationSettings.COUNT_MED then
slowdown = EnemyReplicationSettings.SLOWDOWN_MED
end
local isCloseTick : boolean = (tickCounter % slowdown == 0)
local isMedTick : boolean = (tickCounter % (TICK_MED * slowdown) == 0)
local isFarTick : boolean = (tickCounter % (TICK_FAR * slowdown) == 0)
if not isCloseTick or enemyCount == 0 then return end
-- Cache CFrames to prevent lua-c boundary cross
local cachedCFrames = table.create(enemyCount) :: {CFrame}
local cachedPositions = table.create(enemyCount) :: {Vector3}
for i = 1, enemyCount do
local cf = dense[i].Model:GetPivot()
cachedCFrames[i] = cf
cachedPositions[i] = cf.Position
end
for _, Player in Players:GetPlayers() do
local Character = Player.Character
if not Character then continue end
local playerPosition : Vector3 = Character:GetPivot().Position
local entitiesToSend: {number} = {}
local sendCount : number = 0
for denseIndex = 1, enemyCount do
local distance : number = (playerPosition - cachedPositions[denseIndex]).Magnitude
local shouldReplicate : boolean = false
if distance <= DISTANCE_CLOSE then
shouldReplicate = true
elseif distance <= DISTANCE_MED then
shouldReplicate = isMedTick
elseif distance <= DISTANCE_FAR then
shouldReplicate = isFarTick
end
if shouldReplicate then
sendCount += 1
entitiesToSend[sendCount] = denseIndex
end
end
if sendCount <= 0 then continue end
-- Chunk payloads to avoid the 900-1000 byte limit
local entitiesProcessed : number = 0
while entitiesProcessed < sendCount do
-- Determine how many entities we are sending in this specific packet
local remaining : number = sendCount - entitiesProcessed
local batchCount : number = math.min(remaining, MAX_ENTITIES_PER_PACKET)
local entityDataBuffer : buffer = buffer.create(batchCount * EnemyReplicationUtil.FULL_BYTES)
local bufferIndex : number = 0
for i = 1, batchCount do
-- Shift the index by how many entities we've already processed
local denseIndex : number = entitiesToSend[entitiesProcessed + i]
local entityId : number = entityIds[denseIndex]
local cf : CFrame = cachedCFrames[denseIndex]
buffer.writeu16(entityDataBuffer, bufferIndex, entityId)
bufferIndex += 2
EnemyReplicationUtil.serializeCFrame(entityDataBuffer, cf, bufferIndex)
bufferIndex += 18
end
outgoingRemotes.Replication:FireClient(Player, entityDataBuffer)
entitiesProcessed += batchCount
end
end
end
on my Client side i have an onEnemyCreated and onEnemyRemoved that are hooked to just normal remote events because the server and the client have to sync their entities before you can start updating their data
Then the most important part for replication on the client is to interpolate between the data packets so you can get smooth movement even though it only sends at intervals of 1/16 seconds
Firstly I have a function that decodes the buffer in the same way and then updates the start and target CFrames
local function onReplicated(entityDataBuffer: buffer)
local bufferLength = buffer.len(entityDataBuffer)
local numEntities = bufferLength // FULL_BYTES
local offset = 0
local now = os.clock()
for _ = 1, numEntities do
local entityID: number = buffer.readu16(entityDataBuffer, offset)
offset += 2
local x: number = buffer.readf32(entityDataBuffer, offset)
offset += 4
local y: number = buffer.readf32(entityDataBuffer, offset)
offset += 4
local z: number = buffer.readf32(entityDataBuffer, offset)
offset += 4
local rx: number = unmapRotation(buffer.readu16(entityDataBuffer, offset))
offset += 2
local ry: number = unmapRotation(buffer.readu16(entityDataBuffer, offset))
offset += 2
local rz: number = unmapRotation(buffer.readu16(entityDataBuffer, offset))
offset += 2
local index = entityToIndexMap[entityID]
if index then
local entity = activeEntitiesDense[index]
entity.StartCFrame = entity.StartCFrame:Lerp(entity.TargetCFrame, entity.LerpAlpha)
entity.TargetCFrame = CFrame.new(x, y, z) * CFrame.Angles(rx, ry, rz)
local duration: number = now - entity.LastUpdateTime
duration = math.clamp(duration, EnemyReplicationSettings.TICK_RATE, 1.5)
entity.LerpDuration = duration
entity.LastUpdateTime = now
entity.LerpAlpha = 0
entity.IsSleeping = false -- Wake up for new movement
end
end
end
The actual movement is handled with this function. I think native doesnt work on the client for now so you have to use bulkmoveto to get the best performance for this. Notice that you should set entities to sleep after they reach the destination because not all of them are going to be replicated all the time.
@native local function processSmoothMovement(dt: number)
table.clear(partsToMove)
table.clear(cframesToSet)
local count: number = 0
for i = 1, #activeEntitiesDense do
local entity = activeEntitiesDense[i]
if entity.IsSleeping then
continue
end
entity.LerpAlpha += dt / entity.LerpDuration
if entity.LerpAlpha >= 1 then
entity.LerpAlpha = 1
entity.IsSleeping = true -- Movement complete, sleep next frame
end
count += 1
partsToMove[count] = entity.RootPart
cframesToSet[count] = entity.StartCFrame:Lerp(entity.TargetCFrame, entity.LerpAlpha)
end
if count > 0 then
Workspace:BulkMoveTo(partsToMove, cframesToSet, Enum.BulkMoveMode.FireCFrameChanged)
end
end