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.
What you’ll see in action
Section titled “What you’ll see in action”| SDK feature | Where |
|---|---|
3D events with _x/_y/_z | player/player.gd |
| Top-down screenshot via SubViewport | player/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 events | enemy/enemy.gd |
Two leaderboards (fastest_completion, most_coins_per_run) | _complete_level() |
Remote Config (move_speed, jump_velocity, coins_target) | 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-3d/project.godot, F5Move with WASD, jump with Space, shoot with Click, reset with R. Collect all coins to win. B simulates a Flight Recorder crash.
Code tour
Section titled “Code tour”Boot — fetch + load + activity
Section titled “Boot — fetch + load + activity”# player/player.gd:46-62func _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-191QuestData.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 hidden top-down screenshot
Section titled “The hidden top-down screenshot”The trickiest piece in this demo. To produce a heatmap background that matches the level’s XZ-extent, the script:
- Walks every
VisualInstance3Dto compute the level’s XZ bounding box. - Builds an invisible
SubViewportwith its own orthographic top-down camera aty=200. - Renders one frame, captures the texture, uploads it as the level screenshot.
- 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 pickup → progress save
Section titled “Coin pickup → progress save”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-448func 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-467func _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.
Cloud save round-trip
Section titled “Cloud save round-trip”# player/player.gd:424-429func _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:
- Empty dict means new player. Don’t crash, don’t log an error — just leave defaults.
- 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.
Dashboard walkthrough
Section titled “Dashboard walkthrough”| Page | What to look at |
|---|---|
| Heatmap → level_1 (XZ) | Top-down level with coin/jump/death dots |
| Rich Presence | ”Exploring Level 1 — coins: 7” updating live |
| Leaderboards | Both fastest_completion and most_coins_per_run |
| Achievements | first_jump, first_coin, all_coins, no_death_run |
| Cloud Saves → your player_id | Latest version with coins/completed |
| Configuration → Remote Config | move_speed, jump_velocity, coins_target |
Try it yourself
Section titled “Try it yourself”- Per-section progression. Split the level into 3 progression IDs (
level_1_section_a,..._b,..._c), and callcomplete_progression(...)when crossing region triggers. Now you can spot which section players give up in. - Use Y for filters. Add a
floor: "lower"/"upper"property to spatial events based on_yheight, then filter the heatmap by floor. - Replace the static
coins_targetwith a Segment-targeted value. New players getcoins_target=10; veterans get20. Set the segment up in the dashboard, then tag returning players withset_user_property("veteran", true).
Next Steps
Section titled “Next Steps”- Spatial Tracking —
_x/_y/_zprojection conventions - Cloud Saves — versioning, conflict handling
- Rich Presence —
set_activitylifecycle - Achievements — unlock conditions, secret achievements