FXGL Rhythm Game
Important Timing Constraint
FXGL's public AudioPlayer API does not expose a music-position query. That means the safest
default approach is:
- start music
- start a
songTimeaccumulator inonUpdate(double tpf) - align notes to that accumulator
This is good enough for prototypes, but it is not sample-accurate.
Beatmap Model
public enum NoteType { TAP, HOLD }
public record Note(double time, int lane, NoteType type, double holdDuration) {}
public record Beatmap(double leadInSeconds, List<Note> notes) {}
Starting Playback
private Music song;
private Beatmap beatmap;
private boolean chartRunning = false;
private double songTime = 0.0;
private static final double SCROLL_DURATION = 1.6;
private void startSong() {
song = getAssetLoader().loadMusic("track.mp3");
getAudioPlayer().playMusic(song);
songTime = -beatmap.leadInSeconds();
chartRunning = true;
}
@Override
protected void onUpdate(double tpf) {
if (!chartRunning)
return;
songTime += tpf;
spawnUpcomingNotes();
detectMisses();
}
Note Spawning
private final Set<Note> spawned = new HashSet<>();
private void spawnUpcomingNotes() {
for (Note note : beatmap.notes()) {
if (spawned.contains(note))
continue;
if (songTime >= note.time() - SCROLL_DURATION) {
spawn("note", new SpawnData(laneX(note.lane()), NOTE_START_Y)
.put("note", note));
spawned.add(note);
}
}
}
Timing Windows
private Rating rate(double deltaSeconds) {
double ms = Math.abs(deltaSeconds * 1000.0);
if (ms < 50) return Rating.PERFECT;
if (ms < 150) return Rating.GOOD;
if (ms < 300) return Rating.OK;
return Rating.MISS;
}
Lane Input
private void onLanePressed(int lane) {
Note note = findNearestPendingNote(lane);
if (note == null)
return;
Rating rating = rate(songTime - note.time());
switch (rating) {
case PERFECT -> award(100);
case GOOD -> award(50);
case OK -> award(20);
case MISS -> breakCombo();
}
resolveHit(note, rating);
}
Combo and Grade
private int combo = 0;
private int score = 0;
private int perfectHits = 0;
private void award(int basePoints) {
combo++;
int multiplier = Math.min(8, 1 + combo / 10);
score += basePoints * multiplier;
}
private void breakCombo() {
combo = 0;
}
At the end of the song, compute grade by accuracy percentage.
Hold Notes
Treat hold notes as two checks:
- initial press inside the timing window
- sustain long enough to reach
note.time + holdDuration
Use a small active state object per lane to track held notes.
Visual Structure
- fixed lanes
- a hit zone at the bottom or center
- note entities that tween or move linearly into that zone
- floating rating text and combo feedback in the HUD
Recommended Architecture
- fxgl-audio for music playback and volume control
- fxgl-ui-scenes for lane HUD, score, grade, and result screen
- a beatmap parser that converts JSON / text into
List<Note>
Gotchas
- No public music-position API — do not write code that calls
getAudioPlayer().getMusicPosition(); it does not exist in FXGL's public API. - Engine-time sync can drift — if frame timing stutters,
songTime += tpfmay drift slightly from the actual audio playback. - Lead-in matters — give notes time to enter the lane before the first hit.
- Hold notes need explicit state — they are not just long tap notes; track press, sustain, and release failure separately.