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:
- Progress:
day,generation,depth,blocks,pop. - Support:
support_food,support_shelter,support_safety,support_ratio(min metric over population). - Survival:
food,water,stored_food,stored_water. - Inventory:
inv— amounts for the strategic materials in_TRACKED_MATS(iron,iron_ore,coal,wood,stone,gold,bronze,steel,strange_root,quartz), omitting any at zero. - Defenses and threat:
walls,wall_hp,maw_front_x,maw_pressure,breach_eta_s,machines. - Frame rate:
fps_avg,fps_min,fps_n.
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
scripts/core/SessionTelemetry.gd— the autoload: session/day/event logging, per-frame FPS sampling, file pruning, name hashing.scripts/core/EventBus.gd— declares the signals the logger subscribes to.