Are there better/more optimized ways of implementing this lag compensation script

Lag_Compensation_Test.rbxl (76.5 KB)

I made a server script for a PvE beat/shoot em up that makes a log of the CFrame of every enemy hurtbox for the last 0.5 second and then sets that hurtbox for the enemy to the CFrame that the player would see it at from their perspective. That way, when the player fires a bullet or throws a punch, the server can use the folder containing the hurtboxes specifically lag compensated for the player in order to see if the player actually hit the enemy or not.

The main reason why I did this is because I really didn’t want to have the client determine whether they hit an enemy or not, because of the risk of exploits.

This is the main server script

local Workspace = game:GetService("Workspace")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerStorage = game:GetService("ServerStorage")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")

local EnemiesFolder = Workspace.Environment.Enemies
local LagCompensatedHurtBoxFolder = Workspace.Environment.LagCompensatedHurtBoxes
local PlayerDelayUpdate = ReplicatedStorage.Remotes.PlayerDelayUpdate
local GetEnemyInstanceFromEnemyID = ServerStorage.Bindables.GetEnemyInstanceFromEnemyID

local EnemyHurtBoxTracker = {}

-- SETTINGS --

local MAX_DELAY = 0.5

-----

local uniqueID = 0

local function GenerateID()
	uniqueID += 1
	return uniqueID
end

local function GetLagCompensatedHurtBoxCFrameFromEnemyID(enemyID: string, clientTime: number)
	if not EnemyHurtBoxTracker[enemyID] then warn("EnemyID does not appear on tracker") return end
	
	local result = {}
	
	for hurtBoxName, hurtBoxInfo in pairs(EnemyHurtBoxTracker[enemyID]) do
		-- print(hurtBoxInfo)
		
		-- IMPORTANT: the following for loop has a questionable edge case, where the table referring the the enemy is changed in the middle of the for loop,
		-- but since the times go from right to left, I think that doing a for loop from right to left is fine.
		for i = #hurtBoxInfo , 1, -1 do
			if #hurtBoxInfo[i] < 2 then 
				-- we check if the player delay is so small that the "end" part of the relevant log entry isn't there yet
				if hurtBoxInfo[i][1].TimeStamp <= clientTime then
					local hurtBoxCurrentCFrame = GetEnemyInstanceFromEnemyID:Invoke(enemyID).HurtBoxes:FindFirstChild(hurtBoxName).CFrame
					result[hurtBoxName] = hurtBoxInfo[i][1].CFrame:Lerp(hurtBoxCurrentCFrame, (clientTime - hurtBoxInfo[i][1].TimeStamp)/(os.clock() - hurtBoxInfo[i][1].TimeStamp))
					break
				end
				
			end
			
			if hurtBoxInfo[i][1].TimeStamp <= clientTime and clientTime <= hurtBoxInfo[i][2].TimeStamp then
				-- print("MAX_DELAY not exceeded".. os.clock()-clientTime)
				result[hurtBoxName] = hurtBoxInfo[i][1].CFrame:Lerp(hurtBoxInfo[i][2].CFrame, (clientTime - hurtBoxInfo[i][1].TimeStamp)/(hurtBoxInfo[i][2].TimeStamp - hurtBoxInfo[i][1].TimeStamp))
				
				break
			
			elseif i == 1 then
				-- print("MAX_DELAY exceeded".. os.clock()-clientTime)
				-- print(clientTime)
			 	result[hurtBoxName] = hurtBoxInfo[i][1].CFrame
			end
		end
	end
	
	return result
end



Players.PlayerAdded:Connect(function(plr)
	-- unless a player can disconnect and reconnect insanely fast, I don't think that this will be triggered unless something actually goes wrong
	if LagCompensatedHurtBoxFolder:FindFirstChild(tostring(plr.UserId)) then warn("FATAL ERROR: folder for the player compensated hitbox already exists") return end
	
	local PlayerCompensatedHurtBoxFolder = Instance.new("Folder")
	
	PlayerCompensatedHurtBoxFolder.Name = tostring(plr.UserId)
	PlayerCompensatedHurtBoxFolder:SetAttribute("Delay", 0)
	
	PlayerCompensatedHurtBoxFolder.Parent = LagCompensatedHurtBoxFolder
	
	local playerDelay = PlayerCompensatedHurtBoxFolder:GetAttribute("Delay")
	
	PlayerCompensatedHurtBoxFolder.AttributeChanged:Connect(function(attribute)
		if attribute ~= "Delay" then return end
		
		playerDelay = PlayerCompensatedHurtBoxFolder:GetAttribute("Delay")
	end)
	
	while LagCompensatedHurtBoxFolder:FindFirstChild(tostring(plr.UserId)) do
		for enemyID, enemyInfo in pairs(EnemyHurtBoxTracker) do
			-- the following if statements adds the hitboxes in if it wasn't there before
			if not PlayerCompensatedHurtBoxFolder:FindFirstChild(enemyID) then
				local PlayerCompensatedEnemyFolder = Instance.new("Folder")
				PlayerCompensatedEnemyFolder.Name = enemyID
				
				for name, hurtBoxInfo in enemyInfo do
					local HurtBoxPart = Instance.new("Part")
					
					-- assign the required properties to the part
					HurtBoxPart.Color = Color3.new(0, 1, 0)
					HurtBoxPart.Transparency = 0.75
					HurtBoxPart.Anchored = true
					HurtBoxPart.CanCollide = false
					HurtBoxPart.CanTouch = false
					HurtBoxPart.Name = name
					HurtBoxPart.Size = hurtBoxInfo.Size
					
					HurtBoxPart.Parent = PlayerCompensatedEnemyFolder
				end
				
				PlayerCompensatedEnemyFolder.Parent = PlayerCompensatedHurtBoxFolder
			end 
			
			local hurtBoxCFrames = GetLagCompensatedHurtBoxCFrameFromEnemyID(enemyID, os.clock() - playerDelay)
			
			for hurtBoxName, hurtBoxCFrame in pairs(hurtBoxCFrames) do
				PlayerCompensatedHurtBoxFolder:FindFirstChild(enemyID):FindFirstChild(hurtBoxName).CFrame = hurtBoxCFrame
			end
		end
		task.wait()
	end
end)

Players.PlayerRemoving:Connect(function(plr)
	if LagCompensatedHurtBoxFolder:FindFirstChild(tostring(plr.UserId)) then 
		LagCompensatedHurtBoxFolder:FindFirstChild(tostring(plr.UserId)):Destroy()
	end
end)

PlayerDelayUpdate.OnServerEvent:Connect(function(plr, timeStamp)
	if not LagCompensatedHurtBoxFolder:FindFirstChild(tostring(plr.UserId)) then return end
	local delayValue = os.clock() - timeStamp
	-- print(plr.Name.." has delay of "..delayValue)
	LagCompensatedHurtBoxFolder:FindFirstChild(tostring(plr.UserId)):SetAttribute("Delay", delayValue)
end)

-- I'm gonna do this just for enemies for now, I don't see a good reason to do the same for players in a PvE game, unless I make something like a TF2 Medic Crusader Crossbow support weapon

local function AddEnemyToTracker(enemy:Model)
	if enemy:IsA("Model") and enemy:FindFirstChildOfClass("Humanoid") then
		
		local RootPart: BasePart= enemy.HumanoidRootPart
		if not enemy:GetAttribute("EnemyID") then 
			enemy:SetAttribute("EnemyID", GenerateID())
		end

		EnemyHurtBoxTracker[tostring(enemy:GetAttribute("EnemyID"))] = {}
		print("added enemy to enemy tracker")
		
		for _, hurtBox: BasePart in pairs(enemy.HurtBoxes:GetChildren()) do
			EnemyHurtBoxTracker[tostring(enemy:GetAttribute("EnemyID"))][hurtBox.Name] = {Size = hurtBox.Size}
			
			spawn(function()
				local lastCheckedTime = os.clock()
				local PartTrackingInfo = EnemyHurtBoxTracker[tostring(enemy:GetAttribute("EnemyID"))][hurtBox.Name]
				local TrackedPart = hurtBox
				
				while PartTrackingInfo do
					table.insert(PartTrackingInfo, {{TimeStamp = lastCheckedTime, CFrame = TrackedPart.CFrame}})
					task.wait(0.05)

					lastCheckedTime = os.clock()
					PartTrackingInfo[#PartTrackingInfo][2] = {TimeStamp = lastCheckedTime, CFrame = TrackedPart.CFrame}

					-- remove old entries whose time stamps end at `MAX_DELAY` seconds before current time 
					while PartTrackingInfo[1][2].TimeStamp < lastCheckedTime - MAX_DELAY  do
						table.remove(PartTrackingInfo, 1)
					end
				end
			end)
		end
		
	end 
end

local function RemoveEnemyFromTracker(enemy)
	if enemy:IsA("Model") and enemy:FindFirstChildOfClass("Humanoid") then

		EnemyHurtBoxTracker[tostring(enemy:GetAttribute("EnemyID"))] = nil -- I'm actually kinda surprised that this works considering that the child is destroyed and all
		
		for _, playerFolder:Folder in pairs(LagCompensatedHurtBoxFolder:GetChildren()) do
			if playerFolder:FindFirstChild(tostring(enemy:GetAttribute("EnemyID"))) then
				playerFolder:FindFirstChild(tostring(enemy:GetAttribute("EnemyID"))):Destroy()
			end
		end
	end
end

EnemiesFolder.ChildAdded:Connect(AddEnemyToTracker)

EnemiesFolder.ChildRemoved:Connect(RemoveEnemyFromTracker)


for _,enemy:Instance in pairs(EnemiesFolder:GetChildren()) do
	print(enemy.ClassName)
	AddEnemyToTracker(enemy)
end


--[[ This is just for debugging
while task.wait(0.1) do
	print(os.clock())
	print(EnemyHurtBoxTracker)
	for _, player in pairs(Players:GetPlayers()) do
		-- print(player.Name.." ping is: ".. player:GetNetworkPing())
	end
end
]]--

So these are my following questions:

Is there a more optimized way of doing this? When i was running this test on my laptop it really struggled as soon as more than 5 enemies were in the game world.

I don’t actually need the client to keep track of were these hurtboxes are especially for other players, since only the server calculates the collisions and the hurtboxes on the client will be inherently accurate. Therefore can I just delete the “LagCompensatedHurtBoxes” folder in the client in order to save performance on the client?

Does this actually work, aka, would the hurtboxes made in the server accurately represent what the player is seeing from their end? I’m still rather new to scripting, so I don’t know if roblox has lag compensation built in to the networking.

Edit: I also made a local script for in the client so that the client can send its current time and then the server uses that to determine the delay, the reason for that is because I don’t have a clue how exactly :GetNetworkPing works.

Edit 2: It turns out, that what was causing my bad performance was because I was printing out a ton of stuff every tenth of a second, after removing that while loop at the end of the LagCompensationScript, I could run 32 enemies with no issue.

3 Likes

I will give you one of my backtracking modules later on

It correctly calculates the nearest savepoint to get data from based on a variable step and the time you want to go back (ms)
It also collectively updates everything rather than individually

Something like

local bc = backtrack.New()

bc:SetFunction({
cf = HumanoidRootPart.CFrame
})

bc:Start()

wait(5)

local data = bc:Get(.5) -- get cframe from .5 seconds ago

print(data.cf.Position - bc:Get(5).cf.Position)

Super effective. Takes up a bit of memory. Been using it for a bit

1 Like
local backtracker = {}
backtracker.__index = backtracker

local RunService = game:GetService('RunService')
local defaultFrameRate = 1/10
local maximumFrames = 100

function backtracker.New()
	return setmetatable({
		frameFunction = nil,
		frameRate = defaultFrameRate, -- 10 times a second
		frames = {},
		renderLoop = nil,
		active = false,
	}, backtracker)
end

function backtracker:Start()
	if (self.active) then
		self:Stop()
	end
	
	local accumulation = 0
	self.renderLoop = RunService.Heartbeat:Connect(function(dt)
		accumulation += dt
		
		if (accumulation > self.frameRate) then
			local saves = accumulation / self.frameRate
			accumulation -= dt * saves
			
			for _ = 1, saves do
				table.insert(self.frames, 1, self.frameFunction())
				
				-- remove excess frames
				if (#self.frames > maximumFrames) then
					for k = maximumFrames + 1, #self.frames, 1 do
						self.frames[k] = nil
					end
				end
			end
		end
	end)
	
	return self
end

function backtracker:Stop()
	self.active = false
	if (self.renderLoop) then
		self.renderLoop:Disconnect(); self.renderLoop = nil
	end
	
	return self
end

function backtracker:ClearFrames()
	table.clear(self.frames)
end

function backtracker:GetFrame(secondsAgo)
	-- every frame in self.frames is saved at self.frameRate
	-- therefore self.frameRate is our step
	-- secondsAgo gets rounded to self.frameRate
	local roundedFrame = math.round(math.floor(secondsAgo / self.frameRate + .5) * self.frameRate) -- math.round to remove any discrepancies? is that how you spell it
	
	-- i think we just multiply roundedFrame by the maximum frames
	local frame = math.floor(roundedFrame * maximumFrames)
	
	-- yes we do! it works!
	return self.frames[math.min(frame, #self.frames)]
end

function backtracker:AdjustFrameRate(n)
	self.frameRate = 1/n
end

function backtracker:SetFrameFunction(frameFunction)
	self.frameFunction = frameFunction
	return self
end

function backtracker:Destroy()
	self:Stop()
	self:ClearFrames()
	
	table.clear(self)
	setmetatable(self, nil)
end

return backtracker

Here you go
Tell me when to delete it. Make sure you keep it SBU

1 Like

Okay, I have copied the code, what does SBU mean?

1 Like

Make sure u keep it secret and unshared even tho its not really important
Just wanna avoid threats to your game

I see, well it’s not like an exploiters can take advantage of knowing the code, at least from what I know.

1 Like

How performant is this? I noticed that the default frame rate is 1/10 but is it possible to track 100 NPCs and do it every frame? From what I can see, there is no tweening/lerping involved here

2 Likes

Lerping disregards lag spikes

Yes you can do this with a bunch of enemies.
Track its performsnce with microprofiling. We have different systems

Decreasing the # of frames and allowing Lerping would both progressivley disregard lag spikes or any unpredictable discrepancies in movement. That nullifies the backtracking in a sense.

2 Likes

Less max frames and framerate corresponds to more performance in this case

2 Likes

I also realised that in the example that you used above, you used the :SetFunction method to set the self.framefunction as a table, so that must mean that the self.frameFunction is of the table data type, but then in the module I see that you called the self.frameFunction as a function and I’m guessing that it returns a table with the CFrames as they are at that point in time. Is this a thing that you can just do in Lua?

2 Likes

ah i see what you mean. that was a mistake. setfunction should have a function passed into it

2 Likes

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.