Help with disabling client and server replication (for custom)

Hello devforum!

I recently saw the chrono library and was very impressed with how it works. But I wondered: what are the ways to disable player replication? It’s very interesting, especially considering I’m trying to create my own library to validate all player actions.

It says it on the page

“Optimizing server controlled entity replication by placing server controlled entities into a camera container on the server, stopping Roblox’s replication completely. Chrono then sends its own compressed replication data using a fraction of the bandwidth”

I think the only known way to stop replication is to put stuff into a camera on the server

Yes, that’s clear, I’m talking about how to establish a connection between the local client model and the server one

You have to do it all with RemoteEvents. Client-to-server events to manually replicate each client’s local-only character’s state (position, animation state, humanoid properties, etc), and server-to-client events to broadcast all character spawns and updates. Every client has to have a local-only copy of each player’s avatar. The server can have collision proxies of each avatar parented to the server camera, which you can use to enforce collisions on the server and send corrections to any player whose client-side movement doesn’t validate on the server. It’s a lot of plumbing, and farther you go off the rails of standard Roblox replication, the less future-proof you game becomes, so it’s good to consider the maintenance implications of your custom system.

1 Like

Thank you very much! Can you write a prototype of the scripts so that I can understand better?

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
1 Like