Claude
Skills
Sign in
โ† Back

godot-genre-rhythm

Included with Lifetime
$97 forever

Expert blueprint for rhythm games including audio synchronization (BPM conductor, latency compensation with AudioServer.get_time_since_last_mix), note highways (scroll speed, timing windows), judgment systems (Perfect/Great/Good/Bad/Miss), scoring with combo multipliers, input processing (lane-based, hold note detection), and chart/beatmap loading. Based on DDR/osu!/Beat Saber research. Trigger keywords: rhythm_game, audio_sync, timing_judgment, note_highway, combo_system, BPM_conductor, latency_compensation.

Image & Videoscripts

What this skill does


# Genre: Rhythm

Expert blueprint for rhythm games emphasizing audio-visual synchronization and flow state.

## NEVER Do (Expert Anti-Patterns)

### Audio Sync & Logic
- NEVER use `Time.get_ticks_msec()` for rhythm sync; strictly use **`AudioServer.get_time_since_last_mix()`** combined with latency offsets for sub-frame accuracy.
- NEVER process song logic in `_process()`; strictly use **`_physics_process()`** or a conductor loop to ensure deterministic timing regardless of render frames.
- NEVER use `_process()` to capture hit inputs; strictly use **`_input(event)`** to record the exact timestamp of the button press event.
- NEVER scale engine time_scale for song speed; strictly use **`AudioStreamPlayer.pitch_scale`** to adjust speed and avoid globally breaking physics logic.
- NEVER neglect **Audio Latency** calibration; strictly provide a tool for players to adjust for hardware/Bluetooth delays (~30-100ms) to prevent "unplayable" sync issues.
- NEVER use standard `_process` delta for note-to-audio sync; strictly use the **Hardware Clock** via `AudioServer.get_playback_position() + AudioServer.get_time_since_last_mix()` for sub-frame accuracy.
- NEVER move thousands of note sprites on the CPU; strictly use a **Shader-Based Highway** (UV scrolling) to offload track movement to the GPU.
- NEVER use `yield` or `await` for beat timing; strictly use a sample-accurate **Delta Accumulator** tied to the audio clock.
- NEVER assume a constant BPM; strictly build your conductor to handle a **Tempo Map** for complex track changes.

### Feedback & Performance
- NEVER judge inputs based on world position (pixels); strictly judge against the **Song's Elapsed Time (ms)** to ensure consistency across resolutions.
- NEVER play hit sounds with static pitch; strictly add **ยฑ5% Random Pitch Variation** to hit sounds to avoid the "machine gun" effect.
- NEVER use tight timing windows (e.g., <25ms) for all players; strictly use **Wider Windows for Beginners** to prevent immediate frustration.
- NEVER instantiate note nodes every beat; strictly use **Object Pooling** to recycle note instances and prevent GC spikes during dense tracks.
- NEVER use standard Area2D signals for rhythmic hits; strictly **Poll Inputs** in the conductor loop to compare against target timestamps.
- NEVER calculate FFT for visualization on the main thread; strictly use **AudioEffectSpectrumAnalyzerInstance** for optimized engine-side analysis.
- NEVER allow note spamming/mashing; strictly penalize misses or break combos to maintain the game's integrity.
- NEVER use `load()` dynamically during gameplay; strictly use **ResourceLoader.load_threaded_request()** to avoid thread stalling.
- NEVER forget to pause the conductor/ highway; strictly sync with the audio player's pause state to prevent notes from scrolling while the music is stopped.

---

## ๐Ÿ›  Expert Components (scripts/)

### Original Expert Patterns
- [rhythm_conductor.gd](scripts/rhythm_conductor.gd) - High-precision BPM/beat tracker with latency compensation logic.

### Modular Components
- [input_judge_logic.gd](scripts/input_judge_logic.gd) - Hit-window validation (Perfect/Good/Miss).
- [latency_calibrator.gd](scripts/latency_calibrator.gd) - A/V offset measurement utility.
- [note_object_pool.gd](scripts/note_object_pool.gd) - High-frequency recycling for dense highways.
- [audio_spectrum_analyzer.gd](scripts/audio_spectrum_analyzer.gd) - Optimized engine-side frequency extraction.
- [dynamic_bpm_handler.gd](scripts/dynamic_bpm_handler.gd) - Tempo map and fractional beat support.
- [note_lane_manager.gd](scripts/note_lane_manager.gd) - Spawning routes and variable scroll speed control.

---

## 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

```gdscript
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

```gdscript
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

```gdscript
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

```gdscript
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:

Related in Image & Video