Skip to content

Rhythm Game Walkthrough

This walkthrough covers the Rhythm Game demo. The headline feature is Game Data hot-reload: the beatmap is loaded from a Quest Data table at runtime — edit the rows in the dashboard and the next song restart picks up the new notes.

If your game has any content tables (loot tables, dialogue, levels, item stats), this is the pattern to copy.

SDK featureWhere in game.gd
Game Data loaded at runtime (get_game_data)_ready() calls get_game_data("beatmap_neon_pulse", _on_beatmap_loaded)
Remote Config (scroll_speed, hit windows)_apply_remote_config()
Per-keypress engagement trackingtrack_ui("gameplay", "lane_tap", "lane_N")
Note-level events (note_hit, note_miss)_handle_lane_press, _register_miss
Song completion + leaderboard + achievements_end_song()
Terminal window
docker compose up -d
docker exec -i quest-data-db psql -U quest -d quest_data < backend/sql/seed.sql
# Open examples/rhythm-game/project.godot, F5

Hit notes with D / F / J / K when they cross the white line. R to restart.

# game.gd:71-81
if has_node("/root/QuestData"):
QuestData.track("app_start", {"demo": "rhythm-game"})
QuestData.fetch_remote_config()
_apply_remote_config()
_status_label.text = "Loading beatmap..."
QuestData.get_game_data("beatmap_neon_pulse", _on_beatmap_loaded)
else:
# Offline fallback — embedded beatmap
_beatmap = _fallback_beatmap()

get_game_data(table_name, callback) fetches all rows from the named Game Data table. The callback receives them as an Array[Dictionary]. The schema (which columns exist) is whatever you define in the dashboard — here it’s time_sec + lane.

The callback hardens against bad data:

# game.gd:143-154
func _on_beatmap_loaded(rows: Array) -> void:
if rows.is_empty():
push_warning("[Rhythm] Beatmap empty — falling back to embedded map")
_beatmap = _fallback_beatmap()
else:
_beatmap = rows.duplicate()
_beatmap.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return float(a.get("time_sec", 0.0)) < float(b.get("time_sec", 0.0))
)

Three production-ready habits:

  1. Always have an embedded fallback. If the network is down or the table got accidentally truncated, the game still plays.
  2. Don’t trust column order. Sort, validate, default — rows could be anything an editor saves.
  3. duplicate() defensively. The SDK might cache the array; mutating it would surprise the next caller.
# game.gd:136-140
func _apply_remote_config() -> void:
_scroll_speed = float(QuestData.get_config("scroll_speed", DEFAULT_SCROLL_SPEED))
_hit_perfect_ms = float(QuestData.get_config("hit_window_perfect_ms", DEFAULT_HIT_PERFECT_MS))
_hit_good_ms = float(QuestData.get_config("hit_window_good_ms", DEFAULT_HIT_GOOD_MS))
_hit_ok_ms = float(QuestData.get_config("hit_window_ok_ms", DEFAULT_HIT_OK_MS))

This pattern is exactly the kind of thing rhythm-game players love to argue about. Ship a default that feels good, then loosen hit_window_perfect_ms for casuals or tighten it for pros via Segment-targeted config.

track_ui per keypress — engagement signal

Section titled “track_ui per keypress — engagement signal”
# game.gd:254-257
func _handle_lane_press(lane: int) -> void:
_lane_flash_timers[lane] = 0.15
if has_node("/root/QuestData"):
QuestData.track_ui("gameplay", "lane_tap", "lane_%d" % lane)

This sends a UI-interaction event for every single keypress. Sounds excessive — but the dashboard’s Engagement view aggregates them, so you can spot:

  • Lanes that get tapped less often (poorly distributed beatmap)
  • Players who never use lane 4 (input mapping problem)
  • Drop-off points in the song

The SDK batches these into the regular event flush, so the bandwidth cost is small.

Hit and miss are tracked separately:

# game.gd:301-307 — hit
QuestData.track("note_hit", {
"lane": lane,
"judgement": judgement, # "Perfect" | "Good" | "OK"
"delta_ms": best_delta,
"combo": _combo,
})
# game.gd:316-322 — miss
QuestData.track("note_miss", {"lane": lane})

delta_ms is the per-note timing offset. Histogram it in the dashboard’s Custom Events page to see the timing distribution of your players — if it’s heavily skewed early, your visual cues are misleading them.

Song complete — score, accuracy, achievements

Section titled “Song complete — score, accuracy, achievements”
# game.gd:325-352
func _end_song() -> void:
_song_playing = false
var hits: int = _hits_perfect + _hits_good + _hits_ok
var accuracy: float = (
_hits_perfect * 1.0 + _hits_good * 0.7 + _hits_ok * 0.4
) / float(_song_total_notes) * 100.0
var props: Dictionary = {
"song": "neon_pulse",
"score": _score,
"max_combo": _max_combo,
"perfect": _hits_perfect, "good": _hits_good, "ok": _hits_ok, "miss": _hits_miss,
"accuracy": accuracy,
"rank": _rank(accuracy),
}
QuestData.track("song_complete", props)
QuestData.submit_score("rhythm_high_score", _score, {"song": "neon_pulse", "accuracy": accuracy})
if _hits_miss == 0 and _song_total_notes > 0:
QuestData.unlock_achievement("full_combo")
if _hits_miss == 0 and _hits_good == 0 and _hits_ok == 0 and _song_total_notes > 0:
QuestData.unlock_achievement("all_perfect")

submit_score accepts metadata as the third argument. Use it for tags you’ll want to filter the leaderboard by — here, song and accuracy so you can show “top scores on Neon Pulse” or “best accuracy runs”.

PageWhat to look at
Game Data → beatmap_neon_pulseEdit a row’s time_sec → restart song → note moves
Engagement → gameplayLane-tap distribution per song
Custom Events → note_hitHistogram of delta_ms — your players’ timing
Leaderboards → rhythm_high_scoreFiltered by song
Configuration → Remote Configscroll_speed, three hit windows
  1. Add a second song. Create beatmap_second_song in Game Data, add a song picker UI, swap the table key in get_game_data(...). Done — new song without a code change.
  2. Telemetry-driven difficulty. Compute the average delta_ms over the last N songs from a player. If it’s tightening, increase scroll_speed for them via segment config. If it’s widening, ease off.
  3. Lane heatmap. Add _x (lane × constant) and _y (timing offset) to note_hit. Now the heatmap shows where players land in the lane × timing space.