Issue with server to client replication physics

Hello, I am having an issue with my slingshot script.
I am sure that there is a workaround to it (hopefully) but these are the scripts.

The main issue here is probably not the scripts, but rather the server to client replication beind.. odd.

This is the Client Side script that sends info to the server:

local TweenService = game:GetService("TweenService")
local UserInputService = game:GetService("UserInputService")

local button = script.Parent
local frame = script.Parent.Parent
local event = game.ReplicatedStorage.RemoteEvents.AnimationEvent

local cooldown = frame:WaitForChild("Cooldown")
local globalCooldown = frame.Parent:WaitForChild("GlobalCooldown")

local mouse = game.Players.LocalPlayer:GetMouse()

local function setOverlayVisible(targetFrame, state)
	local overlay = targetFrame:FindFirstChild("OverlayFrame")
	if overlay then
		overlay.Visible = state
	end
end

local function startGlobalCooldown()
	globalCooldown.Value = true

	for _, child in ipairs(frame.Parent:GetChildren()) do
		if child:IsA("Frame") then
			setOverlayVisible(child, true)
		end
	end
	local globalCooldownTime = frame.GlobalCooldownTime.Value
	task.delay(globalCooldownTime, function()
		globalCooldown.Value = false

		for _, child in ipairs(frame.Parent:GetChildren()) do
			if child:IsA("Frame") then
				setOverlayVisible(child, false)
			end
		end
	end)
end

local function playCooldown(cooldownTime, doGlobal)
	cooldown.Value = true

	if doGlobal then
		for _, child in ipairs(frame.Parent:GetChildren()) do
			if child:IsA("Frame") then
				setOverlayVisible(child, true)
			end
		end
	else
		setOverlayVisible(frame, true)
	end

	local cooldownFrame = frame.CooldownFrame
	local bar = cooldownFrame.Bar

	cooldownFrame.Visible = true
	bar.AnchorPoint = Vector2.new(0, 0.5)
	bar.Position = UDim2.fromScale(0, 0.5)
	bar.Size = UDim2.fromScale(0, 1)

	local tween = TweenService:Create(
		bar,
		TweenInfo.new(cooldownTime, Enum.EasingStyle.Linear),
		{ Size = UDim2.fromScale(1, 1) }
	)

	tween:Play()
	tween.Completed:Wait()

	cooldown.Value = false
	cooldownFrame.Visible = false

	if doGlobal then
		for _, child in ipairs(frame.Parent:GetChildren()) do
			if child:IsA("Frame") then
				setOverlayVisible(child, false)
			end
		end
	else
		setOverlayVisible(frame, false)
	end
end

local function activateAnimation(input, isGameProcessedEvent)
	if cooldown.Value or globalCooldown.Value then return end

	local isValidInput = false
	local inputTypeValue = frame.InputType.Value

	if input and not isGameProcessedEvent then
		local successKey, keyCode = pcall(function()
			return Enum.KeyCode[inputTypeValue]
		end)

		if successKey and keyCode and input.KeyCode == keyCode then
			isValidInput = true
		else
			local successInput, userInputType = pcall(function()
				return Enum.UserInputType[inputTypeValue]
			end)

			if successInput and userInputType and input.UserInputType == userInputType then
				isValidInput = true
			end
		end
	end

	if input then
		local successMouse, mouseButtonType = pcall(function()
			return Enum.UserInputType[inputTypeValue]
		end)

		if successMouse and mouseButtonType and input.UserInputType == mouseButtonType then
			isValidInput = true
		end
	end

	if not isValidInput then return end

	startGlobalCooldown()

	local animation = "rbxassetid://" .. frame.Animation.Value
	local attackName = frame.AttackName.Value
	local character = frame.Parent.Character.Value
	local cooldownTime = frame.CooldownTime.Value
	local doGlobal = frame.DoGlobal.Value

	task.spawn(function()
		playCooldown(cooldownTime, doGlobal)
	end)

	event:FireServer({
		Animation = animation,
		AttackName = attackName,
		Character = character,
		Frame = frame,
		DoGlobal = doGlobal,
		CooldownTime = cooldownTime,
		HitPosition = mouse.Hit.Position
	})

end

button.MouseButton1Click:Connect(activateAnimation)
UserInputService.InputBegan:Connect(activateAnimation)

this is the script that recieves that information and sends it to the proper module script:

local animationEvent = game.ReplicatedStorage.RemoteEvents.AnimationEvent
local moduleFolder = game.ServerStorage.AttackModules

local cooldowns = {}
-- cooldowns[player][key] = lastUseTime

local function isOnCooldown(player, key, cooldownTime)
	cooldowns[player] = cooldowns[player] or {}

	local last = cooldowns[player][key]
	if last and (time() - last < cooldownTime) then
		return true
	end

	cooldowns[player][key] = time()
	return false
end

local function handleAnimation(player, data)
	if typeof(data) ~= "table" then return end
	if typeof(data.AttackName) ~= "string" then return end
	if typeof(data.Animation) ~= "string" then return end
	if typeof(data.CooldownTime) ~= "number" then return end

	local characterModel = player.Character
	if not characterModel then return end

	local humanoid = characterModel:FindFirstChildOfClass("Humanoid")
	if not humanoid then return end

	local attackName = data.AttackName
	local cooldownTime = data.CooldownTime
	local doGlobal = data.DoGlobal
	local animFrame = data.Frame
	local mousePos = data.HitPosition
	local character = data.Character
	local animationId = data.Animation

	-- per-attack cooldown (server authoritative)
	if isOnCooldown(player, attackName, cooldownTime) then
		return
	end

	-- optional global cooldown
	if doGlobal and animFrame and animFrame.Parent then
		local globalCooldownValue = animFrame.Parent:FindFirstChild("GlobalCooldownTime")
		if globalCooldownValue then
			if isOnCooldown(player, "GLOBAL", globalCooldownValue.Value) then
				return
			end
		end
	end

	-- play animation
	local anim = Instance.new("Animation")
	anim.AnimationId = animationId
	anim.Parent = characterModel

	local track = humanoid:LoadAnimation(anim)
	track:Play()

	-- load attack module
	local moduleScript = moduleFolder:FindFirstChild(character)
	if not moduleScript then return end

	local attacks = require(moduleScript)
	local attackFunc = attacks[attackName]

	if not attackFunc then
		warn("Attack not found:", attackName)
		return
	end

	-- ✅ EXECUTE ATTACK (CONFIG STYLE)
	attackFunc({
		Player = player,
		AnimTrack = track,
		AnimFrame = animFrame,
		MousePosition = mousePos
	})
end

animationEvent.OnServerEvent:Connect(handleAnimation)

And the real root of our problems here is the module script.

local module = {}

--// SERVICES
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

------------------------------------------------------------
-- FOLDER
------------------------------------------------------------
local bulletsFolder = workspace:FindFirstChild("Bullets")
if not bulletsFolder then
	bulletsFolder = Instance.new("Folder")
	bulletsFolder.Name = "Bullets"
	bulletsFolder.Parent = workspace
end

module.Bullets = {}

------------------------------------------------------------
-- DAMAGE CALCULATION
------------------------------------------------------------
local function calculateDamage(dmg, model)
	if not model then return dmg end
	local stats = model:FindFirstChild("Stats")
	if not stats then return dmg end

	local armor = stats:FindFirstChild("Armor") and stats.Armor.Value or 0
	local resistance = stats:FindFirstChild("Resistance") and stats.Resistance.Value or 0

	dmg = math.max(dmg - armor, 0)
	return dmg * (1 - math.clamp(resistance, 0, 1))
end

------------------------------------------------------------
-- FADE BULLET
------------------------------------------------------------
local function fadeBullet(bullet)
	local tween = TweenService:Create(bullet, TweenInfo.new(0.4), {Transparency = 1})
	tween:Play()
	tween.Completed:Once(function()
		if bullet then bullet:Destroy() end
	end)
end

------------------------------------------------------------
-- FIRE BULLET
------------------------------------------------------------
function module.FireBullet(data)
	if not data then return end

	local origin = data.origin
	local target = data.MousePos
	local speed = data.speed or 50
	local player = data.player

	-- Direction and distance
	local displacement = target - origin
	local flatDir = Vector3.new(displacement.X, 0, displacement.Z)
	local distance = flatDir.Magnitude
	if distance < 0.1 then return end

	-- Physics-based arc
	local g = workspace.Gravity
	local heightDiff = target.Y - origin.Y
	local angle = math.rad(45)
	local v_squared = (g * distance^2) / (2 * math.cos(angle)^2 * (distance * math.tan(angle) - heightDiff))

	local velocity
	if v_squared <= 0 or v_squared ~= v_squared then
		-- fallback
		angle = math.rad(30)
		local time = distance / (speed * math.cos(angle))
		local verticalSpeed = (heightDiff + 0.5 * g * time^2) / time
		local horizontalSpeed = speed * math.cos(angle)
		velocity = flatDir.Unit * horizontalSpeed + Vector3.new(0, verticalSpeed, 0)
	else
		local v = math.sqrt(v_squared)
		local horizontalSpeed = v * math.cos(angle)
		local verticalSpeed = v * math.sin(angle)
		velocity = flatDir.Unit * horizontalSpeed + Vector3.new(0, verticalSpeed, 0)
	end

	-- Clamp horizontal speed
	local horizontalVel = Vector3.new(velocity.X, 0, velocity.Z)
	local horizontalSpeed = horizontalVel.Magnitude
	if horizontalSpeed > 40 then horizontalVel = horizontalVel.Unit * 40
	elseif horizontalSpeed < 1 and horizontalSpeed > 0 then horizontalVel = horizontalVel.Unit * 1 end
	velocity = horizontalVel + Vector3.new(0, velocity.Y, 0)

	------------------------------------------------------------
	-- CREATE SERVER BULLET
	------------------------------------------------------------
	local bullet = Instance.new("Part")
	bullet.Size = Vector3.new(0.4,0.4,0.4)
	bullet.Shape = Enum.PartType.Ball
	bullet.Material = Enum.Material.Plastic
	bullet.Color = Color3.fromRGB(200,200,200)
	bullet.Anchored = false
	bullet.CanCollide = false
	bullet.Massless = false
	bullet.CFrame = CFrame.new(origin)
	bullet.Parent = bulletsFolder
	bullet.AssemblyLinearVelocity = velocity
	bullet:SetNetworkOwner(nil)

	module.Bullets[bullet] = {
		damage = data.damage,
		player = player,
		hasHit = false
	}

	------------------------------------------------------------
	-- CLIENT VISUAL BULLET SPAWN
	------------------------------------------------------------
	local visualEvent = ReplicatedStorage:WaitForChild("RemoteEvents"):WaitForChild("SpawnVisualBullet")
	if player and player:IsA("Player") then
		local bulletData = {
			Position = bullet.Position,
			Size = bullet.Size,
			Color = bullet.Color,
		}
		visualEvent:FireClient(player, bulletData, bullet)
	end

	------------------------------------------------------------
	-- SERVER SIMULATION LOOP
	------------------------------------------------------------
	local params = RaycastParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude
	params.FilterDescendantsInstances = {bulletsFolder, player.Character, bullet}

	local lifetime = data.lifetime or 6
	local alive = 0

	local conn
	conn = RunService.Heartbeat:Connect(function(dt)
		local info = module.Bullets[bullet]
		if not info then conn:Disconnect() return end

		alive += dt
		if alive >= lifetime then
			module.Bullets[bullet] = nil
			fadeBullet(bullet)
			conn:Disconnect()
			return
		end

		local currentPos = bullet.Position
		local nextPos = currentPos + bullet.AssemblyLinearVelocity * dt
		local travel = nextPos - currentPos

		local hit = workspace:Spherecast(currentPos, bullet.Size.X/2, travel, params)
		if hit and not info.hasHit then
			info.hasHit = true

			local part = hit.Instance
			local model = part:FindFirstAncestorOfClass("Model")
			local hum = model and model:FindFirstChildOfClass("Humanoid")
			if hum then
				hum:TakeDamage(calculateDamage(info.damage, model))
			end

			bullet.CanCollide = true
			module.Bullets[bullet] = nil
			task.delay(3, function() fadeBullet(bullet) end)
			conn:Disconnect()
		end
	end)
end

return module

I can say with 100% certainty that there is some dumb mistakes, as I did use some AI to program this.

But here is a video showcasing the delay with the bullet.

As you can see, the NPC takes damage before the bullet hits the noob visually.
I am wondering if there is a workaround for this. I’ve heard of replicating a part on the client for a fake bullet, but how would you make a physics version of that?

Wouldn’t you have to send info like the position, what’s touching it, from the client to the server? Would doing that make it easily exploitable for players?

Anyways, you don’t HAVE to completely correct the code, you could just give me some logic on HOW i could exactly fix this issue.

example: using local scripts and scripts, use the server to replicate the exact bullet position to the client bullet visual (which i’ve tried, the delay makes it just as bad as not having it)

I’ve also tried fastCast, the same issue occurs.
I created a bullet that deletes once it touches something, on the server it deletes when it is supposed to, but on the client it visually deletes before it touches the part.

Thanks to anyone who tries helping me with this big issue,
Hopefully i’m not asking for too much (i know i am:sob:)

1 Like