Unity Rhythm System - BeatDown Deep Dive
Brief Overview of The Game
BeatDown is a rhythm-based 2D party game made in the Spring of 2021. It was made in Unity 2D. In BeatDown, you control a shape on top of a grid of tiles. When the match starts, a song plays and you have to tap to the beat to make moves such as moving, charging, using a powerup, or a devastating ultimate ability. While there are some very cool parts and visuals of this game (I encourage you to check it out and play it for a bit), the feature I will be talking about today is the rhythm system.
Overview/What to Look At
If you are interested in building your own rhythm system for a rhythm game feel free to look through BeatDown’s files. Pay close attention to the scripts inside Assets\Scripts\Rhythm. Conductor.cs is the heart of the rhythm system as I’ll explain later, and Measure.cs, SongTapPattern.cs, and SongData.cs are all Scriptable Object classes used for facilitating and building a unique rhythm within the Unity editor, and bundling it with the particular song. These 3 heavily use each other, and depending on your game or coding prowess you may not need 3 separate scripts to organize this, that’s just how I broke it down.
If you want to look at how the conductor/rhythm system interacts with something physical/visual in the game, you can peek at the scripts inside Assets\Scripts\Beatbar. The Beatbar is the bar at the top of the screen that spawns the diamonds (tap indicators) that move across the screen and tell you when you can use a move and be considered on-beat. As such there is heavy linking between the conductor and the movement of these tap indicators to make sure they are all moving correctly on time and as designed within the custom Song Tap Pattern created for the current song.
For any rhythm-based game you desire to make, you’ll probably need some sort of system for the rhythm. It might track when the song starts/stops/pauses, how many beats have passed, what the tempo/bpm is currently and other data. For this I made a class known as the “Conductor”, following largely from this resource I found online. The Conductor controls when the song starts and keeps track of data regarding the song’s position, which can be used in a variety of ways depending on your rhythm game:
For keeping track of these important data points, look inside the Conductor’s Update() function. For the most part it is what you expect, except maybe:
Use AudioSettings.dspTime when grabbing time for these calculations, it's much more accurate, and accuracy is important!
Having to do rhythm-based things prior to the start of the actual song can complicate things (in my case spawning tap indicators on the BeatBar during the countdown so players can anticipate when to start tapping before the song starts)
Having a pause feature available during this time can complicate things
If you think you can design a system for this that’s different, give it a shot, but one core tip I’ll say is when designing anything Never assume the previous beat was on time. In other words, do not base your calculations for when future beats will occur based on past beats. Doing this can cause issues for a number of reasons:
Depending on a player’s computer, frame rates may not be smooth. If the computer hangs particularly long on the frame it passes a beat, it’s gonna record that beat late.
There are simply floating point inaccuracies for your time data. The computer can only store so many digits for your time data, at some point it has to round or truncate, leading to an (albeit small) inaccuracy.
These inaccuracies may be really small and unnoticeable at first, but what you’ll see is that if you’ve built your system without heeding to that advice, every time a beat happens the inaccuracies will stack up and the problem will begin to snowball, with the beats slowly getting more and more off track as the song goes.
Depending on your rhythm game, you may not need to get as complicated as my system does. Plenty of rhythm games simply ask the player to tap to the beat, essentially tapping quarter notes the entire song to the song’s bpm (See Crypt of the NecroDancer for an example). Depending on your game’s design this may be desirable. If the player already has to worry about enough things happening outside of just tapping on time, adding more complex rhythms could make the game too difficult/stressful. I mostly did this because I wanted to try it, and I recognize that some of BeatDown’s later levels are very hard, in part because of difficult rhythms.
If you are unfamiliar with how rhythm works in music, maybe don’t worry about subdivision just yet. I designed my system mainly off of my understanding of rhythm from sheet music.
The system for complex rhythm lies within the Scriptable Objects SongTapPattern.cs and Measure.cs. SongData.cs is also important as it links the actual song file as well as bpm/offset/tempo changes in the song to the pattern. If you are unfamiliar with Scriptable Objects in Unity I’d recommend watching this video real quick. I chose scriptable objects for these pieces because I wanted people to be able to make their own custom rhythms/tap patterns for songs without having to type any code, and this system lets you do that by typing data into fields in the Unity editor.
The comments inside these files should do a good job explaining them, but the gist is that I represent rhythms in music with an array of bools (the tapsArray in a Measure). True means the player can tap at that time, false means not. The beauty is in the simplicity yet flexibility of this. Essentially you can split measures up as much as you need to capture the complexity of the beat using the beatResolution in the SongTapPattern. If you know music, this should be reminding you of a Time Signature. One key thing of note is that while this can handle all sorts of different time signature songs (4/4, 3/4, 5/4, 7/8, etc.) It’s important all the measures use the same beat resolution. This means while you can represent the rhythm:
Using only a beat resolution of 4, if at some point in your song you need eighth notes to make your tap pattern:
Then all your measures will need to be using that lowest resolution. Thankfully this doesn’t actually limit what you can make:
Just how you write it.
Also note: while this system can handle songs in any time signature, it cannot (currently) handle triplets. Theoretically, there should be a way to add that feature if you desire though. Be prepared to do some math though…
Feel free to browse the Assets\SongData folder to look at examples. Try comparing them to the actual levels in-game, and see if they are sending tap indicators at the times you are expecting them to, based on your understanding of their tap patterns.
Movement of GameObjects based on rhythm
Since I recognize a lot of this is very abstract (believe me I spent a TON of time looking at ceiling trying to picture all this in my head when designing the systems) It might be a good idea to look at the scripts for the BeatBar, and how the rhythm system tells the beatbar when to spawn tap indicators (diamonds) and when/how fast to move them.
I want to sincerely apologize for this code in Conductor.cs:
This is where the tap indicators are told to spawn. If you are wondering what 2.588616603 is, it’s the magic number that keeps BeatDown running. Seriously though I’m not sure I came up with that number, but ideally you shouldn’t need a constant like that in its place. For most of BeatDown I didn’t need it, but after I had to restructure the rhythm system at one point I was panicking and that’s what I did. I think that constant might be derived from a distance somewhere on the BeatBar start and end points, but I’m honestly not sure. In all likeliness your game will not have a beatbar just like mine anyway, so you’ll probably want to invent your own way of spawning stuff on beats anyway.
If you’re wondering how to get from your songData to having your conductor actually recognize when certain taps happen or not, look at CreateFullSongTapPattern(). It basically just uses math to make one big bool array for the entire song for when taps happen. That is then fed into CreateTimesIntoSongToSpawnTapArray() which uses the song’s bpm and the beatResolution to calculate and fill out a list of the time in seconds (since the start of the song) of every true in that array, aka every time the player will be expected to tap during the song. From here, you can check in every call to Update() if the current song time has passed the song time for the next tap in that array, and then perform whatever logic your game demands for a tap, making sure to iterate in your list of times to check for the next time, on the future calls of Update().
As for the actual movement of stuff on beat, you might not have literal diamonds or tap indicators moving across the screen, but in a rhythm-based game you will most likely have something. In my case though, I had diamonds that would spawn 2 beats in advance (an arbitrary number, that in theory should scale with the adjustment of the beatsShownInAdvance variable in BeatBar.cs) and would have to move at the right speed to be right over the bar at the time players needed to tap. After this point, they would keep moving for a little bit, at the same speed, until they reached the end of the BeatBar and despawned.
My first approach was to do some quick maths and calculate an appropriate velocity to apply to the tap Indicators based on the songs bpm + beatResolution. I spent a lot of time on this solution, but it wound up being less than desirable. The tap indicators weren’t quite getting there at the right time. I think this might be in part due to precision errors when setting the velocity, which can then result in a snowballing problem like I mentioned before. Depending on your game and how important it is the movement be exactly in-sync with the beat, you might be able to get away with this approach. For me though, these tap indicators determine whether or not the player taps on-time (yes that’s right, instead of checking times for whether or not a player tapped on beat, it checks the distance between the tap indicator and that middle line) so it was crucial they felt right.
The best solution that I found was Interpolation. Fancy word, a little scary to some maybe but it’s actually quite simple in theory. The Lerp function takes in a start number, an end number, and a number from 0 to 1, and will return the appropriate number in between those start and end numbers that matches that percentage you put in. For example Lerp(0, 4, 0.75) would return 3, because 3 is 75% of the way from 0 to 4, if that makes sense. In my case I used Unity’s Vector2.Lerp() since I wanted to lerp from the start point of my tap indicator (left side of BeatBar) to the end point (right side of BeatBar). The third parameter, the percentage, was calculated based on well, a lot of stuff to do with the timing. Honestly there might be a simpler way (and believe me I was scratching my head forever to figure this out) but this works:
There are more complexities in there due to the option for pausing, and the fact I had to have the tap Indicator move a bit past the actual spot where you tap on it, so you can see it go red if you miss and then get deleted as it hits the true end of the BeatBar.
I hope this was helpful to anyone looking to make a rhythm game in Unity, or even just a rhythm game in general. I know the codebase isn’t the best (this was my first real game in Unity, and first time leading a team) but I hope you could gather the best parts and reuse them or at least reuse their concepts/approach for your own project. The point of these deep dives is to build a knowledge base and help people not fall into the same pits I did, so you don’t waste crucial development time going down a rabbit hole that’s not gonna yield the result you’re looking for. This is actually the first deep dive for VGDev ever written, so let me know if there’s anything that you feel is missing. Hopefully you will be able to figure out anything my words here did not explain in detail by looking inside the aforementioned scripts and playing and testing BeatDown out.