- Genre: Rhythm
- Expert blueprint for rhythm games emphasizing audio-visual synchronization and flow state.
- NEVER Do
- NEVER skip latency compensation
- — Use
- AudioServer.get_time_since_last_mix()
- to sync visuals with audio. Missing this causes desync.
- NEVER use
- _process
- for input
- — Use
- _input()
- for precise timing. Frame-dependent input causes missed notes.
- NEVER forget offset calibration
- — Audio hardware latency varies (10-200ms). Provide player-adjustable offset setting.
- NEVER tight timing windows on low difficulty
- — Perfect: 25ms, Great: 50ms is for experts. Beginners need 100-150ms windows.
- NEVER decouple input from audio
- — Input timing must reference MusicConductor.song_position, not frame time. Framerate drops shouldn't cause misses.
- Available Scripts
- MANDATORY
- Read the appropriate script before implementing the corresponding pattern. conductor_sync.gd BPM conductor with AudioServer latency compensation. Emits beat_hit/measure_hit signals for audio-synced game logic. rhythm_chart_parser.gd JSON chart loader with time-sorted notes. Provides optimized get_notes_in_range() for efficient note querying in highways. Core Loop Music Plays → Notes Appear → Player Inputs → Timing Judged → Score/Feedback → Combo Builds Skill Chain godot-project-foundations , godot-input-handling , sound-manager , animation , ui-framework Audio Synchronization THE most critical aspect - notes MUST align perfectly with audio. Music Time System class_name MusicConductor extends Node signal beat ( beat_number : int ) signal measure ( measure_number : int ) @ export var bpm := 120.0 @ export var music : AudioStream var seconds_per_beat : float var song_position : float = 0.0
In seconds
var song_position_in_beats : float = 0.0 var last_reported_beat : int = 0 @ onready var audio_player : AudioStreamPlayer func _ready ( ) -> void : seconds_per_beat = 60.0 / bpm audio_player . stream = music func _process ( _delta : float ) -> void :
Get precise audio position with latency compensation
song_position
audio_player . get_playback_position ( ) + AudioServer . get_time_since_last_mix ( )
Convert to beats
song_position_in_beats
song_position / seconds_per_beat
Emit beat signals
var current_beat := int ( song_position_in_beats ) if current_beat
last_reported_beat : beat . emit ( current_beat ) if current_beat % 4 == 0 : measure . emit ( current_beat / 4 ) last_reported_beat = current_beat func start_song ( ) -> void : audio_player . play ( ) song_position = 0.0 last_reported_beat = 0 func beats_to_seconds ( beats : float ) -> float : return beats * seconds_per_beat func seconds_to_beats ( secs : float ) -> float : return secs / seconds_per_beat Note System Note Data Structure class_name NoteData extends Resource @ export var beat_time : float
When to hit (in beats)
@ export var lane : int
Which input lane (0-3 for 4-key, etc.)
@ export var note_type : NoteType @ export var hold_duration : float = 0.0
For hold notes (in beats)
enum NoteType { TAP , HOLD , SLIDE , FLICK } Chart/Beatmap Loading class_name ChartLoader extends Node func load_chart ( chart_path : String ) -> Array [ NoteData ] : var notes : Array [ NoteData ] = [ ] var file := FileAccess . open ( chart_path , FileAccess . READ ) while not file . eof_reached ( ) : var line := file . get_line ( ) if line . is_empty ( ) or line . begins_with ( "#" ) : continue var parts := line . split ( "," ) var note := NoteData . new ( ) note . beat_time = float ( parts [ 0 ] ) note . lane = int ( parts [ 1 ] ) note . note_type = NoteType . get ( parts [ 2 ] ) if parts . size ( )
2 else NoteType . TAP note . hold_duration = float ( parts [ 3 ] ) if parts . size ( )
3 else 0.0 notes . append ( note ) notes . sort_custom ( func ( a , b ) : return a . beat_time < b . beat_time ) return notes Note Highway / Receptor class_name NoteHighway extends Control @ export var scroll_speed := 500.0
Pixels per second
@ export var hit_position_y := 100.0
From bottom
@ export var note_scene : PackedScene @ export var look_ahead_beats := 4.0 var active_notes : Array [ NoteVisual ] = [ ] var chart : Array [ NoteData ] var next_note_index : int = 0 func _process ( _delta : float ) -> void : spawn_upcoming_notes ( ) update_note_positions ( ) func spawn_upcoming_notes ( ) -> void : var look_ahead_time := MusicConductor . song_position_in_beats + look_ahead_beats while next_note_index < chart . size ( ) : var note_data := chart [ next_note_index ] if note_data . beat_time
look_ahead_time : break var note_visual := note_scene . instantiate ( ) as NoteVisual note_visual . setup ( note_data ) note_visual . position . x = get_lane_x ( note_data . lane ) add_child ( note_visual ) active_notes . append ( note_visual ) next_note_index += 1 func update_note_positions ( ) -> void : for note in active_notes : var beats_until_hit := note . data . beat_time - MusicConductor . song_position_in_beats var seconds_until_hit := MusicConductor . beats_to_seconds ( beats_until_hit )
Note scrolls down from top
note . position . y = ( size . y - hit_position_y ) - ( seconds_until_hit * scroll_speed )
Remove if too far past
if note . position . y
size . y + 100 : if not note . was_hit : register_miss ( note . data ) note . queue_free ( ) active_notes . erase ( note ) Timing Judgment class_name JudgmentSystem extends Node signal note_judged ( judgment : Judgment , note : NoteData ) enum Judgment { PERFECT , GREAT , GOOD , BAD , MISS }
Timing windows in milliseconds (symmetric around hit time)
const WINDOWS := { Judgment . PERFECT : 25.0 , Judgment . GREAT : 50.0 , Judgment . GOOD : 100.0 , Judgment . BAD : 150.0 } func judge_input ( input_time : float , note_time : float ) -> Judgment : var difference := abs ( input_time - note_time ) * 1000.0
ms
if difference <= WINDOWS [ Judgment . PERFECT ] : return Judgment . PERFECT elif difference <= WINDOWS [ Judgment . GREAT ] : return Judgment . GREAT elif difference <= WINDOWS [ Judgment . GOOD ] : return Judgment . GOOD elif difference <= WINDOWS [ Judgment . BAD ] : return Judgment . BAD else : return Judgment . MISS func get_timing_offset ( input_time : float , note_time : float ) -> float :
Positive = late, Negative = early
return ( input_time - note_time ) * 1000.0 Scoring System class_name RhythmScoring extends Node signal score_changed ( new_score : int ) signal combo_changed ( new_combo : int ) signal combo_broken const JUDGMENT_SCORES := { Judgment . PERFECT : 100 , Judgment . GREAT : 75 , Judgment . GOOD : 50 , Judgment . BAD : 25 , Judgment . MISS : 0 } const COMBO_MULTIPLIER_THRESHOLDS := { 10 : 1.5 , 25 : 2.0 , 50 : 2.5 , 100 : 3.0 } var score : int = 0 var combo : int = 0 var max_combo : int = 0 func register_judgment ( judgment : Judgment ) -> void : if judgment == Judgment . MISS : if combo
0 : combo_broken . emit ( ) combo = 0 else : combo += 1 max_combo = max ( max_combo , combo ) var base_score := JUDGMENT_SCORES [ judgment ] var multiplier := get_combo_multiplier ( ) var earned := int ( base_score * multiplier ) score += earned score_changed . emit ( score ) combo_changed . emit ( combo ) func get_combo_multiplier ( ) -> float : var mult := 1.0 for threshold in COMBO_MULTIPLIER_THRESHOLDS : if combo = threshold : mult = COMBO_MULTIPLIER_THRESHOLDS [ threshold ] return mult Input Processing class_name RhythmInput extends Node @ export var lane_actions : Array [ StringName ] = [ & "lane_0" , & "lane_1" , & "lane_2" , & "lane_3" ] var held_notes : Dictionary = { }
lane: NoteData for hold notes
func _input ( event : InputEvent ) -> void : for i in lane_actions . size ( ) : if event . is_action_pressed ( lane_actions [ i ] ) : process_lane_press ( i ) elif event . is_action_released ( lane_actions [ i ] ) : process_lane_release ( i ) func process_lane_press ( lane : int ) -> void : var current_time := MusicConductor . song_position var closest_note := find_closest_note_in_lane ( lane , current_time ) if closest_note : var note_time := MusicConductor . beats_to_seconds ( closest_note . beat_time ) var judgment := JudgmentSystem . judge_input ( current_time , note_time ) if judgment != Judgment . MISS : hit_note ( closest_note , judgment ) if closest_note . note_type == NoteType . HOLD : held_notes [ lane ] = closest_note func process_lane_release ( lane : int ) -> void : if held_notes . has ( lane ) : var hold_note := held_notes [ lane ] var hold_end_time := hold_note . beat_time + hold_note . hold_duration var current_beat := MusicConductor . song_position_in_beats
Check if released at correct time
if abs ( current_beat - hold_end_time ) < 0.25 :
Quarter beat tolerance
- complete_hold_note
- (
- hold_note
- )
- else
- :
- drop_hold_note
- (
- hold_note
- )
- held_notes
- .
- erase
- (
- lane
- )
- Visual Feedback
- func
- show_judgment_splash
- (
- judgment
- :
- Judgment
- ,
- position
- :
- Vector2
- )
- ->
- void
- :
- var
- splash
- :=
- judgment_sprites
- [
- judgment
- ]
- .
- instantiate
- (
- )
- splash
- .
- position
- =
- position
- add_child
- (
- splash
- )
- var
- tween
- :=
- create_tween
- (
- )
- tween
- .
- tween_property
- (
- splash
- ,
- "scale"
- ,
- Vector2
- (
- 1.2
- ,
- 1.2
- )
- ,
- 0.1
- )
- tween
- .
- tween_property
- (
- splash
- ,
- "scale"
- ,
- Vector2
- (
- 1.0
- ,
- 1.0
- )
- ,
- 0.1
- )
- tween
- .
- tween_property
- (
- splash
- ,
- "modulate:a"
- ,
- 0.0
- ,
- 0.3
- )
- tween
- .
- tween_callback
- (
- splash
- .
- queue_free
- )
- func
- pulse_receptor
- (
- lane
- :
- int
- ,
- judgment
- :
- Judgment
- )
- ->
- void
- :
- var
- receptor
- :=
- lane_receptors
- [
- lane
- ]
- receptor
- .
- modulate
- =
- judgment_colors
- [
- judgment
- ]
- var
- tween
- :=
- create_tween
- (
- )
- tween
- .
- tween_property
- (
- receptor
- ,
- "modulate"
- ,
- Color
- .
- WHITE
- ,
- 0.15
- )
- Common Pitfalls
- Pitfall
- Solution
- Audio desync
- Use
- AudioServer.get_time_since_last_mix()
- latency compensation
- Unfair judgment
- Generous windows at low difficulty, offset calibration
- Notes bunched visually
- Adjust scroll speed or spawn timing
- Hold notes janky
- Separate hold body and tail rendering
- Frame drops cause misses
- Decouple input from framerate
- Godot-Specific Tips
- Audio latency
-
- Calibrate with
- AudioServer
- and custom offset
- Input polling
-
- Use
- _input
- not
- _process
- for precise timing
- Shaders
-
- UV scrolling for note highways
- Particles
- Use GPUParticles2D for hit effects Reference Master Skill: godot-master