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.
What you’ll see in action
Section titled “What you’ll see in action”| SDK feature | Where 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 tracking | track_ui("gameplay", "lane_tap", "lane_N") |
Note-level events (note_hit, note_miss) | _handle_lane_press, _register_miss |
| Song completion + leaderboard + achievements | _end_song() |
Run it
Section titled “Run it”docker compose up -ddocker exec -i quest-data-db psql -U quest -d quest_data < backend/sql/seed.sql# Open examples/rhythm-game/project.godot, F5Hit notes with D / F / J / K when they cross the white line. R to restart.
Code tour
Section titled “Code tour”Beatmap from Game Data
Section titled “Beatmap from Game Data”# game.gd:71-81if 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-154func _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:
- Always have an embedded fallback. If the network is down or the table got accidentally truncated, the game still plays.
- Don’t trust column order. Sort, validate, default —
rowscould be anything an editor saves. duplicate()defensively. The SDK might cache the array; mutating it would surprise the next caller.
Tunable hit windows
Section titled “Tunable hit windows”# game.gd:136-140func _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-257func _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.
Note-level events
Section titled “Note-level events”Hit and miss are tracked separately:
# game.gd:301-307 — hitQuestData.track("note_hit", { "lane": lane, "judgement": judgement, # "Perfect" | "Good" | "OK" "delta_ms": best_delta, "combo": _combo,})
# game.gd:316-322 — missQuestData.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-352func _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”.
Dashboard walkthrough
Section titled “Dashboard walkthrough”| Page | What to look at |
|---|---|
| Game Data → beatmap_neon_pulse | Edit a row’s time_sec → restart song → note moves |
| Engagement → gameplay | Lane-tap distribution per song |
| Custom Events → note_hit | Histogram of delta_ms — your players’ timing |
| Leaderboards → rhythm_high_score | Filtered by song |
| Configuration → Remote Config | scroll_speed, three hit windows |
Try it yourself
Section titled “Try it yourself”- Add a second song. Create
beatmap_second_songin Game Data, add a song picker UI, swap the table key inget_game_data(...). Done — new song without a code change. - Telemetry-driven difficulty. Compute the average
delta_msover the last N songs from a player. If it’s tightening, increasescroll_speedfor them via segment config. If it’s widening, ease off. - Lane heatmap. Add
_x(lane × constant) and_y(timing offset) tonote_hit. Now the heatmap shows where players land in the lane × timing space.
Next Steps
Section titled “Next Steps”- Remote Config & Game Data — full Game Data API
- Engagement Tracking —
track_uipatterns - Leaderboards — metadata filters