# 3. Path Sampling and Death Events
Part of [[Playtest Telemetry and Heatmaps]]. Previous: [[2. Telemetry Recorder and CSV Export]]. Next: [[4. Editor Heatmap Window]].
> [!warning] Code not compiled
> Not compiled or run in Unity. Hook names below assume your existing components expose UnityEvents; adapt the wiring if they use C# events instead.
## 3.1 Path and idle sampler
Two ideas in one component:
- **Path**: log the position every `intervalSeconds`, but skip the row if the player moved less than `minDistance`. This keeps files small when someone stands at a menu.
- **Idle**: if the player has not moved more than `idleRadius` for `idleSeconds`, log one `idle` row with the idle duration in `value`. Do not log again until they move. Idle markers are where players are *confused* (no visible next goal, a door they did not recognise as a door) or *reading* (a sign, a cutscene). Both are worth knowing.
```csharp
using UnityEngine;
namespace Playtest.Telemetry
{
/// <summary>
/// Logs path samples and idle events for the GameObject it sits on (usually the player root,
/// or the XR Origin's camera for VR). Not compiled in an editor.
/// </summary>
public class TelemetryPathSampler : MonoBehaviour
{
[Tooltip("Transform to sample. Leave empty to use this transform. For VR, use the Main Camera so you log where the head is.")]
[SerializeField] Transform target;
[SerializeField] string label = "player";
[Header("Path")]
[SerializeField, Min(0.05f)] float intervalSeconds = 0.5f;
[SerializeField, Min(0f)] float minDistance = 0.25f;
[Header("Idle")]
[SerializeField, Min(1f)] float idleSeconds = 8f;
[SerializeField, Min(0.05f)] float idleRadius = 0.75f;
float nextSampleTime;
Vector3 lastLogged;
bool hasLastLogged;
Vector3 idleAnchor;
float idleSince;
bool idleReported;
Transform Target => target != null ? target : transform;
void OnEnable()
{
hasLastLogged = false;
idleAnchor = Target.position;
idleSince = Time.unscaledTime;
idleReported = false;
}
void Update()
{
Vector3 position = Target.position;
UpdateIdle(position);
if (Time.unscaledTime < nextSampleTime) return;
nextSampleTime = Time.unscaledTime + intervalSeconds;
if (hasLastLogged && (position - lastLogged).sqrMagnitude < minDistance * minDistance) return;
lastLogged = position;
hasLastLogged = true;
PlaytestTelemetry.Log(TelemetryTypes.Path, position, label);
}
void UpdateIdle(Vector3 position)
{
if ((position - idleAnchor).sqrMagnitude > idleRadius * idleRadius)
{
if (idleReported)
{
// Close the idle period with its real duration.
PlaytestTelemetry.Log(TelemetryTypes.Idle, idleAnchor, label + "_end", Time.unscaledTime - idleSince);
}
idleAnchor = position;
idleSince = Time.unscaledTime;
idleReported = false;
return;
}
if (!idleReported && Time.unscaledTime - idleSince >= idleSeconds)
{
idleReported = true;
PlaytestTelemetry.Log(TelemetryTypes.Idle, idleAnchor, label, idleSeconds);
}
}
/// <summary>Call after a respawn or teleport so the jump is not drawn as a path segment.</summary>
public void BreakPath()
{
hasLastLogged = false;
idleAnchor = Target.position;
idleSince = Time.unscaledTime;
idleReported = false;
}
}
}
```
> [!tip] Path breaks
> The heatmap window (part 4) draws a path as a polyline per session. A respawn would draw a long straight line from the death spot back to the checkpoint. The window breaks a path at any `death` or `teleport` row, and `BreakPath()` lets your respawn code reset the sampler too.
## 3.2 Event hooks you wire in the Inspector
Rather than editing the health, checkpoint and pickup scripts from earlier tutorials, add one hook component next to them and connect it to their UnityEvents. This is the **Observer** pattern used without touching the subject.
```csharp
using UnityEngine;
namespace Playtest.Telemetry
{
/// <summary>
/// Public methods to call from UnityEvents (Health.onDied, Checkpoint.onReached, Pickup.onCollected...).
/// The logged position is this GameObject's position unless an override transform is set.
/// Not compiled in an editor.
/// </summary>
public class TelemetryEventHooks : MonoBehaviour
{
[Tooltip("Where to log from. Leave empty to use this transform.")]
[SerializeField] Transform positionSource;
[Tooltip("Default label for ReportDeath() when the event passes no cause.")]
[SerializeField] string defaultDeathCause = "unknown";
[Tooltip("Optional sampler to reset after deaths (respawn should not draw a path line).")]
[SerializeField] TelemetryPathSampler pathSampler;
Vector3 Position => positionSource != null ? positionSource.position : transform.position;
public void ReportDeath()
{
ReportDeathWithCause(defaultDeathCause);
}
public void ReportDeathWithCause(string cause)
{
PlaytestTelemetry.Log(TelemetryTypes.Death, Position, string.IsNullOrEmpty(cause) ? defaultDeathCause : cause);
if (pathSampler != null) pathSampler.BreakPath();
}
/// <summary>For health events that pass the damage or remaining health as a float.</summary>
public void ReportDamage(float amount)
{
PlaytestTelemetry.Log("damage", Position, defaultDeathCause, amount);
}
public void ReportCheckpoint(string checkpointId)
{
PlaytestTelemetry.Log(TelemetryTypes.Checkpoint, Position, checkpointId);
}
public void ReportPickup(string itemId)
{
PlaytestTelemetry.Log(TelemetryTypes.Pickup, Position, itemId);
}
public void ReportCustom(string label)
{
PlaytestTelemetry.Log(TelemetryTypes.Custom, Position, label);
}
}
}
```
## 3.3 Step by step: wire the player
1. Select the player root (the [[Character Controllers]] capsule). Add **TelemetryPathSampler**. Leave `target` empty.
2. Add **TelemetryEventHooks** on the same object. Drag the sampler into `pathSampler`.
3. Find the component that fires on death. In [[Combat Feel and Hit Feedback]] and [[Hazards, Traps and Key-Door Puzzles]] the health/damage receiver exposes a death event; in [[4. Checkpoints and Respawn]] the respawn flow is where the player is moved back.
- If it is a **UnityEvent with no argument**: add a listener, pick `TelemetryEventHooks > ReportDeath ()`.
- If it is a **UnityEvent<string>** (cause): pick the *Dynamic string* version `ReportDeathWithCause`.
- If it is a **C# event** (`event Action`), add three lines in a tiny adapter instead of editing the health script:
```csharp
using UnityEngine;
namespace Playtest.Telemetry
{
/// <summary>
/// Example adapter for a C# event. Replace PlayerHealthLike with your own health type.
/// Not compiled in an editor.
/// </summary>
public class TelemetryDeathAdapterExample : MonoBehaviour
{
[SerializeField] TelemetryEventHooks hooks;
[SerializeField] PlayerHealthLike health;
void OnEnable()
{
if (health != null) health.Died += OnDied;
}
void OnDisable()
{
if (health != null) health.Died -= OnDied;
}
void OnDied(string cause)
{
hooks.ReportDeathWithCause(cause);
}
}
/// <summary>Stand-in so this example compiles on its own. Delete it and use your real component.</summary>
public class PlayerHealthLike : MonoBehaviour
{
public event System.Action<string> Died;
public void Kill(string cause)
{
Died?.Invoke(cause);
}
}
}
```
4. On each checkpoint trigger, add a **TelemetryEventHooks** (or reuse one on a manager) and call `ReportCheckpoint("CP_01")` from its reached event.
5. On pickups, call `ReportPickup` with the item id from your [[Inventory Systems]] item definition.
6. Play the level once. Open the CSV: you should see `path` rows every half second while moving, an `idle` row if you stand still, and your `death` / `checkpoint` rows.
## 3.4 What counts as the "cause"?
Use the hazard or enemy **definition id**, not the GameObject name (`SpikePit`, not `SpikePit (3)`). If your hazards are ScriptableObject-driven ([[ScriptableObject Architecture]]), the asset name is a stable id. Stable causes let part 4 filter the heatmap to "deaths by SpikePit only".
## 3.5 VR note
For VR levels, sample the **Main Camera** under the XR Origin (where the head is), not the XR Origin root (which only moves on teleport/turn). Log teleports with `PlaytestTelemetry.Log(TelemetryTypes.Teleport, destination)` from the locomotion hook shown in [[VR Locomotion and Interaction Deep Dive]], and remember that roomscale players walk, so a "path" in VR includes real footsteps.
Next: [[4. Editor Heatmap Window]]