How to start making a metronome system (like Crypt of the Necrodancer / Cadence of Hyrule)

I want to be able to make a metronome or beat system like in music-based fighting games, such as Hi-Fi Rush or Crypt of the Necrodancer. I have thought of measuring time in between beats and running a script every second or so, but I don’t know if that would work for my methods.

Any help is appreciated. :smiling_face:

Hey! The RenderStepped method of the RunService is a seemingly unlikely but incredible start for this.

In this example I’m using the given parameter of the method, referenced as Delta, which is a number of how long it has been since the previous frame in seconds. (e.g. at 60fps your Delta should average 0.016) This is the most secure way to measure time regardless of performance changes during the game.

local RunService = game:GetService('RunService')
local ContextActionService = game:GetService('ContextActionService')

local BeatsPerMinute = 60 -- Frequency of beats
local ActionTimeInSeconds = 0.25 -- Duration of time before or after precise BPM to give the user a grace period, hitting a frame perfect beat isn't easy but hitting a beat within half a second is p simple.
local TotalTimeInSeconds = 0 -- Keeping track of the time that has passed.

function HitBeat()
    local BeatsPerSecond = BeatsPerMinute / 60
    local BeatDistance = (TotalTimeInSeconds % 1) / BeatsPerSecond
    if BeatDistance <= ActionTimeInSeconds then
        local BeatRating = (BeatDistance / ActionTimeInSeconds) * 100
        print('Beat hit on time! With a rating of: ' .. BeatRating)
    else
        print('Missed!')
    end
end

RunService.RenderStepped:Connect(function(Delta)
    TotalTimeInSeconds += Delta -- Incrementing our total time by Delta

    local BeatsPerSecond = BeatsPerMinute / 60
    local BeatDistance = (TotalTimeInSeconds % 1) / BeatsPerSecond
    if BeatDistance - Delta <= 0 then -- when we reach our beat on time the beat distance should be so low that the Delta subtracted by it is less than 0.
        print('Beat!')
    end
end)

ContextActionService:BindAction('Hit Beat', HitBeat, true, Enum.KeyCode.H, Enum.KeyCode.ButtonY) -- CAS binds the H key to hitting the beat (plus the y button or a mobile button that's auto generated on mobile)

So in this example I showed how you could use RenderStepped to precisely measure time, and determine if a player input was within an acceptable tolerance of that time, as well as providing feedback on every beat, as well as every beat you miss and the rating of how close you were with every beat you hit.

I hope this helps!

2 Likes

Thank you, this is great! I didn’t understand Delta in Renderstepped before this post and now I do. You’re the best for also explaining it so I can use this in the future :smiley: Have a good day!

1 Like

Ok, I’m currently having an issue with the script. here’s my problem:

local RunService = game:GetService('RunService')
local ContextActionService = game:GetService('ContextActionService')

local BeatsPerMinute = 120 -- Frequency of beats
local ActionTimeInSeconds = 0.25 -- Duration of time before or after precise BPM to give the user a grace period, hitting a frame perfect beat isn't easy but hitting a beat within half a second is p simple.
local TotalTimeInSeconds = 0 -- Keeping track of the time that has passed.

function HitBeat()
	local BeatsPerSecond = BeatsPerMinute / 60
	local BeatDistance = (TotalTimeInSeconds % 1) / BeatsPerSecond
	if BeatDistance <= ActionTimeInSeconds then
		local BeatRating = (BeatDistance / ActionTimeInSeconds) * 100
		print('Beat hit on time! With a rating of: ' .. BeatRating)
	else
		print('Missed!')
	end
end

RunService.RenderStepped:Connect(function(Delta)
	TotalTimeInSeconds += Delta -- Incrementing our total time by Delta

	local BeatsPerSecond = BeatsPerMinute / 60
	local BeatDistance = (TotalTimeInSeconds % 1) / BeatsPerSecond
	if BeatDistance - Delta <= 0 then -- when we reach our beat on time the beat distance should be so low that the Delta subtracted by it is less than 0.
		print('Beat!')
	end
end)

ContextActionService:BindAction('Hit Beat', HitBeat, true, Enum.KeyCode.H, Enum.KeyCode.ButtonY)

I’m trying to test a change in BPM for differently paced songs, but when I try this (I’ve changed BeatsPerMinute to 120), instead of the expected result, the metronome still plays at 60 BPM and just runs print('Beat!') twice, probably because 120/2 = 60.

Next, I returned BeatsPerMinute to 60 and tried changing both definitions of BeatDistance to (TotalTimeInSeconds % 0.5) / BeatsPerSecond (Dividing by 2, just like 120). THIS gave me what I was looking for, leading me to the conclusion that, to change BPM, you should use this formula for BeatDistance: (TotalTimeInSeconds % (1 / (BeatsPerMinute / 60))) / BeatsPerSecond.

However, while this does fix the issues with the BPM itself, it’s not fixing the issue of print('Beat!') being run twice. I’m not sure how to proceed from here. Should I add a debounce somehow?

here’s the current script:

local RunService = game:GetService('RunService')
local ContextActionService = game:GetService('ContextActionService')

local BeatsPerMinute = 120 -- Frequency of beats
local ActionTimeInSeconds = 0.25 -- Duration of time before or after precise BPM to give the user a grace period, hitting a frame perfect beat isn't easy but hitting a beat within half a second is p simple.
local TotalTimeInSeconds = 0 -- Keeping track of the time that has passed.

function HitBeat()
	local BeatsPerSecond = BeatsPerMinute / 60
	local BeatDistance = (TotalTimeInSeconds % (1/(BeatsPerMinute / 60))) / BeatsPerSecond
	if BeatDistance <= ActionTimeInSeconds then
		local BeatRating = (BeatDistance / ActionTimeInSeconds) * 100
		print('Beat hit on time! With a rating of: ' .. BeatRating)
	else
		print('Missed!')
	end
end

RunService.RenderStepped:Connect(function(Delta)
	TotalTimeInSeconds += Delta -- Incrementing our total time by Delta

	local BeatsPerSecond = BeatsPerMinute / 60
	local BeatDistance = (TotalTimeInSeconds % (1 / (BeatsPerMinute / 60))) / BeatsPerSecond
	if BeatDistance - Delta <= 0 then -- when we reach our beat on time the beat distance should be so low that the Delta subtracted by it is less than 0.
		print('Beat!')
	end
end)

ContextActionService:BindAction('Hit Beat', HitBeat, true, Enum.KeyCode.H, Enum.KeyCode.ButtonY)

sorry for the long long reply

Never mind. I fixed it in a surprisingly simple way:
I added a beatdebounce variable and made it false. it becomes true if BeatDistance - Delta <= 0 and false otherwise. This works like a charm. (stuff like 200 BPM still repeat twice sometimes though.)

if BeatDistance - Delta <= 0 and not beatdebounce then -- when we reach our beat on time the beat distance should be so low that the Delta subtracted by it is less than 0.
		beatdebounce = true
		print('Beat!')
	else
		beatdebounce = false
	end

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