Ever wanted to make a rhythm game, but found the task of synchronizing visuals and player input to a constant beat too daunting?
Well fear no longer! For I have suffered immensely so you hopefully don’t have to, and I’m here to share the fruits of my immense pain with you today.
1. The Basics
First, we start with the backbone of the entire game: the Metronome class.
```
using System.Collections;
using UnityEngine;
public static class Metronome
{
public delegate void MetronomeBeat();
public static event MetronomeBeat OnBeat;
private static float beatsPerMinute = 80f;
public static float secondsBetweenBeats = 0f;
public static double currentBeatTime = 0;
public static double nextBeatTime = 0;
public static bool metronomeStarted = false;
public static bool metronomePaused = false;
public static IEnumerator StartMetronome()
{
Metronome.secondsBetweenBeats = 60.0f / Metronome.beatsPerMinute;
Metronome.nextBeatTime = AudioSettings.dspTime;
Metronome.metronomeStarted = true;
while (true)
{
if (Metronome.metronomePaused == false)
{
double curTime = AudioSettings.dspTime;
if (curTime >= nextBeatTime)
{
Metronome.currentBeatTime = Metronome.nextBeatTime;
Metronome.nextBeatTime += Metronome.secondsBetweenBeats;
if (Metronome.OnBeat != null)
{
Metronome.OnBeat();
}
}
}
else
{
Metronome.nextBeatTime = AudioSettings.dspTime;
}
yield return null;
}
}
public static void ToggleMetronomePause()
{
Metronome.metronomePaused = !Metronome.metronomePaused;
}
public static void UpdateMetronomeTempo(float newBeatsPerMinute)
{
Metronome.beatsPerMinute = newBeatsPerMinute;
Metronome.secondsBetweenBeats = 60.0f / Metronome.beatsPerMinute;
}
}
```
Surprisingly, there’s actually not much going on here in terms of complicated code. Basically we call a coroutine that runs indefinitely and fires off an event every time we hit a beat based on the Beats per Minute (bpm) we specify. We keep a reference to timestamps of the current beat and the next beat for reasons that I’ll explain later. However, there are a few tricky “gotchas” that I’d like to point out.
Gotcha 1: What the heck is dspTIme?
In case you weren’t aware, Unity has a separate Time thread specifically for audio that is sample-based, aka completely frame independent. If you were to use Unity’s main Time thread (using either Time.deltaTime or Time.fixedDeltaTime), the slightest variance in framerate would slowly shift your metronome out of sync. This was a lesson I learned the hard way with my first foray in the “music-based” genre with an earlier LD compo entry I made called Orbitunes. The last thing you want is a frame-dependent rhythm game.
Gotcha 2: Why are you handling “Pause” so weirdly?
For typical Pause functionality, setting Time.timeScale to 0 would effectively stop calls for FixedUpdate() functions, thus pausing your game. It’s quick and a little dirty, but it (mostly) works. However, the dspTime thread can’t be manipulated like that, and is always ticking. If you don’t update the nextBeatTime when you want to pause your metronome, the moment you unpause it the condition (curTime >= nextBeatTime) will fire off a bunch of times until it catches up with the current dspTime, resulting in rapid-fire beats for a few seconds, depending on how long you kept the metronome paused.
Now that we have this Metronome class, anything that subscribes to the event OnBeat() will have a call back that fires exactly in sync with the Metronome. Pretty neat! Now let’s get into some nitty-gritty inputs…
2. The Input Logic
The entirety of the code for input handling is a little overwhelming to look at all at once if you don’t understand the logic of it. You can find the full code for it here, but I’m going to break it down essentially function-by-function in a way that’s hopefully understandable.
First Up!
public void Awake()
{
InputManager.calibrationKeys = new List<double>();
Metronome.OnBeat += this.ProcessBeat;
}
Simple enough. Make sure you subscribe to the Metronome’s OnBeat event so that you can sync to the rhythm. We’ll get to calibrationKeys later.
```
public void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
if (InputManager.calibrationKeys.Count < 20)
{
this.UpdateCalibration();
}
this.adjustedInputTimestamp = AudioSettings.dspTime;
if (this.IsMostRecentInputOnBeat() == true)
{
this.HitSuccess();
}
else
{
this.HitFail();
}
}
}
private void HitSuccess()
{
this.successSound.PlayScheduled(Metronome.currentBeatTime);
if (InputManager.OnHit != null)
{
InputManager.OnHit();
}
}
private void HitFail()
{
this.failSound.PlayScheduled(Metronome.currentBeatTime);
if (InputManager.OnFail != null)
{
InputManager.OnFail();
}
}
```
The Update loop is a little beefier, but still fairly straightforward. For the first 20 inputs (arbitrarily picked number) we calibrate the player’s inputs so that the game “feels right” for whoever plays it, regardless of their reflexes or machine specs. We’ll go over how to do that later. After that, we process every input, determine whether or not it was a “hit” or a “fail”, and fire off the proper event for each case. And that’s all Update does! Now let’s get into the more complicated stuff for actually determining these hits/fails…
```
private bool IsMostRecentInputOnBeat()
{
bool undershootTest = ((Metronome.nextBeatTime - INPUTGRACEBUFFER) <= this.adjustedInputTimestamp);
bool overshootTest = ((Metronome.currentBeatTime + INPUTGRACEBUFFER) >= this.adjustedInputTimestamp);
return (undershootTest || overshootTest);
}
```
Not much code here, but the logic of it might be a bit hard to follow, so let me break it down with a poorly-made timeline graph made in Paint.

Firstly, we have to remember that human reflexes are not only really delayed, but also widely varied. As such, we need to have a “grace window” for player inputs that will evaluate to “on beat” when they are pressed.
Once we have this grace window established, we need to know what to check. When the player hits an input, they can be considered “on beat” if they hit slightly after the current beat (overshoot) or slightly before the next beat (undershoot). Since we store the currentBeat and nextBeat timestamps in the Metronome class, this is super easy. If we detect a valid overshoot or undershoot from the player input, we consider it to be “on beat.” Nice!
…But hold on now, what if we also want to detect a lack of any input on a beat, rather than just an off-beat press? That requires a bit of additional logic that will fire on every beat in-time with the Metronome (ie: it subscribes to Metronome.OnBeat()). Here’s what that logic looks like:
```
private void ProcessBeat()
{
this.clickSound.PlayScheduled(Metronome.currentBeatTime);
StartCoroutine(this.DetectBeatMiss());
}
private IEnumerator DetectBeatMiss()
{
double currentDspTime = Metronome.currentBeatTime;
double endOfGraceBuffer = Metronome.currentBeatTime + INPUTGRACEBUFFER;
//First, wait grace period
while (currentDspTime < endOfGraceBuffer)
{
currentDspTime = AudioSettings.dspTime;
yield return 0;
}
//Then, check to see if the beat was missed.
//It's possible the player hit the beat within the grace window before and after the beat, so checks both sides
if (this.WasBeatMissed())
{
if (InputManager.OnMiss != null)
{
InputManager.OnMiss();
}
}
}
private bool WasBeatMissed()
{
bool withinUndershootThreshold = (this.adjustedInputTimestamp >= (Metronome.currentBeatTime - INPUTGRACEBUFFER));
bool withinOvershootThreshold = (this.adjustedInputTimestamp <= (Metronome.currentBeatTime + INPUTGRACEBUFFER));
return (!withinUndershootThreshold || !withinOvershootThreshold);
}
```
This is a little tricky, because remember there is a short grace window after a beat that the player can hit and still be "in time." Therefore, we have to wait for that grace window to pass before we can detect a miss due to a lack of input.
After that the logic check in WasBeatMissed() looks very similar to IsMostRecentInputOnBeat(), but there’s one key difference. This time, instead of checking for an overshoot of the current beat and an undershoot of the next beat, we are exclusively checking the grace window before and after the current beat! If we don’t detect any input within this grace window, we conclude that there was no input for the current beat, and fire off an OnMiss() event for other scripts to subscribe to and execute code for.
After all of that’s done, you’re pretty much set up for detecting inputs! Hopefully I was able to make it understandable enough, because I had to draw out timelines and work through it on paper dozens of times before I was able to get the logic right.
Now that we have the Metronome and the Input Logic in place, let’s add ooooone more bit of polish to really make rhythm input feel good.
3. Calibration
As I alluded to earlier, differences in computer power and player reflexes can make a rhythm game feel perfect to some, and completely off for others. In order to combat this, we’re going to add a sneaky calibration processing into the player’s first few inputs. You can theoretically do this wherever you’d like, but my team decided to put it during the player’s first 20 inputs, as we have a short cutscene at the beginning of our game, so it’s the perfect place to seamlessly tune the rhythm to the player’s preferences.
Without further ado, let’s get to the code!
First and foremost, you may have noticed in the previous snippets the variable adjustedTimeStamp. This is actually a property in the InputManager with its own special get and set functionality:
```
private double _rawInputTimestamp = 0;
private double adjustedInputTimestamp
{
get
{
return (_rawInputTimestamp - this.calibrationValue);
}
set { _rawInputTimestamp = value; }
}
This is a handy way of making sure we always apply our calculated calibration value with every reference to player input timestamps. But how do we calculate this calibration value exactly? Let’s find out! Remember in our Update function, we’re calling something called UpdateCalibration() for the first 20 inputs. Let’s see what that function is actually doing now.
private void UpdateCalibration()
{
this.GetCalibrationValue();
this.SetCalibrationAverage();
}
private void GetCalibrationValue()
{
double calibrationTimestamp = AudioSettings.dspTime;
double preBeatCalibration = Metronome.nextBeatTime - calibrationTimestamp;
double postBeatCalibration = calibrationTimestamp - Metronome.currentBeatTime;
if (preBeatCalibration < postBeatCalibration)
{
InputManager.calibrationKeys.Add(-preBeatCalibration);
}
else
{
InputManager.calibrationKeys.Add(postBeatCalibration);
}
}
private void SetCalibrationAverage()
{
double runningTotal = 0;
for (int i = 0; i < InputManager.calibrationKeys.Count; i++)
{
runningTotal += InputManager.calibrationKeys[i];
}
this.calibrationValue = (runningTotal / InputManager.calibrationKeys.Count);
}
```
It’s a lot of lines of code, but the logic is fairly easy to follow if you take it slow.
So first, when the player hits input at a time that they feel is “on beat,” we calculate how close they actually are by calculating their overshoot and undershoot values. Depending on which value is smaller, we add that to the list of calibration keys and take the average. The resulting calibrationValue is what we will use to adjust the player’s input timestamp to match the timestamp in dspTime that they think they’re hitting. The result is that the player's “off beat” inputs (according to the computer) are actually processed “on beat” if they feel that way to them. Sweet!
Unfortunately, I wasn’t able to finish this calibration portion in time for submission of Higher Desire, resulting in some slightly borked rhythm keeping. If you’d like to see the metronome working correctly, feel free to check out our Post-Jam version found at the same game page!
In the meantime, this should be enough to get you started making your own rhythm games with proper input support! Hopefully it was useful! :smile:
‘Til next time! :pointright: :cowboy: :pointright: