Lesson 33

Rhythm

Four keys and an eight second loop. Nothing here asks where you are, only when you pressed, and it is measured in hundredths of a second.

Loading the game engine. This takes a moment the first time.

Play it first, then read this. Four keys, D F J K, and the notes are on the beat, so if you can nod along you can already play it.

Every other lesson in this course asks WHERE. This one only ever asks WHEN. Your position is fixed, there is nothing to dodge, and the only thing being measured is how close to the beat your finger was, in hundredths of a second.

WHAT TO NOTICE WHILE YOU PLAY

THE SONG LEADS
You cannot hurry the game up or slow it down. The music decides the pace and the notes just arrive. The song IS the level.
HOW CLOSE
Hitting a note is not enough. Perfect, Good and Late are the same key press at three different moments.
IT REPEATS
The chart is eight seconds long and starts over forever. Once you have heard it twice you can play it nearly blind, which is what learning a chart means.
ONLY TIME
Miss on purpose and nothing chases you, nothing hurts you and nothing moves. The combo just goes to zero.

WHY THE CLOCK COMES FROM THE SONG

The obvious way to keep time is to add up delta every frame. Do that and it drifts: a frame that took a moment too long is time the counter never gets back, and after a minute the notes are visibly early. So the clock here is read from the SONG instead, with get_playback_position(). Two corrections go on top of it: the audio is mixed in blocks, so the position is a fraction stale, and speakers add a delay of their own. get_time_since_last_mix() and get_output_latency() are those two numbers. Getting this right is most of what makes a rhythm game feel fair, and it is why the whole style has a reputation for being hard to build.

WHERE THE NOTES COME FROM

CHART is a list of [beat, lane] pairs, written in BEATS rather than seconds. One beat is 0.5 seconds at 120 BPM, so beat 6.5 is 3.25 seconds in. Writing a chart in beats is what lets you change the tempo of a song without rewriting every note in it.

New code in this lesson

None of the earlier lessons use these. Each one shows the line where it appears here.

fmod()

The remainder after dividing two decimals, so a time can wrap back to 0 when a loop of music restarts.

game.gd
var through_beat := fmod(maxf(now, 0.0), SECONDS_PER_BEAT) / SECONDS_PER_BEAT

get_playback_position()

How many seconds into its sound a player is right now.

game.gd
var position := music.get_playback_position() + AudioServer.get_time_since_last_mix()

AudioServer

Godot's sound system. Asked here how late the speakers are, so notes line up with what you hear.

game.gd
var position := music.get_playback_position() + AudioServer.get_time_since_last_mix()

InputEventKey

What Godot sends when a keyboard key goes down or up. physical_keycode says which key, and echo is true when a held key repeats.

game.gd
if not event is InputEventKey:

find()

Where something first appears in a list, counting from 0, or -1 if it is not there.

game.gd
var lane := LANE_KEYS.find(int(key.physical_keycode))

add_theme_..._override()

Changes one part of a UI node's look from code, like a label's font color or font size.

game.gd
key_label.add_theme_font_size_override("font_size", 26)

horizontal_alignment

Lines a label's text up on the left, the center or the right.

game.gd
key_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER

The scenes

■ UI node · ■ 2D node · % unique name · .tscn instanced scene

game.tscn
  • Game Node2D game.gd
  • Lanes Node2D
  • HitLine ColorRect
  • Notes Node2D
  • Music AudioStreamPlayer
  • HUD CanvasLayer hud.tscn
hud.tscn
  • HUD CanvasLayer hud.gd
  • Readouts VBoxContainer
  • ComboLabel Label %
  • AccuracyLabel Label %
  • JudgementLabel Label %
  • Hint Label

FileSystem

  • game.tscn
  • game.gd
  • hud.tscn
  • hud.gd

The code

extends Node2D

# One beat at 120 BPM, and the length of the loop in music.wav. Both come
# from scripts/video-game-design/make-rhythm-loop.py, and the three numbers
# have to agree or the notes drift away from the drums.
const SECONDS_PER_BEAT: float = 0.5
const LOOP_BEATS: int = 16
const LOOP_SECONDS: float = LOOP_BEATS * SECONDS_PER_BEAT

# The chart, in [beat, lane] pairs. Lane 0 is the far left.
const CHART: Array[Array] = [
	[0.0, 0], [1.0, 1], [2.0, 2], [3.0, 3],
	[4.0, 3], [5.0, 2], [6.0, 1], [7.0, 0],
	[8.0, 0], [8.5, 1], [9.0, 2], [10.0, 3], [10.5, 2], [11.0, 1],
	[12.0, 0], [12.5, 3], [13.0, 1], [13.5, 2],
	[14.0, 0], [14.5, 3], [15.0, 1], [15.5, 2]
]

# D F J K. The project's input map has no action called "lane 2", so this
# lesson reads the four keys directly. physical_keycode is the key's spot
# on the keyboard rather than the letter printed on it, which is what the
# project's own movement actions use too.
const LANE_KEYS: Array[int] = [KEY_D, KEY_F, KEY_J, KEY_K]
const LANE_NAMES: Array[String] = ["D", "F", "J", "K"]
const LANE_X: Array[float] = [336.0, 456.0, 576.0, 696.0]
const LANE_COLORS: Array[Color] = [
	Color(0.35, 0.72, 0.95),
	Color(0.45, 0.85, 0.6),
	Color(0.95, 0.72, 0.35),
	Color(0.9, 0.45, 0.7)
]

## Where the notes have to be when you press. The line you can see.
const HIT_Y: float = 500.0
## How fast notes fall, in pixels a second. This changes how much warning
## you get, and nothing else. It is not difficulty, it is readability.
@export var fall_speed: float = 420.0

const PERFECT_WINDOW: float = 0.055
const GOOD_WINDOW: float = 0.11
const MISS_WINDOW: float = 0.22

# Every note still waiting to be judged: when it is due, which lane it is
# in, and the rectangle showing it.
var notes: Array[Dictionary] = []

# Which pass through the eight second loop we are on. The song's own clock
# resets every loop, so this is what turns it back into a rising number.
var loop_index: int = 0
var last_position: float = 0.0

var combo: int = 0
var best_combo: int = 0
var judged: int = 0
var score: float = 0.0

@onready var music: AudioStreamPlayer = $Music
@onready var note_layer: Node2D = $Notes
@onready var hit_line: ColorRect = $HitLine
@onready var hud: RhythmHUD = $HUD

func _ready() -> void:
	_build_lanes()
	music.play()
	# Charting starts at loop 1, not loop 0, so the first eight seconds are
	# the drums on their own. You get a whole bar to find the beat before
	# anything is asked of you, which is how every rhythm game opens.
	_chart_loop(1)
	_chart_loop(2)
	hud.set_score(0, 0, 100.0)
	hud.set_judgement("Press D F J K on the beat", Color(0.7, 0.73, 0.8))

func _process(_delta: float) -> void:
	var now := _song_time()
	_watch_for_loop()

	# Every note is placed from the same sum: how long until it is due,
	# times how fast notes fall. Nothing is animated and nothing moves
	# itself, so a note can never drift away from its own moment.
	for note: Dictionary in notes:
		var node := note["node"] as ColorRect
		node.position.y = HIT_Y - (float(note["time"]) - now) * fall_speed - 11.0

	_drop_missed_notes(now)

	# The hit line pulses once a beat, so the beat is visible as well as
	# audible. fmod gives how far through the current beat we are.
	var through_beat := fmod(maxf(now, 0.0), SECONDS_PER_BEAT) / SECONDS_PER_BEAT
	hit_line.color = Color(0.85, 0.88, 0.95, 0.25 + 0.5 * (1.0 - through_beat))

# The song's clock, corrected twice. See the note at the top of the file.
func _song_time() -> float:
	var position := music.get_playback_position() + AudioServer.get_time_since_last_mix()
	position -= AudioServer.get_output_latency()
	return loop_index * LOOP_SECONDS + position

# get_playback_position() jumps back to nearly zero every time the loop
# comes round. A big jump backwards is the only signal that happened.
func _watch_for_loop() -> void:
	var position := music.get_playback_position()
	if position < last_position - 1.0:
		loop_index += 1
		# Always two loops ahead, so there is never a gap in the notes
		# falling in from the top of the screen.
		_chart_loop(loop_index + 2)
	last_position = position

# One pass of the chart, shifted forward by however many loops have gone
# by. The same 22 notes, a new eight seconds later.
func _chart_loop(loop: int) -> void:
	for pair: Array in CHART:
		var lane: int = int(pair[1])
		var time: float = loop * LOOP_SECONDS + float(pair[0]) * SECONDS_PER_BEAT
		var node := ColorRect.new()
		node.color = LANE_COLORS[lane]
		node.size = Vector2(100, 22)
		node.position = Vector2(LANE_X[lane] - 50.0, -200.0)
		note_layer.add_child(node)
		notes.append({"time": time, "lane": lane, "node": node})

func _unhandled_input(event: InputEvent) -> void:
	if not event is InputEventKey:
		return
	var key := event as InputEventKey
	if not key.pressed or key.echo:
		return
	var lane := LANE_KEYS.find(int(key.physical_keycode))
	if lane != -1:
		_judge(lane)

# Find the note in this lane closest to right now, then grade the press by
# how far off it was. Everything about this style lives in these lines.
func _judge(lane: int) -> void:
	var now := _song_time()
	var best := -1
	var gap := MISS_WINDOW
	for i: int in notes.size():
		if int(notes[i]["lane"]) != lane:
			continue
		var distance := absf(float(notes[i]["time"]) - now)
		if distance <= gap:
			gap = distance
			best = i
	# A press with no note anywhere near it is not punished. There is
	# nothing to punish: you cannot be hit in this style.
	if best == -1:
		return

	if gap <= PERFECT_WINDOW:
		_record(1.0, "Perfect", Color(0.5, 0.9, 0.65))
	elif gap <= GOOD_WINDOW:
		_record(0.7, "Good", Color(0.55, 0.78, 0.95))
	else:
		_record(0.4, "Late", Color(0.9, 0.75, 0.4))

	(notes[best]["node"] as Node).queue_free()
	notes.remove_at(best)

# A note nobody pressed. Once it is further past the line than the widest
# window, it can never be hit, so it counts as a miss and goes.
func _drop_missed_notes(now: float) -> void:
	for i: int in range(notes.size() - 1, -1, -1):
		if now - float(notes[i]["time"]) > MISS_WINDOW:
			(notes[i]["node"] as Node).queue_free()
			notes.remove_at(i)
			_record(0.0, "Miss", Color(0.85, 0.4, 0.42))

func _record(worth: float, label: String, color: Color) -> void:
	judged += 1
	score += worth
	if worth <= 0.0:
		combo = 0
	else:
		combo += 1
		best_combo = maxi(best_combo, combo)
	hud.set_judgement(label, color)
	hud.set_score(combo, best_combo, 100.0 * score / judged)

# The lane guides and the key letters under the line. Built from the same
# arrays the notes use, so a fifth lane would be one entry in each.
func _build_lanes() -> void:
	for lane: int in LANE_X.size():
		var guide := ColorRect.new()
		guide.color = Color(0.14, 0.15, 0.19)
		guide.size = Vector2(110, HIT_Y + 30.0)
		guide.position = Vector2(LANE_X[lane] - 55.0, 0.0)
		$Lanes.add_child(guide)

		var key_label := Label.new()
		key_label.text = LANE_NAMES[lane]
		key_label.add_theme_font_size_override("font_size", 26)
		key_label.add_theme_color_override("font_color", LANE_COLORS[lane])
		key_label.size = Vector2(110, 40)
		key_label.position = Vector2(LANE_X[lane] - 55.0, HIT_Y + 30.0)
		key_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
		$Lanes.add_child(key_label)