My wall penetration doesn't work exactly like I want

I wanna fix that 3 studs walls are getting penetrated.
Video:

Script:

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local Workspace = game:GetService("Workspace")

local fireEvent = ReplicatedStorage:WaitForChild("FPS_Fire")

-- Weapon stats table
local WeaponStats = {
	Glock19 = {
		Damage = {
			Head = 114,
			Torso = 37,
			Limb = 22,
			Default = 29,
		},
		ArmorMultiplier = 0.5, -- 50% reduction
		MaxRange = 150, -- studs
		PenetrationCount = 1, -- how many walls it can penetrate
		PenetrationLoss = 0.5, -- lose 50% damage per wall
		PenetrableMaterials = {
			[Enum.Material.Wood] = true,
			[Enum.Material.Fabric] = true,
			[Enum.Material.ForceField] = true,
			[Enum.Material.Neon] = true,
			[Enum.Material.Plastic] = 0.05,
			[Enum.Material.SmoothPlastic] = 0.05,
			-- Less power: can't penetrate metal, concrete, brick, etc.
		},
	},
	AK47 = {
		Damage = {
			Head = 119,
			Torso = 29,
			Limb = 22,
			Default = 37,
		},
		ArmorMultiplier = 0.5, -- 50% reduction
		MaxRange = 600, -- studs
		PenetrationCount = 2,
		PenetrationLoss = 0.4, -- lose 40% damage per wall
		-- For AK, allow probabilistic penetration for metal/concrete/brick
		PenetrableMaterials = {
			[Enum.Material.Wood] = true,
			[Enum.Material.Plastic] = true,
			[Enum.Material.Fabric] = true,
			[Enum.Material.Glass] = true,
			[Enum.Material.ForceField] = true,
			[Enum.Material.SmoothPlastic] = true,
			[Enum.Material.Neon] = true,
			[Enum.Material.Metal] = 0.10,      -- 10% chance to penetrate metal
			[Enum.Material.Concrete] = 0.15,   -- 15% chance to penetrate concrete
			[Enum.Material.Brick] = 0.15,      -- 15% chance to penetrate brick
			-- Still can't penetrate granite, marble, etc.
		},
	},
	Default = {
		Damage = {
			Head = 100,
			Torso = 25,
			Limb = 15,
			Default = 25,
		},
		ArmorMultiplier = 0.6,
		MaxRange = 500, -- fallback
		PenetrationCount = 1,
		PenetrationLoss = 0.5,
		PenetrableMaterials = {
			[Enum.Material.Wood] = true,
			[Enum.Material.Plastic] = true,
			[Enum.Material.Fabric] = true,
			[Enum.Material.Glass] = true,
			[Enum.Material.ForceField] = true,
			[Enum.Material.SmoothPlastic] = true,
			[Enum.Material.Neon] = true,
		},
	}
}

-- Helper to get weapon stats
local function GetWeaponStats(weaponName)
	if not weaponName then return WeaponStats.Default end
	local stats = WeaponStats[weaponName]
	if stats then return stats end
	-- Try lowercase
	stats = WeaponStats[weaponName:lower()]
	if stats then return stats end
	-- Try uppercase
	stats = WeaponStats[weaponName:upper()]
	if stats then return stats end
	return WeaponStats.Default
end

fireEvent.OnServerEvent:Connect(function(player, origin, direction, weaponName)
	-- Raycast parameters: ignore shooter
	local raycastParams = RaycastParams.new()
	raycastParams.FilterType = Enum.RaycastFilterType.Exclude
	raycastParams.FilterDescendantsInstances = {player.Character}
	raycastParams.IgnoreWater = false

	local weaponStats = GetWeaponStats(weaponName)
	local maxRange = weaponStats.MaxRange or 500
	local penetrationCount = weaponStats.PenetrationCount or 1
	local penetrationLoss = weaponStats.PenetrationLoss or 0.5
	local penetrableMaterials = weaponStats.PenetrableMaterials or WeaponStats.Default.PenetrableMaterials

	local rayLength = maxRange
	local remainingRange = maxRange
	local currentOrigin = origin
	local currentDirection = direction
	local ignoreList = {player.Character}
	local debugParts = {} -- Store debug parts to exclude from raycast
	local hitHumanoid = nil
	local hitPartName = nil
	local damage = 0
	local hitType = "Miss"
	local armored = false
	local armorMultiplier = weaponStats.ArmorMultiplier or 0.6
	local penetrations = 0
	local lastResult = nil
	local hitPosition = origin + direction * maxRange

	local MAX_PENETRATION_THICKNESS = 2 -- studs; if wall is thicker than this, can't penetrate

	while penetrations <= penetrationCount and remainingRange > 0 do
		raycastParams.FilterDescendantsInstances = ignoreList
		local result = Workspace:Raycast(currentOrigin, currentDirection * remainingRange, raycastParams)
		if not result then
			-- No more hits, bullet traveled full remaining range
			break
		end

		lastResult = result
		hitPosition = result.Position
		local hitPart = result.Instance
		if hitPart then
			hitPartName = hitPart.Name:lower()
			local character = hitPart:FindFirstAncestorOfClass("Model")
			if character then
				local humanoid = character:FindFirstChildOfClass("Humanoid")
				if humanoid and humanoid.Health > 0 and character ~= player.Character then
					hitHumanoid = humanoid
					-- R15 body part names
					if hitPartName == "head" then
						damage = weaponStats.Damage.Head or 100
						hitType = "Headshot"
					elseif hitPartName == "uppertorso" or hitPartName == "lowertorso" then
						damage = weaponStats.Damage.Torso or 25
						hitType = "Bodyshot"
					elseif hitPartName == "leftupperarm" or hitPartName == "leftlowerarm" or hitPartName == "lefthand"
						or hitPartName == "rightupperarm" or hitPartName == "rightlowerarm" or hitPartName == "righthand"
						or hitPartName == "leftupperleg" or hitPartName == "leftlowerleg" or hitPartName == "leftfoot"
						or hitPartName == "rightupperleg" or hitPartName == "rightlowerleg" or hitPartName == "rightfoot" then
						damage = weaponStats.Damage.Limb or 15
						hitType = "Limbshot"
					else
						damage = weaponStats.Damage.Default or 25
						hitType = "Bodyshot"
					end

					-- Apply penetration loss
					for i = 1, penetrations do
						damage = math.floor(damage * (1 - penetrationLoss))
					end

					-- Check for armor
					local armoredValue = character:FindFirstChild("Armored")
					if armoredValue and armoredValue:IsA("BoolValue") and armoredValue.Value == true then
						armored = true
						local oldDamage = damage
						damage = math.floor(damage * armorMultiplier)
						print("[FPS_ShootHandler] Target is ARMORED! Weapon: "..tostring(weaponName)..". Damage reduced from "..oldDamage.." to "..damage..".")
					end

					break -- Stop on first humanoid hit
				end
			end

			-- If not a humanoid, check if penetrable (per-weapon)
			if hitPart:IsA("BasePart") and penetrableMaterials[hitPart.Material] then
				-- Determine if penetration is allowed (boolean or probabilistic)
				local penetrationEntry = penetrableMaterials[hitPart.Material]
				local canPenetrate = false
				local penetrationChance = nil
				local penetrationSucceeded = false
				if typeof(penetrationEntry) == "boolean" then
					canPenetrate = penetrationEntry
					penetrationSucceeded = canPenetrate
				elseif typeof(penetrationEntry) == "number" then
					penetrationChance = penetrationEntry
					if math.random() < penetrationChance then
						canPenetrate = true
						penetrationSucceeded = true
					else
						canPenetrate = false
						penetrationSucceeded = false
					end
				end

				-- Robust thickness calculation: cast a ray from entry point through the part to find exit point
				local thickness = 0.5 -- default thickness penalty
				if hitPart.Size then
					local entryPoint = result.Position
					local epsilon = 0.01
					local rayOrigin = entryPoint + currentDirection.Unit * epsilon
					local rayDirection = currentDirection.Unit * (hitPart.Size.Magnitude + 2 * epsilon)
					-- Only check for intersection with the hitPart itself
					local thicknessParams = RaycastParams.new()
					thicknessParams.FilterType = Enum.RaycastFilterType.Whitelist
					thicknessParams.FilterDescendantsInstances = {hitPart}
					thicknessParams.IgnoreWater = false
					local exitResult = Workspace:Raycast(rayOrigin, rayDirection, thicknessParams)
					if exitResult and exitResult.Instance == hitPart then
						thickness = (exitResult.Position - entryPoint).Magnitude
					else
						-- If no exit found, fallback to minimum thickness
						thickness = 0.05
					end
					if thickness < 0.05 then thickness = 0.05 end -- minimum epsilon
				end

				-- If wall is too thick, can't penetrate
				if canPenetrate and thickness < MAX_PENETRATION_THICKNESS then
					penetrations = penetrations + 1
					-- Add this part to ignore list for next raycast
					table.insert(ignoreList, hitPart)
					-- Reduce remaining range by distance traveled + a penalty for thickness
					local distanceTraveled = (result.Position - currentOrigin).Magnitude
					remainingRange = remainingRange - distanceTraveled - thickness

					-- Move origin to just outside the wall, along the ray direction, using thickness
					-- This ensures the next raycast starts outside the wall, even for thin walls
					local epsilon = 0.01
					currentOrigin = result.Position + currentDirection.Unit * (thickness + epsilon)

					-- Only spawn yellow debug part if penetration is guaranteed (true) or chance is reasonably high (>= 0.2)
					-- This avoids showing yellow for very low chance penetrations (e.g., AK spraying at metal)
					if (typeof(penetrationEntry) == "boolean" and penetrationEntry == true)
						or (typeof(penetrationEntry) == "number" and penetrationEntry >= 0.2 and penetrationSucceeded) then
						local debugPart = Instance.new("Part")
						debugPart.Size = Vector3.new(0.3, 0.3, 0.3)
						debugPart.Shape = Enum.PartType.Block
						debugPart.Color = Color3.new(1, 1, 0)
						debugPart.Material = Enum.Material.Neon
						debugPart.Anchored = true
						debugPart.CanCollide = false
						debugPart.CFrame = CFrame.new(result.Position)
						debugPart.Parent = Workspace
						game:GetService("Debris"):AddItem(debugPart, 1)
						table.insert(debugParts, debugPart)
						table.insert(ignoreList, debugPart)
					end
					continue -- Continue to next penetration
				else
					-- Not allowed to penetrate (either failed chance or too thick)
					break
				end
			else
				-- Hit non-penetrable part, stop
				break
			end
		else
			break
		end
	end

	-- Optionally, print if the hit is at the very edge of range (e.g., within 1 stud of maxRange)
	local distanceTraveled = (hitPosition - origin).Magnitude
	if distanceTraveled >= maxRange - 1 then
		print("[FPS_ShootHandler] Shot reached maximum range (" .. tostring(maxRange) .. " studs)")
	end

	-- Damage if hit a humanoid
	if hitHumanoid and damage > 0 then
		hitHumanoid:TakeDamage(damage)
		print("[FPS_ShootHandler] "..hitType.."! Weapon: "..tostring(weaponName)..". Hit part: "..(hitPartName or "Unknown")..". Applied "..damage.." damage."..(armored and " (Armored)" or ""))
	end

	-- Spawn debug part at hit position ONLY if not too far (i.e., only if lastResult is not nil)
	if lastResult then
		local debugPart = Instance.new("Part")
		debugPart.Size = Vector3.new(0.3, 0.3, 0.3)
		debugPart.Shape = Enum.PartType.Block
		debugPart.Color = Color3.new(1, 0, 0)
		debugPart.Material = Enum.Material.Neon
		debugPart.Anchored = true
		debugPart.CanCollide = false
		debugPart.CFrame = CFrame.new(hitPosition)
		debugPart.Parent = Workspace

		-- Auto-destroy after 1 second
		game:GetService("Debris"):AddItem(debugPart, 1)
		table.insert(debugParts, debugPart)
		table.insert(ignoreList, debugPart)
	end
end)

What should I do?

Your code looks flawless, and I initially assumed it might be reacting with the yellow parts but you’ve also got protection for that.
This really does seem like an edge case, in the video it only hit when you were moving.
I have no clue what would be causing this and personally I can’t really test, but an alternative method Only thing I could suggest is, for penetration testing you could try using a “backward-ray” method, where when you hit a wall, you place a ray 2 studs forward (Plus 0.1 or so to account for entry point) cast it backwards, and if it hits the object again, it successfully passed through.

I’d only switch methods if it seems like its impossible to diagnose. Chuck a crap ton of print statements in any odd functions or checks, like the no exit found, etc, and see when it occurs so you can understand when and where things break, and what from.

2 Likes

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.