Dashboard

Deaths on the map example

Record every death with its position and cause, then read hotspots on your map.

Last updated Sep 29, 2026

This example records where players die, why, and which stage they were on. It also logs stage starts and checkpoints, so the Spatial page can show deaths per attempt and where sessions end.

What you will see

  • A marker for every death at its position, grouped into hotspots ranked by count.
  • The cause of a death when you select its marker.
  • Per stage: attempts, deaths, unique players, completion rate and quit after death.

Set up the parts in Studio

Tag parts in View → Tag Editor and add the attributes below.

TagAttributesWhat it does
StageStartStageId (string)The first touch records stage_started and makes that stage the player's default.
CheckpointStageId (string)Records checkpoint_reached once per player.
KillPartCause (string), for example LavaKills the player on touch and remembers why.

The script

Create a server Script in ServerScriptService. It assumes you followed the Quick start.

ServerScriptService/DeathAnalytics.server.luau
local CollectionService = game:GetService("CollectionService")
local Players = game:GetService("Players")
local ServerScriptService = game:GetService("ServerScriptService")
local ServerStorage = game:GetService("ServerStorage")

local RoStructure = require(ServerScriptService.RoStructure)
local secrets = require(ServerStorage.RoStructureSecrets)

local analytics = RoStructure.init({
	projectId = "my_game",
	ingestionKey = secrets.ingestionKey,
	buildId = "v" .. game.PlaceVersion,
	endpoint = "https://api.rostructure.com",
	-- We record deaths ourselves below, so each one can carry a cause.
	autoTrack = { deaths = false },
})

local currentStage: { [Player]: string } = {}
local reachedCheckpoints: { [Player]: { [Instance]: boolean } } = {}

local function playerFromHit(hit: BasePart): (Player?, Model?)
	local model = hit:FindFirstAncestorOfClass("Model")
	local player = model and Players:GetPlayerFromCharacter(model)
	return player, model
end

-- Parts tagged "StageStart" (attribute StageId) mark where a stage begins.
local function bindStageStart(part: Instance)
	local stageId = part:GetAttribute("StageId")
	if not part:IsA("BasePart") or type(stageId) ~= "string" then
		return
	end
	part.Touched:Connect(function(hit)
		local player = playerFromHit(hit)
		if not player or currentStage[player] == stageId then
			return
		end
		currentStage[player] = stageId
		analytics:setStage(player, stageId)
		analytics:track(player, "stage_started", { stageId = stageId, position = true })
	end)
end

-- Parts tagged "Checkpoint" (attribute StageId) are recorded once per player.
local function bindCheckpoint(part: Instance)
	local stageId = part:GetAttribute("StageId")
	if not part:IsA("BasePart") or type(stageId) ~= "string" then
		return
	end
	part.Touched:Connect(function(hit)
		local player = playerFromHit(hit)
		local reached = player and reachedCheckpoints[player]
		if not player or not reached or reached[part] then
			return
		end
		reached[part] = true
		analytics:track(player, "checkpoint_reached", { stageId = stageId, position = true })
	end)
end

-- Parts tagged "KillPart" (attribute Cause, for example "Lava") kill on touch.
local function bindKillPart(part: Instance)
	if not part:IsA("BasePart") then
		return
	end
	local cause = part:GetAttribute("Cause")
	part.Touched:Connect(function(hit)
		local _, model = playerFromHit(hit)
		local humanoid = model and model:FindFirstChildOfClass("Humanoid")
		if humanoid and humanoid.Health > 0 and model then
			model:SetAttribute("LastCause", if type(cause) == "string" then cause else part.Name)
			humanoid.Health = 0
		end
	end)
end

local function bindTagged(tag: string, bind: (Instance) -> ())
	for _, instance in CollectionService:GetTagged(tag) do
		bind(instance)
	end
	CollectionService:GetInstanceAddedSignal(tag):Connect(bind)
end

bindTagged("StageStart", bindStageStart)
bindTagged("Checkpoint", bindCheckpoint)
bindTagged("KillPart", bindKillPart)

-- One death event per life, with the position and the cause.
local function onCharacter(player: Player, character: Model)
	local humanoid = character:WaitForChild("Humanoid", 10)
	if not humanoid or not humanoid:IsA("Humanoid") then
		return
	end
	humanoid.Died:Once(function()
		local cause = character:GetAttribute("LastCause")
		analytics:track(player, "player_died", {
			position = true,
			metadata = { cause = if type(cause) == "string" then cause else "Unknown" },
		})
	end)
end

local function onPlayerAdded(player: Player)
	reachedCheckpoints[player] = {}
	player.CharacterAdded:Connect(function(character)
		onCharacter(player, character)
	end)
	if player.Character then
		task.spawn(onCharacter, player, player.Character)
	end
end

Players.PlayerAdded:Connect(onPlayerAdded)
for _, player in Players:GetPlayers() do
	onPlayerAdded(player)
end

Players.PlayerRemoving:Connect(function(player)
	currentStage[player] = nil
	reachedCheckpoints[player] = nil
end)

Read the results

  • Open Spatial and pick a stage. The 3D view shows deaths as markers and clusters them into hotspots.
  • Select a marker to see its cause, stage and device. A red ring marks a death after which the player left the game.
  • Choose Explore these sessions to send the visible sessions to Funnels.
  • To draw your level under the markers, publish a map export for the same buildId. See Spatial analytics.

Notes

  • Why deaths are turned off in autoTrack. The SDK's own death event has no cause. Recording deaths yourself, once per life, lets you attach one.
  • One event per life. Died:Once fires a single time for each character.
  • Falling into the void. The position is where the character was when it died, so the height can be very low, but the horizontal position still shows where the fall began.
  • Keep causes short and consistent (for example Lava, not Touched the lava at 4:32) so they are easy to compare.