Combat System

CombatServer.lua
local CombatServer = {}

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local Debris = game:GetService("Debris")
local PhysicsService = game:GetService("PhysicsService")
local ServerScriptService = game:GetService("ServerScriptService")

local Config = require(ReplicatedStorage:WaitForChild("CombatConfig"))
local AIConfig = require(ReplicatedStorage:WaitForChild("AIConfig"))
local BlockingServer = require(ServerScriptService:WaitForChild("BlockingServer"))

local CombatRemote = ReplicatedStorage:FindFirstChild("CombatRemote")
if not CombatRemote then
	CombatRemote = Instance.new("RemoteEvent")
	CombatRemote.Name = "CombatRemote"
	CombatRemote.Parent = ReplicatedStorage
end

local DashRemote = ReplicatedStorage:FindFirstChild("DashRemote")
if not DashRemote then
	DashRemote = Instance.new("RemoteEvent")
	DashRemote.Name = "DashRemote"
	DashRemote.Parent = ReplicatedStorage
end

pcall(function()
	PhysicsService:RegisterCollisionGroup("RagdollLimbs")
	PhysicsService:RegisterCollisionGroup("RagdollTorso")
	PhysicsService:CollisionGroupSetCollidable("RagdollLimbs", "RagdollLimbs", false)
	PhysicsService:CollisionGroupSetCollidable("RagdollTorso", "RagdollLimbs", true)
end)

local PlayerDashCooldownTime = Config.DASH_COOLDOWN or 3
local GlobalHitCooldown = Config.GLOBAL_HIT_COOLDOWN or 0.5
local FinisherExtraCooldown = Config.FINISHER_EXTRA_COOLDOWN or 0.5
local RateLimitMaxHits = Config.RATE_LIMIT_MAX_HITS or 5
local RateLimitWindow = Config.RATE_LIMIT_WINDOW or 3

local PlayerDashCooldowns = {}
local PlayerStates = {}

type TargetData = {
	Model: Model,
	Humanoid: Humanoid,
	RootPart: BasePart,
	Distance: number,
}

type PlayerState = {
	CurrentHit: number,
	LastHitTime: number,
	NextAllowedHit: number,
	GlobalCooldownUntil: number,
	Processing: boolean,
	HitTimestamps: { number },
}

local function ApplyDash(Character: Model, Distance: number, Duration: number, Direction: Vector3?)
	local RootPart = Character:FindFirstChild("HumanoidRootPart")
	if not RootPart then return end

	local Humanoid = Character:FindFirstChildWhichIsA("Humanoid")
	if not Humanoid then return end

	local MoveDirection = Direction or (RootPart :: BasePart).CFrame.LookVector

	local Attachment = Instance.new("Attachment")
	Attachment.Parent = RootPart

	local LinearVelocity = Instance.new("LinearVelocity")
	LinearVelocity.Attachment0 = Attachment
	LinearVelocity.VelocityConstraintMode = Enum.VelocityConstraintMode.Vector
	LinearVelocity.Vector = MoveDirection * (Distance / Duration)
	LinearVelocity.MaxForce = Config.LINEAR_VELOCITY_MAX_FORCE or 1e5
	LinearVelocity.RelativeTo = Enum.ActuatorRelativeTo.World
	LinearVelocity.Parent = RootPart

	Debris:AddItem(LinearVelocity, Duration)
	Debris:AddItem(Attachment, Duration)
end

local function ApplyHitstun(Humanoid: Humanoid)
	if not Humanoid or not Humanoid.Parent then return end

	local CountKey = "HitstunCount"
	local WalkSpeedKey = "NaturalWalkSpeed"
	local JumpPowerKey = "NaturalJumpPower"
	local JumpHeightKey = "NaturalJumpHeight"

	local Count = (Humanoid:GetAttribute(CountKey) or 0) + 1
	Humanoid:SetAttribute(CountKey, Count)

	if Count == 1 then
		if not Humanoid:GetAttribute(WalkSpeedKey) then
			Humanoid:SetAttribute(WalkSpeedKey, Config.WALKSPEED_NORMAL)
		end
		if not Humanoid:GetAttribute(JumpPowerKey) then
			Humanoid:SetAttribute(JumpPowerKey, Humanoid.JumpPower > 5 and Humanoid.JumpPower or 50)
		end
		if not Humanoid:GetAttribute(JumpHeightKey) then
			Humanoid:SetAttribute(JumpHeightKey, Humanoid.JumpHeight > 1 and Humanoid.JumpHeight or 7.2)
		end
	end

	Humanoid.WalkSpeed = Config.HITSTUN_WALKSPEED or 2
	Humanoid.JumpPower = Config.HITSTUN_JUMPPOWER or 0
	Humanoid.JumpHeight = Config.HITSTUN_JUMPHEIGHT or 0

	task.delay(Config.HITSTUN_DURATION, function()
		if not Humanoid or not Humanoid.Parent then return end

		local Current = math.max(0, (Humanoid:GetAttribute(CountKey) or 1) - 1)
		Humanoid:SetAttribute(CountKey, Current)

		if Current == 0 then
			local WalkSpeed = Humanoid:GetAttribute(WalkSpeedKey) or Config.WALKSPEED_NORMAL
			local JumpPower = Humanoid:GetAttribute(JumpPowerKey) or 50
			local JumpHeight = Humanoid:GetAttribute(JumpHeightKey) or 7.2

			Humanoid.WalkSpeed = WalkSpeed
			Humanoid.JumpPower = JumpPower
			Humanoid.JumpHeight = JumpHeight
		end
	end)
end

local function ApplyKnockback(TargetRoot: BasePart, Direction: Vector3, Strength: number, UpForce: number?)
	local HorizontalDir = Vector3.new(Direction.X, 0, Direction.Z)
	if HorizontalDir.Magnitude < 0.01 then
		HorizontalDir = Vector3.new(0, 0, 1)
	end

	local Attachment = Instance.new("Attachment")
	Attachment.Parent = TargetRoot

	local LinearVelocity = Instance.new("LinearVelocity")
	LinearVelocity.Attachment0 = Attachment
	LinearVelocity.VelocityConstraintMode = Enum.VelocityConstraintMode.Vector
	LinearVelocity.Vector = HorizontalDir.Unit * Strength + Vector3.new(0, UpForce or Config.KNOCKBACK_DEFAULT_UPFORCE or 0.25, 0)
	LinearVelocity.MaxForce = Config.LINEAR_VELOCITY_MAX_FORCE or 1e5
	LinearVelocity.RelativeTo = Enum.ActuatorRelativeTo.World
	LinearVelocity.Parent = TargetRoot

	Debris:AddItem(LinearVelocity, Config.KNOCKBACK_DEBRIS_TIME or 0.22)
	Debris:AddItem(Attachment, Config.KNOCKBACK_DEBRIS_TIME or 0.22)
end

local function RagdollCharacter(Character: Model, Duration: number)
	local Humanoid = Character:FindFirstChildWhichIsA("Humanoid")
	if not Humanoid then return end

	local RagdollKey = Config.RAGDOLL_COUNT_ATTRIBUTE or "RagdollCount"
	local IsRagdolledKey = Config.RAGDOLL_IS_RAGDOLLED_ATTRIBUTE or "IsRagdolled"
	
	local Count = (Humanoid:GetAttribute(RagdollKey) or 0) + 1
	Humanoid:SetAttribute(RagdollKey, Count)
	Humanoid:SetAttribute(IsRagdolledKey, true)

	if Count == 1 then
		local Animator = Humanoid:FindFirstChildWhichIsA("Animator")
		if Animator then
			for _, Track in ipairs(Animator:GetPlayingAnimationTracks()) do
				Track:Stop(0)
			end
		end

		Humanoid.PlatformStand = true
		Humanoid:ChangeState(Enum.HumanoidStateType.Physics)

		for _, Motor in ipairs(Character:GetDescendants()) do
			if Motor:IsA("Motor6D") and Motor.Name ~= "Root" then
				local ConnectedPart = Motor.Part1
				if ConnectedPart then
					Motor.Enabled = false

					local JointName = Config.RAGDOLL_JOINT_NAME or "Ragdoll_Joint"
					if not ConnectedPart:FindFirstChild(JointName) then
						local Attachment0 = Motor.Part0:FindFirstChild(Motor.Name .. "RigAttachment")
						local Attachment1 = Motor.Part1:FindFirstChild(Motor.Name .. "RigAttachment")

						if Attachment0 and Attachment1 then
							local BallSocket = Instance.new("BallSocketConstraint")
							BallSocket.Name = JointName
							BallSocket.Attachment0 = Attachment0 :: Attachment
							BallSocket.Attachment1 = Attachment1 :: Attachment
							BallSocket.Parent = ConnectedPart
						end
					end
				end
			end
		end

		for _, Part in ipairs(Character:GetDescendants()) do
			if Part:IsA("BodyGyro") or Part:IsA("BodyAngularVelocity") or Part:IsA("AlignOrientation") then
				Part:Destroy()
			end

			if Part:IsA("BasePart") then
				Part.Anchored = false
				Part.CanCollide = Part.Name ~= "HumanoidRootPart"

				if not Players:GetPlayerFromCharacter(Character) then
					pcall(function()
						Part:SetNetworkOwner(nil)
					end)
				end
			end
		end

		local RootPart = Character:FindFirstChild("HumanoidRootPart")
		if RootPart and RootPart:IsA("BasePart") then
			local MinAng = Config.RAGDOLL_ANGULAR_MIN or 20
			local MaxAng = Config.RAGDOLL_ANGULAR_MAX or 50
			local Impulse = Config.RAGDOLL_IMPULSE or 50
			
			RootPart.AssemblyAngularVelocity = Vector3.new(math.random(MinAng, MaxAng), 0, math.random(MinAng, MaxAng))
			RootPart:ApplyImpulse(Vector3.new(0, Impulse, 0))
		end
	end

	task.delay(Duration, function()
		if not Humanoid or not Humanoid.Parent then return end

		local Current = math.max(0, (Humanoid:GetAttribute(RagdollKey) or 1) - 1)
		Humanoid:SetAttribute(RagdollKey, Current)

		if Current == 0 then
			for _, Motor in ipairs(Character:GetDescendants()) do
				if Motor:IsA("Motor6D") then
					Motor.Enabled = true
				end
			end

			local JointName = Config.RAGDOLL_JOINT_NAME or "Ragdoll_Joint"
			for _, Item in ipairs(Character:GetDescendants()) do
				if Item.Name == JointName then
					Item:Destroy()
				end
			end

			local RootPart = Character:FindFirstChild("HumanoidRootPart")
			if RootPart and RootPart:IsA("BasePart") then
				RootPart.CanCollide = true
			end

			Humanoid.PlatformStand = false
			Humanoid:SetAttribute(IsRagdolledKey, false)
			Humanoid:ChangeState(Enum.HumanoidStateType.GettingUp)
		end
	end)
end

local function TriggerRagdoll(Character: Model, Duration: number)
	local TargetPlayer = Players:GetPlayerFromCharacter(Character)
	if TargetPlayer then
		CombatRemote:FireClient(TargetPlayer, "ApplyRagdoll", Duration)
	end
	RagdollCharacter(Character, Duration)
end

local function CreateHitbox(Character: Model, HitNumber: number, SizeOverride: Vector3?, OffsetOverride: CFrame?): Part?
	local RootPart = Character:FindFirstChild("HumanoidRootPart")
	if not RootPart or not RootPart:IsA("BasePart") then return nil end

	local Hitbox = Instance.new("Part")
	Hitbox.Anchored = true
	Hitbox.CanCollide = false
	Hitbox.CanTouch = false
	Hitbox.CanQuery = true
	Hitbox.Transparency = 1

	Hitbox.Size = SizeOverride
		or (HitNumber == 3 and Config.HITBOX_SIZE_HIT3 or Config.HITBOX_SIZE_HIT12)

	local Offset = OffsetOverride
		or (HitNumber == 3 and Config.HITBOX_OFFSET_HIT3 or Config.HITBOX_OFFSET_HIT12)

	Hitbox.CFrame = (RootPart :: BasePart).CFrame * Offset
	Hitbox.Parent = workspace

	return Hitbox
end

local function GetTargets(AttackerCharacter: Model, Hitbox: Part, _HitNumber: number, ArcOverride: number?, MaxTargets: number?): { TargetData }
	local RootPart = AttackerCharacter:FindFirstChild("HumanoidRootPart")
	if not RootPart or not RootPart:IsA("BasePart") then return {} end

	local Arc = ArcOverride ~= nil and ArcOverride or Config.FRONT_ARC_DOT
	local Limit = MaxTargets or Config.MAX_TARGETS_PER_HIT or 3

	local QueryParams = OverlapParams.new()
	QueryParams.FilterType = Enum.RaycastFilterType.Exclude
	QueryParams.FilterDescendantsInstances = { AttackerCharacter }
	QueryParams.MaxParts = 30

	local Parts = workspace:GetPartsInPart(Hitbox, QueryParams)
	local Seen: { [Model]: boolean } = {}
	local Results: { TargetData } = {}

	local AttackerHumanoid = AttackerCharacter:FindFirstChildWhichIsA("Humanoid")
	local AttackerIsPlayer = Players:GetPlayerFromCharacter(AttackerCharacter) ~= nil

	for _, Part in ipairs(Parts) do
		local FoundModel = Part:FindFirstAncestorOfClass("Model")
		if FoundModel and not Seen[FoundModel] then
			Seen[FoundModel] = true

			local Humanoid = FoundModel:FindFirstChildWhichIsA("Humanoid")
			local TargetRoot = FoundModel:FindFirstChild("HumanoidRootPart")

			if Humanoid and Humanoid ~= AttackerHumanoid and Humanoid.Health > 0 and TargetRoot and TargetRoot:IsA("BasePart") then
				local TargetIsPlayer = Players:GetPlayerFromCharacter(FoundModel) ~= nil

				if AttackerIsPlayer or TargetIsPlayer then
					local ToTarget = TargetRoot.Position - (RootPart :: BasePart).Position
					local Distance = ToTarget.Magnitude
					local InArc = Arc <= -1
						or (Distance > 0.01 and (RootPart :: BasePart).CFrame.LookVector:Dot(ToTarget.Unit) > Arc)

					if InArc then
						table.insert(Results, {
							Model = FoundModel,
							Humanoid = Humanoid,
							RootPart = TargetRoot :: BasePart,
							Distance = Distance,
						})
					end
				end
			end
		end
	end

	table.sort(Results, function(A, B)
		return A.Distance < B.Distance
	end)

	if #Results > Limit then
		local LimitedResults = {}
		for i = 1, Limit do
			LimitedResults[i] = Results[i]
		end
		return LimitedResults
	end

	return Results
end

local function GetHitStats(HitNumber: number)
	if HitNumber == 1 then
		return Config.DAMAGE_HIT1, Config.KNOCKBACK_HIT12
	elseif HitNumber == 2 then
		return Config.DAMAGE_HIT2, Config.KNOCKBACK_HIT12
	else
		return Config.DAMAGE_HIT3, Config.KNOCKBACK_HIT3
	end
end

local function ApplyHitEffect(AttackerRoot: BasePart, Target: TargetData, HitNumber: number, Damage: number, Knockback: number, IsBlocked: boolean, KnockbackMultiplier: number)
	local Direction = Target.RootPart.Position - AttackerRoot.Position
	Direction = Vector3.new(Direction.X, 0, Direction.Z)

	if Direction.Magnitude < 0.01 then
		Direction = AttackerRoot.CFrame.LookVector
	end

	Direction = Direction.Unit

	if IsBlocked then
		local AdjustedKnockback = Knockback * KnockbackMultiplier
		if HitNumber == 3 then
			ApplyKnockback(Target.RootPart, Direction, AdjustedKnockback, Config.BLOCK_KNOCKBACK_UPFORCE_HIT3 or 0.35)
		else
			ApplyKnockback(Target.RootPart, Direction, AdjustedKnockback, Config.BLOCK_KNOCKBACK_UPFORCE_HIT12 or 0.08)
			ApplyHitstun(Target.Humanoid)
		end
		return
	end

	local CreatorTag = Target.Humanoid:FindFirstChild("Creator")
	if not CreatorTag then
		CreatorTag = Instance.new("ObjectValue")
		CreatorTag.Name = "Creator"
		CreatorTag.Parent = Target.Humanoid
	end

	local AttackingPlayer = Players:GetPlayerFromCharacter(AttackerRoot.Parent)
	if AttackingPlayer and CreatorTag:IsA("ObjectValue") then
		CreatorTag.Value = AttackingPlayer
	end

	Target.Humanoid:TakeDamage(Damage)

	if HitNumber == 3 then
		ApplyKnockback(Target.RootPart, Direction, Knockback, 1.0)
		TriggerRagdoll(Target.Model, Config.RAGDOLL_DURATION or 2.5)
	else
		ApplyKnockback(Target.RootPart, Direction, Knockback, 0.25)
		ApplyHitstun(Target.Humanoid)
	end
end

local function CheckRateLimit(State: PlayerState, Now: number): boolean
	local WindowStart = Now - RateLimitWindow
	local Write = 1

	for i = 1, #State.HitTimestamps do
		if State.HitTimestamps[i] >= WindowStart then
			State.HitTimestamps[Write] = State.HitTimestamps[i]
			Write += 1
		end
	end

	for i = Write, #State.HitTimestamps do
		State.HitTimestamps[i] = nil
	end

	if #State.HitTimestamps >= RateLimitMaxHits then return false end

	table.insert(State.HitTimestamps, Now)
	return true
end

local function RejectHit(Player: Player, Reason: string)
	CombatRemote:FireClient(Player, "HitRejected", Reason)
end

local function ProcessHit(Player: Player, HitNumber: number)
	local State = PlayerStates[Player] :: PlayerState
	if not State then RejectHit(Player, "no_state") return end

	local Now = os.clock()

	if Now < State.GlobalCooldownUntil then RejectHit(Player, "global_cooldown") return end
	if not CheckRateLimit(State, Now) then RejectHit(Player, "rate_limited") return end

	local AnimationCooldown = HitNumber == 3 and Config.RECOVERY_HIT3 or Config.RECOVERY_HIT12
	if Now < State.NextAllowedHit then RejectHit(Player, "animation_cooldown") return end
	if State.Processing then RejectHit(Player, "busy") return end

	local ExpectedHit = State.CurrentHit + 1
	if ExpectedHit > (Config.COMBO_LENGTH or 3) then ExpectedHit = 1 end

	if HitNumber ~= ExpectedHit then
		State.CurrentHit = 0
		RejectHit(Player, "wrong_order")
		return
	end

	if State.CurrentHit > 0 and Now - State.LastHitTime > Config.COMBO_WINDOW then
		State.CurrentHit = 0
		RejectHit(Player, "window_expired")
		return
	end

	local Character = Player.Character
	if not Character then RejectHit(Player, "no_character") return end

	local AttackerHumanoid = Character:FindFirstChildWhichIsA("Humanoid")
	local IsRagdolledKey = Config.RAGDOLL_IS_RAGDOLLED_ATTRIBUTE or "IsRagdolled"
	if AttackerHumanoid and AttackerHumanoid:GetAttribute(IsRagdolledKey) then
		RejectHit(Player, "ragdolled")
		return
	end

	State.Processing = true

	local Targets: { TargetData } = {}
	local Hitbox = CreateHitbox(Character, HitNumber)
	
	if Hitbox then
		local Ok = pcall(function()
			Targets = GetTargets(Character, Hitbox, HitNumber, nil)
		end)
		Hitbox:Destroy()
		
		if not Ok then
			State.Processing = false
			return
		end
	end

	State.Processing = false

	local TotalCooldown = math.max(AnimationCooldown, GlobalHitCooldown)
		+ (HitNumber == 3 and FinisherExtraCooldown or 0)

	State.NextAllowedHit = Now + TotalCooldown
	State.GlobalCooldownUntil = Now + GlobalHitCooldown

	local Damage, Knockback = GetHitStats(HitNumber)
	local HitData: { { TargetRoot: BasePart, Damage: number } } = {}
	local AttackerRoot = Character:FindFirstChild("HumanoidRootPart")

	if not AttackerRoot or not AttackerRoot:IsA("BasePart") then return end

	for _, Target in ipairs(Targets) do
		if Target.Humanoid.Health > 0 then
			local TargetPlayer = Players:GetPlayerFromCharacter(Target.Model)
			local IsBlocked = false
			local KnockbackMultiplier = 1.0

			if TargetPlayer then
				IsBlocked, KnockbackMultiplier = BlockingServer.checkBlock(Player, TargetPlayer, Target.Model)
				if IsBlocked then BlockingServer.sendBlockedFeedback(TargetPlayer) end
			else
				IsBlocked, KnockbackMultiplier = BlockingServer.isCharacterBlockingAttacker(Character, Target.Model)
			end

			ApplyHitEffect(AttackerRoot :: BasePart, Target, HitNumber, Damage, Knockback, IsBlocked, KnockbackMultiplier)

			if not IsBlocked then
				table.insert(HitData, { TargetRoot = Target.RootPart, Damage = Damage })
			end
		end
	end

	if Config.DASH_ENABLED then
		for _, TriggerHit in ipairs(Config.DASH_TRIGGER_HITS) do
			if TriggerHit == HitNumber then
				ApplyDash(Character, Config.DASH_DISTANCE, Config.DASH_DURATION)
				break
			end
		end
	end

	State.LastHitTime = Now
	State.CurrentHit = HitNumber == (Config.COMBO_LENGTH or 3) and 0 or HitNumber

	if #HitData > 0 then
		CombatRemote:FireClient(Player, "HitConfirmed", HitNumber, HitData)
	else
		CombatRemote:FireClient(Player, "HitMissed", HitNumber)
	end
end

DashRemote.OnServerEvent:Connect(function(Player: Player)
	local Now = os.clock()
	local CooldownUntil = PlayerDashCooldowns[Player] or 0
	local Remaining = CooldownUntil - Now

	if Remaining > 0 then
		DashRemote:FireClient(Player, "DashRejected", Remaining)
		return
	end

	local Character = Player.Character
	if not Character then
		DashRemote:FireClient(Player, "DashRejected", 0)
		return
	end

	local Humanoid = Character:FindFirstChildWhichIsA("Humanoid")
	if not Humanoid or Humanoid.Health <= 0 then
		DashRemote:FireClient(Player, "DashRejected", 0)
		return
	end

	local IsRagdolledKey = Config.RAGDOLL_IS_RAGDOLLED_ATTRIBUTE or "IsRagdolled"
	if Humanoid:GetAttribute(IsRagdolledKey) then
		DashRemote:FireClient(Player, "DashRejected", 0)
		return
	end

	local DashDistance = AIConfig.DashDistance or 15
	local DashDuration = AIConfig.DashDuration or 0.3

	ApplyDash(Character, DashDistance, DashDuration)
	PlayerDashCooldowns[Player] = Now + PlayerDashCooldownTime

	DashRemote:FireClient(Player, "DashOk")
end)

function CombatServer.PerformDummyPunch(DummyCharacter: Model, HitNumber: number, SizeOverride: Vector3?, OffsetOverride: CFrame?)
	local RootPart = DummyCharacter:FindFirstChild("HumanoidRootPart")
	if not RootPart or not RootPart:IsA("BasePart") then return end

	local Humanoid = DummyCharacter:FindFirstChildWhichIsA("Humanoid")
	if not Humanoid or Humanoid.Health <= 0 then return end

	local IsRagdolledKey = Config.RAGDOLL_IS_RAGDOLLED_ATTRIBUTE or "IsRagdolled"
	if Humanoid:GetAttribute(IsRagdolledKey) then return end

	local Hitbox = CreateHitbox(DummyCharacter, HitNumber, SizeOverride, OffsetOverride)
	if not Hitbox then return end

	local Targets
	local Ok = pcall(function()
		Targets = GetTargets(DummyCharacter, Hitbox, HitNumber, -1)
	end)

	Hitbox:Destroy()

	if not Ok or not Targets then return end

	local Damage, Knockback = GetHitStats(HitNumber)
	local HitData: { { TargetRoot: BasePart, Damage: number } } = {}

	for _, Target in ipairs(Targets) do
		if Target.Humanoid.Health > 0 then
			local TargetPlayer = Players:GetPlayerFromCharacter(Target.Model)
			local IsBlocked = false
			local KnockbackMultiplier = 1.0

			if TargetPlayer then
				IsBlocked, KnockbackMultiplier = BlockingServer.checkBlockByCharacter(DummyCharacter, TargetPlayer, Target.Model)
				if IsBlocked then BlockingServer.sendBlockedFeedback(TargetPlayer) end
			else
				IsBlocked, KnockbackMultiplier = BlockingServer.isCharacterBlockingAttacker(DummyCharacter, Target.Model)
			end

			ApplyHitEffect(RootPart :: BasePart, Target, HitNumber, Damage, Knockback, IsBlocked, KnockbackMultiplier)

			if not IsBlocked then
				table.insert(HitData, { TargetRoot = Target.RootPart, Damage = Damage })
			end
		end
	end

	return HitData
end

CombatServer.ApplyDash = ApplyDash

local function NewPlayerState(): PlayerState
	return {
		CurrentHit = 0,
		LastHitTime = 0,
		NextAllowedHit = 0,
		GlobalCooldownUntil = 0,
		Processing = false,
		HitTimestamps = {},
	}
end

Players.PlayerAdded:Connect(function(Player: Player)
	PlayerStates[Player] = NewPlayerState()
	PlayerDashCooldowns[Player] = 0

	Player.CharacterAdded:Connect(function()
		PlayerStates[Player] = NewPlayerState()
		PlayerDashCooldowns[Player] = 0
	end)
end)

Players.PlayerRemoving:Connect(function(Player: Player)
	PlayerStates[Player] = nil
	PlayerDashCooldowns[Player] = nil
end)

for _, Player in ipairs(Players:GetPlayers()) do
	PlayerStates[Player] = NewPlayerState()
	PlayerDashCooldowns[Player] = 0
end

CombatRemote.OnServerEvent:Connect(function(Player: Player, Action: string, HitNumber: number)
	if Action == "RequestHit" and type(HitNumber) == "number" then
		ProcessHit(Player, math.clamp(math.floor(HitNumber), 1, 3))
	elseif Action == "ResetCombo" then
		if PlayerStates[Player] then
			PlayerStates[Player].CurrentHit = 0
		end
	end
end)

return CombatServer