Telemetry

SessionTelemetry records playtest data to local files so balance passes can be tuned against real session data instead of feel. It writes one JSONL file per app launch under user://telemetry/. Nothing is ever transmitted — the data stays on the player's machine, and no personal data is recorded.

What it is

scripts/core/SessionTelemetry.gd is an autoload (registered as SessionTelemetry in project.godot). On _ready() it opens a new session file and subscribes to a set of EventBus signals. A whole session is only a few KB. A module-level constant gates the whole system:

const ENABLED := true

Where it writes

Each launch creates one file:

user://telemetry/run_<unix>.jsonl

<unix> is the launch time from Time.get_unix_time_from_system(). On Windows, user:// resolves to %APPDATA%\Godot\app_userdata\Underroot\.

Every app launch creates a new file, so the directory is capped. _MAX_SESSION_FILES (20) newest files are kept; _prune_old_sessions() runs once at startup and deletes the oldest beyond the cap (on Web this prevents unbounded IndexedDB growth). Filenames sort oldest-first because the unix timestamp sorts lexicographically within its digit count.

Privacy

No personal data is stored. The player-entered name is kept only as a one-way hash so a single player's sessions can be grouped during analysis without retaining the name itself:

func _hash_player_name() -> String:
	return "%08x" % SaveManager.get_player_name().hash()

This appears as player_hash in the session header. Nothing is sent anywhere — the JSONL files are the only output.

Line format

Each line is a standalone JSON object with a kind field. There are three kinds.

session (header)

Written once per session, lazily, the first time anything is logged (_ensure_session_header()). Fields include:

Field Meaning
kind "session"
ts Unix time of the header
version App config version
save_version SaveManager.SAVE_VERSION
slot Active save slot
player_hash One-way hash of the player name
generation Current lineage generation
low_perf PerformanceMode.enabled at session start
idle_mult Survival idle multiplier

day (snapshot)

Written on each EventBus.new_day, but only while a run is active — _run_active() returns SaveManager.get_active_slot() >= 0, because TimeManager can roll a day on the title screen too. Each snapshot (_day_snapshot()) carries the support metrics, resource counts, Maw state, and frame rate:

Frame rate is sampled once per frame in _process, but only while a run is active and the window is focused (DisplayServer.window_is_focused()) — so title-screen and backgrounded frames, which idle-throttle, don't skew the read. fps_avg is the mean of Engine.get_frames_per_second() over the day's samples, fps_min the worst single reading, and fps_n the sample count behind them. Weight the average by fps_n: a day rolled during offline catch-up, or the moment a run loads, can carry few or zero samples (fps_avg/fps_min fall back to 0.0 when fps_n is 0). The window resets after each snapshot is written.

event (one-shot)

Written via _log_event() from the signal handlers wired in _ready(). Each event line carries kind: "event", an event name, ts, the current day, and event-specific fields merged in. The tracked events:

event Source signal Extra fields
died player_died cause, generation
generation_started generation_started generation
specialist specialist_arrived id
astrolabe astrolabe_activated uses, chew_mult
milestone milestone_reached days
building building_completed id, level
storm storm_ended storm result dict
breach maw_breached_base
recipe_unlocked recipe_unlocked id

Like day snapshots, events are dropped unless a run is active.

Reading the data

Open the newest run_<unix>.jsonl under user://telemetry/ and parse it line by line — each line is a complete JSON object, so a stream/JSONL reader works directly. Filter on kind: the single "session" line gives the run's context, "day" lines form a per-day time series (plot support_ratio, breach_eta_s, maw_pressure, inventory, and fps_avg/fps_min against day), and "event" lines mark discrete moments (deaths, rituals, breaches). Group across sessions by player_hash when you need one player's history.

Comparing fps_min day-over-day across builds turns "did that change hurt performance?" into a data question rather than a feel one: a steady floor is evidence it didn't, and a step-down that lines up with a particular build points at the regression. Read fps_min alongside fps_n and low_perf (from the session header) so you don't over-read a thin-sample day or compare a low-perf session against a full-graphics one.

Key files