Skip to content

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.

SDK featureWhere
Heatmap events (player_jump, player_death, player_position)player/player.gd
Level screenshot with world-coordinate boundsplayer/player.gd _ready()
Progression startcomplete / fail("fell")player/player.gd + level/princess.gd
Leaderboard fastest_completionlevel/princess.gd
Remote Config (walk_max_speed, jump_speed)player/player.gd
Flight Recorder demo (B key)player/player.gd
Terminal window
docker compose up -d
docker 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+, F5

Move 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).

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-51
if 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.

# Jumps — discrete event, anchored at takeoff position
# player/player.gd:71-75
QuestData.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 += delta
if _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-108
QuestData.track("player_death", {
"_x": position.x,
"_y": position.y,
"level": "level_1",
})

Three rules to internalize:

  1. _x / _y prefix — the backend projects these into dedicated pos_x/pos_y columns for fast spatial queries.
  2. Always include level — the heatmap level filter requires it.
  3. Periodic sampling has a budget. 2s is a reasonable default. 0.1s would flood the backend; 30s would miss too much detail.

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-29
QuestData.fetch_remote_config()
await get_tree().create_timer(1.0).timeout
walk_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:

# Falling
QuestData.fail_progression("level_1", "fell")
# Pressing Escape
QuestData.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.

Level completion fires from a different script — the princess’s collision body:

level/princess.gd
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.

PageWhat to look at
Heatmap → level_1Jump + death dots overlaid on the level.
Progression → level_1start_progression count vs complete_progression rate. Fail breakdown by fell vs reset.
Leaderboards → fastest_completionTime-based leaderboard (lower = better)
Errors → NullReferencePress B in-game then refresh — Flight Recorder shows last 60s
Configuration → Remote Configwalk_max_speed, jump_speed
  1. Per-jump tracking — too noisy? Sample every Nth jump instead. Replace the unconditional track("player_jump") with if jump_count % 3 == 0: track(...). Compare the heatmap before/after.
  2. Add a coins_collected event with _x/_y/level. Use it for a positive heatmap (where players are succeeding, not dying).
  3. 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.