Appearance
Analytics
TypeTorch has its own analytics. It is optional and fully yours: your game logs what players do, your own backend stores it, and you query it yourself. It doesn't depend on Roblox's analytics (the explorer can show Roblox's own numbers next to yours when the backend has an Open Cloud key). Every event is labelled with the build (artifact) that was live, so you can compare builds, experiments and devices.
- Write side:
AnalyticsEnginein@typetorch/framework(0.3.0+), on the server and the client. - Backend, your choice: a self-hosted DuckDB server (one small VPS), or Cloudflare Basin (no server).
- Read side: the backend (
@typetorch/backend, run from a checkout or its Docker image; not on npm): the queries, the DuckDB server, the fleet API,bun run report, and the web explorer at the backend's URL (The explorer: its pages, filters and command palette). - Live experiment overrides:
typetorch experiment(CLI 0.10.2+; below), or prepared on the explorer's Experiments page.
Contents: Using it in a game · What is collected · The event format · Backends · Settings · Experiments · Queries · Node graphs · bun run report · Privacy · Quick start: a local test
Related: The explorer · Recordings and maps · Live events · Player feedback · Notifications
Using it in a game
Nothing runs until you create an engine: no connections, threads or requests. Games that don't use it pay only the module load.
ts
// A server module (onInit)
import { AnalyticsEngine } from "@typetorch/framework";
const analytics = new AnalyticsEngine();
analytics.track(player, "quest_done", { quest: "tutorial" }); // anything custom
analytics.step(player, "onboarding", 3, "opened_shop"); // a funnel step: funnel, index, name
analytics.purchase(player, { product: 1234, robux: 99, where: "shop" }); // after the receipt is granted
analytics.currency(player, "coins", 50, "round_reward"); // economy: + in, - out
analytics.state("round"); // every player's activity; analytics.state(player, "shop") for one
const variant = analytics.experiment(player, "onboarding", ["short", "long"]); // the first is the controlts
// A client controller (onStart): everything goes through the server, never to the internet
const analytics = new AnalyticsEngine();
analytics.track("opened_map");
analytics.screen("Inventory"); // for UIs that aren't separate ScreenGuis
const variant = analytics.experiment("onboarding", ["short", "long"]); // the same answer as the server's- One engine per generation and realm. Every
new AnalyticsEngine()joins the one already running (the first one's options win), so any module can create its own. It stops with the generation. Unsent rows wait inpersistand the next generation sends them, so a hot swap loses nothing. - It starts on the first
new AnalyticsEngine()of the generation, not before. Create it in theonInitof a module with a lowloadOrder. Created only from a helper that first runs on a game event, it doesn't start on an idle server, and that server sends nothing (no sessions, no tech rows). - Server calls take the player first. Without a player, an event is server-only (no player id).
- Client calls are about the local player. They are marked
src = "client"(a client can lie), and the server checks their shape, size (props at most 4 KB) and rate (120 a minute, 5,000 a session; a client's experiment calls count too).purchaseandcurrencyare server-only (revenue and the economy are server-authoritative): on the client they warn once and send nothing, and both the game server and the analytics server refuse them from clients. - Revenue counts server-sent purchases only. Payers and Robux come from
purchaserows the server sent. Clients can't sendpurchaseorcurrencyat all (framework after 0.3.2; older engines' client rows are refused at ingest and never counted). Rows from beforesrcexisted still count. Callanalytics.purchaseon the server, after the receipt is granted. - The cloud test sends nothing (framework after 0.3.2). In
typetorch test --cloud(before every prod deploy) the engine collects as usual but never uploads: no event rows, no identity rows, no HTTP request. A prod deploy never adds a fake server session. - Options (all on by default):
sessions,tech,zones,screens,recording,fleet,identity(privacy).positions(framework 0.7.1+) is a list of event names that always carry the player's position (below).settings(server only) replaces the settings key, for tests. analytics.stats()(server) returns the counters (queued, sent, dropped, refused);analytics.flush()sends soon.- In edit mode (UI Labs stories) it is inert.
- The template has a complete example:
AnalyticsService(server) andAnalyticsController(client). - Game settings: Allow HTTP Requests must be on (Game Settings > Security).
What is collected
On every event: the time, a random player id (never the UserId), the session, the server (JobId, type, place), the build (artifact id, #seq, branch, channel), the device, new vs returning, the player's experiment variants, and the player's state (zone:Lobby|screen:Shop|activity:round).
Automatic (the same in every game):
| Group | Option | Events |
|---|---|---|
| Sessions | sessions | join and leave, session length, where they came from (direct, teleport, friend follow, share link...), device and input type, screen size, account age bucket, Premium, country, language (locale), the player policy (ads, paid random items, trading, sharing, subscriptions, China, allowed links; framework 0.6.3), the device trust level (Player.DeviceTrustLevel, settled on the leave row; framework 0.6.3), friends in the server (one friend-list lookup per join), first visit or returning, days since the last visit |
| Tech health | tech | server FPS and heartbeat, client FPS, ping, memory, load time, client and server errors, hot swaps and rollbacks, and who left within 60 s of a swap |
| Zones | zones | entering and leaving zones: parts or models tagged TTZone in the place, named by a Name attribute or the instance name. The smallest zone around the player wins |
| Screens | screens | ScreenGuis in PlayerGui, and GuiObjects tagged TTScreen |
| First session | recording | below |
| Fleet | fleet | each server's status and deploy reports from the kernel, for history (Fleet and alerts is the live view) |
From your code (game-specific): funnels such as onboarding (step), purchases (purchase), currency in and out (currency), activities (state), and anything custom (track).
Never collected: chat or anything typed, usernames and display names (error texts have them replaced with <player>), UserIds.
The first-ever session, recorded in detail
Only a player's first session in the game is recorded, from the start until recordSeconds after their first input (60 s by default, 10-600; at most 10 minutes from the start; framework 0.7.1+, older frameworks always use 60 s). About 10 times a second it samples the character position and facing and the camera. It also records each input (key, click, tap, gamepad button, thumbstick), each button pressed or hovered (by path, e.g. Shop/Buy/Coins100), screens opened and closed, proximity prompts, deaths and spawns, and your own track() events in that window.
- Keys are never recorded while a TextBox or the chat has focus. A text box only says which box was used, never its text.
- It is packed into small binary chunks (
tt-rec-1), sent through the server: about 10-20 KB per new player at the default 60 s, about 55-70 KB at 300 s and 110-135 KB at 600 s. A recording also ends at 256 KB or 80 chunks. recordShare(0 to 1) in the settings picks the share of new players who are recorded (stable per player). Turn it down first when volume spikes.- The explorer's Recordings page draws them on a map and replays them: Recordings and maps.
Positions
Framework 0.7.1+. Game-code events (custom, funnel, purchase, currency) can carry the player's character position as the props px, py, pz (studs, rounded to 0.1; the model's PrimaryPart, else HumanoidRootPart). The explorer's map layers read exactly these keys.
ts
// One event: the event's player (client: the local player), or any player
analytics.track(player, "down", { cause: "fall" }, { position: true });
// Every event with one of these names, whichever call logs it (at most 64 names)
const analytics = new AnalyticsEngine({ positions: ["down", "death_final", "round_result"] });- The settings'
positionslist (below) adds names live, without a deploy. A client event logged before the server's hello doesn't know that list yet; the engine option works from the start. - Nothing is added without a character or root part, and props the game set itself (
px,pyorpz) are never overwritten. - Which map: put the map's name in a
mapprop on some event (for exampletrack(player, "round_start", { map: "Forest" })). The backend files each position under the latest map event before it, and exports the models that match those names (Recordings and maps).
The event format
Two tables of flat JSON rows. Every column is always present: a missing value is "", 0 or false. The full contract, with every event and its props, is SCHEMA.md in the framework.
events:
| Column | Meaning |
|---|---|
v | format version, 1 |
t | unix ms, on the game server's clock (client events are corrected) |
kind | session, tech, zone, funnel, purchase, currency, state, experiment, custom, recording_meta, fleet, notify (below), feedback (Player feedback) |
name | the event name (1-64 characters) |
pid, sid | random player id and session id ("" on server-only events) |
job, srv, place | JobId, server type (public, private, reserved, studio), PlaceId |
art, seq, branch, channel | the build that was live |
dev | desktop, phone, tablet, console, vr, unknown |
newp | part of the player's first-ever session |
state | the player's state after the event, zone:<z>|screen:<s>|activity:<a> |
exp | the player's experiments as JSON, {"onboarding":"short"} |
sexp | the server's experiment: the artifact id of an A/B pin, or "" |
src | server or client (backend on rows the backend writes itself: notification rows) |
props | the event's own data as JSON text, at most 4096 bytes |
recordings (first sessions): v, t, pid, sid, job, art, chunk, codec (tt-rec-1), data (base64), n.
Delivery is at least once: a hot swap during a request sends that batch again. The DuckDB server drops exact duplicates when it writes each day's file.
The join row
The session row written when a player joins carries all of Roblox's join data and the player facts, in props:
| Prop | From | Note |
|---|---|---|
from | direct, teleport, teleport_game, referral, share, follow, or notification (a notification tap whose launch data verified) | |
nid, nk | the launch data | the notification's send id and kind, only with from = "notification" |
launch | LaunchData | notification (verified), tt_bad (starts with tt1n. but fails the check; launch_bad says why: sig, user, old, format), or, for your game's own launch data, its first 32 characters only when the analytics setting joinLaunchPrefix is true (in the settings record's backend.analytics; default off, since launch data can hold anything). Its length is always kept as launch_len |
src_place, src_game | SourcePlaceId, SourceGameId | public ids |
ref | ReferredByPlayerId | the referrer's pid when this backend has seen them, else "other"; never a UserId |
follow | FollowUserId | the same treatment |
party, party_pids | Members | the party's size, and the pids of members seen before |
ctx, ctx_item, ctx_asset, ctx_outfit, ctx_asset_type | GameJoinContext | JoinSource, ItemType, AssetId, OutfitId, AssetType |
tp, tp_bytes | TeleportData | whether there is any, and its JSON size (never the content) |
age_days | AccountAge | exact days, next to the age bucket |
locale | Player.LocaleId | e.g. en-us; the explorer's language card and lang filter read it. Framework 0.7.1 writes it on every join row (also with joinData "off"); 0.6.0-0.7.0 wrote it with the rest of the join data, not with "off" |
verified | HasVerifiedBadge | |
membership | MembershipType | its name |
No raw UserId of another player leaves the game: the row's own pid stays its only identity. Other players' pids (ref, follow, party_pids) cost a DataStore read each, so a join looks up at most 2 of them; the rest count as "other" or aren't listed.
jd: the raw join data
Next to the summary props above, the join row keeps a privacy-safe copy of Player:GetJoinData() itself as the prop jd, field by field, so you can see exactly what Roblox handed the server (for example, whether a notification's launch data arrived at all). It is on by default; backend.analytics.joinData turns it off ("summary") or trims the join row further ("off").
jd field | From GetJoinData() | What is kept |
|---|---|---|
keys | the table's top-level keys | every key Roblox returned, sorted |
LaunchData | LaunchData | the string, cut to 200 bytes (Roblox's own limit) at a UTF-8 boundary. Present only when Roblox sent the key, even as "" |
LaunchDataLen | LaunchData | its full length in bytes, before any cut |
SourcePlaceId, SourceGameId | the same | numbers (public ids), present only when Roblox sent them |
GameJoinContext | GameJoinContext | every field as a string (enum items by name). {} means Roblox sent the key with nothing in it; no GameJoinContext means it didn't send the key |
TeleportData | TeleportData | { bytes, type, keys }: its JSON size, its Luau type and, for a table, its top-level keys (at most 20, each cut to 32 characters, sorted). Never its content |
Members | Members | { count, pids }: each party member's pid, or "other". Never a UserId |
ReferredByPlayerId, FollowUserId | the same | the player's pid, or "other"; absent when Roblox sent 0 or nothing. Never a UserId |
other | any key Roblox adds later | { "<key>": "<type name>" }: the type only, never the value |
trunc | true only when jd was cut to fit its cap |
- Other players are pids or
"other". Looking a player's pid up costs a DataStore read;jdand the summary props (ref,follow,party_pids) share the same budget of at most 2 reads per join, and one lookup per player serves both. Everyone past the budget, or never seen, is"other". - At most 1 KB. When the JSON is bigger, it is cut in this order until it fits, and
trunc: trueis set:TeleportData.keys(thenTeleportDataitself),Members.pids,LaunchData(shortened;LaunchDataLenkeeps the real length), eachGameJoinContextvalue (to 64 characters), thenother. - Where you see it: the explorer's player Timeline shows it on the join event (pretty-printed, collapsible, with a
truncbadge when cut). The Notifications page's per-player view shows the opening session'sjdnext to each opened notification, where the backend provides it.
Example: a player tapped a notification, but the launch data never reached the server (there is no nid / nk, and from isn't notification). The row's jd shows that the key arrived empty. (This was a real case: the send put join_experience at the top level of the Open Cloud body instead of inside payload, so Roblox ignored it; the backend since puts it inside payload.)
json
{ "keys": ["GameJoinContext", "LaunchData"], "LaunchData": "", "LaunchDataLen": 0, "GameJoinContext": {} }Notification rows
Notifications write rows with kind = "notify". The game server writes the framework's; the backend writes its own with src = "backend", sid empty, and pid from the UserId it has seen before (else empty: the row still counts). No row holds a UserId.
name | Written by | Props |
|---|---|---|
want | game server | k (kind), key (the first 12 hex of the key's SHA-256), at, until, offline |
drop | game server | k, key, why (the game's) |
optin_check | game server (from the client) | can, where, err |
optin_shown | game server | where |
optin_closed | game server | where, secs (how long the prompt was up) |
optin_inferred | backend | state: subscribed (a first send was accepted) or not_subscribed (Roblox said "not opted in") |
planned | backend | nid, k, for (the send time), why (asap / hour) |
skipped | backend | k, why (online, quiet, capped_day, capped_week, lapsed, muted, outranked, expired, dropped, pref_off (the player switched the kind off: setOff), repeat) |
sent | backend | nid, k, try, roblox_id, category |
failed | backend | nid, k, http, code (e.g. bad_template, daily_limit, dup_refused), final |
open | game server | nid, k, age (seconds since sent), first |
open_bad | game server | why (sig, user, old, format) |
played | game server | nid, k, secs (the opened session's length) |
Roblox doesn't tell the game whether a player accepted the opt-in prompt, so optin_closed never says; the backend's optin_inferred is the only answer.
Backends
The game speaks one event format. An adapter on each side translates it for the backend you pick: the framework's sink writes (basin or duckdb), and the read side's store renders each query as SQL for that backend.
| DuckDB server | Cloudflare Basin | |
|---|---|---|
| What you run | one Bun process on a small VPS (1 GB is enough) | nothing: Cloudflare's pipelines into Iceberg tables on R2 |
| Ingest | gzip batches, appended to raw files first | JSON arrays to two stream endpoints |
| Delay | seconds | one roll interval (60 s minimum; expect 1-2 minutes) |
| Queries | bun run report, the HTTP API, createStore | createStore (Basin SQL, read-only) |
| Deleting a player's rows | yes | no (rows become anonymous) |
| Live server status | the fleet API runs in the same process | run the fleet API alone (TYPETORCH_PARTS=fleet) |
The DuckDB server
The backend's analytics part (src/server/main.ts in the backend repo): Bun or Node 20+, DuckDB inside the process (no database server).
- Raw files first. Each accepted batch is appended to a raw file and answered at once. A loader moves raw files into DuckDB every 5 s. A spike fills files, never the database, and nothing accepted is lost on a restart.
- One Parquet file per day. After midnight UTC each finished day is written as a compressed Parquet file (exact duplicates dropped), then rollups run. Queries read today's DuckDB file plus the day files in range. Old data goes by deleting old day files (
TYPETORCH_KEEP_DAYS, 400 by default). - Fits in 1 GB: DuckDB is capped at 400 MB and spills to disk. Measured on one core: 10,000 events/s at a p99 of 35 ms and about 300 MB of memory. 1,000 players online is about 10 million events a day, far below that.
- Tokens: game servers send with the write-only game key (
TYPETORCH_API_KEY); queries need the admin token (TYPETORCH_ADMIN_TOKEN), which stays on your PC. - A 1 GB VPS: the backend README has the full guide (swap file, firewall, a systemd service, Caddy for TLS, backups): Run it on a VPS without Docker. Back up the day files and the raw archives off the VPS.
Cloudflare Basin
Done once, in your own Cloudflare account (Workers Paid plan and R2). The backend README has every prompt: Cloudflare Basin setup. In short:
- Create two pipelines,
typetorch_eventsandtypetorch_recordings, withnpx wrangler basin pipelines setup. Load their schemas from the backend repo's generated files,basin/events.schema.jsonandbasin/recordings.schema.json. HTTP endpoint on, authentication on, Data Catalog (Iceberg), roll interval 60 s. - Create a send-only API token (Basin Pipelines Send). Game servers get this one.
- Keep the R2 token (for Basin SQL), your account id and the bucket on your PC only.
- No CLI command writes this sink (since CLI 0.9):
typetorch backend setupmakes the game send to the backend instead. A Basin sink already in the record (settings) stays there until you run it.
Know these before you start:
- Rows that don't match the schema are dropped silently (the endpoint still answers 2xx). The framework sends every column on every row, so use the generated schemas exactly.
- Schemas are fixed when a stream is created. A new format version means new streams.
- Basin SQL is read-only. There is no DELETE, and every query needs a
LIMITof at most 10,000 (the queries handle this). It is billed by data scanned. - Not yet tested against a live Basin account (the backend README lists the open points).
Settings: the analytics field
Game servers read their sink settings from the game's signed settings record (kernel 0.3.8+). It is server-only (never sent to clients), and a change reaches running servers within seconds, so you change dials live, with no deploy or place publish.
- DuckDB (framework 0.4+, kernel 0.4+): the record's
backend={ url, key, analytics? }is the sink: events go to<url>/v1/ingestwith the game key, and the dials come frombackend.analytics(flushSeconds,recordShare,recordSeconds,positions,techEvery,experiments, and the join data dialsjoinDataandjoinLaunchPrefix: Settings). - Basin, and older frameworks: the
analyticsfield below.backend setupkeeps deriving it frombackendfor frameworks before 0.4; a Basin sink is always read from it.
json
{
"backend": "duckdb",
"events": "https://analytics.example.com/v1/ingest",
"token": "<the game key, write-only>",
"flushSeconds": 15,
"recordShare": 1,
"techEvery": 60,
"experiments": { "onboarding": { "weights": [1, 1] } }
}| Field | Meaning |
|---|---|
backend | duckdb or basin |
events | DuckDB: the server's ingest URL. Basin: the events stream endpoint |
recordings | Basin: the recordings stream endpoint (without it nothing is recorded). DuckDB: unused |
token | the write-only token: the DuckDB ingest token, or the Basin send token. Never logged |
flushSeconds | seconds between sends, 5-300 (default 15) |
recordShare | share of new players recorded in detail, 0-1 (default 1) |
recordSeconds | seconds a first-session recording runs after the first input, 10-600 (default 60; framework 0.7.1+) |
positions | event names (1-64 characters, at most 64) whose game-code events always carry px, py, pz, added to the engine option (Positions; framework 0.7.1+) |
techEvery | seconds between tech samples, 15-3600 (default 60) |
experiments | live dials per experiment |
How to write it: backend setup writes it from the game folder, after it checks the backend's address and both keys (settings):
sh
bun run typetorch backend setup --url https://backend.example.com --flush-seconds 15 --record-share 1It derives this section from backend and keeps the other fields (techEvery, experiments). typetorch experiment writes the experiment overrides (below). No command sets techEvery, recordSeconds, positions or the join data dials yet (backend setup keeps whatever is already in backend.analytics), and settings set analytics only points to backend setup. Read it back with bun run typetorch settings get analytics (the token hidden), and stop sending with bun run typetorch settings unset backend (it drops fleet and analytics too). The CLI signs the record with both prod keys, and servers refuse one that doesn't verify.
From code, writeBackendSettings({ gameDir, url, apiKey, adminToken }) from @typetorch/backend runs the same command.
Without settings the engine still collects, but keeps only the newest 1,000 rows until settings appear. Removing them stops sending, live. On a kernel before 0.3.8 the engine sends nothing and warns once (update the kernel).
Experiments
Our own A/B tests, per player or per server.
Per player: analytics.experiment(player, "onboarding", ["short", "long"]) returns the player's variant. It is fixed per player and experiment name, so they keep it in every session, and both groups play on the same servers. The first call may yield briefly (until the player's id loads). Later events carry the variant in exp. You write the code for each variant.
A session holds at most 32 experiments (framework after 0.3.2). A 33rd name isn't assigned: the call returns the first variant (the control), isn't stamped, and logs a warning.
Change it live with typetorch experiment, no deploy. It writes the override into the signed settings record (the same signed write as settings set: read, verify, change, sign with both keys, ping), so servers pick it up within seconds:
sh
bun run typetorch experiment list # every override, with the variants the backend saw (7 days)
bun run typetorch experiment weights onboarding 3,1 # 75% get the first variant, 25% the second
bun run typetorch experiment settle onboarding long # everyone gets "long", stamped: make it final
bun run typetorch experiment stop onboarding # everyone gets the control (the first variant), not stamped
bun run typetorch experiment resume onboarding # drop stop / settle, keep the weights
bun run typetorch experiment clear onboarding # remove the override: equal split again
bun run typetorch experiment force onboarding 123456 long # one player (UserId or pid) gets "long", for QA
bun run typetorch experiment unforce onboarding 123456| Dial (in the override) | Set by | Effect |
|---|---|---|
"active": false | stop | everyone gets the first variant (not stamped) |
"weights": [3, 1] | weights | 75% / 25%, in the order the game lists the variants |
"variant": "long" | settle | one variant for everyone |
"players": { "<UserId or pid>": "long" } | force | those players get that variant over everything else (at most 200; needs a framework that reads players) |
- The variants live in game code, so the CLI can't check them by itself:
--variants a,b(the game's list, in order) makesweightsandsettlecheck the count and the name first.--dry-runshows the override before and after. - Weights move as few players as possible: a player only switches when the boundary moves past their hash.
- It needs a backend in the record (
typetorch backend setup) first. The overrides live inbackend.analytics.experiments(framework 0.4+), mirrored into the oldanalytics.experiments. Every change is logged toexperiments.jsonlin the state dir;settings statuslists the overrides. - The explorer's Experiments page shows each experiment's state, weights and forced players, and opens the players of a variant (
GET /v1/experimentslists the overrides the backend knows;GET /v1/experiments/<key>/playersthe players, at most 200, eachrandomorforced).
Pick your own variant (framework 0.7.1+). In game, the dev menu's Server > Experiments lists every experiment the game asked about for you, your variant and why (random, forced by the settings, or your pick). A button per variant picks it for you, Clear drops the pick; the command palette has the same as "Experiment <name>: use <variant>". Devs where the dev-only tools are open to them, owners everywhere (the dev menu); at most one change a second, and only ever your own variant. The pick is stored in the analytics DataStore (dexp/<UserId>), follows you to every server, wins over the settings (stop, settle, weights, players), and is stamped forced on the experiment row, so the results leave you out. The game sees it the next time it asks for your variant, usually your next join.
Run it from the explorer (owners). Under an experiment's results, the Run it panel has Start, Stop, Clear override, Change split and Settle. The backend never holds the signing keys, so it can't apply the change: it checks it, logs it in the audit log (experiment.prepare) and answers the exact command to run in the game's folder, with its --dry-run twin (POST /v1/experiments/<key>/change):
sh
typetorch experiment weights onboarding 3,1 --variants short,longViewers see no controls, and the route answers them 403.
Per server: a share of servers runs a different build: a kernel A/B pin (bun run typetorch pin <artifact> --branch prod --pct 10, signed on prod; on dev branches also the dev menu's "Load a build" > Share of a branch). Events from those servers carry the pinned artifact in sexp. The experiment query with scope: "server" compares each pin with the unpinned servers. Whole-server changes need more players to show a difference.
Results come per variant (players, returned, payers, playtime, Robux, sessions) with "how sure" in plain words: long keeps more players: 99% sure (a two-proportion test; bootstrap or Welch for averages). Per player they also:
- leave forced players out: a player whose
experimentrow in the range is markedforced(settled,force, or a dev's own pick) didn't get a random variant;forcedPlayerscounts them; - check the split (sample ratio mismatch): the random assignments per variant against the override's weights, or an even split, with a chi-square test. Below p = 0.001 (at least 5 players expected per variant) the page warns not to trust the numbers: a variant that crashes or loses its events, a bug in when the game asks, bots, or a split changed inside the range. Uneven weights need the variant's position on the rows (
vi, framework 0.7.1+); older rows are checked against an even split only; - correct for several variants: each variant is compared with the control, and with more than two variants
sureuses Holm-corrected p-values (pAdjusted; the test's own ispValue); - say what the traffic can find:
mde/mdeLift(the smallest true difference these group sizes find 80% of the time at 5%, the 5% split over the variants when there are more than two),needed(players per group for the observed difference) anddays(how long at the range's traffic until it is sure; 0 = now,null= no difference to find).
Queries
Every query works on both backends, with the same filters: from, to (a date, ISO time or unix ms; a date-only to includes that day), art, branch, channel, dev, players ("new" or "returning"), variant ({ experiment, variant }), sexp, place. Without from, a query covers its default range.
Audience filters (player-level): country (ISO codes, "US"), lang (language codes without the region, "en", from the join row's locale), age (account-age buckets <1d, 1-7d, 7-30d, 30-90d, 90-365d, 1-3y, 3y+), prem (true / false) and source (the join's from: direct, teleport, referral, ...). Each takes one value or a list. A player matches when one of their join rows in the range has all the set values, and then all their rows count, so "players from this campaign" works on every query. Queries over server or fleet rows (perf-server, servers, deployReport, the server parts of benchmarks and realtime) don't take them, as they don't take dev.
| Query | What it answers | Default range |
|---|---|---|
| Players and retention | ||
overview | players, new players, sessions, playtime, in total and per day (per hour, 5 min or minute for windows of a day or less) | 30 days |
roblox | Roblox-style numbers: first-play bounce (first session under 60 s), qualified plays (sessions of 5 min or more), D1/D7, playtime and play days per user, payer conversion, Robux per user and per payer | 30 days |
benchmarks, realtime, trends | the Roblox page: headline numbers as Creator Hub defines them for the last full UTC days vs the days before; concurrent players (peak and average, by device); daily or 12 / 6-hour moving averages per join source | 7, 7, 28 days |
retention | retention by join-day cohort (days: up to 31 offsets from 1 to 90; default 1, 3, 7, 14, 30) | 30 days |
ltv | revenue per join-day cohort: cumulative Robux per player, revenue per player (after Roblox's 30%), payer share by D0 to D30 | 30 days |
churn | churn risk per player: active, at_risk, lapsing, churned, from days since the last play against their usual gap | 90 days |
demographics | who plays, from join rows: country, language, device, account age, Premium (share of players) | 28 days |
explain | why a metric moved between two windows: per segment (device, country, language, account age, Premium, join source, build, branch, new / returning), split into rate and mix effects | 7 days |
| Funnels and flow | ||
funnel | one funnel step by step: reached, share of the start, from the step before, median time, the biggest drop. Without a name, the list of funnels | 30 days |
funnel-progress | how far given players (pids, at most 500) got in each funnel | 30 days |
funnel-players | the players who reached, or dropped off at, one step (at most 200) | 30 days |
playtime-funnel | players whose playtime reached 30 s, 1 m, 3 m, 5 m, 10 m, 30 m, 1 h, 2 h (measure: total or first session) | 30 days |
flow | the merged graph for a group: where most players go next, and where they quit | 7 days |
confusion | first-session signals per zone and button: early leaves, opening and closing the same screen, walking back and forth, and from the recordings idle spots (10 s without input or movement), camera spins (360 degrees in 6 s without moving), repeated clicks (3 presses of one button within 2 s) | 14 days |
| One player | ||
players | players seen in the range, most recent first, or top spenders (sort: "robux"); search = part of a pid | 30 days |
timeline | one player's sessions and events (by pid) | 90 days |
player-graph | one player's node graph over all their sessions, or one (sid) | 90 days |
player-stats | one player's Robux, purchases, sessions and playtime per day, with each session and purchase | 30 days |
player-events, player-summary | the support key views: one player's named events, and their counts and totals | 30 days |
| Experiments | ||
experiment | per-variant numbers and how sure (scope: "player" or "server"), the split check and what the traffic can find | 30 days |
experiment-players | the players in one experiment: variant, random or forced, when assigned (at most 200) | 30 days |
| Recordings and maps (Recordings and maps) | ||
recording-layers | first-session recordings on a top-down map: paths, idle spots, deaths, screens, buttons, prompts, quits and positioned events | 7 days |
recording-sessions, recording-replay | the recorded sessions (by map cell, ending or signal), and one for the replay | 7, 30 days |
quit-autopsy | why first-session players quit: their last seconds against the players who stayed | 7 days |
first-action | seconds from spawn to the first move, screen, button, prompt and round start | 7 days |
map-positions | map names from the events' map prop, and positioned events per map in 0.5-4 stud cells | 7 days |
| Economy and revenue | ||
economy | one currency's earned vs spent per day, the running balance, top sources and sinks | 30 days |
monetization | Robux, purchases and payers per day, revenue by product, payer conversion, ARPPU, ARPDAU, repeat purchases, first purchase of new players | 30 days |
monetization-players, monetization-purchases | who bought a product (or anything), and the latest purchases (at most 200) | 30 days |
| Notifications, feedback, live events | ||
notify-funnel, notify-optin, notify-queue, notify-player | notifications: the funnel per kind, the opt-in prompt, the sender per hour, one player's rows | 14, 30, 1, 400 days |
feedback-summary, feedback-list | player feedback: the distribution per scale and trend, and the rows | 30 days |
live-event-lift | the raw numbers of a live event's lift report | 7 days |
| Performance | ||
perf-client | client fps, memory and ping over time (p10 / p50 / p90 / p99) by device, input, screen size, branch or build | 7 days |
perf-server | server TPS, physics FPS, memory and players over time, by branch or build | 7 days |
perf-compare | client and server numbers for builds side by side, or before vs after a moment | 7 days |
| Raw and fleet | ||
events | the newest rows, optionally of one kind, name or pid (up to 1,000) | 7 days |
top-events | the most logged names per kind | 7 days |
values | the branches, builds, channels and devices seen (for filter pickers) | 30 days |
servers, deployReport | fleet history from fleet rows (the live view is Fleet and alerts) | 1, 2 days |
Each query's options are in the backend README; GET /v1/queries lists the names with a summary and the default range. Run them in three ways:
bun run reportin the backend repo: the main ones in your terminal (below).HTTP (DuckDB server):
POST /v1/query/<name>withAuthorization: Bearer <admin token>and the body{ "filters": { "from": "2026-09-01", "dev": "phone" }, "options": { "funnel": "onboarding" } }. The answer is{ result, ms }. The explorer's read-only viewers and named API keys with the right scope can query too.Code, on either backend (a
.tsfile in a folder with@typetorch/backendinstalled, run withbun):tsimport { createStore } from "@typetorch/backend"; const store = await createStore({ backend: "duckdb", url: "https://analytics.example.com", token: process.env.TYPETORCH_ADMIN_TOKEN! }); // or: { backend: "basin", accountId, bucket: "typetorch-analytics", token: <R2 token> } const numbers = await store.query("roblox", { from: "2026-09-01", players: "new" }); const result = await store.query("experiment", {}, { experiment: "onboarding" });store.render(name, filters, options)returns the SQL without running it.
Useful options: funnel (funnel name), timeline and player-graph (pid), flow and player-graph (facet: all, zone, screen or activity; minCount; maxEdges), experiment (experiment, scope, control, weights for the split check), explain (metric, dimension, baseline).
"Is anything arriving?" Ask the DuckDB server directly instead of waiting for a chart: POST /v1/sql with the admin token and one read-only SELECT over events and recordings (today's live rows included) answers in seconds. Charts bucket by time, so their newest bucket can trail by a few minutes. See Debugging endpoints.
Node graphs
A graph's nodes are states (zones, screens, activities) and its edges are moves between them, with how often and how long players stayed. player-graph builds one player's graph over all their sessions; flow merges a group (new players, a variant, a build, a device).
Graphs come back as data, and toMermaid() turns one into a Mermaid diagram that any Markdown viewer renders (GitHub too), or paste it into mermaid.live:
ts
const graph = await store.query("flow", { players: "new" }, { facet: "zone" });
console.log(graph.toMermaid()); // or JSON.stringify(graph)bun run report
A quick look at your data in your terminal, from a checkout of the backend while its server runs (bun run local, or your own server):
sh
bun run report -- --game ../my-game
bun run report -- --game ../my-game --pid <pid>It asks the running backend (http://127.0.0.1:8787 by default, --url changes it) with the admin token from the game repo's .env, and prints the overview, the Roblox-style numbers, the top events, the onboarding funnel, experiments, the live servers, and one player's timeline and node graph (Mermaid). Without --pid it picks the player seen last. It never prints tokens.
Privacy and Right to Erasure
- Events carry a random player id (
pid, 32 hex characters), never the UserId. The link lives in your game's DataStoreTypeTorchAnalytics, keyp/<UserId>={ pid, first, last }(one read per join, a write on the first join and at leave).firstmarks the first-ever session. - Deleting that key makes the player's analytics anonymous, on both backends.
- Your own server also keeps pid -> UserId. Once a player's pid is known, the game server sends one identity row
{ pid, uid, t }(the UserId and nothing else) per session to your analytics server: DuckDB games in the upload batch, Basin games to the backend's/v1/identity(Basin rows can't be deleted, so they never go there). It lands in a deletable table next to the fleet data, never in the events, and lets you look a player up by UserId (the explorer's Players page,GET /v1/identity?uid=) and answer erasure requests without a DataStore read. Players who joined before this update are mapped only by a backfill from the DataStore links (POST /v1/identity/backfill, needsOPENCLOUD_API_KEYwithuniverse-datastores.objects:listand:read). Turn it off with the framework optionidentity: false. - The DuckDB server also deletes the rows. In Creator Hub > Webhooks, add
https://<your host>/v1/erasurefor "Right to erasure request" with a secret (ROBLOX_WEBHOOK_SECRET). The server checks Roblox's signature, ignores other games (TYPETORCH_UNIVERSE_ID), and maps the UserId to its pids through the identity table, and through Open Cloud whenOPENCLOUD_API_KEYis set (a key withuniverse-datastores.objects:read; add:deleteandTYPETORCH_ERASURE_DELETE_LINK=1to also delete the link). The pids' rows leave the live file at once; day files, rollups and raw archives are rewritten in the background, and later rows of those pids are dropped. Then the UserId's identity rows are deleted (and refused afterwards). Its log keeps the notification id and outcome, never the UserId. The admin token can also erase by pid:POST /v1/erasure{ "pid": "..." }. - Basin can't delete rows (its SQL is read-only). Delete the
p/<UserId>link with the rest of the player's data: their rows stay, anonymous.
Quick start: a local test
The backend on your own PC, reached by game servers through a free Cloudflare quick tunnel. For trying it out: the tunnel URL changes every time it starts. Needs Bun, git and cloudflared.
The steps are the backend's Quick start (local). In short:
Get the backend next to your game:
git clone https://github.com/typetorch/backend, then in that folderbun install,bun run web:installandbun run web:build(the explorer, once).Make two keys, 32+ random characters each and different (
openssl rand -hex 32makes one), and put them in the game repo's.env(gitignored):TYPETORCH_API_KEY(the game servers' write-only key) andTYPETORCH_ADMIN_TOKEN(yours, for the CLI and the explorer).Run it, from the backend folder, pointing at the game repo:
shbun run local -- --game ../my-gameIt starts the backend, opens a quick tunnel and runs
typetorch backend setupin the game. That writes the settings record (it needs the prod signing keys, fresh setup step 7, and kernel 0.3.8+ in the place) with the backend's URL, and setsbackend.urlintypetorch.json. That local edit changes with every tunnel, so don't commit it.Check it: open
https://<words>.trycloudflare.com/healthzin a browser; it answers{"ok":true}.
Play, then look
- Make sure the game creates an
AnalyticsEngineon the server and the client (the template does), and deploy it, for example todev. - Join a server and play for a minute. Running servers switch to the new backend URL and analytics settings within seconds of the write (new servers at once).
bun run typetorch serverslists your server.- In the backend folder:
bun run report -- --game ../my-game.
When the tunnel restarts, its URL changes: run bun run local -- --game ../my-game again (it runs backend setup for the new URL). For a real game, use a VPS with your own domain instead (Deploy on Coolify or run it on a VPS without Docker).