2D Platformer Walkthrough
This walkthrough explains how the 2D Platformer demo integrates Quest Data. The focus here is spatial analytics — the demo shows how to build a usable heatmap by tracking player_jump, player_death, and periodic player_position events with proper world coordinates.
What you’ll see in action
Section titled “What you’ll see in action”| SDK feature | Where |
|---|---|
Heatmap events (player_jump, player_death, player_position) | player/player.gd |
| Level screenshot with world-coordinate bounds | player/player.gd _ready() |
Progression start → complete / fail("fell") | player/player.gd + level/princess.gd |
Leaderboard fastest_completion | level/princess.gd |
Remote Config (walk_max_speed, jump_speed) | player/player.gd |
| Flight Recorder demo (B key) | player/player.gd |
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/platformer-2d/project.godot in Godot 4.6+, F5Move with A/D, jump with Space. Reach the princess to complete the level. B simulates a bug for the Flight Recorder. Escape voluntarily resets you (counted as a reset failure, not a fell failure — the dashboard distinguishes them).
Code tour
Section titled “Code tour”Level screenshot with world bounds
Section titled “Level screenshot with world bounds”This is the most important pattern in the file. A heatmap is useless if the screenshot you upload doesn’t match the coordinate system the events use. The demo solves this by computing world bounds from the active camera:
# player/player.gd:39-51if not _screenshot_uploaded: _screenshot_uploaded = true await get_tree().process_frame var cam := get_viewport().get_camera_2d() var vp_size := get_viewport().get_visible_rect().size var cam_pos := cam.global_position + cam.offset var world_bounds := { "min_x": cam_pos.x - vp_size.x / 2.0, "max_x": cam_pos.x + vp_size.x / 2.0, "min_y": cam_pos.y - vp_size.y / 2.0, "max_y": cam_pos.y + vp_size.y / 2.0, } QuestData.upload_level_screenshot("level_1", world_bounds)The dashboard’s heatmap renderer uses world_bounds to map raw _x/_y event values onto pixels. Without bounds, every game would need its own coordinate-system glue. Pass them once on level start and you’re done.
Spatial events — three flavors
Section titled “Spatial events — three flavors”# Jumps — discrete event, anchored at takeoff position# player/player.gd:71-75QuestData.track("player_jump", { "_x": position.x, "_y": position.y, "level": "level_1",})
# Periodic position sample — every 2s, paints movement patterns# player/player.gd:78-85_position_timer += deltaif _position_timer >= 2.0: _position_timer = 0.0 QuestData.track("player_position", { "_x": position.x, "_y": position.y, "level": "level_1", })
# Death — discrete, where the player fell out# player/player.gd:104-108QuestData.track("player_death", { "_x": position.x, "_y": position.y, "level": "level_1",})Three rules to internalize:
_x/_yprefix — the backend projects these into dedicatedpos_x/pos_ycolumns for fast spatial queries.- Always include
level— the heatmap level filter requires it. - Periodic sampling has a budget. 2s is a reasonable default. 0.1s would flood the backend; 30s would miss too much detail.
Remote Config for physics tuning
Section titled “Remote Config for physics tuning”The defaults are baked in, but walk_max_speed and jump_speed are pulled from Remote Config 1 second after launch:
# player/player.gd:24-29QuestData.fetch_remote_config()
await get_tree().create_timer(1.0).timeoutwalk_max_speed = float(QuestData.get_config("walk_max_speed", 200.0))jump_speed = float(QuestData.get_config("jump_speed", 200.0))Edit either value in the dashboard (Configuration → Remote Config) and restart the demo to see physics change.
Death vs Reset — same shape, different reason
Section titled “Death vs Reset — same shape, different reason”The script distinguishes between falling off and voluntarily resetting by passing a different reason to fail_progression:
# FallingQuestData.fail_progression("level_1", "fell")
# Pressing EscapeQuestData.fail_progression("level_1", "reset")In the dashboard’s progression view this becomes a stacked breakdown: how many runs end in fell vs reset. If reset dominates, your level is too long or the checkpoint is missing. If fell dominates, you have a difficulty problem.
Completion event (the princess)
Section titled “Completion event (the princess)”Level completion fires from a different script — the princess’s collision body:
func _on_body_entered(body: Node2D) -> void: if body.name == "Player": var duration := Time.get_ticks_msec() / 1000.0 - start_time var deaths: int = body.get("_deaths") if body.get("_deaths") != null else 0
QuestData.complete_progression("level_1", int(duration)) QuestData.track("level_complete", { "level": "level_1", "duration_seconds": snappedf(duration, 0.1), "deaths": deaths, }) QuestData.submit_score("fastest_completion", duration)Note how deaths is read from the player via body.get("_deaths") — this is how you build a “deaths-per-completed-run” metric without a global state object.
Dashboard walkthrough
Section titled “Dashboard walkthrough”| Page | What to look at |
|---|---|
| Heatmap → level_1 | Jump + death dots overlaid on the level. |
| Progression → level_1 | start_progression count vs complete_progression rate. Fail breakdown by fell vs reset. |
| Leaderboards → fastest_completion | Time-based leaderboard (lower = better) |
| Errors → NullReference | Press B in-game then refresh — Flight Recorder shows last 60s |
| Configuration → Remote Config | walk_max_speed, jump_speed |
Try it yourself
Section titled “Try it yourself”- Per-jump tracking — too noisy? Sample every Nth jump instead. Replace the unconditional
track("player_jump")withif jump_count % 3 == 0: track(...). Compare the heatmap before/after. - Add a
coins_collectedevent with_x/_y/level. Use it for a positive heatmap (where players are succeeding, not dying). - Build a level-difficulty signal. Fire
track("difficulty_signal", {"level": "level_1", "score": deaths_per_completion})on every completion. Plot it in Custom Events to spot levels that are getting harder over time.
Next Steps
Section titled “Next Steps”- Spatial Tracking — full
_x/_y/_zreference + heatmap rendering - Progression — start/complete/fail semantics
- 3D Platformer Walkthrough — XZ-projection heatmap (the 3D twist on the same patterns)