# 4. Editor Heatmap Window
Part of [[Playtest Telemetry and Heatmaps]]. Previous: [[3. Path Sampling and Death Events]]. Next: [[5. Worked Example Classroom Playtest Loop]].
> [!warning] Code not compiled
> Written against the 6000.5 Scripting API for `SceneView.duringSceneGui`, `Handles.DrawSolidRectangleWithOutline` and `Handles.DrawAAPolyLine`, and the UI Toolkit `EditorWindow.CreateGUI` pattern from [[Editor Windows with UI Toolkit]]. Not compiled or run.
The Editor is the analysis app. Three scripts, all in an `Editor` folder (or an Editor-only asmdef):
1. `TelemetryCsvReader` - parse CSV rows back into `TelemetryEvent`s.
2. `HeatmapGrid` - a uniform XZ grid (the **Spatial Partition** pattern) with an optional blur kernel.
3. `TelemetryHeatmapWindow` - UI Toolkit controls plus a `SceneView.duringSceneGui` callback that draws.
## 4.1 CSV reader
The reader handles quoted fields and skips malformed rows with a warning instead of throwing, because student builds will produce a few odd lines (a crash mid-write, an old schema).
```csharp
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using UnityEngine;
namespace Playtest.Telemetry.EditorTools
{
/// <summary>
/// Reads session_*.csv files written by PlaytestTelemetry. Editor only. Not compiled in an editor.
/// </summary>
public static class TelemetryCsvReader
{
public static List<TelemetryEvent> ReadFolder(string folder, List<string> warnings)
{
var all = new List<TelemetryEvent>();
if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder))
{
warnings?.Add("Folder not found: " + folder);
return all;
}
// Recursive, so a folder of per-headset subfolders (part 5) loads in one go.
foreach (string file in Directory.GetFiles(folder, "session_*.csv", SearchOption.AllDirectories))
{
all.AddRange(ReadFile(file, warnings));
}
return all;
}
public static List<TelemetryEvent> ReadFile(string path, List<string> warnings)
{
var result = new List<TelemetryEvent>();
string[] lines;
try
{
lines = File.ReadAllLines(path, Encoding.UTF8);
}
catch (IOException ex)
{
warnings?.Add(Path.GetFileName(path) + ": " + ex.Message);
return result;
}
for (int i = 1; i < lines.Length; i++) // line 0 is the header
{
string line = lines[i];
if (string.IsNullOrWhiteSpace(line)) continue;
List<string> c = SplitCsvLine(line);
if (c.Count < 9)
{
warnings?.Add(Path.GetFileName(path) + " line " + (i + 1) + ": expected 9 columns, got " + c.Count);
continue;
}
if (!TryFloat(c[1], out float time) ||
!TryFloat(c[4], out float x) ||
!TryFloat(c[5], out float y) ||
!TryFloat(c[6], out float z))
{
warnings?.Add(Path.GetFileName(path) + " line " + (i + 1) + ": bad number");
continue;
}
TryFloat(c[8], out float value);
result.Add(new TelemetryEvent
{
sessionId = c[0],
time = time,
scene = c[2],
type = c[3],
position = new Vector3(x, y, z),
label = c[7],
value = value
});
}
return result;
}
static bool TryFloat(string s, out float f)
{
return float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out f);
}
/// <summary>RFC-4180-style split: commas inside quotes are kept, "" is an escaped quote.</summary>
public static List<string> SplitCsvLine(string line)
{
var fields = new List<string>(9);
var sb = new StringBuilder();
bool inQuotes = false;
for (int i = 0; i < line.Length; i++)
{
char ch = line[i];
if (inQuotes)
{
if (ch == '"')
{
if (i + 1 < line.Length && line[i + 1] == '"')
{
sb.Append('"');
i++;
}
else
{
inQuotes = false;
}
}
else
{
sb.Append(ch);
}
}
else if (ch == '"')
{
inQuotes = true;
}
else if (ch == ',')
{
fields.Add(sb.ToString());
sb.Clear();
}
else
{
sb.Append(ch);
}
}
fields.Add(sb.ToString());
return fields;
}
}
}
```
## 4.2 Heatmap grid
Each event adds weight to its cell, and optionally to neighbours with a linear falloff (`blurRadius` in cells). Blur matters: 20 deaths spread across four adjacent 1 m cells look like four cold spots without it, but they are one problem.
The grid also tracks a weighted average height per cell, so a multi-floor level draws heat on the floor where the events happened rather than at y = 0.
```csharp
using System.Collections.Generic;
using UnityEngine;
namespace Playtest.Telemetry.EditorTools
{
/// <summary>
/// Uniform XZ grid of weights. Editor only. Not compiled in an editor.
/// </summary>
public class HeatmapGrid
{
public struct Cell
{
public float weight;
public float heightSum;
public float heightWeight;
public int rawCount;
public float AverageHeight => heightWeight > 0f ? heightSum / heightWeight : 0f;
}
readonly Dictionary<Vector2Int, Cell> cells = new();
public float CellSize { get; }
public float MaxWeight { get; private set; }
public int CellCount => cells.Count;
public IReadOnlyDictionary<Vector2Int, Cell> Cells => cells;
public HeatmapGrid(float cellSize)
{
CellSize = Mathf.Max(0.05f, cellSize);
}
public Vector2Int ToCell(Vector3 world)
{
return new Vector2Int(Mathf.FloorToInt(world.x / CellSize), Mathf.FloorToInt(world.z / CellSize));
}
public void Add(Vector3 world, int blurRadius)
{
Vector2Int centre = ToCell(world);
int r = Mathf.Max(0, blurRadius);
for (int dx = -r; dx <= r; dx++)
{
for (int dz = -r; dz <= r; dz++)
{
float distance = Mathf.Sqrt(dx * dx + dz * dz);
if (distance > r + 0.01f) continue;
float w = r == 0 ? 1f : 1f - distance / (r + 1f);
Accumulate(new Vector2Int(centre.x + dx, centre.y + dz), w, world.y, dx == 0 && dz == 0);
}
}
}
void Accumulate(Vector2Int key, float w, float height, bool isCentre)
{
cells.TryGetValue(key, out Cell cell);
cell.weight += w;
cell.heightSum += height * w;
cell.heightWeight += w;
if (isCentre) cell.rawCount++;
cells[key] = cell;
if (cell.weight > MaxWeight) MaxWeight = cell.weight;
}
/// <summary>Four corners of a cell at the given height, in order, for Handles.DrawSolidRectangleWithOutline.</summary>
public void GetQuad(Vector2Int key, float height, Vector3[] corners)
{
float x0 = key.x * CellSize;
float z0 = key.y * CellSize;
float x1 = x0 + CellSize;
float z1 = z0 + CellSize;
corners[0] = new Vector3(x0, height, z0);
corners[1] = new Vector3(x0, height, z1);
corners[2] = new Vector3(x1, height, z1);
corners[3] = new Vector3(x1, height, z0);
}
public Vector3 CellCentre(Vector2Int key, float height)
{
return new Vector3((key.x + 0.5f) * CellSize, height, (key.y + 0.5f) * CellSize);
}
}
}
```
## 4.3 The window
What it does:
- **Folder**: defaults to `Application.persistentDataPath/Telemetry` (where Play mode and Windows builds write); **Browse** to a folder of CSVs pulled from Quest headsets.
- **Filters**: event type (`death`, `path`, `idle`, `teleport`...), cause/label contains, build label contains (from `session_start`), only the active scene.
- **Draw**: heat cells with a gradient, the top five cells labelled with their raw counts, and each session's path as a polyline (broken at deaths and teleports).
- **Export** the visible grid as `heatmap_<type>.csv` for a spreadsheet chart.
```csharp
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.SceneManagement;
using UnityEngine.UIElements;
namespace Playtest.Telemetry.EditorTools
{
/// <summary>
/// Tools > Playtest > Telemetry Heatmap. Draws telemetry in the Scene view.
/// Not compiled in an editor.
/// </summary>
public class TelemetryHeatmapWindow : EditorWindow
{
[MenuItem("Tools/Playtest/Telemetry Heatmap")]
static void Open()
{
GetWindow<TelemetryHeatmapWindow>("Telemetry Heatmap");
}
// Serialized so settings survive domain reloads.
[SerializeField] string folder;
[SerializeField] string eventType = TelemetryTypes.Death;
[SerializeField] string labelContains = "";
[SerializeField] string buildContains = "";
[SerializeField] bool onlyActiveScene = true;
[SerializeField] float cellSize = 1f;
[SerializeField] int blurRadius = 1;
[SerializeField] bool showHeat = true;
[SerializeField] bool showPaths = true;
[SerializeField] bool xRay = false;
[SerializeField] float heightOffset = 0.05f;
[SerializeField, Range(0.1f, 1f)] float maxAlpha = 0.7f;
readonly List<TelemetryEvent> allEvents = new();
readonly List<string> warnings = new();
readonly Dictionary<string, string> buildBySession = new();
readonly List<Vector3[]> pathSegments = new();
readonly List<Vector2Int> topCells = new();
readonly Vector3[] quad = new Vector3[4];
HeatmapGrid grid;
Gradient gradient;
Label status;
int sessionsShown;
int eventsShown;
void OnEnable()
{
if (string.IsNullOrEmpty(folder))
{
folder = Path.Combine(Application.persistentDataPath, "Telemetry");
}
gradient = BuildGradient();
SceneView.duringSceneGui += OnSceneGui;
}
void OnDisable()
{
SceneView.duringSceneGui -= OnSceneGui;
}
void CreateGUI()
{
VisualElement root = rootVisualElement;
root.style.paddingLeft = 6;
root.style.paddingRight = 6;
root.style.paddingTop = 6;
var folderField = new TextField("CSV folder") { value = folder };
folderField.RegisterValueChangedCallback(e => folder = e.newValue);
root.Add(folderField);
var folderRow = new VisualElement();
folderRow.style.flexDirection = FlexDirection.Row;
folderRow.Add(new Button(() =>
{
string picked = EditorUtility.OpenFolderPanel("Telemetry folder", folder, "");
if (!string.IsNullOrEmpty(picked))
{
folder = picked;
folderField.value = picked;
}
}) { text = "Browse..." });
folderRow.Add(new Button(() =>
{
if (Directory.Exists(folder)) EditorUtility.RevealInFinder(folder);
}) { text = "Reveal" });
folderRow.Add(new Button(Load) { text = "Load / Reload" });
root.Add(folderRow);
root.Add(MakeTextField("Event type", eventType, v => eventType = v));
root.Add(MakeTextField("Label contains", labelContains, v => labelContains = v));
root.Add(MakeTextField("Build label contains", buildContains, v => buildContains = v));
root.Add(MakeToggle("Only active scene", onlyActiveScene, v => onlyActiveScene = v));
var cellField = new FloatField("Cell size (m)") { value = cellSize };
cellField.RegisterValueChangedCallback(e => { cellSize = Mathf.Max(0.1f, e.newValue); Rebuild(); });
root.Add(cellField);
var blurField = new SliderInt("Blur radius (cells)", 0, 4) { value = blurRadius, showInputField = true };
blurField.RegisterValueChangedCallback(e => { blurRadius = e.newValue; Rebuild(); });
root.Add(blurField);
var alphaField = new Slider("Max alpha", 0.1f, 1f) { value = maxAlpha, showInputField = true };
alphaField.RegisterValueChangedCallback(e => { maxAlpha = e.newValue; SceneView.RepaintAll(); });
root.Add(alphaField);
root.Add(MakeToggle("Show heat", showHeat, v => showHeat = v));
root.Add(MakeToggle("Show paths", showPaths, v => showPaths = v));
root.Add(MakeToggle("X-ray (draw through walls)", xRay, v => xRay = v));
root.Add(new Button(ExportGrid) { text = "Export grid CSV" });
status = new Label("No data loaded. Press Load.");
status.style.whiteSpace = WhiteSpace.Normal;
status.style.marginTop = 6;
root.Add(status);
}
TextField MakeTextField(string label, string initial, System.Action<string> set)
{
var field = new TextField(label) { value = initial, isDelayed = true };
field.RegisterValueChangedCallback(e => { set(e.newValue); Rebuild(); });
return field;
}
Toggle MakeToggle(string label, bool initial, System.Action<bool> set)
{
var toggle = new Toggle(label) { value = initial };
toggle.RegisterValueChangedCallback(e => { set(e.newValue); Rebuild(); });
return toggle;
}
void Load()
{
warnings.Clear();
allEvents.Clear();
allEvents.AddRange(TelemetryCsvReader.ReadFolder(folder, warnings));
buildBySession.Clear();
foreach (TelemetryEvent e in allEvents)
{
if (e.type == TelemetryTypes.SessionStart && !buildBySession.ContainsKey(e.sessionId))
{
buildBySession[e.sessionId] = e.label ?? "";
}
}
Rebuild();
}
bool PassesCommonFilters(TelemetryEvent e, string activeScene)
{
if (onlyActiveScene && e.scene != activeScene) return false;
if (!string.IsNullOrEmpty(buildContains))
{
buildBySession.TryGetValue(e.sessionId, out string build);
if (build == null || !build.Contains(buildContains)) return false;
}
return true;
}
void Rebuild()
{
string activeScene = SceneManager.GetActiveScene().name;
grid = new HeatmapGrid(cellSize);
pathSegments.Clear();
topCells.Clear();
eventsShown = 0;
var sessions = new HashSet<string>();
foreach (TelemetryEvent e in allEvents)
{
if (e.type != eventType) continue;
if (!PassesCommonFilters(e, activeScene)) continue;
if (!string.IsNullOrEmpty(labelContains) && (e.label == null || !e.label.Contains(labelContains))) continue;
grid.Add(e.position, blurRadius);
sessions.Add(e.sessionId);
eventsShown++;
}
BuildPaths(activeScene);
topCells.AddRange(grid.Cells
.OrderByDescending(kv => kv.Value.rawCount)
.Take(5)
.Where(kv => kv.Value.rawCount > 0)
.Select(kv => kv.Key));
sessionsShown = sessions.Count;
UpdateStatus();
SceneView.RepaintAll();
}
void BuildPaths(string activeScene)
{
// Group path rows by session, in time order, and break at deaths, teleports and scene changes.
foreach (IGrouping<string, TelemetryEvent> session in allEvents
.Where(ev => PassesCommonFilters(ev, activeScene))
.GroupBy(ev => ev.sessionId))
{
var current = new List<Vector3>();
string currentScene = null;
foreach (TelemetryEvent e in session.OrderBy(ev => ev.time))
{
bool breaks = e.type == TelemetryTypes.Death || e.type == TelemetryTypes.Teleport || e.scene != currentScene;
if (breaks)
{
if (current.Count > 1) pathSegments.Add(current.ToArray());
current.Clear();
currentScene = e.scene;
}
if (e.type == TelemetryTypes.Path)
{
current.Add(e.position + Vector3.up * heightOffset);
}
}
if (current.Count > 1) pathSegments.Add(current.ToArray());
}
}
void UpdateStatus()
{
if (status == null) return;
var sb = new StringBuilder();
sb.Append(allEvents.Count).Append(" rows loaded from ").Append(buildBySession.Count).Append(" sessions. ");
sb.Append("Showing ").Append(eventsShown).Append(" '").Append(eventType).Append("' events from ")
.Append(sessionsShown).Append(" sessions in ").Append(grid != null ? grid.CellCount : 0).Append(" cells; ")
.Append(pathSegments.Count).Append(" path segments.");
if (warnings.Count > 0)
{
sb.Append("\n").Append(warnings.Count).Append(" warnings, first: ").Append(warnings[0]);
}
status.text = sb.ToString();
}
void OnSceneGui(SceneView view)
{
if (Event.current.type != EventType.Repaint) return;
if (grid == null) return;
CompareFunction previousZTest = Handles.zTest;
Handles.zTest = xRay ? CompareFunction.Always : CompareFunction.LessEqual;
if (showHeat && grid.MaxWeight > 0f)
{
foreach (KeyValuePair<Vector2Int, HeatmapGrid.Cell> kv in grid.Cells)
{
float t = kv.Value.weight / grid.MaxWeight;
Color c = gradient.Evaluate(t);
c.a = Mathf.Lerp(0.15f, maxAlpha, t);
grid.GetQuad(kv.Key, kv.Value.AverageHeight + heightOffset, quad);
Handles.DrawSolidRectangleWithOutline(quad, c, Color.clear);
}
foreach (Vector2Int key in topCells)
{
HeatmapGrid.Cell cell = grid.Cells[key];
Vector3 p = grid.CellCentre(key, cell.AverageHeight + heightOffset + 0.5f);
Handles.Label(p, cell.rawCount.ToString(CultureInfo.InvariantCulture) + " " + eventType);
}
}
if (showPaths)
{
Handles.color = new Color(0.2f, 0.8f, 1f, 0.6f);
foreach (Vector3[] segment in pathSegments)
{
Handles.DrawAAPolyLine(3f, segment);
}
}
Handles.zTest = previousZTest;
}
void ExportGrid()
{
if (grid == null || grid.CellCount == 0)
{
EditorUtility.DisplayDialog("Telemetry Heatmap", "Nothing to export. Load data first.", "OK");
return;
}
CultureInfo ci = CultureInfo.InvariantCulture;
var sb = new StringBuilder("cellX,cellZ,worldX,worldZ,height,weight,rawCount\n");
foreach (KeyValuePair<Vector2Int, HeatmapGrid.Cell> kv in grid.Cells.OrderByDescending(k => k.Value.weight))
{
Vector3 centre = grid.CellCentre(kv.Key, kv.Value.AverageHeight);
sb.Append(kv.Key.x).Append(',').Append(kv.Key.y).Append(',')
.Append(centre.x.ToString("F2", ci)).Append(',')
.Append(centre.z.ToString("F2", ci)).Append(',')
.Append(centre.y.ToString("F2", ci)).Append(',')
.Append(kv.Value.weight.ToString("F3", ci)).Append(',')
.Append(kv.Value.rawCount).Append('\n');
}
string path = Path.Combine(folder, "heatmap_" + eventType + ".csv");
File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
EditorUtility.RevealInFinder(path);
}
static Gradient BuildGradient()
{
var g = new Gradient();
g.SetKeys(
new[]
{
new GradientColorKey(new Color(0.1f, 0.2f, 1f), 0f),
new GradientColorKey(new Color(0.1f, 0.9f, 0.3f), 0.35f),
new GradientColorKey(new Color(1f, 0.9f, 0.1f), 0.65f),
new GradientColorKey(new Color(1f, 0.15f, 0.1f), 1f)
},
new[]
{
new GradientAlphaKey(1f, 0f),
new GradientAlphaKey(1f, 1f)
});
return g;
}
}
}
```
> [!note] Why the file pattern is `session_*.csv`
> `ExportGrid` writes `heatmap_<type>.csv` into the same folder. The reader only picks up `session_*.csv`, so exports never get re-read as telemetry.
## 4.4 Step by step
1. Put the three scripts in an `Editor` folder (or the Editor asmdef from part 2). Wait for the compile.
2. **Tools > Playtest > Telemetry Heatmap**. Dock it next to the Inspector.
3. Open the scene the telemetry came from (the window filters by the **active scene** name by default).
4. Press **Load / Reload**. The status line shows rows, sessions and cells.
5. Set **Event type** to `death`. Heat cells appear in the Scene view. The five hottest cells show their raw counts.
6. Change **Event type** to `idle`, then `path`. A `path` heatmap is a "where do people walk" map; compare it with where you *wanted* them to walk.
7. Toggle **X-ray** when a heat cell is hidden under a ceiling or inside a mesh.
8. **Export grid CSV** for a chart in a spreadsheet, or to attach to a playtest report.
> [!tip] Cell size is a design choice
> Use roughly the player's footprint for deaths (0.5 to 1 m), larger cells (2 to 4 m) for idle and path in big outdoor spaces ([[Terrain]]). Blur radius 1 is a good default; 0 when you want to check one exact hazard tile.
## 4.5 Extending the window
- Add a **ListView** of sessions with checkboxes, using the patterns in [[4. Lists Selection and Refresh]].
- Draw `checkpoint` rows as labelled discs (`Handles.DrawWireDisc`) and show average time between checkpoints.
- Replace the Scene view quads with a baked `Texture2D` projected by a decal for a screenshot-friendly overlay. The Handles version is fine for analysis and costs nothing in builds.
Next: [[5. Worked Example Classroom Playtest Loop]]