Client-Server Projectile

I’ve written a quick mockup, but haven’t tested in studio. It should show you the general idea though. (You don’t need to rely on GetPartsInPart to do this. You can also use raycasts)

--Server

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


--// Variables
local Remotes = ReplicatedStorage.Remotes


--// Types
type _Projectile = {
	["Lifetime"] : number, -- Lifetime of Projectile
	["Velocity"] : number, -- Speed of the Projectile
	["CFrame"] : CFrame, -- Position + Direction of Projectile
	["Key"] : string -- Key used to identify the projectile, to allow us to find and destroy on client.
}


--// Functions
local function SomeRandomSkill(Player : Player, Mouse : Vector3)
	local Character = Player.Character
	if not Character then return end
	
	if typeof(Mouse) ~= "Vector3" then return end
	
	local Projectile : _Projectile = {
		["Key"] = Player.Name .. DateTime.now().UnixTimestampMillis + math.random(1, 1000),
		["CFrame"] = CFrame.lookAt(Character:GetPivot().Position, Mouse),
		["Velocity"] = 100,
		["Lifetime"] = 4000, --Equivalent to 4 seconds.
	}
	
	local Hitbox = Instance.new("Part") -- I actually recommend looking into PartCache!
	
	Hitbox:PivotTo(Projectile.CFrame)
	Hitbox.Parent = workspace.SkillDebris.Projectiles
	
	local Params = OverlapParams.new()
	Params.FilterDescendantsInstances = {Character, workspace.SkillDebris}
	--Params.CollisionGroup = "Projectile" -- I highly recommend utilizing CollisionGroups!!!
	Params.MaxParts = 1
	
	--// Send information to client!
	Remotes.SetFx:FireAllClients(
		{
			["SkillName"] = "SomeRandomSkill",
			["Status"] = "Projectile",
			["Projectile"] = Projectile
		}
	)
	--//
	
	local StartTimer = DateTime.now().UnixTimestampMillis
	
	local Connected
	
	local Hit = false
	
	Connected = RunService.Heartbeat:Connect(function(deltaTime)
		if (DateTime.now().UnixTimestampMillis - StartTimer) >= Projectile.Lifetime then
			Hit = true
		end
		
		local PartsInPart = workspace:GetPartsInPart(Hitbox, OverlapParams) --Raycast checks or PartsInPart. Im just gonna use PartsInPart to get idea across

		if PartsInPart[1] then
			Hit = true
		end
		
		if Hit then
			Connected:Disconnect()
			
			--// Send information to client!
			Remotes.SetFx:FireAllClients(
				{
					["SkillName"] = "SomeRandomSkill",
					["Status"] = "Hit",
					
					["Data"] = {
						["HitPosition"] = Hitbox.CFrame,
						["Key"] = Projectile.Key,
					}
				}
			)
			--//
			
			table.clear(Projectile)
			
			Projectile = nil
			StartTimer = nil
			Connected = nil
			Params = nil
			Hit = nil
		else
			Hitbox.CFrame += Hitbox.CFrame.LookVector.Unit * Projectile.Velocity * deltaTime
		end
	end)
end

--// Scipt
Remotes.RequestAttack.OnServerEvent:Connect(SomeRandomSkill)
--Client
local SkillFx = {}

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

--// Variables
local Remotes = ReplicatedStorage:WaitForChild("Remotes")

local SkillDebris = workspace:WaitForChild("SkillDebris")


--// Functions
function SkillFx.SetFx(Data) -- Finds hitbox and sets attribute 'Active' to nil.
	local SkillFound = SkillFx[Data.SkillName]
	
	if typeof(SkillFound) == "function" then
		SkillFound(Data)
	end
end

function SkillFx.SetInactive(Key)
	local Found = SkillDebris:FindFirstChild(Key)
	
	if Found then
		Found:SetAttribute("Active", nil)
	end
end

function SkillFx.SomeRandomSkill(Data)
	if Data.Status == "Projectile" then
		local Projectile = Data.Projectile
		
		if not Projectile then
			return
		end
		
		--Generate visuals stuff
		local Part = Instance.new("Part") -- Once again I recommend PartCache!!!!
		Part.Name = Projectile.Key
		Part:SetAttribute("Active", true)
		Part:PivotTo(Projectile.CFrame)
		Part.Parent = SkillDebris
		
		local StartTimer = DateTime.now().UnixTimestampMillis
		
		local Connected
		
		Connected = RunService.RenderStepped:Connect(function(deltaTime)
			if Part:GetAttribute("Active") == nil then
				Connected:Disconnect()
				
				Part:Destroy()
				
				StartTimer = nil
				Connected = nil
				Part = nil
				
			elseif (DateTime.now().UnixTimestampMillis - StartTimer) >= Projectile.Lifetime then
				Part:SetAttribute("Active", nil) -- Lifetime exceeded so set to nil (also works as failsafe!!!)
			else
				Part.CFrame += Projectile.CFrame.LookVector.Unit * Projectile.Velocity * deltaTime
			end
		end)
		
	elseif Data.Status == "Hit" then
		--Hit visual stuff
		
		local HitData = Data.HitData
		
		if not HitData then
			return
		end
		
		SkillFx.SetInactive(HitData.Key) -- Call function to set projectile to nil
		
		-- Other visuals, etc
	end
end


--// Script
Remotes.SetFx.OnClientEvent:Connect(SkillFx.SetFx)

I’ve been using this method in my Projects, and everything seems all aligned and consistent. If you need help understanding my code, just reply.

1 Like