The Basics of Combat Games: Projectiles

This is the third 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 (ignore that for this post). 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.

Today we’re going to cover something a little more advanced; projectiles. They’re tricky to add, but absolutely vital depending on what kind of game you’re making.

Getting up to speed
The base of our projectile system will be very similar to what we do in my other post, The Basics of Combat Games: Hitboxes. We have client input that gets processed by the server, then the server tells the client to make a hitbox, which is then finally returned back to the server to deal damage.

Anyways, most of the advice regarding the types of hitboxes was in the context of melee weapons, so I’ll go over what the different types mean for projectiles:

  • Raycast: The default, this is what you’ll use for 99% of projectiles.
  • Spatial Query (GetPartsInBox): Rarely useful, but if your projectile is large and mostly linear, it might just be more efficient.
  • Magnitude: I can’t think of much use for this method outside of something like airburst shells.
  • .Touched(): Supposedly good for physics heavy projectiles, but I’ve never needed anything like bouncy projectiles before. Still would avoid using it if possible.

Moving the Projectile
The best way to move projectiles that aren’t physics heavy is by using RenderStepped. It’s an event that fire every single frame (before rendering), which lets us make our projectiles as smooth as they can possibly be. Here’s a basic example for a linear projectile:

--//I think this is implied, but this should be a client-sided script.
local DetectionInterval = 0.1

function DoProjectile(Projectile,OriginalDirection)
	--//OriginalDirection is a CFrame, btw.
	local RendLoop
	local ElapsedTime = 0
	local LastCheck = os.clock()	

	RendLoop = game:GetService("RunService").RenderStepped:Connect(function(delta))
		--//Move the part forward based on the time passed (delta).
		ElapsedTime += delta

		--//This formula can be replaced to create projectiles with drop/acceleration. 
		local NewPos = (OriginalDirection * CFrame.new(0,0,(-1 * Speed * ElapsedTime))).Position
		Projectile.MainPart.CFrame = CFrame.new(NewPos,Projectile.MainPart.CFrame) --//CFrame is used incase your projectile is more than a single basepart.
		
		--//It's not neccesary to check every single frame..
		local C_Clock = os.clock()
		if C_Clock <= LastCheck + DetectionInterval then
			LastCheck += DetectionInterval --//There are probably better ways to do this but this works for now.

			HitDetection()
		end
	end)
end

However, if we use a lot of projectiles, creating a new connection to RenderStepped every single time a projectile is made will have costly impacts on preformance. Instead, we’ll optimize this to use only one connection that handles every current projectile.

IsInRendLoop = false

ListOfProjectiles = {--//Each entry is a projectile being calculated.
	{ --//Example
		--//For every bit of data we need in order to use the projectile, we store it here.
		Object = "Instance";
		OriginalDirection = "CFrame";
		DetectionRate = 0.1;
		LastDetection = os.clock();
		ElapsedTime = 0;
		Speed = 5;
	}
}

function StartRendLoop(Projectile,OriginalDirection)
	--//Only run this loop if it's not being run already.
	if IsInRendLoop == true then return end
	IsInRendLoop = true
	local RendLoop 

	RendLoop = game:GetService("RunService").RenderStepped:Connect(function(delta))
		for i,vPInfo in ListOfProjectiles do

			--//Movement
			vPInfo.ElapsedTime += delta

			local NewPos = (vPInfo.OriginalDirection * CFrame.new(0,0,(-1 * vPInfo.Speed * vPInfo.ElapsedTime))).Position
			vPInfo.Object.MainPart.CFrame = CFrame.new(NewPos,Projectile.MainPart.CFrame) --//CFrame is used to move any other parts connected to your projectile.
		
			--//Interval
			local C_Clock = os.clock()
			if C_Clock <= vPInfo.LastDetection + vPInfo.DetectionRate then
				vPInfo.LastDetection += vPInfo.DetectionRate

				HitDetection(vPInfo)
			end
		end
		--//You'll want to add something to break the loop when no projectiles remain.
	end)
end

If you have a really, really large amount of projectiles though, you may want to consider using RunService.Heartbeat() instead of RenderStepped. Since RenderStepped fires before a frame is rendered, any expensive operations connected to it will cause a drop in fps. Heartbeat on the other hand, fires after each frame which prevents fps drops (though the projectiles might look a little weird if the operations take more than a frame).

Projectile Creation
Now’s a good time to mention projectile creation. There’s a couple of ways to go about projectile creation; client-sided, server-sided, or client-origin.

Client-sided creation means giving the client all the information necessary to create the desired projectile, then having the client make it themselves. It saves resources and reduces lag, at the cost of not being able to give constant updates on the projectile’s position. Good for projectiles with a predictable trajectory.

On the other hand, if your projectile is more complicated, such as a projectile that constantly follows a player’s cursor or is heavily-physics based, you’ll want to initially make the projectile on the server. Then once the projectile is ready to be fired (it’s best to have a bit of time before it’s fired), network ownership is given to the client. This way, the projectile is replicated to the other clients since it was first made on the server. Careful though, as this does strain the server if too many projectiles like this are made.

In certain cases, you may want to create the projectile before any validation from the server, which would be a client-origin projectile. This method is used in FPS games, where waiting for the server to validate every single shot from a machine gun would cause notable delays. The sanity checks to see if there’s any ammo to shoot can be preformed once the client calls a hit, and it should be relatively easy to sync up client and server on things like ammo.

Cosmetic Connection
So far, our system works great, however, it does not replicate client-made projectiles to other clients. We’ll need to create a copy of the projectile on all the clients, not just whoever’s attacking. To do this, we need to have the attacking client fire back to the server with the information on the direction of the projectile (We can only get that information once the command to create the projectile is sent to the client).

Our system looks like this now:

  • Client Input → Server → Client Projectile Creation → Server → Replication to other clients.

Alternatively, using client-origin.

  • Client Projectile Creation → Server → Replication to other clients.

This leads us to another crossroads: How will the other clients know when a projectile stops? The first way we can solve this is by simulating it on the other clients, and they will determine when the projectile stops. For rapidly-fired or unimportant projectiles, this will save us the need to fire yet another remote event telling the replicating clients to stop the projectile. It would be very costly if we had get a remote event telling us when every singe bullet from a machine gun has hit a target.

But if the projectile is important, such as causing an explosion where it lands, you’ll want to send a remote event signifying when and where the projectile has stopped. It would be problematic if a client thinks a rocket landed earlier than it did, only to have their character blown up by a projectile that blew up (on their screen) long ago.

Server-sided Ghosts
Like any other method of hit detection, we will need to sanity check it on the server. The easiest way to figure out where the general position of the projectile should be is by firing a ghost projectile. This ghost projectile is fired on the server, and has all the same parameters as the one we made on the client, though we will need to wait for the client to fire back to figure out it’s direction. It is somewhat costly to make ghost projectiles, so don’t use them for automatic weapons in an FPS game, but something more important like projectile attacks in a fighting game.

When the client calls a hit, we can do a basic magnitude check between the position of our ghost projectile and the character the client wants to hit. If it’s an outrageous distance, the request should be denied in case the client is exploiting.

It might sound like a good idea to move the projectile a latency unit (the delay between server-client communication) ahead, since it’ll be one latency unit behind from where the client’s projectile is. However, you need to consider the fact that it will also take one latency unit for the server to recieve the remote event that tells us the client hit a projectile, putting the projectile a unit ahead of where it should be. Basically, if we just keep the ghost projectile as-is, it’ll be in the spot where the client’s projectile was when it called a hit, all nice and synced up.

Tips and Tricks

  • Sometimes, it’s not always necessary to replicate every projectile to every client. For example, you could save resources by only replicating half the bullets a minigun fires.
  • If a projectile is far away from a client’s character and isn’t aiming in their general direction, you may want to avoid replicating it to that client entirely, since it’s unlikely to affect them.
  • To save resources, if you detect that a projectile will hit it’s target near-instantly, you can fire a raycast or use spatial query methods instead of a using a projectile at all.
  • For server-sided ghost projectiles, you can avoid creating a projectile at all if you can estimate where the projectile should be given the time passed. This does get harder with more complex projectiles though.
24 Likes

awesome tutorial you should do like movesets too, or certain custom scripts to apply to a specific player

1 Like

I don’t think movesets are complicated enough to warrant their own guide. All you really need is a local script that sends inputs, and a server script to translate those inputs into attacks.

4 Likes

What’s the DetectionInterval,

Can you explain the math for the z-vector? And also, wouldn’t multiplying the speed by the elapsed time cause the rocket to infinitely increase in speed overtime?

What’s the point of this?

It’s how often the calculations for the hitbox are run.

So first, -1 is used so the projectile moves forward. The speed value is how fast the projectile moves, which should be in studs per second. Lastly, the ElaspedTime works here because the projectile distances itself from it’s origin, not itself. Though I probably should change it for the sake of versatility.

It makes it so that the hit calculations aren’t run every single frame since it would be very expensive. Instead, it uses a benchmark and checks if another one is due this frame.

1 Like