Make game with lots of projectiles less laggy

so i’m working on a bullet hell game where you have to dodge monster’s attacks, and it can be different types of attacks, some attacks go in a straight line from the monster to player, some attacks bounce of walls, some attacks follow the player until they hit them or disappear, etc…

what i did before is making the server side handle the visual part and the damage, which caused tons of lags when i was spawning a lot of projectiles, so i tried to find a way to make the game handle a lot of and found this post

it’s a bit different than what i’m trying to achieve but i followed a bit of this idea with some changes.

here’s my script, they are currently make the projectile only go forward

Server Side:

return function(atkObject: Instance, owner: Model, target: Model, dataMod: {})
	local close = false
	Event:FireAllClients(atkObject, owner, target, dataMod)
	local speed: Vector3 = Vector3.new(1, 0, 0) * dataMod.Speed
	local startPos = owner.PrimaryPart.Position
	-- movement
	coroutine.wrap(function()
		while task.wait() and not close do
			startPos = startPos + Vector3.new(1, 0, 0)
		end
	end)()
	
	-- damage
	coroutine.wrap(function()
		local db = false
		while task.wait() and not close do
			if not db and DmgScript(startPos, dataMod, target) then
				db = true	
				task.wait(dataMod.DamageDelay)
				db = false
			end
		end
	end)()
	task.wait(dataMod.TimeForNextMove)
	close = true
end

DamageScript:

i took the partsInRadius function from the post above and changed it a bit, i know i can’t use the radius for every attack because not every attack will be a sphere, but i’ll leave that like this for now

function partsInRadius(hitboxPosition, hitboxRadius, filter: {Instance})
	-- if a cframe was passed, just take the position out of it
	if typeof(hitboxPosition) == "CFrame" then
		hitboxPosition = hitboxPosition.Position
	end

	-- make a default overlap params if one is not passed in the argument
	local overlapParams = OverlapParams.new()
	overlapParams.FilterDescendantsInstances = filter
	overlapParams.FilterType = Enum.RaycastFilterType.Include
	
	local hit = workspace:GetPartBoundsInRadius(hitboxPosition, hitboxRadius, overlapParams)

	-- now that we have all the parts the spatial query found, let's go through it and see if we can find anyone!
	local hitCharacters = {}

	for i, v in pairs(hit) do

		if v.Parent then
			v = v.Parent -- get the actual character model, since spatial query will return the parts in the model
		end

		-- if we've already accounted for their character or they don't have a humanoid, skip them!
		if table.find(hitCharacters, v) or not v:FindFirstChild("Humanoid") then continue end

		-- insert them into the array
		table.insert(hitCharacters, v)

	end
	return hitCharacters
end

return function(position: CFrame, Attack, target: Model)
	local hit = partsInRadius(position, 2, {target})
	if #hit > 0 then	
		print(hit)
		local player = game.Players:GetPlayerFromCharacter(hit[1])
		if player and not hit[1]:FindFirstChildWhichIsA("ForceField") then
			-- visualizehit(position)
			_G.DamagePlayer(player, Attack.Damage)
			return true
		end
	end
	return false
end

Client Side:

vent.OnClientEvent:Connect(function(atkObject: Instance, owner: Model, target: Model, dataMod: {})
	local speed: Vector3 = Vector3.new(1, 0, 0) * dataMod.Speed
	local con
	local atk: Part = atkObject:Clone()
	atk.CanCollide = false
	local startingPos = owner.PrimaryPart.Position
	con = RunService.Heartbeat:Connect(function()
		startingPos += Vector3.new(1, 0, 0)
		atk.Position = startingPos
	end)
	atk.Parent = workspace.Temp
	Derbis:AddItem(atk, dataMod.LifeTime)
	task.wait(dataMod.LifeTime)
	con:Disconnect()
end)

also here’s how the attack data look like

{
	Damage = 2, -- amount of damage dealt to the player
	DamageDelay = 1, -- time between each damage
	LifeTime = 5, -- time till attack destroyed
	Speed = 50, -- speed of the attack
	TimeForNextMove = 1, -- time before next attack
	TimeBeforeAttack = 2, -- time before attack starts
}

i’m currently not using the speed variable, because it seems like when i do use it the projectiles is a little too fast to even see it

there’s a little problem which it seams like the server side position is a bit ahead of the client side one, so i get damage a little before the attack could reach me. also i’m not sure if it’s a good approach in general with the wide variety of the attacks i will have.

i’d recommend using a modulescript instead of _G since it causes similar deoptimisations to using globals, it’s also incompatible with typechecking

another recommendation i can think of (if possible) is putting the attack data into a lookup table and sending the client the key to that lookup table to save on network traffic

I work with projectiles for Wands etc.
My framework is basically a Module that handles both Client and Server and uses .Run service to determine which is which when calling it.

From there if the server calls it it utilises a RemoteEvent with :FireAllClients() to render the projectiles origin and direction; it registers which Client was the initial shooter (i.e. you) and if that Client has a Result on a Raycast it’ll use another RemoteEvent with :FireServer() to deal damage.

i’m not sure what you mean by lookup table, but i’m assuming you mean array since i’ve heard arrays are more efficient to transfer through network than dictionaries, so i updated the script and made it send array instead

i don’t think trusting client to decide when it was hit is a good idea, but making modulescript to decide which side ran the script is a good idea, i’ll probably do that once i figure out how to fix the problem with the delay

here’s the updated version of the script

Server Script:

return function(atkObject: Instance, owner: Model, target: Model, dataMod: {})
	local close = false
	Event:FireAllClients(atkObject, owner, target, {
		dataMod.Speed,
		dataMod.LifeTime,
		dataMod.TimeBeforeAttack,
		tick()
	})
	local speed: Vector3 = Vector3.new(1, 0, 0) * dataMod.Speed
	local startPos = owner.PrimaryPart.Position
	-- movement
	coroutine.wrap(function()
		while not close do
			startPos = startPos + speed * task.wait()
		end
	end)()
	
	-- damage
	coroutine.wrap(function()
		local db = false
		while task.wait() and not close do
			if not db and DmgScript(startPos, dataMod, target) then
				db = true	
				task.wait(dataMod.DamageDelay)
				db = false
			end
		end
	end)()
	task.wait(dataMod.TimeForNextMove)
	close = true
end

Client Side:

Event.OnClientEvent:Connect(function(atkObject: Instance, owner: Model, target: Model, dataMod: {})
	local timeTook = tick() - dataMod[4]
	print("time took to recieve: " .. timeTook .. " seconds")
	local speed: Vector3 = Vector3.new(1, 0, 0) * dataMod[1]
	local con
	local atk: Part = atkObject:Clone()
	atk.CanCollide = false
	local startingPos = owner.PrimaryPart.Position
	con = RunService.Heartbeat:Connect(function(delta)
		startingPos += speed * delta
		atk.Position = startingPos
	end)
	atk.Parent = workspace.Temp
	Derbis:AddItem(atk, dataMod[2])
	task.wait(dataMod[2])
	con:Disconnect()
end)

i’ve decided to calculate the time it took for the client to receive and by that decide where to place the atkObject at the client, so it’ll be the same place as the server, i’m just not sure how to exactly do that yet

1 Like

Looks promising!
Thing is with modern ROBLOX is if a hacker is determined they’ll find a way regardless.
With the introducion of Byfron it’s so much harder to exploit ; I just use a helluva lot of sanity checks.