How to see where players die in your game
The Glitchless team · October 27, 2026 · Unity, Godot, Unreal Engine and web games
Record every death with the place it happened, count the deaths in each square of a grid laid over the level, and shade the squares on a picture of it. That's a death heatmap. The busiest squares are where players get stuck, and the cause and what players did next tell you whether that's the challenge you meant or something to fix.
This guide covers what to record, the code to record it in Unity, Godot, Unreal and a web game, a short script that bins the deaths and draws the heatmap, and how to read the result.
The short answer
- At each death, record the position in world coordinates, the level, the cause, the seconds since the level started and the game version.
- Write deaths to a file while you test. From a shipped game, send them in batches, with nothing that names the player.
- Count deaths per square of a fixed grid on the level's two ground axes, then shade each square over a picture of the level.
- A tight cluster with one cause, where players also quit, is usually unfair. Deaths spread over a hard section that players get past on a later try are usually the challenge you designed.
- Compare versions on the same grid, per player, to check that a fix shrank the cluster and didn't just move it.
What to record at each death
Five fields are enough. Record them at the moment the game decides the player died, before a respawn moves the character.
| Field | Example | What it's for |
|---|---|---|
| Position | x: 1412.5, y: 903, z: 0 | Where the character was, in the level's world coordinates, so it lines up with the level whatever the camera did |
| Level | cave_2 | Each level gets its own heatmap |
| Cause | spikes, fell, boss_laser | What killed them, from a short list of words in your code, never free text |
| Time into the level | 48.2 seconds | Tells deaths at the start (spawns, openings) from deaths deep into a long level |
| Version | 1.4.0 | Lets you compare the heatmap before and after a change |
Record the position of the player's character, not of the camera or the enemy. If a death has no sensible place, such as a timer running out, record it without a position: it stays in your death counts and stays off the heatmap.
Record deaths in Unity
Call DeathLog.Record(transform.position, "spikes") from the player's death handler. It appends one line of JSON per death to a file in Application.persistentDataPath, which is enough for playtests. Time.timeSinceLevelLoad counts from when the scene finished loading; if your levels aren't scenes, keep your own start time instead.
DeathLog.cs
using System;
using System.IO;
using UnityEngine;
using UnityEngine.SceneManagement;
[Serializable]
public class Death
{
public string level, cause, version;
public float x, y, z, seconds;
}
public static class DeathLog
{
// From the player's death handler: DeathLog.Record(transform.position, "spikes");
public static void Record(Vector3 position, string cause)
{
var death = new Death
{
level = SceneManager.GetActiveScene().name,
cause = cause,
version = Application.version,
x = position.x, y = position.y, z = position.z,
seconds = Time.timeSinceLevelLoad,
};
var path = Path.Combine(Application.persistentDataPath, "deaths.jsonl");
File.AppendAllText(path, JsonUtility.ToJson(death) + "\n");
}
}
Unity is y up, so a 3D level's ground is x and z, and a 2D game uses x and y. In a shipped game, collect the deaths and post them to your server in batches with UnityWebRequest, not one request per death.
Record deaths in Godot
Add this script as an autoload named DeathLog. Call DeathLog.level_started() when a level begins and DeathLog.record(global_position, "spikes") when the player dies (in 2D, pass the position as the comment in the script shows). Godot 4's FileAccess has no append mode, so the script opens the file for reading and writing and jumps to the end.
death_log.gd (add as an autoload)
# death_log.gd: add as an autoload named DeathLog.
extends Node
const PATH := "user://deaths.jsonl"
var _started_ms := Time.get_ticks_msec()
func level_started() -> void:
_started_ms = Time.get_ticks_msec()
# In 2D: DeathLog.record(Vector3(global_position.x, global_position.y, 0), "spikes")
func record(where: Vector3, cause: String) -> void:
var death := {
"level": get_tree().current_scene.scene_file_path.get_file().get_basename(),
"cause": cause,
"version": ProjectSettings.get_setting("application/config/version", ""),
"x": where.x, "y": where.y, "z": where.z,
"seconds": (Time.get_ticks_msec() - _started_ms) / 1000.0,
}
var mode := FileAccess.WRITE
if FileAccess.file_exists(PATH):
mode = FileAccess.READ_WRITE # Godot has no append mode: open, then jump to the end
var file := FileAccess.open(PATH, mode)
if file == null:
return
file.seek_end()
file.store_line(JSON.stringify(death))
In Godot 2D, y grows down the screen. 3D is y up, so a 3D level's ground is x and z. scene_file_path names the level after its scene file; pass your own level id if one scene holds several levels.
Record deaths in Unreal Engine
Call this from your character when it dies. GetWorld()->GetTimeSeconds() is the time since the level started play, and it stops while the game is paused. The lines go to deaths.jsonl in the project's Saved folder.
MyCharacter.cpp
#include "Kismet/GameplayStatics.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "HAL/FileManager.h"
// From your death handler: RecordDeath(TEXT("fell"));
void AMyCharacter::RecordDeath(const FString& Cause)
{
const FVector Where = GetActorLocation(); // centimetres, Z up
const FString Line = FString::Printf(
TEXT("{\"level\":\"%s\",\"cause\":\"%s\",\"x\":%.1f,\"y\":%.1f,\"z\":%.1f,\"seconds\":%.1f}\n"),
*UGameplayStatics::GetCurrentLevelName(this), *Cause,
Where.X, Where.Y, Where.Z, GetWorld()->GetTimeSeconds());
const FString Path = FPaths::ProjectSavedDir() / TEXT("deaths.jsonl");
FFileHelper::SaveStringToFile(Line, *Path, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM,
&IFileManager::Get(), FILEWRITE_Append);
}
Unreal measures in centimetres with Z up, so a level's ground is X and Y. Add your game's version to the line the same way, and in a shipped game send the deaths in batches with the HTTP module instead of writing a file.
Record deaths in a web game
Keep deaths in an array and send them every 15 seconds and when the page is hidden, with navigator.sendBeacon, which still delivers as the tab closes. /api/deaths stands for an address on your own server.
deaths.js
// deaths.js: record deaths and send them to your server in batches.
const GAME_VERSION = '1.4.0';
const queue = [];
let levelStartedAt = performance.now();
export function levelStarted() {
levelStartedAt = performance.now();
}
// Phaser: recordDeath(this.scene.key, player.x, player.y, 'spikes')
export function recordDeath(level, x, y, cause, z = 0) {
queue.push({
level, cause, version: GAME_VERSION, x, y, z,
seconds: Math.round(performance.now() - levelStartedAt) / 1000,
});
}
function send() {
if (queue.length) navigator.sendBeacon('/api/deaths', JSON.stringify(queue.splice(0)));
}
setInterval(send, 15000);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') send();
});
In Phaser and most canvas games, y grows down the screen. In Three.js, Babylon.js and PlayCanvas, y is up, so a 3D level's ground is x and z. If your Phaser game is slow as well as hard, see Phaser performance optimization.
Bin deaths into a grid
Pick the level's two ground axes: x and y in a 2D game, x and z in a 3D game with y up, X and Y in Unreal. Then cut the level into squares on a fixed grid that starts at the world origin, and count the deaths in each square. A fixed grid keeps every square in the same place as more deaths arrive, and from one version to the next, so you can compare them.
Make a square about as wide as the player's character. Much smaller and every death gets a square of its own; much bigger and two different hazards land in one square.
bins.py
# bins.py: count one level's deaths per square of a fixed grid.
import json
from collections import Counter, defaultdict
def load(path, level):
"""One level's deaths, from a file with one JSON object per line."""
with open(path) as f:
return [d for d in map(json.loads, f) if d["level"] == level]
def bin_deaths(deaths, cell, plane=("x", "y")):
"""Deaths per square, `cell` world units wide. `plane` names the level's two axes:
x and y for a 2D game, x and z for a 3D game with y up (Unity, Godot), X and Y in Unreal."""
a, b = plane
counts, causes = Counter(), defaultdict(Counter)
for d in deaths:
square = (int(d[a] // cell), int(d[b] // cell)) # // rounds down, so negatives land right
counts[square] += 1
causes[square][d["cause"]] += 1
return counts, causes
def hotspots(counts, causes, cell, top=5):
"""Print the busiest squares with their share of the level's deaths and top cause."""
total = sum(counts.values())
for (i, j), n in counts.most_common(top):
cause = causes[(i, j)].most_common(1)[0][0]
print(f"{i * cell:g} to {(i + 1) * cell:g}, {j * cell:g} to {(j + 1) * cell:g}: "
f"{n} deaths ({n / total:.0%}), mostly {cause}")
A hazard on the edge of a square splits its deaths between two or four squares, so the busiest squares are often neighbours. Read them together, or try squares twice the size.
Draw the heatmap over the level
You need a picture of the whole level and the world coordinates of its corners. In 2D, export the level or the tilemap as one image. In 3D, put an orthographic camera straight above the level and take a screenshot. The script below writes an SVG: the picture, with a red square over every square that had deaths, more opaque the more deaths there were. Save it next to bins.py, set the values at the top, run it and open the SVG in a browser.
heatmap.py
# heatmap.py: draw one level's deaths over a picture of the level, as an SVG.
# Set these for your level, then run: python3 heatmap.py
import math
from bins import bin_deaths, hotspots, load
DEATHS, LEVEL = "deaths.jsonl", "cave"
CELL = 32 # world units per square: about a character's width
PICTURE = "cave.png" # the whole level, from the side or from straight above
BOX = (0, 0, 3200, 1800) # the world coordinates the picture covers: x0, y0, x1, y1
PLANE = ("x", "y") # ("x", "z") for a 3D game with y up; ("x", "y") in Unreal
Y_DOWN = True # y grows down the screen (Godot 2D, Phaser); False for y up
def draw(counts, cell, picture, box, y_down):
x0, y0, x1, y1 = box
w, h = x1 - x0, y1 - y0
most = math.log1p(max(counts.values()))
squares = []
for (i, j), n in counts.items():
x = i * cell - x0
y = j * cell - y0 if y_down else y1 - (j + 1) * cell
heat = 0.15 + 0.7 * math.log1p(n) / most # log scale, so one deadly pit doesn't hide the rest
squares.append(f'<rect x="{x}" y="{y}" width="{cell}" height="{cell}" '
f'fill="#e8202a" fill-opacity="{heat:.2f}"/>')
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}">'
f'<image href="{picture}" width="{w}" height="{h}" preserveAspectRatio="none"/>'
+ "".join(squares) + "</svg>")
counts, causes = bin_deaths(load(DEATHS, LEVEL), CELL, PLANE)
hotspots(counts, causes, CELL)
with open(f"{LEVEL}-deaths.svg", "w") as f:
f.write(draw(counts, CELL, PICTURE, BOX, Y_DOWN))
The shading is on a log scale, so a pit with 200 deaths doesn't make one with 20 disappear. If the squares come out mirrored top to bottom, flip Y_DOWN. The script needs nothing but Python 3.
Read the heatmap
A cluster tells you where players die. These tell you whether to change anything:
- Divide by traffic. A square on the main path collects deaths because every player walks through it. Record positions a few times a second as well, bin them the same way, and compare deaths with visits in each square: the share of players who die when they get there is the real difficulty.
- Unfair spot or designed challenge. A designed challenge spreads deaths across the hard part, from the causes you'd expect, and players get through on a later try. An unfair spot packs deaths into one square with one cause, often from something players can't see coming: an enemy just off-screen, a hitbox bigger than its sprite, a jump a few pixels too long.
- Deaths followed by a quit. Dying is part of play; quitting a few seconds after dying is the signal. Mark the last death before each quit on a layer of its own. A cluster of those is the first thing to fix.
- Time into the level. Deaths in the first seconds point at spawns and openings, such as an enemy that hits before the player can move. Deaths late in a long level cost players more progress, so they hurt more.
- Strange places. Deaths far outside the level or at the origin are usually bugs: falling through the floor, a kill zone in the wrong place, a position read after the respawn.
- Versions. Keep the grid and the square size, and compare deaths per player or per attempt rather than raw counts, since a new version may have more players. A fix worked if the cluster shrank and more players finished the level, not if the deaths moved to the next hazard.
Privacy: positions are places in your game
A death position is where the character stood in your game's world: an x, y and z in the level's own units. It says nothing about where the person playing is, and it doesn't come from GPS, an IP address or the device.
Keep it that way. A heatmap needs where and how players died, not who: don't attach names, emails, account IDs or device IDs to deaths. If you need to tell one player's retries from another's, use a random ID the game makes for the session. List gameplay data such as deaths and positions in your privacy policy all the same.
How Glitchless does it
Glitchless is open-source observability for Unity, Godot, Unreal and web games, and it records deaths, positions and levels for you. Call its death function where the player dies: glitchless.death({ x, y, z, cause }) in a web game, Glitchless.Death(player.position, "spikes") in Unity, Glitchless.death(pos.x, pos.y, "fell", pos.z) in Godot (leave out z in 2D) and FGlitchless::Death(GetActorLocation(), TEXT("fell")) in Unreal. Call its set-position function every frame (setPosition, SetPosition, set_position) for where players go; it samples 4 times a second. Every death carries its level: Unity and Phaser scenes and Unreal maps count as levels on their own, or call the level start function with your own names.
Positions are off by default, so deaths arrive without a place until you turn them on: with full privacy mode, or with the positions option on its own (positions: true on the web, Positions = true in Unity's options, positions in Godot's, Positions in Unreal's settings). They are places in the game's own world, not the player's location. Unreal positions are converted to metres with y up before they're sent.
The Heatmaps view shows each level with where players go (time spent, from the position samples) and where they die, coloured by cause, with a table of death hotspots: where each one is, its deaths, its share of the level's deaths, the top cause and the rage quits there. A rage quit is a session that ends within 20 seconds of a death or a failed level, with no later level start or completion. Next to the map are the level's completion rate and deaths per session. In Three.js, Babylon.js and PlayCanvas games, captureMap renders a picture of the level to draw them on.
Your coding agent reads the same data over MCP. glitchless_death_hotspots returns a level's death clusters in world coordinates (each one's x range, share of the level's deaths, top cause and rage quits), the level's bounds and its completion stats, and takes version to look at one build. glitchless_triage lists difficulty spikes among its findings. With the coordinates, the agent can open the level data around a hotspot and suggest a change.
Ask your coding agent
Use Glitchless to find where players die in my game: run glitchless_death_hotspots on the level with the lowest completion rate, open the level data around the top hotspot, tell me whether it looks unfair or like a designed challenge, and suggest a fix. After I ship the fix, compare that hotspot between the two versions.
Frequently asked questions
How many deaths do I need before a heatmap means anything?
Enough that no single player can make a cluster: one stubborn player can die dozens of times on the same jump. Count the players or sessions that died in each square as well as the deaths, and trust a cluster once many different players add to it.
Do I need positions all the time, or only at deaths?
Deaths alone draw the heatmap. Positions a few times a second add where players go, which you need to tell a dangerous square from a busy one, and they show the paths players take around a hazard. They're more data, so sample them rather than sending every frame.
My game is 2D. Which coordinates do I use?
x and y. In Godot 2D, Phaser and most canvas games y grows down the screen; in Unity 2D it grows up. Get it wrong and the heatmap comes out upside down, which the Y_DOWN setting in the script above fixes.
Can I make heatmaps for procedurally generated levels?
Positions only line up within the same layout. Record the room, chunk or seed with each death, and the position within that room, then draw one heatmap per room type. In an open world, record the region and draw one per region.
Is a death position personal data?
On its own it's a point in your game's world, so it says neither who the player is nor where they are. Tied to an account it becomes part of a record of how that person played, so keep deaths apart from anything that names the player, and list gameplay data in your privacy policy.
Sources
- Unity docs: Time.timeSinceLevelLoad
- Godot docs: FileAccess
- Unreal Engine API: FFileHelper
- MDN: Navigator.sendBeacon()
- Glitchless on GitHub: SDKs, the heatmap and the MCP server
Something wrong or out of date? Email support@glitchless.gg and we'll fix it. Glitchless isn't affiliated with Unity Technologies, the Godot Foundation, Epic Games or Phaser Studio; product names are their owners' trademarks.