Skip to content

3D Platformer Walkthrough

This walkthrough covers the 3D Platformer demo, which is the most feature-complete example in the repo. The interesting twist over the 2D Platformer is that 3D events get XZ-projected for the heatmap (top-down view, ignore Y) and the level screenshot is rendered into a hidden SubViewport — the player never sees the camera swap.

SDK featureWhere
3D events with _x/_y/_zplayer/player.gd
Top-down screenshot via SubViewportplayer/player.gd _upload_topdown_screenshot()
Cloud save (coins, level, completed flag)player/player.gd
Rich Presence (set_activity)player/player.gd
Achievements (first_jump, first_coin, all_coins, no_death_run)player/player.gd
Coin pickup events (coin/coin.gd)propagated to player
Enemy kill eventsenemy/enemy.gd
Two leaderboards (fastest_completion, most_coins_per_run)_complete_level()
Remote Config (move_speed, jump_velocity, coins_target)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-3d/project.godot, F5

Move with WASD, jump with Space, shoot with Click, reset with R. Collect all coins to win. B simulates a Flight Recorder crash.

# player/player.gd:46-62
func _ready() -> void:
_level_start_time = Time.get_ticks_msec() / 1000.0
QuestData.fetch_remote_config()
QuestData.load_game(_on_save_loaded)
QuestData.track("app_start", {"game": "platformer_3d", "version": "1.0.0"})
QuestData.start_progression("level_1")
QuestData.log_info("Level started", {"level": "level_1"})
QuestData.set_activity("Exploring Level 1", {"coins": coins})
await get_tree().create_timer(1.0).timeout
MAX_SPEED = float(QuestData.get_config("move_speed", 6.0))
JUMP_VELOCITY = float(QuestData.get_config("jump_velocity", 12.5))

set_activity powers the Rich Presence dashboard — the live “what are players doing right now” view. Update it whenever the player’s situation changes (entered new level, picked up a milestone, etc.). Don’t update it on every frame — it’d be wasteful.

XZ projection — 3D coords for a 2D heatmap

Section titled “XZ projection — 3D coords for a 2D heatmap”

Every spatial event ships all three axes:

# player/player.gd:77-82, 90-96, 186-191
QuestData.track("player_position", {
"_x": global_position.x,
"_y": global_position.y, # vertical — ignored by XZ heatmap, but stored
"_z": global_position.z,
"level": "level_1",
})

The dashboard’s 3D heatmap projects (_x, _z) onto the floor plane and uses _y only for filters (“show me deaths in the lower section”). Every event in the script follows this _x / _y / _z / level shape.

The trickiest piece in this demo. To produce a heatmap background that matches the level’s XZ-extent, the script:

  1. Walks every VisualInstance3D to compute the level’s XZ bounding box.
  2. Builds an invisible SubViewport with its own orthographic top-down camera at y=200.
  3. Renders one frame, captures the texture, uploads it as the level screenshot.
  4. Tears down the SubViewport — the player’s main camera was never disturbed.
# player/player.gd:336-421 (abbreviated — read the full function in the file)
func _upload_topdown_screenshot() -> void:
var level := _compute_level_xz_bounds() # walks scene, returns Rect2
var subvp := SubViewport.new()
subvp.size = Vector2i(capture_width, capture_height)
subvp.world_3d = get_viewport().find_world_3d() # share scene
var topdown := Camera3D.new()
topdown.projection = Camera3D.PROJECTION_ORTHOGONAL
topdown.size = level.size.y # vertical world units
topdown.position = Vector3(center_x, 200.0, center_z)
topdown.rotation_degrees = Vector3(-90.0, 0.0, 0.0)
subvp.add_child(topdown)
# Force a render
subvp.render_target_update_mode = SubViewport.UPDATE_ONCE
await RenderingServer.frame_post_draw
await RenderingServer.frame_post_draw
var image := subvp.get_texture().get_image()
QuestData.upload_level_screenshot_from_image("level_1", image, world_bounds, "xz")

If you have a 3D game, steal this function. The two await RenderingServer.frame_post_draw lines are load-bearing — Godot needs both ticks before the texture is populated.

Coin collection is a chain across three scripts:

# coin/coin.gd (simplified)
func _on_body_entered(body):
if body is Player:
body.coins += 1
body.on_coin_collected(global_position)
queue_free()
# player/player.gd:432-448
func on_coin_collected(coin_position: Vector3) -> void:
QuestData.track("coin_collected", {
"_x": coin_position.x,
"_y": coin_position.y,
"_z": coin_position.z,
"level": "level_1",
"total": coins,
})
QuestData.set_activity("Exploring Level 1", {"coins": coins})
if coins == 1:
QuestData.unlock_achievement("first_coin")
var target: int = int(QuestData.get_config("coins_target", 20))
if coins >= target:
_complete_level()

The coins_target is remote-configurable. You can A/B test “20 coins to win” vs “30 coins to win” without shipping an update.

Level complete — the maximum-density burst

Section titled “Level complete — the maximum-density burst”
# player/player.gd:451-467
func _complete_level() -> void:
var duration: float = Time.get_ticks_msec() / 1000.0 - _level_start_time
QuestData.complete_progression("level_1", int(duration))
QuestData.track("level_complete", {
"level": "level_1",
"duration_seconds": snappedf(duration, 0.1),
"coins": coins,
"deaths": _deaths,
})
# Two leaderboards — speed AND completionism
QuestData.submit_score("fastest_completion", duration)
QuestData.submit_score("most_coins_per_run", float(coins))
QuestData.unlock_achievement("all_coins")
if _deaths == 0:
QuestData.unlock_achievement("no_death_run")
QuestData.save_game({"coins": coins, "level": "level_1", "completed": true})
QuestData.set_activity("Level Complete", {"duration": snappedf(duration, 0.1), "coins": coins})

Two leaderboards is a key idea. Fast players and completionists are different audiences — give them each a leaderboard to climb.

# player/player.gd:424-429
func _on_save_loaded(data: Dictionary, _version: int) -> void:
if data.is_empty():
return
if data.has("coins"):
coins = int(data.get("coins", 0))
QuestData.log_info("Restored cloud save", {"coins": coins})

Two patterns to copy:

  1. Empty dict means new player. Don’t crash, don’t log an error — just leave defaults.
  2. Defensive .get("key", default) everywhere. Saves persist across game updates; tomorrow’s schema might add or remove fields.

For deeper conflict-resolution (409 handling, multi-slot saves, data-loss recovery), see the dedicated Save / Load Demo Walkthrough.

PageWhat to look at
Heatmap → level_1 (XZ)Top-down level with coin/jump/death dots
Rich Presence”Exploring Level 1 — coins: 7” updating live
LeaderboardsBoth fastest_completion and most_coins_per_run
Achievementsfirst_jump, first_coin, all_coins, no_death_run
Cloud Saves → your player_idLatest version with coins/completed
Configuration → Remote Configmove_speed, jump_velocity, coins_target
  1. Per-section progression. Split the level into 3 progression IDs (level_1_section_a, ..._b, ..._c), and call complete_progression(...) when crossing region triggers. Now you can spot which section players give up in.
  2. Use Y for filters. Add a floor: "lower" / "upper" property to spatial events based on _y height, then filter the heatmap by floor.
  3. Replace the static coins_target with a Segment-targeted value. New players get coins_target=10; veterans get 20. Set the segment up in the dashboard, then tag returning players with set_user_property("veteran", true).