The Basics of Combat Games: Hitboxes

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.

47 Likes

What does Denouncing mean? ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎

3 Likes

Basically saying something is bad in a dramatic way.

2 Likes

Hi, do you have examples of projectiles, specifically in a .rbxl to check out?

Thanks

2 Likes

I don’t have access to roblox studio right now, but the process should look something like this:

  • Server fires to client with data on which projectile to make.
  • Client creates projectile, and then replicates this to the server and then all other clients (We won’t have the proper direction to give to the other clients until the projectile is made, so we do it here.)
  • The projectile can be moved in a variety of ways, such as moving it with RenderStepped, using physics constraints, ect.
1 Like

Waiting for the third part of this awesome guide!!

2 Likes

I recommend choosing between raycast/magnitude. Spatial query methods such as getpartsinboundsbox can be a bit costly.

1 Like

There are quite a few cases where spatial query is a better fit than raycast and magnitude, such as ground-level attacks that can be jumped over, or a tall and thin hitbox. Neither raycast nor magnitude could effectively provide that without having to write a whole new function.

1 Like

Edit: Added new section for ‘Early Attack Signaling’

sorry if I’m late, but i got a question.
In the Part of RayCast Hitboxes, there’s the parameter Attachments, and then it is used and looped trought. though i don’t understand what should be passed when called the function (Yeah its some attachments but i don’t understand what their position has to be ec…). Thanks in advice and Nice tutorial : )

I love it but something i immediately noticed is comments being started with --//letter, its kinda hard to read, i get it’s probably a style or plugin or something but it was kinda annoying to read (you get used to it over time) you can probably add a space after the // (–// letter) or just use a normal comment (-- letter) not judging you just a nitpick i had reading, i’m gonna read the rest now

should

be OnServerEvent? just confused as it’s a server script

good job on the tutorial btw its already very high quality, i’ve had it bookmarked for a while and just got to reading it

The position of the attachments should be along whatever the base of the attack is. For example, if you have a sword, you should have the attachments along the blade.

You’re correct about the Event connection, it should be OnServerEvent.I’ll edit the post to fix that.

1 Like

Oh alright, Thank you ! :slight_smile: Very nice tutorial.

pardon me if im dumb but i cant test right now and its kinda been bothering me a little, wouldnt this always be over os.clock? how will this line work, my brain might be malfunctioning again

os.clock() will keep bringing newer and newer times, eventually surpassing the first benchmark.

1 Like

Oh, thank you, I see! I got time to test and tested using this code to understand it better.

local StartTime = os.clock()

while (StartTime + 5 > os.clock()) do
	task.wait(0.05)
end

print("finished in", os.clock() - StartTime)

DetectionInfo.Time is how long the attack/hitbox lasts, correct?

That’s correct.

keystrokes keystrokes

1 Like

wouldn’t you want to update the attachment every 0.1 (checkInterval) seconds rather than everytime it recieves a result? Pardon if I’m wrong here.

Hi, you said in your post that we should check the distance of the players hit on the server to prevent cheaters from passing wrong info on the server. But the problem is there’s a difference between what the player sees and what the server sees due to latency so even if the player was close enough to hit the player on his view the server would still ignore the request. One partial solution is to have a big margin of the minimum distance that the player should be in range but if we had a game where different player have different speeds then this approach is also flawed since a very fast player will have bigger delay than a slow player. Any solution to this problem ?