After a lot of research and practice done on my part after asking this question myself several months ago, I’ve decided to shed some light for everyone on some more efficient network practices when it comes to projectiles and their hitboxes. Games with lots of stuff going on shouldn’t have to have 150+ kb/s of network traffic all the time.
A few key things to note before diving deeper into this:
-
There are lots of ways to go about doing these depending on your use-case, and 95% of the time, if it follows the rules I lay out here, you can apply this set of steps to make your code more network efficient.
-
You should understand some basic practices of keeping VFX on the client and doing simple sanity checks on remotes.
-
This is most optimally used for larger projectiles where raycasting is cumbersome or time-consuming to set up. Like a large fireball or a rocket. If your projectile is small enough to be handled by a raycast or two, like a bullet, FastCast might be for you. You can in fact still use FastCast in conjunction with this to handle wall/floor impacts, like if the rocket can hit players while in the air, but if it hits nobody it’ll explode when it hits something. I won’t be going over that in this, but I can personally confirm that it combines amazing with this.
Let’s begin!
The main reason you’d want to do this is to avoid needlessly replicating objects. Why have the projectile on the server if the projectile’s path is predictable anyways? Let’s make all the clients do the work and send way less data across the network. Rather than a whole model being replicated to everyone, we can condense it down into a single remote event.
Let’s lay out a couple ground rules that the projectile we’re making should adhere to in order for us to best implement this strategy.
-
The projectile should have a path that is predictable. By this I mean it should follow a per-determined path. No homing projectiles! This is because we’ll need the server to be able to predict where the projectile is without being able to see it.
-
The projectile’s properties must be the same within the server and the client. This includes starting point and speed. They need to be on the same page so the client can have the most responsive experience possible while also keeping it as fair as possible for everyone else.
When thinking of your projectiles with these rules in mind, identify some characteristics you want the projectile to follow. Do you want it to go to where the client clicks their mouse? Do you just want it to go in front of the player? How fast do you want it?
The Setup:
Remotes
We need 2 remotes, one for us to tell clients what to do, and another for the clients to respond back with what they’ve gathered. Since I’m going to make a fireball, I named the former “DoEffect” and I named the latter “Respond”.
Scripts
Next, we need a LocalScript on the player so we can receive what the server wants us to do. To keep it simple, I’m going to just fire a string to tell the client what function to run, along with the parameters the fireball is going to follow.
So, I placed a LocalScript in StarterPlayerScripts, and put this in there.
-- in a LocalScript in StarterPlayerScripts
-- declare variables
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local remotes = ReplicatedStorage.Remotes
local effectRemote = remotes.DoEffect
local respondRemote = remotes.Respond
local LocalPlayer = game.Players.LocalPlayer
local function fireball(params)
end
-- connect to the remote
effectRemote.OnClientEvent:Connect(function(effect, params)
if effect == "Fireball" then
fireball(params)
end
end)
The parameters table is going to be an array. We could use a dictionary, but having string indexes increases network usage very quickly, and we don’t want that! This means that order does matter with what you put into the array when you send it to the client.
Next, let’s pass the client some stuff it needs to run the fireball.
First, we’re going to want to pass the character that’s using the fireball, then its spawning point and then its speed. Since this is a fireball that will be going straight, we don’t need direction, the CFrame we provide will give the orientation it needs. After all that, we want to send the time value the server knows the fireball spawned at. This is essentially an ID we’ll use later on.
Next, we want the fireball to move, so I’ll make a variable for RunService so we can make the fireball move every frame.
-- declare variables
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local remotes = ReplicatedStorage.Remotes
local effectRemote = remotes.DoEffect
local respondRemote = remotes.Respond
local LocalPlayer = game.Players.LocalPlayer
local function fireball(params)
-- properties of the fireball
local character = params[1]
local spawnPoint = params[2]
local speed = params[3] or 45 -- add a default speed in case it's not provided, saves a few bytes if we don't send it
local tickVal = params[4] -- i'll get to this later!
end
Now that we have everything we need to get the fireball going, let’s write the code to have it start moving!
I made a simple fireball, it contains a part with an attachment inside for the particles.
First, we’ll clone it on the client, and then place it at the position we provided the function!
Next, we’ll connect to RunService to make the fireball move! We’ll move the fireball straight by our speed multiplied by delta time so it’ll always stay the same speed even on slower/faster servers.
-- this is within our fireball function
local moveConnect
moveConnect = RunService.Heartbeat:Connect(function(DT)
if fireballClone.Parent ~= nil then
fireballClone.CFrame = fireballClone.CFrame * CFrame.new(0,0, -speed * DT) -- -Z goes straight
else
-- if the fireball is gone, disconnect our connection!
moveConnect:Disconnect()
end
end)
Here’s what it looks like with our current code if we run this on the server:
local startTickVal = workspace:GetServerTimeNow()
game.ReplicatedStorage.Remotes.DoEffect:FireAllClients("Fireball", {game.Workspace.Player1, game.Workspace.Player1.HumanoidRootPart.CFrame, nil, startTickVal})
If you can’t view it, click here!
First, we passed in the function we’re running, which is “Fireball”, next we passed in the CFrame that the fireball will be spawning in the array, which is the HumanoidRootPart of my character! We don’t pass a speed value since we have a default one, saving us a few bytes, so we put nil where speed should be. Then we send the time value that will be returned to us later on.
Now that we have something that’s moving, let’s get on to the hitboxes!
Since this fireball is a circle, I want the hitbox to be relatively circular too! This means the spatial query method PartBoundsInRadius will do the trick.
I put this into a ModuleScript in ReplicatedStorage:
local PartBoundsInRadius = function(hitboxPosition, hitboxRadius, overlapParams)
-- 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 defOverlapParams = OverlapParams.new()
defOverlapParams.FilterDescendantsInstances = {game.Workspace}
defOverlapParams.FilterType = Enum.RaycastFilterType.Include
overlapParams = overlapParams or defOverlapParams
local hit
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 PartBoundsInRadius
This function lets us get characters within a spatial query so we can iterate through them in the function we’re going to handle our damaging in!
Let’s require this in the client, and check to see if we hit anybody on the client first.
So at the top of the script, I require the function and then use it within the heartbeat event so that it runs every time the fireball moves!
-- at the top of the script
local partsInRadius = require(ReplicatedStorage.PartBoundsInRadius)
-- inside the heartbeat connection, after we move the fireball
local hitChars = partsInRadius(fireballClone.CFrame, 3) -- we check the fireball's position in a radius of 3
Now, this is where all the magic comes together on the client!
First, we check to see if the player that’s using the fireball is the same as the LocalPlayer running the function, we do this through the GetPlayerFromCharacter method in PlayerService. We only want the person using the fireball to be checking the hitbox. If they are, then we can check the hitbox.
After that, we’ll declare a taggedCharacters table. We’ll use this table to keep track of who we’ve hit already with the fireball.
Finally, we’ll do some checks on the hitchars table so we can ensure that we don’t hit ourselves, nor do we hit the people we’ve already hit!
Here’s how our whole fireball function looks right now:
local function fireball(params)
-- properties of the fireball
local character = params[1]
local spawnPoint = params[2]
local speed = params[3] or 45 -- add a default speed in case it's not provided, saves a few bytes if we don't send it
local tickVal = params[4] -- i'll get to this later!
local fireballClone = ReplicatedStorage.Fireball:Clone()
fireballClone.CFrame = spawnPoint
fireballClone.Parent = game.Workspace
game.Debris:AddItem(fireballClone, 4)
local checkHitbox = false
if LocalPlayer == game.Players:GetPlayerFromCharacter(character) then
checkHitbox = true
end
local taggedCharacters = {}
local moveConnect
moveConnect = RunService.Heartbeat:Connect(function(DT)
if fireballClone.Parent ~= nil then
fireballClone.CFrame = fireballClone.CFrame * CFrame.new(0,0, -speed * DT) -- -Z goes straight
if checkHitbox then
local hitChars = partsInRadius(fireballClone.CFrame, 3)
-- if there's nobody in the table, ignore the rest!
if #hitChars <= 0 then return end
-- we don't want to be hit by our own fireball, if we're the only ones hit, then ignore the rest!
if #hitChars == 1 and table.find(hitChars, character) then
return
end
-- we don't want the same people to be hit by the fireball multiple times, so let's check to see if anyone in the table has been
-- hit by the fireball already, and if they have, then remove them
-- we do this in a backwards order so we don't skip any indexes, since table.remove will automatically shift the table to fill
-- the nil instances!
for i = #hitChars, 1, -1 do
if taggedCharacters[hitChars[i]] == true then
table.remove(hitChars, i)
end
end
-- if after everything is said and done we're the only ones left in the table, then ignore the rest!
if #hitChars == 1 and table.find(hitChars, character) then
return
end
-- tag everyone we send as true so we don't hit them again later!
for i, v in pairs(hitChars) do
taggedCharacters[v] = true
end
respondRemote:FireServer(hitChars, tickVal)
end
else
-- if the fireball is gone, disconnect our connection!
moveConnect:Disconnect()
end
end)
end
That’s a lot, but it’ll be worth it! Let’s head over to the server and handle the responses.
I’m going to make a function on the server that when ran will spawn the fireball and handle the responses back. I would not do it like this in a real game, I’d use ModuleScripts to hold these functions, as it makes it easier to call them.
Here’s how it looks on the server!
-- declare variables
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local remotes = ReplicatedStorage.Remotes
local effectRemote = remotes.DoEffect
local respondRemote = remotes.Respond
local function doFireball(player)
local character = player.Character
local spawnPoint = character.HumanoidRootPart.CFrame
local taggedChars = {}
-- set up our response before we tell all the clients what to do
local startTickVal = workspace:GetServerTimeNow()
respondRemote.OnServerEvent:Connect(function(playerResponded, hitChars, respondTickVal)
end)
effectRemote:FireAllClients("Fireball", {character, spawnPoint, nil, startTickVal})
end
Now, let’s finally add how we’re going to respond to the response from the player!
First, we’re going to check to see if the starting time value we took is the same as what was sent back, if it isn’t, then ignore it. This is how we differentiate fireballs from each other. If the player uses two of them, then those fireballs are going to have two different time values. Since workspace:GetServerTimeNow() is really precise, the odds two fireballs spawned by the same person will have the exact same time is really unlikely.
if startTickVal ~= respondTickVal then return end
Next, we’ll check to see if the person that responded back is the person the connection is looking for. We only want to listen to the player that actually used the fireball, so we’ll check to see if they’re the correct player.
if player ~= playerResponded then return end
Alright, now that we’ve got a response that is valid, let’s go through the characters they returned and see who we can hit.
Here’s the code I have for that:
local hitboxConnect
hitboxConnect = respondRemote.OnServerEvent:Connect(function(playerResponded, hitChars, respondTickVal)
if startTickVal ~= respondTickVal then return end
if player ~= playerResponded then return end
local respondedTick = workspace:GetServerTimeNow()
for i, v in pairs(hitChars) do
-- if we somehow sent our character, then skip us
if v == character then continue end
-- if we've already hit this person, then skip them
if taggedChars[v] then continue end
-- get the possible position of the fireball on the server, since the server can't see it!
-- this is why projectiles must be predictable for this to work!
local positionCFrame = spawnPoint * CFrame.new(0, 0, -45 * (respondedTick - startTickVal))
-- make sure no exploiters hitbox extend too far
if (v.HumanoidRootPart.Position - positionCFrame.Position).magnitude > 7 then return end
taggedChars[v] = true
v.Humanoid:TakeDamage(10)
end
end)
This is where the magic on the server happens! Since we have a predictable path the projectile is following, we can guess where the projectile is with how long it took for a response to be received. We subtract the time the server received a response by the time the server started the projectile.
Once we get the position of the fireball, we can do a magnitude check to if they’re within a good range to get hit by the fireball. This helps with latency since the character’s positions the server sees is different. We give them some leeway by making the range bigger than what the hitbox actually was. We also don’t want exploiters sending characters that are way too far away from the fireball, hitting them in the process! Make sure not to set this too big though!
After that, we now have a character that is within range and hasn’t been hit yet, so we insert them into the taggedChars table so we can’t hit them again, then we make them take damage!
Here’s how it looks ingame:
If you can’t view it, click here!
Here’s all the code in both scripts:
Server Script:
-- declare variables
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local remotes = ReplicatedStorage.Remotes
local effectRemote = remotes.DoEffect
local respondRemote = remotes.Respond
local function doFireball(player)
local startTickVal = workspace:GetServerTimeNow()
local character = player.Character
-- set up our response before we tell all the clients what to do
local spawnPoint = character.HumanoidRootPart.CFrame
local taggedChars = {}
local hitboxConnect
hitboxConnect = respondRemote.OnServerEvent:Connect(function(playerResponded, hitChars, respondTickVal)
if startTickVal ~= respondTickVal then return end
if player ~= playerResponded then return end
local respondedTick = workspace:GetServerTimeNow()
for i, v in pairs(hitChars) do
-- if we somehow sent our character, then skip us
if v == character then continue end
-- if we've already hit this person, then skip them
if taggedChars[v] then continue end
-- get the possible position of the fireball on the server, since the server can't see it!
-- this is why projectiles must be predictable for this to work!
local positionCFrame = spawnPoint * CFrame.new(0, 0, -45 * (respondedTick - startTickVal))
-- make sure no exploiters hitbox extend too far
if (v.HumanoidRootPart.Position - positionCFrame.Position).magnitude > 7 then return end
taggedChars[v] = true
v.Humanoid:TakeDamage(10)
end
end)
effectRemote:FireAllClients("Fireball", {character, spawnPoint, nil, startTickVal})
-- we know the fireball is going to be there for 4 seconds, since we told debris service to add it for that long
task.delay(4, function()
hitboxConnect:Disconnect()
end)
end
-- this is to just fire the fireball, in a real game you'd have a skill handler that players would call on to use skills!
while true do
for i, v in pairs(game.Players:GetChildren()) do
local suc, res = pcall(function()
doFireball(v)
end)
if not suc then
print(res)
end
end
task.wait(1)
end
Local Script:
-- declare variables
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local remotes = ReplicatedStorage.Remotes
local effectRemote = remotes.DoEffect
local respondRemote = remotes.Respond
local LocalPlayer = game.Players.LocalPlayer
local partsInRadius = require(ReplicatedStorage.PartBoundsInRadius)
local function fireball(params)
-- properties of the fireball
local character = params[1]
local spawnPoint = params[2]
local speed = params[3] or 45 -- add a default speed in case it's not provided, saves a few bytes if we don't send it
local tickVal = params[4] -- i'll get to this later!
local fireballClone = ReplicatedStorage.Fireball:Clone()
fireballClone.CFrame = spawnPoint
fireballClone.Parent = game.Workspace
game.Debris:AddItem(fireballClone, 4)
local checkHitbox = false
if LocalPlayer == game.Players:GetPlayerFromCharacter(character) then
checkHitbox = true
end
local taggedCharacters = {}
local moveConnect
moveConnect = RunService.Heartbeat:Connect(function(DT)
if fireballClone.Parent ~= nil then
fireballClone.CFrame = fireballClone.CFrame * CFrame.new(0,0, -speed * DT) -- -Z goes straight
if checkHitbox then
local hitChars = partsInRadius(fireballClone.CFrame, 3)
-- if there's nobody in the table, ignore the rest!
if #hitChars <= 0 then return end
-- we don't want to be hit by our own fireball, if we're the only ones hit, then ignore the rest!
if #hitChars == 1 and table.find(hitChars, character) then
return
end
-- we don't want the same people to be hit by the fireball multiple times, so let's check to see if anyone in the table has been
-- hit by the fireball already, and if they have, then remove them
-- we do this in a backwards order so we don't skip any indexes, since table.remove will automatically shift the table to fill
-- the nil instances!
for i = #hitChars, 1, -1 do
if taggedCharacters[hitChars[i]] == true then
table.remove(hitChars, i)
end
end
-- if after everything is said and done we're the only ones left in the table, then ignore the rest!
if #hitChars == 1 and table.find(hitChars, character) then
return
end
-- tag everyone we send as true so we don't hit them again later!
for i, v in pairs(hitChars) do
taggedCharacters[v] = true
end
respondRemote:FireServer(hitChars, tickVal)
end
else
-- if the fireball is gone, disconnect our connection!
moveConnect:Disconnect()
end
end)
end
-- connect to the remote
effectRemote.OnClientEvent:Connect(function(effect, params)
if effect == "Fireball" then
fireball(params)
end
end)
That’s it! You now know the process of keeping network usage low while also making projectiles responsive and safe. This way of thinking can be applied to a variety of things, like maybe an arrow or spikes that circle around you.
There are some downsides to this system!
- This system assumes that clients instantly receive a response from the server and vice-versa. This is not the case, as it takes time for the remote to fire to the client and for them to fire it back. This can lead to missing shots if the client takes too long to receive and send the event. This can be solved by finding out the length of time it takes for a remote to be received by the client and the length of time it takes for the server to receive the remote from the client, and then subtracting this total travel time between both sides from the time it took to receive the event in the hitbox connection when figuring out where the projectile is. This is normally not an issue as the leeway we provide usually handles this travel time for us, but it can likely become an issue with faster projectiles.
local travelTime = timeForClientToGet + timeForServerToGet
local positionCFrame = spawnPoint * CFrame.new(0, 0, -45 * ((respondedTick - startTickVal) - travelTime))
- Since we compare the server’s character positions rather than what the client sees, this can lead to missing shots as well, this can also be solved by keeping track of character positions over time and storing it somewhere. This essentially takes “snapshots” of everybody’s positions over time. If the client is 0.4 seconds behind the server and the server knows this, the server can look through all the hit characters, get their position 0.4 seconds ago, and then they’d have the position of the character as the client saw it, leading to smoother results. If anybody has an article or thread for this type of system, let me know and I can add it!
Here’s some other great tutorials that I recommend you to read through to get an idea of how to keep things network efficient.
Network Practices by Pyseph
Network Optimization by Hexcede
Constructive critique is welcome! Cheers!