Animation events: syncing sound, damage and effects to the right frame
A step that makes a noise before the foot touches the ground is the symptom of a sound fired by a timer. An event marker on the timeline fixes that, and its parameter turns one marker into many.
Produced with AI from the sources cited below, and checked by automated verification against those same sources. No person read this text before publication. Editorial Policy

There is one defect every game with its own animation goes through: the footstep sound comes out slightly before the foot touches the ground. Or the damage happens while the sword is still on the way up. Or the dust appears after the impact.
The cause is almost always the same, and it is not in the animation: the effect was fired by time, not by frame. Somebody measured that the foot lands roughly half a second after the start, wrote a wait into the script, and left it there. It works once and drifts every other time.
The right tool for this exists inside the animation editor itself, and it is the event marker.
The problem
Firing an effect from a timer breaks for three independent reasons, and one is enough.
The first is speed. An animation played with AdjustSpeed set to anything other than one — running faster with a buff, attacking more slowly with a heavy weapon — changes when each frame happens. The timer carries on waiting the same amount.
The second is editing. All it takes is moving a keyframe two frames forward and the number written in the script becomes a lie. And nothing warns you: the script keeps running, the sound keeps coming out, just at the wrong moment.
The third is the most annoying to trace: with two effects in the same movement, adjusting one misaligns the other, and you end up adjusting both in turns.
The event marker solves all three at once because it changes the question. Instead of "how long after the start", it answers "when this frame goes past". In the official definition: you can define event markers along the timeline and detect them with GetMarkerReachedSignal() while the animation runs.
The underlying change is about who is in charge. With a timer, the script decides the moment, and the animation is a video playing alongside — two parallel timelines somebody tries to keep aligned by hand. With a marker, the animation decides, and the script only reacts. There comes to be a single source of truth about when the foot lands, and it is the same file that draws the foot landing.
That has an organisational effect that only shows up later: whoever edits the animation stops needing to tell whoever writes the script. The animator moves the impact two frames, saves, and the game stays right. Without markers, that combination becomes a message, a meeting or a bug — and usually all three, in that order.
Step by step
1. Show the events track. It is not visible by default. Go to the right of the timeline, click the gear icon and tick Show Animation Events. The events bar opens just below the playback controls.
2. Position the scrubber. Drag it to the exact frame where the event should happen. This step comes first on purpose, and it is what gives the rest its precision.
3. Create the marker. On the events track, click Edit Animation Events. In the dialog, click + Add Event and type the event name. The name is what the script will use, so it is worth choosing something stable — FootStep, HitFrame, Landed — and not something so descriptive you will want to change it later.
4. Fill in the parameter, if it makes sense. The Parameter field takes a string, and it is optional. The next section shows what it unlocks.
5. Save. A marker symbol appears on that frame. An event marker is exactly that — the visual indicator of where the event begins. And once created, it can be moved to any frame on the timeline, which is the way out when fine-tuning changes the moment of impact.
6. Listen in the script. Detection in a LocalScript is done by connecting a function to the AnimationTrack's GetMarkerReachedSignal(). The connection is per marker name: each name is a different signal.
The artifact
The router below solves the problem that appears at the third or fourth event: connections scattered through the code, each made somewhere, with nobody knowing how many exist.
It keeps a table of actions by marker name, wires them all to a track at once, and returns the connections for anyone who wants to disconnect later. Adding a new event becomes adding a line to the table.
Reading the parameter is the part worth copying even if the rest is no use: the marker hands over a string, and the documentation says it can be interpreted or converted and used for whatever action you want. Reading it in key=value format separated by commas turns a text field into a small configuration format.
And notice HitFrame: the client does not apply damage. It tells the server the hit frame went past, and the server decides. An animation marker is timing information, never authority.
Common mistakes
The common mistake is creating one marker per occurrence. It is natural: the left foot lands, you create FootStepLeft; the right foot lands, you create FootStepRight. Before long there are eight names in the animation and eight connections in the script.
The documentation points the other way in two sentences. The first: an event once created is available for the whole animation, not only the frame where it was born. The second, in the official example: you can create a FootStep marker at the point where the foot lands, and use the same event when the other foot lands.
What distinguishes the two occurrences is not the name, it is the parameter. Any marker can have a parameter value set in the editor, and it lets you pass a string of your own to GetMarkerReachedSignal() — including a comma-separated one. A FootStep marker with parameter foot=left and another with foot=right do the work of the eight, with a single connection.
To duplicate without retyping: right-click the marker and choose Copy Selected, then right-click another point on the timeline and Paste Events. If the copy's parameter needs to be different, the route is Edit Animation Event in the duplicated marker's context menu.
And here lives a trap that is the same one as with keyframes, and that catches for a second time anyone already caught once: the copied event goes in at the time position nearest to where you clicked — not where the scrubber is. You carefully position the scrubber on the impact frame, right-click somewhere convenient on the bar, and the marker is born in the convenient place. The difference is a few frames, the sound comes out almost right, and nobody suspects the click.
Next step
With the markers in place, it is worth sweeping the code for fixed waits near animation. Any task.wait with a hard-coded number just before or after a :Play() is a candidate to become a marker — and each one that does stops breaking when the animation is edited.
The next step is standardising names across animations. If every attack animation has a HitFrame and every locomotion animation has a FootStep, the same router serves the whole game, and swapping one animation for another stops requiring a script change.
It is a small contract, written in marker names, and it is what separates animation that is art from animation that is a piece of software.
One check worth making routine: after touching any animation that has markers, run it once with a print in the router before any effect. If the marker arrives, it shows up in the output; if it does not, nothing shows up — and "nothing shows up" is exactly the result a renamed, deleted or misplaced marker produces.
That detail deserves attention because the failure here is silent by nature. GetMarkerReachedSignal() connects by name, and a name that does not exist in the animation is not an error: it is a valid connection that never fires. The game runs, the animation plays, and the sound simply does not come out. There is no console message, no exception, nothing to debug — only the absence. Writing FootStep in the editor and Footstep in the script is enough, and the difference of one capital letter appears nowhere.
That is why it is worth keeping marker names in a table on the code side, as the artifact's ACTIONS does, instead of typed loose into each connection. A wrong name in a table is still a wrong name — but at least it is all on one screen, and comparing it with the editor becomes a matter of looking.
Sources
Ready-to-use artifact
-- StarterPlayerScripts (LocalScript): one single place that listens to the
-- markers and routes by event NAME, reading the parameter as "key=value".
--
-- Why route by name instead of connecting case by case: each connection of
-- GetMarkerReachedSignal is per marker NAME. Without a router, every new
-- sound becomes a new connection scattered through the code, and nobody
-- knows any more how many exist on a track.
local Players = game:GetService("Players")
local SoundService = game:GetService("SoundService")
local player = Players.LocalPlayer
local character = player.Character or player.CharacterAdded:Wait()
local humanoid = character:WaitForChild("Humanoid")
local animator = humanoid:WaitForChild("Animator")
-- "sound=step_stone,volume=0.4" -> { sound = "step_stone", volume = "0.4" }
local function readParameter(text)
local fields = {}
if type(text) ~= "string" or text == "" then
return fields
end
for piece in string.gmatch(text, "[^,]+") do
local key, value = string.match(piece, "^%s*(.-)%s*=%s*(.-)%s*$")
if key then
fields[key] = value
else
-- a parameter with no "=" counts as a loose value, in arrival order
table.insert(fields, (string.gsub(piece, "^%s*(.-)%s*$", "%1")))
end
end
return fields
end
-- marker name -> what to do. Adding an event is adding a line HERE.
local ACTIONS = {}
function ACTIONS.FootStep(fields)
local root = character:FindFirstChild("HumanoidRootPart")
if not root then
return
end
local template = SoundService:FindFirstChild(fields.sound or "step_default")
if not template or not template:IsA("Sound") then
warn("FootStep marker asked for a sound that does not exist: " .. tostring(fields.sound))
return
end
local sound = template:Clone()
sound.Volume = tonumber(fields.volume) or template.Volume
sound.Parent = root
sound:Play()
sound.Ended:Once(function()
sound:Destroy()
end)
end
function ACTIONS.HitFrame(fields)
-- THE CLIENT DOES NOT APPLY DAMAGE. It only tells the server the hit
-- frame went past; range, timing and target are validated by the server.
local remote = game:GetService("ReplicatedStorage"):FindFirstChild("HitFrame")
if remote and remote:IsA("RemoteEvent") then
remote:FireServer(fields.part or "right_hand")
end
end
-- Wires a track to every known marker, and returns the connections so
-- whoever loaded it can disconnect later.
local function wire(track)
local connections = {}
for name, action in ACTIONS do
table.insert(
connections,
track:GetMarkerReachedSignal(name):Connect(function(parameter)
local ok, err = pcall(action, readParameter(parameter))
if not ok then
warn("event " .. name .. " failed: " .. tostring(err))
end
end)
)
end
return connections
end
-- example of use
local walk = Instance.new("Animation")
walk.AnimationId = "rbxassetid://5432167890"
local track = animator:LoadAnimation(walk)
wire(track)
track:Play()


