.AnimationPlayed and .Stopped Don't Work Reliably

So basically, I’m trying to make a footstep script that detects when a new animation is played, and if that animation is a walk or sprint, it’ll hook the sound effects to that playing animation track. And if the walk is already playing, then the player starts sprinting, it should rehook to the correct animation track.

This system worked consistently before I added new materials. Now, when I redid some parts of the script, it worked, to say the least. For some reason, .AnimationPlayed and track.Stopped seem to work really unreliably. I added some print statements at the start of each and they just seem to, not run sometimes? Sometimes the sound just stops playing after I unsprint and keep walking (couldn’t get footage of that sadly), and sometimes the sound is hooked to the walk animation, when the sprint animation is playing. I also discovered that if I spam new animations in general, it will stop the sounds.
(Note: The walk animation has less priority than the sprint, so they both play at the same time, but the sprint plays over the walk.)


^ (The footsteps syncing to the walk animation while I’m running at 0:06)

Here are my scripts: (they may not be the best systems so excuse me :sob:)
Client Side: (detecting movements and base materials)

local runtime = game:GetService('RunService')
--script.Parent:WaitForChild('FootstepSounds').Parent = sounds
repeat wait() until script.Parent:FindFirstChild("Humanoid") and script.Parent.HumanoidRootPart:FindFirstChild("FootstepSounds")
local materials = script.Parent:WaitForChild("HumanoidRootPart"):WaitForChild('FootstepSounds')
local plr = game.Players.LocalPlayer
repeat wait() until plr.Character
local char = plr.Character
local hrp = char:WaitForChild("HumanoidRootPart")
local hum = char:WaitForChild("Humanoid")
local walking
local prevWalkSpeed = nil

hum.Running:connect(function(speed)
	if speed > hum.WalkSpeed/2 then
		walking = true
	else
		walking = false
	end
end)

function getMaterial()
	local floormat = hum.FloorMaterial
	if not floormat then floormat = 'Air' end
	local matstring = string.split(tostring(floormat),'Enum.Material.')[2]
	local material = matstring
	return material
end

local prevMat

runtime.Heartbeat:connect(function()
	if walking then
		local material = getMaterial()
		if not materials:FindFirstChild(material) then
			return 
		end
		
		local materialSound = materials[material]
		local ws = tonumber(hum.WalkSpeed)
		if ws ~= prevWalkSpeed or materialSound.Name ~= prevMat then
			prevWalkSpeed = ws
			prevMat = materialSound.Name
			--print(ws, prevWalkSpeed)
			game.ReplicatedStorage.Events.Footsteps:FireServer(materialSound, char, ws)
		end
	else
		prevWalkSpeed = nil
		lastFiredMat = nil
	end
end)

Server Side: (handling sounds themselves and changing materials if needed)

rs.Events.Footsteps.OnServerEvent:Connect(function(plr, sound, char, walkspeed)
	if not sound then return end
	if not char then char = plr:WaitForChild("Character") end
	local hum = char:WaitForChild("Humanoid")
	local velocity = hum.RootPart.Velocity.Magnitude
	local materialSound
	if sound:IsA("Attachment") then
		if animConnection then
			animConnection:Disconnect()
			animConnection = nil
		end
		if animStopConnection then
			animStopConnection:Disconnect()
			animStopConnection = nil
		end
		task.spawn(function()
			local function hookTrack(track)
				-- disconnect previous
				if connectionMain then
					connectionMain:Disconnect()
					connectionMain = nil
				end
				if connectionL then
					connectionL:Disconnect()
					connectionL = nil
				end
				if connectionR then
					connectionR:Disconnect()
					connectionR = nil
				end
				
				if track == nil then return end

				connectionMain = track:GetMarkerReachedSignal("Footstep"):Connect(function()
					local materialSound = sound:FindFirstChild(tostring(math.random(1, #sound:GetChildren())))
--					print(materialSound.Parent)
					if materialSound and hum.FloorMaterial ~= Enum.Material.Air then
						if sound.Name ~= hum.FloorMaterial.Name then
							materialSound = char.HumanoidRootPart.FootstepSounds[hum.FloorMaterial.Name]:FindFirstChild(tostring(math.random(1, #char.HumanoidRootPart.FootstepSounds[hum.FloorMaterial.Name]:GetChildren())))
						end
						materialSound:Play()
					end
				end)
				connectionL = track:GetMarkerReachedSignal("FootstepL"):Connect(function()
					task.spawn(function()
						if hum.FloorMaterial ~= Enum.Material.Air then
							for _, v in pairs(char["Left Leg"].Footstep:GetChildren()) do
								if v:IsA("ParticleEmitter") then
									wait()
									v:Emit(5)
								end
							end
						end
					end)
				end)
				connectionR = track:GetMarkerReachedSignal("FootstepR"):Connect(function()
					task.spawn(function()
						if hum.FloorMaterial ~= Enum.Material.Air then
							for _, v in pairs(char["Right Leg"].Footstep:GetChildren()) do
								if v:IsA("ParticleEmitter") then
									wait()
									v:Emit(5)
								end
							end
						end
					end)
				end)
			end
			
			animConnection = hum.AnimationPlayed:Connect(function(track) 
				if track.Animation.AnimationId == char.Animations.Walk.AnimationId --[[if new track is the walk anim]] or track.Animation.AnimationId == char.Animations.Sprint.AnimationId --[[if new track is the sprint anim]]  then
					hookTrack(track) 
				end 
				animStopConnection = track.Stopped:Connect(function() 
					for i, newTrack in hum:GetPlayingAnimationTracks() do 
						if (newTrack.Animation.AnimationId == char.Animations.Walk.AnimationId and track.Animation.AnimationId == char.Animations.Sprint.AnimationId) --[[if new track is the walk anim and old track is the sprint]] or (track.Animation.AnimationId == char.Animations.Walk.AnimationId and newTrack.Animation.AnimationId == char.Animations.Sprint.AnimationId) --[[old track is the walk anim and the new track is the sprint]] then
							--override the previous
							hookTrack(newTrack) 
							break
						else
							hookTrack()
						end 
					end 
				end) 
			end)
		end)
	end
end)
1 Like

AnimationPlayed and Animation Stopped are really not the most reliable things to work with, and you should try to void using them in-general as a signal since like… animations playing and stopping more-so depend on external script conditions… like walk speed being 0.


Also have the actual animation handling be done on the client-level, if you want to broadcast it to other devices have it actually do a message to the server to send to other users since Animations are best handled by the client.

Source for this is uhh trust me bro


AI Slop Solution

1. AnimationPlayed is being used as a state machine (incorrect)

Problem:
They are treating animation events as “walk vs sprint state detection,” but animations are an output of movement state, not the source.

Fix:
Move state logic out of AnimationPlayed.

Client-side example:

local isSprinting = false

UserInputService.InputBegan:Connect(function(input)
	if input.KeyCode == Enum.KeyCode.LeftShift then
		isSprinting = true
	end
end)

UserInputService.InputEnded:Connect(function(input)
	if input.KeyCode == Enum.KeyCode.LeftShift then
		isSprinting = false
	end
end)

Then decide animation + footsteps from isSprinting.


2. Multiple connections are stacking inside AnimationPlayed

Problem:
Every AnimationPlayed call creates new:

  • track:GetMarkerReachedSignal connections
  • track.Stopped connections

Old ones are not reliably cleaned up.

Fix:
Store a single active track and disconnect old connections.

local activeTrack
local connections = {}

local function clearConnections()
	for _, c in pairs(connections) do
		c:Disconnect()
	end
	table.clear(connections)
end

3. Stopped event used as a transition detector (unreliable)

Problem:
Stopped does not mean “replaced by another locomotion animation.”
It can fire due to:

  • animation blend out
  • priority override
  • speed changes
  • humanoid state resets

Fix:
Do NOT use Stopped for state transitions.

Instead:

humanoid.AnimationPlayed:Connect(function(track)
	if track.Priority == Enum.AnimationPriority.Movement then
		activeTrack = track
	end
end)

And ignore Stopped entirely.


4. No single “current locomotion track” authority

Problem:
They attempt to resolve conflicts by scanning GetPlayingAnimationTracks inside Stopped, which causes race conditions.

Fix:
Maintain one authoritative track:

local function setActiveTrack(track)
	if activeTrack == track then return end

	clearConnections()
	activeTrack = track

	connections[#connections+1] =
	track:GetMarkerReachedSignal("Footstep"):Connect(onFootstep)
end

5. Footstep system is server-driven unnecessarily

Problem:
Client sends sound objects to server, server plays them.
This causes:

  • replication delay
  • ordering issues
  • unnecessary network dependency
  • race conditions when spammed

Fix:
Do footstep audio on client.

Server should NOT receive:

FireServer(materialSound, char, ws)

Instead:

-- client only
materialSound:Play()

If replication needed:

RemoteEvent:FireServer(materialName, footPosition)

6. Heartbeat polling creates redundant spam

Problem:
Every frame checks:

  • walking state
  • material
  • walk speed changes

This causes unnecessary remote firing.

Fix:
Throttle state changes.

local lastState

local function updateState()
	local state = isSprinting .. hum.FloorMaterial.Name .. hum.WalkSpeed
	if state == lastState then return end
	lastState = state

	-- fire or update footsteps
end

Call via:

  • Humanoid.Running
  • Material change detection via raycast on step, not every frame

7. Material lookup logic is fragile

Problem:
String parsing Enum.Material is unnecessary and slow.

Fix:

local material = hum.FloorMaterial.Name

No tostring/split.


8. Sprint/walk overlap is not handled as priority system

Problem:
They rely on AnimationId comparisons manually.

Fix:
Use explicit priority:

local PRIORITY = {
	Sprint = 2,
	Walk = 1
}

Then:

if newPriority > currentPriority then
	setActiveTrack(track)
end

9. Marker connections can multiply silently

Problem:
Every new track adds:

  • Footstep
  • FootstepL
  • FootstepR

Old ones may still fire.

Fix:
Always clear connections before binding new track.

1 Like

Is this chatgpt’d perchance? I don’t really care it’s working anyway, just a small issue where after I stop sprinting but keep walking, the sounds end up not playing until I stop then start again.
Any idea how this could be fixed?
Client Side:

local rs = game:GetService("ReplicatedStorage")
local ts = game:GetService("TweenService")
local ss = game:GetService("ServerStorage")
local debris = game:GetService("Debris")

local plr = game.Players.LocalPlayer
local char = plr.Character or plr.CharacterAdded:Wait()
local hum = char:WaitForChild("Humanoid")
local rootPart = char:WaitForChild("HumanoidRootPart")
local statsF = char:WaitForChild("Stats")
local sprinting = statsF.Sprinting

local footstep = rs.Events.Footsteps

local activeTrack
local connections = {}

local lastState

local priority = {
	Sprint = 2,
	Walk = 1
}

local newPriority = nil
local currentPriority = 0

local function updateState()
	local state = tostring(sprinting.Value) .. " " .. hum.FloorMaterial.Name .. " " .. hum.WalkSpeed .. " " .. hum.MoveDirection.Magnitude
	if state == lastState then return end
--	print(lastState, state)
	lastState = state

	-- fire or update footsteps
end

local function clearConnections()
	for _, c in pairs(connections) do
		c:Disconnect()
	end
	table.clear(connections)
end

local function onFootstep()
	if not activeTrack then return end
	footstep:FireServer(hum.FloorMaterial)
end

local function setActiveTrack(track)
	if activeTrack == track then return end

	clearConnections()
	activeTrack = track

	connections[#connections+1] = track:GetMarkerReachedSignal("Footstep"):Connect(onFootstep)
	
end

hum.AnimationPlayed:Connect(function(track)
	if track.Animation.AnimationId == script.Parent.Animations.Walk.AnimationId or track.Animation.AnimationId == script.Parent.Animations.Sprint.AnimationId then
		newPriority = priority[track.Name]
		if newPriority > currentPriority then
			currentPriority = newPriority
			setActiveTrack(track)
		end
	end
end)

while wait(.1) do
	if char.Humanoid.MoveDirection.Magnitude <= 0 then
		currentPriority = 0
	end
	updateState()
end

Server:

local rs = game:GetService("ReplicatedStorage")
local ts = game:GetService("TweenService")
local ss = game:GetService("ServerStorage")
local debris = game:GetService("Debris")

local footstep = rs.Events.Footsteps

footstep.OnServerEvent:Connect(function(plr)
	print("footstep server")
	local char = plr.Character
	local hum = char:WaitForChild("Humanoid")
	local rootPart = char:WaitForChild("HumanoidRootPart")
	local sound = rootPart.FootstepSounds[hum.FloorMaterial.Name]

	local materialSound = sound:FindFirstChild(tostring(math.random(1, #sound:GetChildren())))
	--					print(materialSound.Parent)
	if materialSound then
		local matClone = materialSound:Clone()
		matClone.Parent = rootPart
		matClone:Play()
		debris:AddItem(matClone, 3)

	end
end)

The list at the end was (was tempted to label it as AI slop code but decided not to), the top part was me.


Anywho let me take a look real quick and uh…

oh I caught it you have a check

if newPriority > currentPriority then
	currentPriority = newPriority
	setActiveTrack(track)
end

might not downgrade. When sprint stops, the walk animation is already playing so AnimationPlayed doesn’t fire again, leaving you with no active track

TLDR you go from a walk to a sprint, but can’t go back down to a walk.


Try adding this right after the hum.AnimationPlayed connection.

sprinting:GetPropertyChangedSignal("Value"):Connect(function()
	currentPriority = 0

	for _, track in ipairs(hum:GetPlayingAnimationTracks()) do
		if track.Animation.AnimationId == script.Parent.Animations.Walk.AnimationId
		or track.Animation.AnimationId == script.Parent.Animations.Sprint.AnimationId then
			
			local p = priority[track.Name] or 0
			if p > currentPriority then
				currentPriority = p
				setActiveTrack(track)
			end
		end
	end
end)

basically it checks the state of sprinting, and when the property changes value it resets to lowest active priority (walking or none). There’s a-lot cleaner solutions out there, but this should solve the immediate problem (hopefully).

ah right should’ve caught that. Welp, I fixed it by adding a check in the while loop if I’m sprinting then set the active track there. Thanks for the help!
Edit: did this before you edited sooooo…

1 Like

ah great yep :+1:
glad to uh… “help”…

1 Like

The issue you’re running into is a classic animation priority conflict combined with signal timing problems. Here’s what’s likely happening:**The Core Problem:**When animations have overlapping priorities, Roblox can play multiple tracks simultaneously without properly firing .Stopped on the lower-priority track. Your sprint animation (higher priority) is playing over the walk animation, but the walk track never actually stops—it just gets masked. So your .Stopped event never fires, leaving your sound hooked to a “stopped” animation that’s still technically playing.**Solutions:**1. Use GetState() polling instead of relying solely on signals:lualocal lastAnimationId = nilgame:GetService("RunService").Heartbeat:Connect(function() for _, track in pairs(humanoid:GetPlayingAnimationTracks()) do if track.Animation.AnimationId == sprintAnimId and track:IsPlaying() then if lastAnimationId ~= sprintAnimId then -- Rehook sound to sprint lastAnimationId = sprintAnimId end end endend)2. Explicitly stop conflicting animations before playing new ones:luafor _, track in pairs(humanoid:GetPlayingAnimationTracks()) do if track.Priority == Enum.AnimationPriority.Movement then track:Stop() endendanimTrack:Play()3. **Add debouncing to prevent rapid rehooking:**Only rehook sounds if enough time has passed since the last rehook, preventing the spam-animation issue you mentioned.4. Use track.KeyframeReached for sound triggers instead of relying on animation state changes—this gives you precise control over when footsteps play rather than fighting animation priority systems.The polling approach is more reliable for state machines like this because it doesn’t depend on potentially-missed signals when animations overlap.

The issue you’re running into is a classic animation priority conflict combined with signal timing problems. Here’s what’s likely happening:**The Core Problem:**When animations have overlapping priorities, Roblox can play multiple tracks simultaneously without properly firing .Stopped on the lower-priority track. Your sprint animation (higher priority) is playing over the walk animation, but the walk track never actually stops—it just gets masked. So your .Stopped event never fires, leaving your sound hooked to a “stopped” animation that’s still technically playing.**Solutions:**1. Use GetState() polling instead of relying solely on signals:lualocal lastAnimationId = nilgame:GetService("RunService").Heartbeat:Connect(function() for _, track in pairs(humanoid:GetPlayingAnimationTracks()) do if track.Animation.AnimationId == sprintAnimId and track:IsPlaying() then if lastAnimationId ~= sprintAnimId then -- Rehook sound to sprint lastAnimationId = sprintAnimId end end endend)2. Explicitly stop conflicting animations before playing new ones:luafor _, track in pairs(humanoid:GetPlayingAnimationTracks()) do if track.Priority == Enum.AnimationPriority.Movement then track:Stop() endendanimTrack:Play()3. **Add debouncing to prevent rapid rehooking:**Only rehook sounds if enough time has passed since the last rehook, preventing the spam-animation issue you mentioned.4. Use track.KeyframeReached for sound triggers instead of relying on animation state changes—this gives you precise control over when footsteps play rather than fighting animation priority systems.The polling approach is more reliable for state machines like this because it doesn’t depend on potentially-missed signals when animations overlap.

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