Spatial Tracking
Send X/Y/Z coordinates with events to power death-hotspot heatmaps, traffic-flow maps, and pickup-density overlays. Upload your level art directly from the running game so the heatmap aligns to whatever the player actually saw.
Tracking Positions
Section titled “Tracking Positions”Spatial events are just regular events — add pos_x, pos_y, optional pos_z, and a level property. The dashboard recognizes the schema and renders them on the heatmap.
# Death (most common)QuestData.track("player_death", { "level": current_level, "pos_x": global_position.x, "pos_y": global_position.y, "pos_z": global_position.z, # optional, for 3D "cause": "enemy_collision"})
# PickupQuestData.track("item_pickup", { "item_id": "health_potion", "level": current_level, "pos_x": pickup.global_position.x, "pos_y": pickup.global_position.y})
# Movement sample (every 5s — DO NOT call per frame)if movement_sample_timer.is_stopped(): QuestData.track("player_movement", { "level": current_level, "pos_x": global_position.x, "pos_y": global_position.y, "activity": "walking" }) movement_sample_timer.start(5.0)level is required for the dashboard’s level-filter dropdown. Use a stable identifier — a scene name, a level slug, anything you’ll recognize three months from now.
Uploading Level Art
Section titled “Uploading Level Art”Upload the visual context of a level so the heatmap renders on top of it. The SDK can capture from the running game on demand, or you can upload from the Heatmap page in the dashboard.
upload_level_screenshot()
Section titled “upload_level_screenshot()”QuestData.upload_level_screenshot(level_name: String, bounds: Dictionary = {}, projection: String = "xy")Captures the current viewport, encodes it as PNG on a worker thread (so the main thread never stutters), and uploads to the server.
| Parameter | Type | Default | Description |
|---|---|---|---|
level_name | String | required | Must match the level property you send with spatial events |
bounds | Dictionary | auto from viewport | World coordinates the image covers — see Bounds below |
projection | String | "xy" | "xy" (top-down or 2D) or "xz" (3D side projection) |
# Capture once when a level finishes loading — viewport bounds auto-detectedfunc _on_level_ready(): QuestData.upload_level_screenshot("level_1")When bounds are omitted, the SDK uses the viewport’s visible rectangle: { min_x: 0, max_x: viewport_width, min_y: 0, max_y: viewport_height }. That’s correct for top-down 2D games where viewport pixels equal world units, but wrong for any game with a camera that scrolls, zooms, or projects. In those cases pass explicit bounds (see below).
upload_level_screenshot_from_image()
Section titled “upload_level_screenshot_from_image()”QuestData.upload_level_screenshot_from_image(level_name: String, image: Image, bounds: Dictionary, projection: String = "xy")Uploads a pre-rendered Image you’ve prepared yourself. Use this when:
- You want to capture from a SubViewport (invisible to the player, e.g. a top-down render of a 3D level)
- You have a procedurally generated map that doesn’t match the visible viewport
- You’re rendering the level art once at build time and want to upload from a tool scene
bounds is required here — there’s no viewport to autodetect from.
# Render a top-down view of a 3D level into a SubViewport, upload itfunc capture_overhead_map(): var sub_vp: SubViewport = $OverheadCamera/SubViewport sub_vp.update_mode = SubViewport.UPDATE_ONCE await RenderingServer.frame_post_draw
var image := sub_vp.get_texture().get_image() QuestData.upload_level_screenshot_from_image( "boss_arena", image, { "min_x": -50.0, "max_x": 50.0, "min_y": -50.0, "max_y": 50.0 }, "xz" )Bounds
Section titled “Bounds”bounds tells the dashboard what world coordinate range the image covers, so spatial events plot at the right pixel. Get this wrong and your death markers float off-screen.
| Field | Type | Meaning |
|---|---|---|
min_x | float | World X coordinate at the left edge of the image |
max_x | float | World X coordinate at the right edge |
min_y | float | World Y (or Z, for xz projection) at the top edge |
max_y | float | World Y (or Z) at the bottom edge |
For a top-down 2D level where the camera shows the whole map: bounds = the level’s worldspace extents. For a 3D level rendered with projection: "xz": use the world’s X and Z extents and ignore Y (height).
Projection
Section titled “Projection”| Value | Use Case |
|---|---|
"xy" | 2D games (top-down, side-scroller). Heatmap uses event pos_x/pos_y |
"xz" | 3D games rendered top-down. Heatmap uses event pos_x/pos_z, pos_y is ignored (collapses height) |
The projection on the upload must match the projection in the dashboard’s level filter. If you upload an xz image and view the heatmap with the xy filter, you’ll see an empty map.
Hash-based Versioning
Section titled “Hash-based Versioning”The SDK is safe to call on every level load — the server hashes the image bytes and:
- If the hash matches the latest version: returns
is_new: false, no new version stored - If the hash differs: creates a new version, returns
is_new: true
So upload_level_screenshot() on _ready() only consumes storage when your level art actually changes (new build, level edit). Repeated identical uploads are a no-op server-side.
Example: Idle 2D Game
Section titled “Example: Idle 2D Game”extends Node2D
func _ready(): # Tell the heatmap what level we're in var bounds := { "min_x": 0, "max_x": 1920, "min_y": 0, "max_y": 1080 } QuestData.upload_level_screenshot("forest_level", bounds, "xy")
func _on_player_died(pos: Vector2, cause: String): QuestData.track("player_death", { "level": "forest_level", "pos_x": pos.x, "pos_y": pos.y, "cause": cause })Limits
Section titled “Limits”| Limit | Value |
|---|---|
| Image format | PNG (default), JPEG, WebP |
| Image size | 10 MB max |
level_name | 1–255 chars |
projection | "xy" or "xz" |
How It Works
Section titled “How It Works”upload_level_screenshot()grabs the viewport texture on the main thread (cheap)- PNG encoding + Base64 (~50–200 ms) runs on
WorkerThreadPool— no main-thread stutter - Encoded payload posts to
POST /v1/level-images/sdk-upload - Server hashes the image; if unchanged, returns
is_new: falsewith the existing version - Otherwise, a new version is stored alongside the bounds and projection
- The dashboard’s Heatmap page picks up the latest version automatically
Best Practices
Section titled “Best Practices”- Match
levelproperty tolevel_name— spatial events withlevel: "forest_level"only render on the heatmap when an image was uploaded under the samelevel_name - Sample movement, don’t track every frame — once every 1–5 seconds is plenty; per-frame events will blow your rate limit
- Pass explicit bounds whenever the camera moves — the auto-bounds default only works for static, full-screen 2D levels
- Re-upload on level edits — the hash check means accidental re-uploads are free; no need to gate it
- Use
xzfor 3D top-down —pos_y(height) is rarely interesting; collapsing onto the floor plane gives a usable heatmap
Next Steps
Section titled “Next Steps”- Heatmap dashboard — visualize the data you’ve collected
- Event Tracking — full event API reference