Thanks for playing! Glad the keyboard controls worked out, they definitely are not as nice as controller controls haha
PizzaLovers007
Creator of
Recent community posts
Pretty neat! Love me some rhythm games :D
I think the platforming overall was a pretty good difficulty, and seeing the 2nd half change one beats 1 and 2 made it an interesting challenge. I struggled with the camera a bit, and some of the perspectives made jumps difficult (especially the one right after the platforms in the wall), but otherwise this is a solid game!
Super cool concept and really solid execution! It's impressive how you managed to get all the physics working without feeling janky at all (I had ~60 fps the whole time, which probably helped). I would have loved to see more of the object blending incorporated into the gameplay somehow, though I'm sure you had plenty of ideas that you didn't have time to implement.
Pretty neat shadow effect! I think because of it though, the enemies needed some other sort of tell since otherwise you turn the corner and immediately get shot. Maybe like a footstep or other sound? Something that let you know something was there, but not exactly where.
Well, a cactus hiding around the corner ended my run anyway and they don't make sounds lol
Really interesting mechanic! I definitely had a lot of trouble understanding what color it would switch to at the beginning, and even after seeing the compass flash I think this could be communicated better. Maybe if the blurred section itself was colored to what it would be after rotating?
I did complete the main levels, but once I saw a green zone I noped out haha. Great puzzle design overall, and I enjoyed it!
Glad you liked the animation! Art is definitely not one of my strong suits, but I'm pretty happy with how everything turned out in the end. I actually did consider limiting movements to be strictly on the beat, but I think that would have led to more frustrating gameplay that would feel like dropped inputs rather than incorrect timing. Congrats on the 80!
Hey everyone! I wanted to share my experience working with Godot to make a rhythm game and the challenges I encountered with it. Hopefully this devlog passes some useful information and implementation in case you want to make your own rhythm game in Godot.
(Code formatting on itch is unfortunately not very readable, so if you want a better experience you can read it on GitHub here!)
Why rhythm games are difficult (to make)
Besides being difficult to play, rhythm games are also difficult to make. For most game engines, there are two important computing threads for rhythm games: the main thread and the audio thread. The main thread contains all of the game logic like inputs, physics, animations, etc., but most importantly it signals to the audio thread what sounds to play. Unfortunately this communication has some few milliseconds of delay, which for rhythm games can be problematic.
To make matters worse, the internal clocks between the main thread and the audio thread are not synced with each other. For shorter durations, this is not a problem. However, if you start two stopwatches in both threads at the same time, they will eventually go out of sync with each other.
Godot has a great writeup on how you can address these issues as well as going into more detail on Godot's specific audio implementations. This was instrumental knowledge that I needed to program my game.
Goals for my game
With the above in mind, what did I actually need for my game?
- Infinite gameplay
- Obstacles (ants) that move one square forward/backward on the beat
- Sounds that play on the beat
Once I came up with these ideas, it was time to get to work!
Determining when a beat happens
The 1st goal actually dictated which method of syncing I used. While not completely accurate, you can get the approximate timestamp of where you are in a song and use that to get what beat you're on:
# First, get the playback position var time_seconds = $Player.get_playback_position() # Playback position only updates when an audio mix happens, so we can # add the time that has passsed since then time_seconds += AudioServer.get_time_since_last_mix() # The he audio thread is slightly ahead of the actual sound coming out # of the speakers/headphones because of latency, so subtract that time_seconds -= AudioServer.get_output_latency() # Finally, just a bit of math to get the beat var beat = time_seconds / 60 * bpm
Now that I had the beat value, I could tell when the next beat happens by keeping track of the previous time and comparing it to the current time after truncating the decimal. Once the values changed, I could then signal that a beat happened. All this was centralized in a Conductor class:
class_name Conductor
var _prev_time_seconds: float
# Other scripts can connect to this signal to know when the beat happens
# and what beat it was
signal quarter_passed(beat: int)
func _process(delta: float) -> void:
var time_seconds = $Player.get_playback_position() + AudioServer.get_time_since_last_mix() - AudioServer.get_output_latency()
# Validation
if not _is_valid_update(time_seconds):
return
var beat = time_seconds / 60 * bpm
var prev_beat = _prev_time_seconds / 60 * bpm
if floor(beat) > floor(prev_beat):
# Beat happened this frame!
quarter_passed.emit(floor(beat))
# Keep track of the previous frame's time
_prev_time_seconds = time_seconds
With this done, I could connect my ant movement logic to the quarter_passed signal!
You may be asking, why do you need to check if an update is valid? Due to how threading works, the current time can sometimes go backwards, so you need to account for this. There was also some weird bug on web builds that caused time_seconds to be extremely big. So, I didn't process frames where these oddities occurred:
func _is_valid_update(time_seconds: float) -> bool:
return (
# No weird web issue
time_seconds < 1000 and
# Time moved forward
time_seconds > _prev_time_seconds)
It's important to note that the quarter_passed signal doesn't happen exactly on the beat. Signals can only be emitted on frame updates, so some amount of deviation is expected. Having a high framerate makes this better. For my game, this deviation was ok, but if you are writing a game where even this deviation needs to be known, you can adjust the quarter_passed signal to take the non-truncated beat.
Dealing with song loops
With infinite gameplay, I had no choice but to have a looping song. This is actually a valid reason for why the current time goes backwards, so I added some logic to account for this properly.
var _loops: int = 0
var _num_beats_in_song: int = round($Player.stream.get_length() / 60 * bpm)
func _process(delta: float) -> void:
# ...calculate/validate time_seconds
if time_seconds - _prev_time_seconds < -5:
# Loop happened!
_loops += 1
# Make prev time on the same "loop" as the curr time. It's not
# recommended to use song length directly as there can be small
# inaccuracies with audio looping and the song itself
_prev_time_seconds -= num_beats_in_song / bpm * 60
var beat = time_seconds / 60 * bpm
var prev_beat = _prev_time_seconds / 60 * bpm
# Now add additional beats from previous loops
beat += _loops * num_beats_in_song
prev_beat += _loops * num_beats_in_song
# And do the rest as usual...
# Validation needs to change as well
func _is_valid_update(time_seconds: float) -> bool:
return (
# No weird web issue
time_seconds < 1000 and (
# Time moved forward
time_seconds > _prev_time_seconds or
# Loop happened
time_seconds - _prev_time_seconds < -5))
"Scheduling" sounds
Unfortunately, Godot does not support scheduling sounds, so this needs to be done in a more manual way.
Instead of checking if a beat happens *now*, I checked if a beat happens in the *near future*. This can be done by re-adding the output latency back to the current time.
# Similar to quarter_passed, but with audio latency accounted for. Use
# this to schedule sounds on the beat.
signal quarter_will_pass(beat: int)
func _process(delta: float) -> void:
# ...calculate/validate time_seconds
# ...emit quarter_passed signal
# Now adjust the time to be in the future
var latency_in_beats = AudioServer.get_output_latency() / 60 * bpm
beat += latency_in_beats
prev_beat += latency_in_beats
if floor(beat) > floor(prev_beat):
# Beat will happen soon!
quarter_will_pass.emit(floor(beat))
# Keep track of the previous frame's time
_prev_time_seconds = time_seconds
I used this to schedule both the backing metronome as well as the other tick sounds indicating how the ants will move/are moving. Here's a small snippet of the backing metronome:
@onready var conductor: Conductor = get_tree().get_first_node_in_group("conductor")
@onready var hi_tick_player: AudioStreamPlayer = $HiTick
@onready var lo_tick_player: AudioStreamPlayer = $LoTick
func _ready() -> void:
conductor.quarter_will_pass.connect(_on_beat_passed)
func _on_beat_passed(beat: int) -> void:
if beat % 4 == 0:
hi_tick_player.play()
else:
lo_tick_player.play()
Again, having a higher framerate will result in more accurate sounds, but 100% accuracy will always be impossible. Due to the nature of Godot's audio chunking, sounds can only be played at 15ms intervals (by default), and calling Player.play() schedules the sound to be played at the next mix. My approach schedules the sound to be played on the mix just after the true beat time. However, you can technically look further into the future with AudioServer.get_time_to_next_mix() to ensure sounds start on the mix just before the true beat time.
Summary
So, can you make a rhythm game in Godot? Yes! With some limitations:
- Framerate matters! Higher framerates result in more accurate beat signals, so game performance needs to be top notch.
- Scheduling sounds is not yet possible, but this can be somewhat worked around using Godot's audio APIs. Some amount of variance is unavoidable due to audio chunking and a lack of a DSP time/scheduling implementation.
That's all!
If you're curious, the full source code for my Conductor code can be found here on my GitHub.
Also, check out my game here! I'll be happy to check out yours as well :)
Visual representation was honestly the biggest struggle the entire development time haha, I definitely agree even now it's hard to tell visually that you can fit in the diagonal gap. I probably could have figured something out, but alas I was running out of time and needed to work on other mechanics. Thanks for the feedback, and I'm glad you liked it!
Props for writing this in your own engine! I think the map design was really well done, and it was very clear when I needed to return to an area. The actual spike/enemy placement was a bit unfair at time (e.g. leaving the final boss room forces you to take damage), but overall I had fun playing this. The first boss was so rough with the wide spikes haha




