Skip to content

Bullet Shower Walkthrough

This walkthrough covers the Bullet Shower demo. It exists for one reason: prove the SDK doesn’t fall over under load and ship the performance telemetry to back that up.

If your game has bullet hell, particles, simulation, or anything else that pressures FPS, copy these patterns.

SDK featureWhere in game.gd
Device info on session start (auto)SDK ships OS, CPU, GPU, memory on first event
perf_sample events every 1s_update_perf_sampler()
track_error on FPS drop under load_update_perf_sampler()
Wave-milestone achievements_spawn_wave()
Survival leaderboard with metadata_end_run()
Remote-config tunable difficulty_apply_remote_config()
Terminal window
docker compose up -d
docker exec -i quest-data-db psql -U quest -d quest_data < backend/sql/seed.sql
# Open examples/bullet-shower/project.godot, F5

WASD/Arrows to move. B to boost difficulty (multiplier compounds). R to restart. Survive as long as you can.

Every second the demo grabs fps, memory_mb, bullet_count and ships a perf_sample:

# game.gd:227-245
func _update_perf_sampler(delta: float) -> void:
_perf_timer += delta
if _perf_timer < _perf_interval:
return
_perf_timer = 0.0
var fps := Engine.get_frames_per_second()
var mem_mb := OS.get_static_memory_usage() / (1024.0 * 1024.0)
var survived_sec := float(Time.get_ticks_msec() - _run_start_ms) / 1000.0
QuestData.track("perf_sample", {
"fps": fps,
"memory_mb": mem_mb,
"bullet_count": _bullets.size(),
"wave": _wave,
"survived_sec": survived_sec,
})
# Escalate to an error if FPS tanks under load
if fps < _fps_warning and not _fps_warning_sent and _bullets.size() > 100:
_fps_warning_sent = true
QuestData.track_error("fps_drop_under_load",
"FPS=%d bullets=%d wave=%d" % [fps, _bullets.size(), _wave])

Three deliberate decisions:

  1. 1-second interval is a sweet spot. Faster than 1Hz makes the dashboard charts noisy without revealing more. Slower than 1Hz misses brief stutters.
  2. bullet_count and wave ride along. Without context, an FPS dip is meaningless. With context, you can plot FPS vs bullet count and find the device-class breakpoint.
  3. Only escalate to track_error under load. A 30 FPS reading at the title screen isn’t a bug. The _bullets.size() > 100 guard makes sure the error fires only when load matters. The _fps_warning_sent flag deduplicates — one error per run, not per frame.

You don’t see device-info code in this demo. The SDK captures it automatically on the first event of the session — OS, CPU, GPU, memory. Open the dashboard’s Performance page after a run and you’ll see the FPS distribution sliced by GPU model.

This is why track("app_start") should be the first call you make: it makes sure the SDK has triggered its session bootstrap before anything else fires.

# game.gd:165-179
QuestData.track("wave_spawned", {
"wave": _wave,
"bullets_added": spawned,
"total_bullets": _bullets.size(),
"boost": _boost_multiplier,
})
if _wave == 1: QuestData.unlock_achievement("first_dodge")
elif _wave == 10: QuestData.unlock_achievement("wave_10")
elif _wave == 25: QuestData.unlock_achievement("wave_25")
if _bullets.size() >= 2000:
QuestData.unlock_achievement("apocalypse") # hidden achievement

apocalypse is a hidden achievement — players don’t see it in the achievement list until they unlock it. Mark it as hidden in the dashboard’s Achievements page; the SDK call is the same.

# game.gd:265-282
func _end_run() -> void:
_run_alive = false
var survived := float(Time.get_ticks_msec() - _run_start_ms) / 1000.0
QuestData.track("run_end", {
"survived_sec": survived,
"wave_reached": _wave,
"peak_bullets": _peak_bullets,
})
QuestData.submit_score("bullet_shower_survival", survived, {
"wave": _wave,
"peak_bullets": _peak_bullets,
})
if survived >= 300.0:
QuestData.unlock_achievement("survivor_5m")

peak_bullets in the score metadata lets you filter the leaderboard for “longest survival under heavy load” — a useful boast for the dashboard’s hardware breakdown.

# game.gd:259-262
elif event.is_action_pressed("boost_difficulty") and _run_alive:
_boost_multiplier = min(_boost_multiplier + 0.5, 5.0)
QuestData.track_ui("gameplay", "boost_difficulty", "x%.1f" % _boost_multiplier)

How often do players self-impose harder difficulty? track_ui answers that. If players never press B, your default difficulty is tuned correctly. If 30% of players boost to 5x within 30 seconds, the default is too easy.

Almost every magic number is read from Remote Config:

# game.gd:88-97
_wave_interval = float(QuestData.get_config("wave_interval_sec", DEFAULT_WAVE_INTERVAL))
_bullets_per_wave = int(QuestData.get_config("bullets_per_wave", DEFAULT_BULLETS_PER_WAVE))
_bullet_speed = float(QuestData.get_config("bullet_speed", DEFAULT_BULLET_SPEED))
_max_bullets = int(QuestData.get_config("max_bullets", DEFAULT_MAX_BULLETS))
_player_speed = float(QuestData.get_config("player_speed", DEFAULT_PLAYER_SPEED))
_perf_interval = float(QuestData.get_config("perf_sample_interval_sec", DEFAULT_PERF_INTERVAL))
_fps_warning = int(QuestData.get_config("fps_warning_threshold", DEFAULT_FPS_WARNING))

In a stress test this is exactly right — everything that affects load should be remote-tunable so you can dial it up or down without rebuilding.

PageWhat to look at
PerformanceFPS distribution per GPU. Memory growth over survived_sec.
Custom Events → perf_samplePlot FPS against bullet_count to find your breakpoint
Errors → fps_drop_under_loadFlight Recorder showing the run leading up to the dip
Leaderboards → bullet_shower_survivalFilter by peak_bullets for hardware-class boasts
Achievements → apocalypseHidden until unlocked
Configuration → Remote ConfigTune bullets_per_wave, max_bullets, fps_warning_threshold
  1. Per-frame timing budget. Add frame_time_ms = 1000.0 / max(fps, 1) to perf_sample. Plot the 99th percentile per GPU model — if any device sits above 16ms p99, you have a stutter problem on that hardware.
  2. Quality-tier auto-downgrade. If fps_drop_under_load fires for a player, set set_user_property("quality_tier", "low") and target a Remote Config segment that lowers max_bullets.
  3. Memory leak detector. memory_mb is already tracked. Plot it over survived_sec and look for a positive slope in long runs — that’s a leak.