High Caliber Ballistic Physics & Penetration Mechanics for Military Games

Ballistics is an extremely diverse topic with many rabbit holes you can dive into. However in this tutorial, I’ll be covering how to go about implementing ballistic physics with armor penetration mechanics in categories like naval warfare, armored tank combat, and aerial warfare. Additionally, I’ll provide some examples of its implementation that you can use or learn from. A basic understanding of luau is recommended for this tutorial.

In most milsim categories, there are usually three stages to high caliber ballistics, Internal, External, and Terminal ballistics. For an intuitive flow, ill breakdown how to implement ballistics in the order its typically executed for a consistent line of reasoning.

Internal Ballistics Internal Ballistics, for our purposes, is defined by a few simple concepts. Primarily Muzzle Velocity(Speed of munition after firing) and dispersion, which we will use a bit of RNG scaling to replicate. The dispersion of our gun, defined initially in our gun stat profile, will be a radius(in studs) in which the shell can deviate from the intended trajectory(A little RNG here). Along with this we handle our reload time and ammo count. To properly store our gun statistics, as well as the profiles of the ammunition they feature, we use meta tables to store and pass our weapon profiles so we can use them during runtime. Here is an example of the stat table for an M1 Abrams:
Profiles["M1A2"] = {
	Performance = {
		MaxSpeed       = {Forward = 40, Reverse = -25}, -- km/h
		HullTraverse   = 30,    -- Degrees per second (turn speed)
		TurretTraverse = 40,    -- Degrees per second (turret rotation)
	},
	
	Cannon = {
		MuzzleVelocity  = 1675, -- studs/s (APFSDS)
		penetration     = 850,  -- in mm
		MaxRange 		= 4000, -- studs
		ReloadTime 		= 6,    -- rounds per minute
		Dispersion 	    = 0.1,  -- radius of dispersion
	},
}

We start with a controls module which calls the Fire function every frame, where we update our reload times, and handle firing the cannon:

function Cannon.Fire(cannonPosition, cannonLookDir, firePressed, dt)
	-- Update reload timer when reloading
	if Reloading then
		lastFire     = lastFire + dt 
		timeToReload = math.max(0, reloadTime - lastFire)
		if lastFire >= reloadTime then Reloading = false lastFire = 0 end
	end

	-- Update any active projectile (simulate its movement(handle external ballistics))
	Cannon.UpdateProjectile(dt)

	-- Only fire if firePressed is true, we're not reloading, and no projectile is active.
	if firePressed and not Reloading and not Projectile then
		-- Calculate dispersion, and add it to the firing direction
		local dispersion = Vector3.new(math.random() * dispersion, math.random() * dispersion, math.random() * dispersion)
		local fireDirection = (cannonLookDir + dispersion).Unit

		-- Create the projectile and reload
		Projectile = {
			traveled  = 0,							   -- Keep track of its distance traveled
			position  = cannonPosition,                -- Projectile starts at the position of the cannon
			velocity  = muzzleVelocity * fireDirection -- Fire towards where the cannon is looking at the speed of muzzle velocity
		}
		
		Reloading = true
		lastFire  = 0
	end
end

Once the ammunition is fired, we can handle external ballistics.

External Ballistics External ballistics in our case is defined by gravity and drag. Every frame we calculate drag and reduce the projectile's speed accordingly. Additionally, we apply gravity to the projectile's flight path. Each frame we calculate the distance the projectile will travel based on the combined drag/gravity forces calculated, by updating the table responsible for containing the projectile's state. In short, we are casting rays each frame, recording the results, doing new calculations to cast new rays, then repeating the process. To be clear, there is no actual object or part we are using to simulate this. However, the reason i chose this method is so that in the future, you CAN visualize this by creating a part, then setting its properties to the current recorded state of the shell from our calculations. Despite this, i just used a beam to visualize the flight trajectory instead. In the end, this is our function to handle external ballistics:
function Cannon.UpdateProjectile(dt)
	if Projectile then
		-- Get current speed
		local currentSpeed = Projectile.velocity.Magnitude

		-- Apply simplified drag(opposite to velocity so we just reduce the velocity)
		local speedReduction = 0.5 * currentSpeed^2 * 0.0002 * dt
		local newSpeed       = math.max(0, currentSpeed - speedReduction)
		Projectile.velocity  = Projectile.velocity.Unit * newSpeed

		-- Apply gravity vector to the projectile in the down direction
		local gravity        = Vector3.new(0, -9.81, 0) 
		Projectile.velocity  = Projectile.velocity + gravity * dt
		local travelDistance = newSpeed * dt -- Calculate distance to travel this frame

		-- Visualize the projectile's flight path
		Cannon.Visualize(Projectile.position + Projectile.velocity * dt, Projectile.position)

		-- Update projectile position and distance traveled
		Projectile.traveled = Projectile.traveled + travelDistance
		Projectile.position = Projectile.position + Projectile.velocity * dt

		-- Cast a ray from the old position to the new position and check for collisions(AKA move the projectile)
		local ray = Ray.new(Projectile.position, Projectile.velocity.Unit * travelDistance)
		local hitPart, hitPosition = workspace:FindPartOnRay(ray)
		if hitPart then
			Shell.Penetrate(Projectile, hitPart, hitPosition, Projectile.velocity)
			Projectile = nil
		elseif Projectile.traveled >= maxRange then Projectile = nil end -- Despawn if projectile traveled beyond Max Range
	end
end
Terminal Ballistics Terminal Ballistics is where we will handle our damage models, and penetration mechanics. Tanks and Navy ships have armor; armor with different thicknesses in different places. Roblox doesn't have a great way to define armor maps on our vehicle meshes, so we define the armor thickness of our vehicles from different sides in a lookup table(in the tank profile table). We separate things like turrets/hulls, and define front/side/rear aspect armor values:
Armor = {
		Turret = { -- in mm
			Front = 900, 
			Side  = 500, 
			Rear  = 250
		},

		Hull = {
			Front = 600, 
			Side  = 350, 
			Rear  = 150
		}
	}

We can simulate whether the ammunition will penetrate through the armor, by calculating how much armor, based on the defined armor value at that aspect, the bullet must penetrate through to do damage. If the penetration value of the shell is higher than the armor it must pass through, it will do damage. We also add a slight likelihood that the shell will bounce off the target based on the angle of incidence, getting more likely as the angle gets more parallel with the the armor. First we calculate the aspect of the tank we’ve hit, then we get its armor value. Finally, we get the effective armor by comparing the angle it hits relative to the direction of the surface of the target. The more perpendicular it is, the less effective armor it has to penetrate through. If the relative angle becomes to low, it will bounce. In the end, we get something like this:

local Shell = {}

local RequestManager = require(game:GetService("ReplicatedStorage"):WaitForChild("RequestManager"))

function Shell.Penetrate(Projectile, hitPart, hitPosition, velocity, penetration)
	-- Retrieve the armor values for the hit part (Hull or Turret)
	local armorValues = Shell.getArmor(hitPart)
	if not armorValues then
		print("Error: Failed to retrieve armor values for part: " .. hitPart.Name)
		return
	end

	-- Get the aspect (Front, Side, or Rear) of the hit
	local aspect = Shell.getAspect(hitPart, Projectile)
	local rawArmor = armorValues[aspect] -- Get the raw armor thickness for that aspect
	print("Hit aspect: " .. aspect)

	-- Calculate the impact angle (angle between the projectile's velocity vector and the hit part's normal)
	-- Define the impact angle as how parallel the projectile is relative to the surface:
	-- An impact angle of 0° means the projectile is exactly parallel to the surface; 90° means perpendicular
	local impactAngle = Shell.getImpactAngle(hitPart, Projectile) 
	print("Impact Angle: " .. impactAngle .. "°")
	if impactAngle <= 20 then print("Bounced") return end -- Bounce if the relative angle is 20° or less

	-- Otherwise, calculate effective armor and continue with penetration logic
	local effectiveArmor = Shell.getEffectiveArmor(rawArmor, impactAngle)
	print("Raw Armor: " .. rawArmor .. "mm")
	print("Effective Armor: " .. math.floor(effectiveArmor) .. "mm")

	if penetration > effectiveArmor then
		print("Armor Penetrated")
		-- Do whatever you want here
		
		
	else print("Armor not penetrated") end
end

function Shell.getArmor(hitPart)
	-- Check if the hit part is from the Turret or Hull
	local parentModel = hitPart.Parent
	local tankProfile = RequestManager:requestProfile(parentModel.Name)

	if not tankProfile or not tankProfile.Armor then
		print("No armor data found for model: " .. parentModel.Name)
		return nil
	end

	if hitPart.Name:find("Turret")   then return tankProfile.Armor.Turret -- Return only Turret armor values
	elseif hitPart.Name:find("Hull") then return tankProfile.Armor.Hull   -- Return only Hull armor values
	else print("This part is neither Hull nor Turret") return nil end 
end

function Shell.getAspect(hitPart, Projectile)
	-- Get the local forward direction of the hit part (hull, turret, etc.)
	-- Get the direction of the projectile from the hit point (i.e. from the point where we hit to the projectile's position)
	-- Use the product to get the angle between the part's forward direction and the projectile's direction
	local partForward         = hitPart.CFrame.LookVector
	local projectileDirection = (Projectile.position - hitPart.Position).unit
	local dotProduct          = partForward:Dot(projectileDirection)

	-- Determine the aspect of the hit (Front, Side, or Rear) based on the dot product
	local aspect = ""
	if dotProduct     < -0.8 then aspect = "Front"
	elseif dotProduct >  0.8 then aspect = "Rear"
	else aspect = "Side" end

	return aspect
end

function Shell.getImpactAngle(hitPart, Projectile)
	local armorNormal = hitPart.CFrame.LookVector.unit -- Get the normal vector of the armor plate
	local projectileDirection = Projectile.velocity.unit -- Get the projectile's normalized direction

	-- Calculate the angle using the dot product
	local dotProduct = armorNormal:Dot(-projectileDirection) 
	local angle = math.deg(math.acos(math.clamp(dotProduct, -1, 1))) -- Clamp so no NaN values
	if angle > 90 then angle = 180 - angle end -- Angle should stay from 0-90 degrees

	return angle
end

function Shell.getEffectiveArmor(rawArmor, impactAngle)
	local angleRad = math.rad(impactAngle)

	-- At 90 degrees (perpendicular), effective armor equals raw armor
	-- At 0 degrees (parallel), effective armor approaches infinity
	local penetrationFactor = math.sin(angleRad)

	-- Prevent division by zero
	if penetrationFactor < 0.1 then return math.huge end 

	-- Calculate effective armor (increases as angle decreases)
	local effectiveArmor = rawArmor/penetrationFactor

	return effectiveArmor
end

return Shell

Of course, the penetration calculations change to account for hull and turret rotation/orientation.

Finally, different ammunitions have different properties. AP(Armor Piercing) shells use mass and high velocity to break through armor. APCR(AP Composite Rigid) rounds have higher velocity and penetration than standard AP rounds. High Explosive (HE) shells blow up on impact. High Explosive Anti Tank (HEAT) shells use an explosive charge to penetrate armor and create a molten jet to pierce through the target. Most of these rounds have their own properties, like explosive payloads, fragmentation, or kinetic penetration depending on their intended effect. It’s up to you how you want to handle these, as well as how you want to construct your damage model, but in general, you can simulate these by creating hitboxes within the armored vehicle that represent modules, then calculating how far the shell, after penetration, will go through the vehicle, dealing damage to all modules it hits along the way(ray cast of course). Implementing fragmentation and blast radius’s requires handling some edge cases, but overall, the rest should be pretty straightforward.

If you made it to the end, thank you for reading. I also created a nice camera that works with a HingeConstraint to rotate the turret and the gun towards where the camera is pointing, so the player can just rotate the camera to change where the gun points. If your curious how this example plays, you can check it out here(gunsight is not 100% accurate): Ballistic Physics.rbxl (268.1 KB)

Its got target practice, 3 different low poly modern tank models, and projectile flight path visualizations. Keep in mind, this example is primarily proof of concept, and doesn’t have any fancy UI elements to tell you what’s going on, most of it is just printed to the dev console. Regardless, I haven’t (to my knowledge) seen any other games besides MTC 4 with a good level of penetration mechanics, let alone any tutorials on how to go about making something like this, so I thought I would give it a try. Tell me what you think about this tutorial, or if it helped you out.

8 Likes