This is the second post in a series about different vital parts about games revolving around combat and how to make them. RPGs, Fighting Games, and pretty much anything that isn’t just an FPS game. Of course, you don’t need to use everything in every tutorial, but these will cover just about everything you need.
The reccomended level of experience for this tutorial is intermediate. You should know the basics of scripting and how to use Roblox Studio.
One of the biggest mistakes I see with beginners making combat games is using the wrong types of hit detection. This post will go over what you should use, how to use it, and sanity checks.
Denouncing .Touched()
.Touched() is what many newer developers will attempt to use when making hitboxes. Do not make this mistake. Touched() is inaccurate when the connected part is moving, or when attempting to detect parts already inside the part. It should only be used for parts that are anchored and wait for long periods of time before being activated, like a touch trigger or a landmine, not a melee hitbox.
So what should be used instead? Well, before I go over the types of hit detection, I’ll need to go over client-side hit detection,
Client-Side Hit Detection
If you don’t know, there is a delay between when the server can client communicate with each other, such as the client telling the server where their player character is. This delay is enough to make a client’s attacks feel very off from where they should be. You can see for yourself by spawning a part in front of a player with a server script and have that player move erratically.
This can be smoothened out with a method known as well, you saw the name of this section. How it works is that the server will fire a remote event to the client that wants to attack with all the information to create the hitbox of the attack. When the client detects something, it notifies the server with another remote event with what it hit. This method does add a slight delay to when an input is recieved and when an enemy is hit, but the price of smoothness is worth it.
Here’s what the our server-sided script should look like:
eventAttackIn = "" --//Your remoteevent here
eventAttackOut = "" --//ditto
LatestAttackNumber = 0 --//Proxies can't be sent to clients :(
function UseClientDetection(ucd_Player,DetectionInfo)
--//DetectionInfo is a dictionary for the sake of easily adding values and allowing certain ones to be empty.
--//This will let us keep track of which attack is which.
--//Though I don't know if it's more optimal to create a new remote event each time, but eh.
LatestAttackNumber += 1
local AttackID = LatestAttackNumber
--//Setup the reception first.
local ReturnTargets = {}
local OnReturnedHit
OnReturnedHit = eventAttackOut.OnServerEvent:Connect(function(orh_Player,orh_ID,GivenTargets)
if orh_Player == ucd_Player and orh_ID == AttackID then --//Make sure it's our client and our attack.
ReturnTargets = GivenTargets
end
end)
--//Fire to client.
eventAttackIn:FireClient(ucd_Player,AttackID,DetectionInfo)
--//Wait to disconnect function
StartTime = os.clock()
while (StartTime + DetectionInfo.Time > os.clock()) and #ReturnTargets == 0 do task.wait(0.05)
OnReturnedHit:Disconnect()
return ReturnTargets --//The data should now be processed elsewhere to apply damage.
end
The client side of this is very straightforward, just set up a script that reacts to eventAttackIn and fires eventAttackOut when it detects a hit using any of the next various methods. Oh, and the reason we use events instead of remote functions (besides their security issues) is so you have the option to add attacks that hit multiple targets.
Early Attack Signaling
There’s probably a correct name for this, but this is just what I’m gonna call it. So our current system looks like this:
Client Input → Server → Client Hitbox (2 delays)
As you know, there is a delay each time we go from client to server or server to client, which makes things less responsive. However, there is a way to essentially remove the second delay within our system while still keeping client-sided hitboxes and server logic. The only requirement is that there needs to be some kind of intentional delay, like a windup, for it to work.
Instead of firing the hitbox event when we need it to be created, we send it early and then have the client wait until it’s time for it to appear. The wait will be adjusted according to how long it took the remote event to go through, making the hitbox appear at the same time as if we used server-sided hitboxes. Here’s an example:
--//Server side.
--//os.clock() is a function that returns the time passed since a certain date with extreme precision.
--//It can be used to determine how much time has passed since a certain period.
eventAttackIn:FireClient(ucd_Player,os.clock(),DelayTime,AttackID,DetectionInfo)
--//ClientSide
eventAttackIn.OnClientEvent:Connect(function(OriginalTime,DelayTime,AttackID,DetectionInfo)
local TimeToWait = DelayTime - (os.clock() - OriginalTime)
task.wait(math.clamp(TimeToWait,0,99999)) --//math.clamp limits how high or low the first number can be by the second and third. Basically, we don't want negatives.
HitboxStuff(DetectionInfo,AttackID)
end)
Hit Detection: Magnitude
A magnitude ‘hitbox’ is really just a check between the distance of one point to another. This results in a sphere-like hitbox, with the acceptable distance for a hit being it’s radius. While not flashy or impressive, it’s cheap and gets the job done. Most of the time, an extremely precise hitbox is simply not needed.
CheckInterval = 0.05
function MagnitudeCheck(ServerAttackID,ActiveTime,HitPosition,Radius)
local PassedTime = 0
local OldHits = {Player.Character} --//Prevent hitting yourself.
while PassedTime < ActiveTime do
--//Now the real work.
local HitTargets = {}
local ValidTargets = --//Get an array of the primaryparts of all the targets that can be hit, like characters and destructable objects.
for i,vTarget in ValidTargets do
if (vTarget.Position - HitPosition).Magnitude <= Radius and not table.find(OldHits,vTarget.Parent) then
table.insert(HitTargets,vTarget.Parent)
table.insert(OldHits,vTarget.Parent)
end
end
if #HitTargets > 0 then --//Check if we can send anything.
--//Send info to server.
end
task.wait(CheckInterval)
PassedTime += CheckInterval
end
end
Hit Detection: GetPartsInBox
GetPartsInBox does exactly what it says, it returns the parts in a box defined by a CFrame for position/rotation and a Vector3 for size. It is more expensive than magnitude and a bit more complicated, but allows for a much wider variety of shapes. It’s quite similar to the fighting games of old.
CheckInterval = 0.1
function PartsBoxCheck(ServerAttackID,ActiveTime,CFrame,Size)
local PassedTime = 0
local OldHits = {Player.Character}
while PassedTime < ActiveTime do
local HitTargets = {}
--//Here's where things become different.
local OParams = OverlapParams.new(OldHits ,Enum.RaycastFilterType.Blacklist,40,"Default")
local BoxReturns = workspace:GetPartBoundsInBox(boxPosition,boxSize,params)
--//Now we've got a table full of everything we just hit.
for i,vTarget in BoxReturns do
if not table.find(OldHits,vTarget.Parent) and if vTarget.Parent:FindFirstChild("Humanoid") then
table.insert(HitTargets,vTarget.Parent)
table.insert(OldHits,vTarget.Parent)
end
end
if #HitTargets > 0 then --//Check if we can send anything.
--//Send info to server.
end
task.wait(CheckInterval)
PassedTime += CheckInterval
end
end
Hit Detection: Raycasting
But if you want laser precision, you can use raycasts. This is a little more complicated, but the basic idea is that we raycast between where the weapon/attack was last interval and where it currently is. Of course, one little raycast isn’t much, so we have multiple points (stored as attachments) along whatever we’re using that raycasts are shot from. However, while the most accurate, it is difficult to adjust hitboxes to specific sizes since it’s based on motion, which requires adjusting the animation.
CheckInterval = 0.1
function RayCheck(ServerAttackID,ActiveTime,Attachments)
--//Attachments is a table of attachments, preferably ones parents to a moving part such as a swinging sword.
local PassedTime = 0
local OldHits = {Player.Character}
local ListedRaypoints = {}
--//Let's store all the points.
for i,v in Attachments do
table.insert(ListedRaypoints,{v,v.WorldPosition})
end
while PassedTime < ActiveTime do
local HitTargets = {}
--//Switch things up again
raycastParams = RaycastParams.new()
raycastParams.FilterType = Enum.RaycastFilterType.Exclude
--|| You'll probably want to add a collision group type here so you don't hit cosmetics/visual effects.
--//Iterate through all the ray points.
for i,vPoint in ListedRaypoints do
raycastParams.FilterDescendantsInstances = OldHits --//Keep raycast params updated.
--//vPoint[1] is the current position, the other is the last checked.
local Direction = (vPoint[1].WorldPosition - vPoint[2]).Unit * (vPoint[1].WorldPosition - vPoint[2]).Magnitude --//Get out direction, and then multiply it by the proper distance
newRaycast = workspace:Raycast(vPoint[2],Direction,raycastParams)
if newRaycast.Instance and newRaycast.Instance.Parent:FindFirstChild("Humanoid") then
--//Hit! Add to targets.
table.insert(HitTargets,newRaycast.Instance.Parent)
table.insert(OldHits,newRaycast.Instance.Parent)
end
--//Also update our attachment.
vPoint[2] = vPoint[1].WorldPosition
end
if #HitTargets > 0 then --//Check if we can send anything.
--//Send info to server.
end
task.wait(CheckInterval)
PassedTime += CheckInterval
end
end
Hit Detection: Projectiles
Alright, so now we’re moving onto projectiles. Since many projectiles will outpace an interval of 0.1-0.05, we’ll need to check between the last recorded position and the current interval instead. Wait… doesn’t this sound familiar? Yes, that’s right, we can copy and paste the code from the raycast section with zero changes to use for our projectiles. Well, that was easy.
However, if you have a large, boxy, somewhat linear projectile, you may way to consider using :GetPartsInBox() instead. Adjust the size to account for the distance travelled each interval as well as the projectile’s own size.
Sanity Checks
There is one weakness with our client-side detection: exploiters. If you don’t know, exploiters are able to send whatever information they wish when returning a remote function/sending a remote event. Thus, we must make sure the data being recieved by the server is reasonable before applying any damage.
The best thing we can do is use a somewhat lenient magnitude hitbox when recieving a hit detection event on the server. This will prevent exploiters hitting everything, everywhere, all at once, and the leniency will prevent help account for latency (lag). They still have some advantage, but the game would still be relatively playable.
Whenever you rely on the client for data, be suspicious. Make sure they aren’t trying to hit the same character multiple times, make sure that the attack hasn’t expired, and increase the strictness of sanity checks for suspicious clients.