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.
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.
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.
None of the earlier lessons use these. Each one shows the line where it appears here.
The remainder after dividing two decimals, so a time can wrap back to 0 when a loop of music restarts.
var through_beat := fmod(maxf(now, 0.0), SECONDS_PER_BEAT) / SECONDS_PER_BEATHow many seconds into its sound a player is right now.
var position := music.get_playback_position() + AudioServer.get_time_since_last_mix()Godot's sound system. Asked here how late the speakers are, so notes line up with what you hear.
var position := music.get_playback_position() + AudioServer.get_time_since_last_mix()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.
if not event is InputEventKey:Where something first appears in a list, counting from 0, or -1 if it is not there.
var lane := LANE_KEYS.find(int(key.physical_keycode))Changes one part of a UI node's look from code, like a label's font color or font size.
key_label.add_theme_font_size_override("font_size", 26)Lines a label's text up on the left, the center or the right.
key_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER■ UI node · ■ 2D node · % unique name · .tscn instanced scene
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)class_name RhythmHUD
extends CanvasLayer
@onready var combo_label: Label = %ComboLabel
@onready var accuracy_label: Label = %AccuracyLabel
@onready var judgement_label: Label = %JudgementLabel
@onready var home_button: Button = %HomeButton
func _ready() -> void:
home_button.pressed.connect(_on_home_pressed)
## Called BY game.gd after every note is hit or missed.
func set_score(combo: int, best_combo: int, accuracy: float) -> void:
combo_label.text = "%d combo" % combo
accuracy_label.text = "%.0f%% accurate · best combo %d" % [accuracy, best_combo]
## The one big word in the middle. Its colour carries as much of the
## message as the word does, which is why the level picks both.
func set_judgement(text: String, color: Color) -> void:
judgement_label.text = text
judgement_label.add_theme_color_override("font_color", color)
# Loads the lesson list screen, which replaces this whole scene.
func _on_home_pressed() -> void:
get_tree().change_scene_to_file("res://shared/ui/lesson_select.tscn")