Client Server Linear Velocities have different outcomes

I am attempting to make an 8 ball game but am having issues having outcomes be the same between server and client. Right now, I have custom elastic collisions which works well on the client as you can see in the client versions of the balls (black/white). However, when the server balls are fired (red), they have different outcomes. Both of the simulations are running the exact same collision code math as well as the same sphere properties.

Server Code


local objects = workspace.ServerBalls:GetChildren()

local CollisionUtils = require(script.CollisionUtils)

game:GetService("RunService").Heartbeat:Connect(function()
	for i=1, #objects-1 do

		local part1 = objects[i]

		local overlapParams = OverlapParams.new()
		overlapParams.FilterType = Enum.RaycastFilterType.Whitelist

		for j=i+1, #objects do
			local part2 = objects[j]

			overlapParams.FilterDescendantsInstances = {part2}

			if #workspace:GetPartsInPart(part1, overlapParams) == 1 then

				local part1Mass = part1.Mass
				local part2Mass = part2.Mass

				local part1Velocity = Vector2.new(part1.AssemblyLinearVelocity.X, part1.AssemblyLinearVelocity.Z)
				local part2Velocity = Vector2.new(part2.AssemblyLinearVelocity.X, part2.AssemblyLinearVelocity.Z)

				local part1Position = Vector2.new(part1.Position.X, part1.Position.Z)
				local part2Position = Vector2.new(part2.Position.X, part2.Position.Z)

				local Velocity1, Velocity2 = CollisionUtils.calculateElasticCollisionVelocities(part1Velocity, part2Velocity, part1Position, part2Position, part1Mass, part2Mass)

				local midpointX = (part1Position.X + part2Position.X) / 2
				local midpointY = (part1Position.Y + part2Position.Y) / 2

				local dist = math.sqrt((part1Position.X - part2Position.X)^2 + (part1Position.Y - part2Position.Y)^2)

				local part1PositionX = midpointX + (part1.Size.X / 2) * (part1Position.X - part2Position.X) / dist
				local part1PositionY = midpointY + (part1.Size.Y / 2) * (part1Position.Y - part2Position.Y) / dist

				local part2PositionX = midpointX + (part2.Size.X / 2) * (part2Position.X - part1Position.X) / dist
				local part2PositionY = midpointY + (part2.Size.Y / 2) * (part2Position.Y - part1Position.Y) / dist

				part1.AssemblyLinearVelocity = Vector3.new(Velocity1.X, 0, Velocity1.Y)
				part2.AssemblyLinearVelocity = Vector3.new(Velocity2.X, 0, Velocity2.Y)
				
				part1.Position = Vector3.new(part1PositionX, part1.Position.Y, part1PositionY)
				part2.Position = Vector3.new(part2PositionX, part2.Position.Y, part2PositionY)
			end
		end
	end
end)

game.ReplicatedStorage.FireShot.OnServerEvent:Connect(function(player)
	workspace.ServerBalls.CueBall:ApplyImpulse(Vector3.new(234138.36802486575,0,0))
end)

while true do
	task.wait()
	
	if game.Players:FindFirstChild("jakebball11") then
		for _,v in workspace.ClientBalls:GetChildren() do
			v:SetNetworkOwner(game.Players.jakebball11)
		end
	end
end

Client Code


local objects = workspace.ClientBalls:GetChildren()

local CollisionUtils = require(script.CollisionUtils)

game:GetService("RunService").Heartbeat:Connect(function()
	for i=1, #objects-1 do
		
		local part1 = objects[i]
		
		local overlapParams = OverlapParams.new()
		overlapParams.FilterType = Enum.RaycastFilterType.Whitelist
		
		for j=i+1, #objects do
			local part2 = objects[j]
			
			overlapParams.FilterDescendantsInstances = {part2}
			
			if #workspace:GetPartsInPart(part1, overlapParams) == 1 then
				
				local part1Mass = part1.Mass
				local part2Mass = part2.Mass
				
				local part1Velocity = Vector2.new(part1.AssemblyLinearVelocity.X, part1.AssemblyLinearVelocity.Z)
				local part2Velocity = Vector2.new(part2.AssemblyLinearVelocity.X, part2.AssemblyLinearVelocity.Z)
				
				local part1Position = Vector2.new(part1.Position.X, part1.Position.Z)
				local part2Position = Vector2.new(part2.Position.X, part2.Position.Z)
				
				local Velocity1, Velocity2 = CollisionUtils.calculateElasticCollisionVelocities(part1Velocity, part2Velocity, part1Position, part2Position, part1Mass, part2Mass)
	
				local midpointX = (part1Position.X + part2Position.X) / 2
				local midpointY = (part1Position.Y + part2Position.Y) / 2
				
				local dist = math.sqrt((part1Position.X - part2Position.X)^2 + (part1Position.Y - part2Position.Y)^2)
				
				local part1PositionX = midpointX + (part1.Size.X / 2) * (part1Position.X - part2Position.X) / dist
				local part1PositionY = midpointY + (part1.Size.Y / 2) * (part1Position.Y - part2Position.Y) / dist
				
				local part2PositionX = midpointX + (part2.Size.X / 2) * (part2Position.X - part1Position.X) / dist
				local part2PositionY = midpointY + (part2.Size.Y / 2) * (part2Position.Y - part1Position.Y) / dist
				
				part1.AssemblyLinearVelocity = Vector3.new(Velocity1.X, 0, Velocity1.Y)
				part2.AssemblyLinearVelocity = Vector3.new(Velocity2.X, 0, Velocity2.Y)
				
				part1.Position = Vector3.new(part1PositionX, part1.Position.Y, part1PositionY)
				part2.Position = Vector3.new(part2PositionX, part2.Position.Y, part2PositionY)
				
			end
		end
	end
end)

task.wait(4)

workspace.ClientBalls.CueBall:ApplyImpulse(Vector3.new(234138.36802486575,0,0))

task.wait(3)
game.ReplicatedStorage.FireShot:FireServer()

Collision Utils

	
local CollisionUtils = {}


function CollisionUtils.calculateElasticCollisionVelocities(v1, v2, pos1, pos2, mass1, mass2)
	
	local numerator1 = (v1 - v2):Dot(pos1 - pos2)
	local numerator2 = (v2 - v1):Dot(pos2 - pos1)
	
	local denominator1 = (pos1 - pos2).Magnitude^2
	local denominator2 = (pos2 - pos1).Magnitude^2
	
	local newV1 = v1 - (2 * mass2 / (mass1 + mass2)) * (numerator1 / denominator1) * (pos1 - pos2)
	local newV2 = v2 - (2 * mass1 / (mass1 + mass2)) * (numerator2 / denominator2) * (pos2 - pos1)
	
	return newV1, newV2
end

return CollisionUtils

While the code might be the same the size of each time step on the client and server might differ due to interpolation. Basically, it is almost impossible to sync all clients and the server’s simulation both in rate and phase. I talk about how this is typically addressed in the start of this vid: (I will DM this vid to you because im not 100% sure if I can post it publically)

Fixing this involves choosing one observer to be the authority and copying the final positions of the shot to the others. For example, when one client is shooting they can be the authority, and you can send the results to the other clients (through the server using remotes).

Another, less feasible, option would be to enforce determinism in the simulation code. This would require using a fixed time step for each physics update. I don’t think that floating-point operations are deterministic across different computers running the Roblox engine, so you may need to come up with your own fixed point math system. There very well may be other sources of non-determinism that I can’t think of so actually achieving ‘determinism’ may be challenging. For the fixed point math I would use lua numbers by only working with integers and then to convert to floating point for rendering you would just divide by the fixed point scale (or maybe use bit32 if shifting is faster?). The other difficulty with working with fixed-points will be that you will have either less precision for small values, or overflow with larger values.