Skip to content

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.

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"
})
# Pickup
QuestData.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.

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.

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.

ParameterTypeDefaultDescription
level_nameStringrequiredMust match the level property you send with spatial events
boundsDictionaryauto from viewportWorld coordinates the image covers — see Bounds below
projectionString"xy""xy" (top-down or 2D) or "xz" (3D side projection)
# Capture once when a level finishes loading — viewport bounds auto-detected
func _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).

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 it
func 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 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.

FieldTypeMeaning
min_xfloatWorld X coordinate at the left edge of the image
max_xfloatWorld X coordinate at the right edge
min_yfloatWorld Y (or Z, for xz projection) at the top edge
max_yfloatWorld 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).

ValueUse 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.

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.

scripts/level.gd
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
})
LimitValue
Image formatPNG (default), JPEG, WebP
Image size10 MB max
level_name1–255 chars
projection"xy" or "xz"
  1. upload_level_screenshot() grabs the viewport texture on the main thread (cheap)
  2. PNG encoding + Base64 (~50–200 ms) runs on WorkerThreadPool — no main-thread stutter
  3. Encoded payload posts to POST /v1/level-images/sdk-upload
  4. Server hashes the image; if unchanged, returns is_new: false with the existing version
  5. Otherwise, a new version is stored alongside the bounds and projection
  6. The dashboard’s Heatmap page picks up the latest version automatically
  1. Match level property to level_name — spatial events with level: "forest_level" only render on the heatmap when an image was uploaded under the same level_name
  2. Sample movement, don’t track every frame — once every 1–5 seconds is plenty; per-frame events will blow your rate limit
  3. Pass explicit bounds whenever the camera moves — the auto-bounds default only works for static, full-screen 2D levels
  4. Re-upload on level edits — the hash check means accidental re-uploads are free; no need to gate it
  5. Use xz for 3D top-downpos_y (height) is rarely interesting; collapsing onto the floor plane gives a usable heatmap