Need help implementing a VFX swing for my melee

Hey everyone :waving_hand:

I’m currently working on a melee combat system for my R6 PvP game and I’d like to add some swing VFX (like a slash effect) whenever the player attacks with their sword.

Right now, I already have:

  • Animations set up (idle, walk, run, swing, block).
  • Swing logic that plays the animation and handles cooldowns.
  • My weapons and animations stored under ReplicatedStorage/Melee/[WeaponName].
  • The actual VFX (a Part with the VFX attachment)

What I’m missing is the actual scripting logic and I’m unsure if positioning etc is correct.
Just want the swing VFX to appear when I press Q / left click to swing.

Here is my AnimHandler module script :

local AnimHandler = {}

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")

function AnimHandler.SetupAnimations(character: Model, weaponName: string, toolRef)
	local humanoid = character:FindFirstChildOfClass("Humanoid")
	if not humanoid then
		warn("AnimHandler: No Humanoid")
		return
	end

	local animateScript = character:FindFirstChild("Animate")
	if animateScript then
		animateScript.Disabled = true
	end

	local animator = humanoid:FindFirstChildOfClass("Animator")
	if not animator then
		animator = Instance.new("Animator")
		animator.Name = "Animator"
		animator.Parent = humanoid
		animator:WaitForChild("AnimatorLoaded", 1)
	end

	local meleeFolder = ReplicatedStorage:WaitForChild("Melee")
	local animFolder = meleeFolder:FindFirstChild(weaponName)
	if not animFolder then
		warn("AnimHandler: Missing weapon folder:", weaponName)
		if animateScript then animateScript.Disabled = false end
		return
	end

	local animSaves = animFolder:FindFirstChild("AnimSaves")
	if not animSaves then
		warn("AnimHandler: Missing AnimSaves folder for", weaponName)
		if animateScript then animateScript.Disabled = false end
		return
	end

	-- Fetch animation objects
	local idleAnim   = animSaves:FindFirstChild("Idle")
	local walkAnim   = animSaves:FindFirstChild("Walk")
	local runAnim    = animSaves:FindFirstChild("Run")
	local swing1Anim = animSaves:FindFirstChild("Swing1")
	local blockAnim  = animSaves:FindFirstChild("Block")

	if not swing1Anim then
		warn("AnimHandler: Missing Swing1 animation for", weaponName)
		if animateScript then animateScript.Disabled = false end
		return
	end

	-- Load tracks
	local idleTrack   = idleAnim and animator:LoadAnimation(idleAnim)
	local walkTrack   = walkAnim and animator:LoadAnimation(walkAnim)
	local runTrack    = runAnim and animator:LoadAnimation(runAnim)
	local swing1Track = animator:LoadAnimation(swing1Anim)
	local blockTrack  = blockAnim and animator:LoadAnimation(blockAnim)

	-- Priorities
	if idleTrack then idleTrack.Priority = Enum.AnimationPriority.Idle end
	if walkTrack then walkTrack.Priority = Enum.AnimationPriority.Movement end
	if runTrack then runTrack.Priority = Enum.AnimationPriority.Movement end

	swing1Track.Priority = Enum.AnimationPriority.Action4
	if blockTrack then blockTrack.Priority = Enum.AnimationPriority.Action end

	-- Looping flags
	if idleTrack then idleTrack.Looped = true end
	if walkTrack then walkTrack.Looped = true end
	if runTrack then runTrack.Looped = true end
	if blockTrack then blockTrack.Looped = true end

	-- --- State
	local isSwinging = false
	local isBlocking = false
	local isRunning  = false
	local currentMove: "idle" | "walk" | "run" | nil = nil
	local currentSwingTrack: AnimationTrack? = nil

	local locomotionConn: RBXScriptConnection? = nil
	local swingStoppedConn: RBXScriptConnection? = nil

	-- --- Helpers
	local function stopMovementTracks(fade: number?)
		fade = fade or 0.12
		if idleTrack and idleTrack.IsPlaying then idleTrack:Stop(fade) end
		if walkTrack and walkTrack.IsPlaying then walkTrack:Stop(fade) end
		if runTrack and runTrack.IsPlaying then runTrack:Stop(fade) end
		currentMove = nil
	end

	local function playOnly(target: "idle" | "walk" | "run")
		if currentMove == target then return end
		if not idleTrack or not walkTrack or not runTrack then return end

		local fadeTime = 0.15
		if idleTrack.IsPlaying then idleTrack:Stop(fadeTime) end
		if walkTrack.IsPlaying then walkTrack:Stop(fadeTime) end
		if runTrack.IsPlaying then runTrack:Stop(fadeTime) end

		task.wait(0.05)

		if target == "idle" then
			idleTrack:Play(fadeTime)
		elseif target == "walk" then
			walkTrack:Play(fadeTime)
		elseif target == "run" then
			runTrack:Play(fadeTime)
		end
		currentMove = target
	end

	local function refreshLocomotion()
		if isSwinging or isBlocking then return end
		if not (idleTrack and walkTrack and runTrack) then return end

		local moving = humanoid.MoveDirection.Magnitude > 0.1
		if not moving then
			playOnly("idle")
		else
			playOnly(isRunning and "run" or "walk")
		end
	end


	if idleTrack then
		idleTrack:Play(0.15)
		currentMove = "idle"
	end

	-- Locomotion driver
	locomotionConn = RunService.Heartbeat:Connect(refreshLocomotion)

	local api = {}

	function api.Swing()
		if isBlocking or isSwinging then return end
		isSwinging = true

		stopMovementTracks(0.06)

		if swingStoppedConn then
			swingStoppedConn:Disconnect()
			swingStoppedConn = nil
		end
		if currentSwingTrack and currentSwingTrack.IsPlaying then
			currentSwingTrack:Stop(0.03)
		end

		currentSwingTrack = swing1Track
		currentSwingTrack.Looped = false
		currentSwingTrack:Play(0.03)

		swingStoppedConn = currentSwingTrack.Stopped:Connect(function()
			isSwinging = false
			if swingStoppedConn then
				swingStoppedConn:Disconnect()
				swingStoppedConn = nil
			end
			task.defer(refreshLocomotion)
		end)

		local fallbackLen = (currentSwingTrack.Length > 0 and currentSwingTrack.Length or 0.5) + 0.2
		task.delay(fallbackLen, function()
			if isSwinging then
				isSwinging = false
				if swingStoppedConn then
					swingStoppedConn:Disconnect()
					swingStoppedConn = nil
				end
				task.defer(refreshLocomotion)
			end
		end)
	end

	function api.SetRunning(state: boolean)
		local wasRunning = isRunning
		isRunning = state
		humanoid.WalkSpeed = state and 24 or 16

		if wasRunning ~= isRunning and (runTrack and walkTrack) then
			if currentMove == "run" and not state then
				runTrack:Stop(0.1)
				currentMove = nil
			elseif currentMove == "walk" and state then
				walkTrack:Stop(0.1)
				currentMove = nil
			end
			task.defer(refreshLocomotion)
		end
	end

	function api.SetBlocking(state: boolean)
		if state then
			if isBlocking then return end
			isBlocking = true

			if isSwinging then
				isSwinging = false
				if swingStoppedConn then
					swingStoppedConn:Disconnect()
					swingStoppedConn = nil
				end
				if currentSwingTrack and currentSwingTrack.IsPlaying then
					currentSwingTrack:Stop(0.03)
				end
			end

			stopMovementTracks(0.06)
			if blockTrack then
				if not blockTrack.IsPlaying then blockTrack:Play(0.06) end
			elseif idleTrack then
				if not idleTrack.IsPlaying then idleTrack:Play(0.1) end
			end
		else
			if not isBlocking then return end
			isBlocking = false
			if blockTrack and blockTrack.IsPlaying then
				blockTrack:Stop(0.08)
			end
			task.defer(refreshLocomotion)
		end
	end

	function api.Cleanup()
		if locomotionConn then
			locomotionConn:Disconnect()
			locomotionConn = nil
		end
		if swingStoppedConn then
			swingStoppedConn:Disconnect()
			swingStoppedConn = nil
		end

		local allTracks = { idleTrack, walkTrack, runTrack, swing1Track, blockTrack }
		for _, tr in ipairs(allTracks) do
			if tr then
				if tr.IsPlaying then tr:Stop(0.08) end
				tr:Destroy()
			end
		end

		if animateScript then
			animateScript.Disabled = false
		end
	end

	return api
end

return AnimHandler

Here is my Tool’s LocalScript :

--// Services
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local UserInputService = game:GetService("UserInputService")
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
local Debris = game:GetService("Debris")
local Workspace = game:GetService("Workspace")

--// Player / Character / Tool
local player = Players.LocalPlayer
local character = player.Character or player.CharacterAdded:Wait()
player.CharacterAdded:Connect(function(newChar) character = newChar end)

local tool = script.Parent
assert(tool and tool:IsA("Tool"), "This script must be a LocalScript under a Tool")

--// Remotes & Modules
local Remotes = ReplicatedStorage:WaitForChild("Remotes")
local meleeSwingEvent = Remotes:WaitForChild("MeleeSwing")
local blockStateEvent = Remotes:WaitForChild("BlockState")
local AnimHandler = require(ReplicatedStorage:WaitForChild("System"):WaitForChild("AnimHandler"))

--// Config
local CROSSHAIR_IMAGE = "rbxassetid://77159165581033"
local BASE_SIZE = 89
local EXPANDED_SIZE = 98
local EXPAND_TIME = 0.08
local CONTRACT_TIME = 0.12
local SWING_COOLDOWN = 1.0

local BAR_WIDTH = 20
local BAR_HEIGHT = 3
local BAR_MARGIN = 0
local BAR_EDGE_OFFSET = -20
local BAR_ANIMATION_TIME = 0.8

local HIDE_CURSOR_IN_STUDIO = true

--// Runtime refs
local swingFunc, setRunningFunc, setBlockingFunc, cleanupFunc
local isEquipped = false
local rsConn
local activeExpandTween, activeContractTween, activeLeftBarTween, activeRightBarTween
local lastSwingTime = 0

local playerGui = player:WaitForChild("PlayerGui")
local screenGui
local crosshair
local leftBar, rightBar

--======== Tween safety helpers ========
local function canTween(inst: Instance?)
	return inst and inst.Parent ~= nil
end

local function safeTween(inst: Instance?, info: TweenInfo, props: table)
	if not canTween(inst) then return nil end
	local t
	local ok, err = pcall(function()
		t = TweenService:Create(inst, info, props)
	end)
	if not ok or not t then 
		warn("Tween creation failed:", err)
		return nil 
	end
	t:Play()
	return t
end

local function cancelTween(t)
	if t then 
		pcall(function() 
			t:Cancel()
			t:Destroy()
		end) 
	end
end

--======== UI build / destroy ========
local function destroyExistingUI()
	local existingGui = playerGui:FindFirstChild("SwordCrosshairGui")
	if existingGui then
		existingGui:Destroy()
	end
end

local function createUI()
	destroyExistingUI()

	screenGui = Instance.new("ScreenGui")
	screenGui.Name = "SwordCrosshairGui"
	screenGui.IgnoreGuiInset = true
	screenGui.ResetOnSpawn = false
	screenGui.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
	screenGui.Parent = playerGui

	-- Crosshair
	crosshair = Instance.new("ImageLabel")
	crosshair.Name = "Crosshair"
	crosshair.AnchorPoint = Vector2.new(0.5, 0.5)
	crosshair.Size = UDim2.fromOffset(BASE_SIZE, BASE_SIZE)
	crosshair.BackgroundTransparency = 1
	crosshair.Image = CROSSHAIR_IMAGE
	crosshair.ImageTransparency = 0
	crosshair.ZIndex = 1000
	crosshair.Visible = false
	crosshair.Parent = screenGui

	-- Left progress bar
	leftBar = Instance.new("Frame")
	leftBar.Name = "LeftCooldownBar"
	leftBar.AnchorPoint = Vector2.new(1, 0.5)
	leftBar.Size = UDim2.fromOffset(0, BAR_HEIGHT)
	leftBar.BackgroundColor3 = Color3.fromRGB(255, 255, 255)
	leftBar.BackgroundTransparency = 0.1
	leftBar.BorderSizePixel = 0
	leftBar.ZIndex = 1001
	leftBar.Visible = false
	leftBar.Parent = screenGui

	-- Right progress bar
	rightBar = Instance.new("Frame")
	rightBar.Name = "RightCooldownBar"
	rightBar.AnchorPoint = Vector2.new(0, 0.5)
	rightBar.Size = UDim2.fromOffset(0, BAR_HEIGHT)
	rightBar.BackgroundColor3 = Color3.fromRGB(255, 255, 255) 
	rightBar.BackgroundTransparency = 0.1
	rightBar.BorderSizePixel = 0
	rightBar.ZIndex = 1001
	rightBar.Visible = false
	rightBar.Parent = screenGui
end

local function destroyUI()
	if rsConn then 
		rsConn:Disconnect() 
		rsConn = nil 
	end

	cancelTween(activeExpandTween)
	cancelTween(activeContractTween)
	cancelTween(activeLeftBarTween)
	cancelTween(activeRightBarTween)
	activeExpandTween, activeContractTween, activeLeftBarTween, activeRightBarTween = nil, nil, nil, nil

	if screenGui then 
		screenGui:Destroy() 
	end
	screenGui, crosshair, leftBar, rightBar = nil, nil, nil, nil
end

local function setMouseVisible(visible)
	if HIDE_CURSOR_IN_STUDIO or not RunService:IsStudio() then
		UserInputService.MouseIconEnabled = visible
	end
end

local lastMousePos = Vector2.new(0, 0)
local function startMouseFollow()
	if rsConn then rsConn:Disconnect() end
	rsConn = RunService.RenderStepped:Connect(function()
		if not crosshair then return end

		local pos = UserInputService:GetMouseLocation()
		if (pos - lastMousePos).Magnitude < 1 then return end
		lastMousePos = pos

		crosshair.Position = UDim2.fromOffset(pos.X, pos.Y)

		local currentSizeX = crosshair.AbsoluteSize.X
		if currentSizeX <= 0 then currentSizeX = BASE_SIZE end

		local radius = currentSizeX * 0.5
		local edge = radius + BAR_MARGIN + BAR_EDGE_OFFSET

		if leftBar then
			leftBar.Position = UDim2.fromOffset(pos.X - edge, pos.Y)
		end
		if rightBar then
			rightBar.Position = UDim2.fromOffset(pos.X + edge, pos.Y)
		end
	end)
end

local function playCrosshairPulse()
	if not canTween(crosshair) then return end
	cancelTween(activeExpandTween)
	cancelTween(activeContractTween)

	crosshair.Size = UDim2.fromOffset(BASE_SIZE, BASE_SIZE)

	activeExpandTween = safeTween(
		crosshair,
		TweenInfo.new(EXPAND_TIME, Enum.EasingStyle.Back, Enum.EasingDirection.Out),
		{ Size = UDim2.fromOffset(EXPANDED_SIZE, EXPANDED_SIZE) }
	)

	if activeExpandTween then
		activeExpandTween.Completed:Once(function()
			if not canTween(crosshair) then return end
			activeContractTween = safeTween(
				crosshair,
				TweenInfo.new(CONTRACT_TIME, Enum.EasingStyle.Quad, Enum.EasingDirection.Out),
				{ Size = UDim2.fromOffset(BASE_SIZE, BASE_SIZE) }
			)
		end)
	end
end

-- Cooldown bars
local function playCooldownBars()
	if not (canTween(leftBar) and canTween(rightBar)) then return end

	leftBar.Visible = true
	rightBar.Visible = true
	leftBar.Size = UDim2.fromOffset(0, BAR_HEIGHT)
	rightBar.Size = UDim2.fromOffset(0, BAR_HEIGHT)
	leftBar.BackgroundTransparency = 0.1
	rightBar.BackgroundTransparency = 0.1

	cancelTween(activeLeftBarTween)
	cancelTween(activeRightBarTween)

	local barInfo = TweenInfo.new(BAR_ANIMATION_TIME, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)

	activeLeftBarTween = safeTween(leftBar, barInfo, { Size = UDim2.fromOffset(BAR_WIDTH, BAR_HEIGHT) })
	activeRightBarTween = safeTween(rightBar, barInfo, { Size = UDim2.fromOffset(BAR_WIDTH, BAR_HEIGHT) })

	local function onBarsFinished()
		if not (leftBar and rightBar) then return end
		local fadeInfo = TweenInfo.new(0.2, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)

		if canTween(leftBar) then
			safeTween(leftBar, fadeInfo, { BackgroundTransparency = 1 })
		end
		if canTween(rightBar) then
			safeTween(rightBar, fadeInfo, { BackgroundTransparency = 1 })
		end

		task.wait(0.25)
		if leftBar then
			leftBar.Visible = false
			leftBar.BackgroundTransparency = 0.1
		end
		if rightBar then
			rightBar.Visible = false
			rightBar.BackgroundTransparency = 0.1
		end
	end

	if activeLeftBarTween then activeLeftBarTween.Completed:Once(onBarsFinished) end
end

local function showCrosshairUI()
	createUI()
	if crosshair then crosshair.Visible = true end
	setMouseVisible(false)
	startMouseFollow()
end

local function hideCrosshairUI()
	setMouseVisible(true)
	destroyUI()
end

local function canSwing()
	return (tick() - lastSwingTime) >= SWING_COOLDOWN
end

-- Equip / Unequip
tool.Equipped:Connect(function()
	isEquipped = true
	task.wait()

	local handlers = AnimHandler.SetupAnimations(character, tool.Name or "WoodSword", tool)
	if handlers then
		swingFunc = handlers.Swing
		setRunningFunc = handlers.SetRunning
		setBlockingFunc = handlers.SetBlocking
		cleanupFunc = handlers.Cleanup
	end
	showCrosshairUI()
end)

tool.Unequipped:Connect(function()
	isEquipped = false
	if cleanupFunc then cleanupFunc() end
	swingFunc, setRunningFunc, setBlockingFunc, cleanupFunc = nil, nil, nil, nil
	blockStateEvent:FireServer(false)
	hideCrosshairUI()
end)

tool.Activated:Connect(function() end)

-- INPUT HANDLING
local inputConnections = {}

local function doSwing()
	if not canSwing() then return end
	if swingFunc then
		lastSwingTime = tick()
		swingFunc()
		meleeSwingEvent:FireServer()
		playCrosshairPulse()
		playCooldownBars()
	end
end

local function startBlock()
	if setBlockingFunc then setBlockingFunc(true) end
	blockStateEvent:FireServer(true)
end

local function stopBlock()
	if setBlockingFunc then setBlockingFunc(false) end
	blockStateEvent:FireServer(false)
end

local function connectInput()
	-- Clean old
	for _, conn in pairs(inputConnections) do
		conn:Disconnect()
	end
	table.clear(inputConnections)

	inputConnections.began = UserInputService.InputBegan:Connect(function(input, gp)
		if gp or not isEquipped then return end

		-- Run (hold)
		if input.KeyCode == Enum.KeyCode.LeftShift then
			if setRunningFunc then setRunningFunc(true) end
			return
		end

		-- Swing: Q OR Left Click
		if input.KeyCode == Enum.KeyCode.Q
			or input.UserInputType == Enum.UserInputType.MouseButton1 then
			doSwing()
			return
		end

		-- Block (hold): F OR Right Click
		if input.KeyCode == Enum.KeyCode.F
			or input.UserInputType == Enum.UserInputType.MouseButton2 then
			startBlock()
			return
		end
	end)

	inputConnections.ended = UserInputService.InputEnded:Connect(function(input, gp)
		if gp or not isEquipped then return end

		-- Stop run
		if input.KeyCode == Enum.KeyCode.LeftShift then
			if setRunningFunc then setRunningFunc(false) end
			return
		end

		-- Stop block: F release OR Right Click release
		if input.KeyCode == Enum.KeyCode.F
			or input.UserInputType == Enum.UserInputType.MouseButton2 then
			stopBlock()
			return
		end
	end)
end

-- Initialize input connections
connectInput()

-- Clean up on tool removal
tool.AncestryChanged:Connect(function()
	if not tool.Parent then
		for _, conn in pairs(inputConnections) do
			conn:Disconnect()
		end
		destroyUI()
	end
end)

Finally, here is my directory :

If anyone can help me, I’d really appreciate it :folded_hands:

Thanks in advance!

I recommend making a trail that is disabled when not swinging.

So i should make a trail and put the attachment inside ?

I say take half of the texture, straighten it and make it an image/decal. You need an attachment at the top and bottom, which should be placed inside the blade to avoid a gap, then set the image, set the lifetime and length, and disable it. When the player swings activate the trail. Unfortunately this method will mean that vfx won’t get used, but I’m sure you can use it in another place.