What analytics to add to a Three.js game
The Glitchless team · October 27, 2026 · three.js and React Three Fiber, in current browsers
Measure what tells you whether players are having a good time and where they stop: sessions, levels started and finished, the handful of moments that make your game what it is, purchases, players coming back, and how smoothly it runs on their machines. Page-view analytics sees none of that, because a three.js game is one page and one canvas, so you send the game's own events.
The code below is plain JavaScript and works with any backend that accepts a POST, in plain three.js or React Three Fiber. Near the end are the same calls with the Glitchless SDK, and where PostHog, GameAnalytics and Sentry fit.
The short answer
- Sessions: one per page load, with a random ID kept in memory. Count time with frames on screen, not time the tab was open.
- Levels: start, complete, fail and abandon, each with the level's name. The last level started with no outcome is where a player quit.
- Your game's moments: a short list of named events with a few attributes in your game's words (
boss_endwithbossandwon), pluspurchasewith amount and currency once the store confirms it. - Retention needs a random ID stored on the device, in
localStorage. Without one, every visit is a new player. - How it runs: GPU tier and screen, and frame time as the typical frame, the 95th percentile and the worst. Average FPS hides the freezes players notice.
- Sending: keep events in memory and send a batch every 15 seconds or so and when the tab is hidden. Nothing per frame, nothing that names the player, nothing they typed.
Start from the questions you want answered
Every event should answer a question you'd act on. Write the questions first, then send the least that answers them. Most games start with these:
| Question | What to send |
|---|---|
| How many people play, and for how long? | A session start, and time with frames on screen |
| Where do players quit? | Level start, complete, fail and abandon, and the last event before the tab was hidden |
| Which level is too hard? | Fails and completes per level, with the cause of each fail |
| What do players choose? | An event when they pick a mode, character or weapon, with the choice as an attribute |
| Does the shop work? | shop_open, then purchase with the item, amount and currency |
| Do players come back? | A random ID stored on the device, for day 1, 7 and 30 retention |
| Does it run well for them? | GPU tier, screen and pixel ratio, and frame-time percentiles per level |
If no number would make you change the game, don't send it. A dozen well-chosen events answer more than a hundred nobody reads.
Sessions and players
A session is one visit: from page load until the tab is closed or hidden for good. Give it a random ID made at load and kept in memory, and send it with every batch. That's enough to count sessions, how long they last and what happened in each, with nothing stored on the player's device.
Measure session length as time with frames on screen. Browsers pause requestAnimationFrame in background tabs, so a game left open in another tab all afternoon draws nothing; adding up frame times counts only real play.
Sessions are not players. One player with three visits is three sessions, and without an ID kept between visits you can't tell them apart. That's fine for most questions (where sessions end, which level is hard) and only matters for retention, below.
Levels, and where players quit
Send four level events: start, complete, fail and abandon. Fail means the player lost (died, ran out of time); abandon means they left before it ended (back to the menu, a restart, closing the tab). Keep them apart: a level everyone abandons because it drags is a different problem from one everyone fails.
Give each level a stable name, not an index that shifts when you add one, and put what describes the attempt on the start event: difficulty, character, party size. Complete carries the time and score, fail the cause. Then the completion rate per level shows which level is a wall, fails by cause show what kills players, and the last level started with no outcome before the tab was hidden is where they quit.
levels.js
import { levelStart, levelEnd, track } from './analytics.js';
levelStart('forest-2', { difficulty: 'hard' });
// Then exactly one of:
levelEnd('forest-2', 'complete', { duration_s: 94, score: 1200 });
levelEnd('forest-2', 'fail', { cause: 'fell' }); // lost: died, ran out of time
levelEnd('forest-2', 'abandon'); // left: back to the menu, a restart
track('death', { cause: 'golem' }); // deaths inside a level, if it allows several
A game without levels still has runs, rounds or matches: treat each as a level, named by its mode or map.
Your game's own moments, as events with attributes
The questions that matter most are about your game: which weapon players pick, whether anyone finds the secret room, how many tries the second boss takes. Send each of those moments as a named event with a few attributes.
- Name events in your game's words, in snake_case, from a fixed list:
boss_end,powerup,shop_open. - Give attributes values from a small set (a mode, a weapon, a boss) so they can be counted and split. Use numbers for quantities (
tries: 3) and booleans for outcomes (won: true). - Never put IDs or free text in an attribute: they can't be counted, and they can identify people.
- For things that happen many times a second (shots, coins, hits), add them up in the game and send one total every few seconds, not an event each.
| Moment | Event | Attributes |
|---|---|---|
| A boss fight ends | boss_end | boss: 'golem', won: true, tries: 3 |
| The player picks a mode | play | mode: 'endless' |
| A power-up is picked up | powerup | kind: 'speed' |
| The tutorial moves on | tutorial_step | step: 3 |
| Graphics settings change | settings | quality: 'low' |
Players turning the quality down is worth an event of its own: it often means the game runs badly for them.
Purchases
Send a purchase when the store or payment provider confirms it, with the item, the amount in the currency's units (4.99, not 499) and the ISO currency code. Your payment provider stays the record of revenue and refunds; the event ties a purchase to the session, the level and the moment it happened in.
Never send receipts, order IDs or payment details. A shop that's opened but never bought from is a question too: send shop_open, and the funnel from it to purchase shows how many buy.
purchase.js
// When the store or payment provider confirms the payment, not when the player taps Buy.
track('purchase', { item: 'skin_pack', amount: 4.99, currency: 'USD' });
Retention, and what it needs
Retention is the share of new players who come back. Day 1 retention is the share of players first seen on a day who played again the next day; day 7 and day 30 are the same a week and a month later. It's the clearest single sign of whether a game holds people.
It needs one thing a session ID can't give: an ID that lasts between visits. Make a random one on the first visit, keep it in localStorage and send it with each batch. Don't use an account ID, an email or a device fingerprint; a random ID tied to nothing is enough to count returns.
- Clearing site data, a private window or another browser all make a new ID, so retention reads a little low.
- Safari deletes a site's script-writable storage,
localStorageincluded, after seven days of Safari use without the player interacting with the site. A Safari player who comes back after a longer gap counts as new. Web apps added to the home screen keep their own count. - A stored ID is kept on the player's device: say so on your privacy page, and give players a way to reset it or turn analytics off.
player-id.js
// Only for retention: a random ID kept between visits, tied to nothing. Send it with each batch.
function playerId() {
try {
let id = localStorage.getItem('player_id');
if (!id) localStorage.setItem('player_id', (id = Math.random().toString(36).slice(2, 14)));
return id;
} catch {
return null; // storage blocked: this visit counts as its own player
}
}
Device, GPU and frame times, not average FPS
Analytics that leaves out performance can't tell a level that's hard from one that stutters. Send the device once per session and frame times all session.
For the device: phone or not, CPU cores, screen size and pixel ratio, and a GPU tier. The GPU's name comes from the WEBGL_debug_renderer_info extension, which privacy settings can turn off. Map it to a tier in the browser and send only the tier: the full renderer string helps fingerprint a browser.
For frame time, record each frame's duration and every 5 seconds or so keep the typical frame (the median), the 95th percentile and the worst. Average FPS hides exactly what players feel: 5 seconds at 60 fps with one 300 ms freeze still averages about 57 fps. Tag each summary with the level, so you can see which level runs badly on which tier.
To get one 95th percentile across all players, don't average each session's. Also send how many frames fell in each band (16.7 ms or less, up to 33.3, up to 50, up to 100, and longer) and add the bands up across sessions.
device.js
// device.js: once per session, what the game runs on, in buckets rather than raw strings.
import { track } from './analytics.js';
export function trackDevice(renderer) {
const gl = renderer.getContext();
const ext = gl.getExtension('WEBGL_debug_renderer_info'); // privacy settings can turn it off
const gpu = ext ? String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)) : '';
track('device', {
mobile: /Mobi|Android/i.test(navigator.userAgent),
cores: navigator.hardwareConcurrency ?? null,
screen: `${screen.width}x${screen.height}`,
dpr: Math.round(devicePixelRatio * 100) / 100,
gpu_tier: gpuTier(gpu), // the tier, never the renderer string
});
}
function gpuTier(name) {
const s = name.toLowerCase();
if (/swiftshader|llvmpipe|software/.test(s)) return 'software';
if (/rtx|radeon rx|apple m\d+ (pro|max|ultra)/.test(s)) return 'high';
if (/adreno|mali|powervr|intel.*(hd|uhd) graphics/.test(s)) return 'low';
if (/apple m\d|iris|arc|radeon|geforce|nvidia/.test(s)) return 'mid';
return 'unknown';
}
The helper in the next section summarises frame times this way. To find out why a frame was slow, see How to track stutters and crashes in a browser game and Three.js performance optimization.
Send it without costing frames
Do almost nothing per frame. In the render loop, push one number into an array. On a timer, summarise the frames and add the summary to a queue of events. Every 15 seconds or so, turn the queue into one request. JSON.stringify and network calls never run inside the loop.
When the tab is hidden, send what's left with navigator.sendBeacon: the browser queues the request and sends it even if the page is closing. visibilitychange to hidden is the last event a page can reliably see, on phones too, where unload, beforeunload and pagehide may not fire at all. sendBeacon and fetch with keepalive are both limited to 64 KiB, so small, frequent batches suit both.
With many players, sample: time frames in one session in ten and still count every level and purchase. A string body is sent as text/plain, which needs no CORS preflight when your collector is on another domain.
analytics.js
// analytics.js: a small analytics helper for a browser game. Events wait in memory, frame times
// are summarised every 5 seconds, and one batch goes out every 15 seconds and when the tab is hidden.
const ENDPOINT = 'https://collect.example.com/v1/events'; // your collector
const VERSION = '1.4.0'; // the game's version
const FRAME_SAMPLE = 1; // 0.1: frame times from 1 session in 10
const BANDS = [16.7, 33.3, 50, 100, Infinity]; // frame-time bands in ms: they add up across players
const session = Math.random().toString(36).slice(2, 14); // per page load, kept in memory only
const timeFrames = Math.random() < FRAME_SAMPLE;
let queue = [];
let frames = [];
let level = null;
let skipFrame = false;
export function track(name, attrs = {}) {
queue.push({ name, attrs, level, ts: Date.now() });
}
export function levelStart(id, attrs) {
summariseFrames(); // a summary never spans two levels
level = id;
track('level_start', attrs);
}
// outcome: 'complete', 'fail' or 'abandon'
export function levelEnd(id, outcome, attrs) {
level = id;
track(`level_${outcome}`, attrs);
summariseFrames();
level = null;
}
// Once per frame, with the time since the last frame in ms. Nothing else happens per frame.
export function frameTime(ms) {
if (!timeFrames) return;
if (skipFrame) { skipFrame = false; return; } // the first frame back from a hidden tab spans the time away
frames.push(ms);
}
function summariseFrames() {
if (frames.length >= 10) {
const s = frames.sort((a, b) => a - b);
const at = (q) => Math.round(s[Math.min(s.length - 1, Math.floor(s.length * q))] * 10) / 10;
track('frames', {
n: s.length, p50: at(0.5), p95: at(0.95), worst: at(1),
bands: BANDS.map((top, i) => s.filter((ms) => ms > (BANDS[i - 1] ?? 0) && ms <= top).length),
});
}
frames = [];
}
function flush(closing = false) {
if (!queue.length) return;
const body = JSON.stringify({ session, version: VERSION, events: queue }); // a string goes as text/plain
queue = [];
if (closing && navigator.sendBeacon(ENDPOINT, body)) return; // queued, even if the page is closing
fetch(ENDPOINT, { method: 'POST', body, keepalive: true }).catch(() => {});
}
setInterval(summariseFrames, 5000);
setInterval(flush, 15000);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
summariseFrames();
track('hidden'); // a session's last one is where the player left
flush(true);
} else {
skipFrame = true;
}
});
Wire it into three.js or React Three Fiber
Call frameTime once per frame with the time since the last one. In plain three.js that's the callback you give renderer.setAnimationLoop; in React Three Fiber, a small component with useFrame, whose delta is in seconds. Send the device once the renderer exists: in React Three Fiber, onCreated on the <Canvas> gets it as gl.
main.jsx
import { frameTime } from './analytics.js';
import { trackDevice } from './device.js';
// three.js
trackDevice(renderer);
let last;
renderer.setAnimationLoop((time) => {
if (last !== undefined) frameTime(time - last);
last = time;
update();
renderer.render(scene, camera);
});
// React Three Fiber
function FrameTimer() {
useFrame((_, delta) => frameTime(delta * 1000)); // delta is in seconds
return null;
}
<Canvas onCreated={({ gl }) => trackDevice(gl)}>
<FrameTimer />
<Level />
</Canvas>
Keep it anonymous
Game analytics needs to know what happened, not who it happened to. Build it so it couldn't identify a player even if you wanted it to.
- No names, emails, account IDs, IP addresses or advertising IDs in events. If you want retention, a random ID tied to nothing.
- Nothing the player typed: chat, player names, search boxes, the text of a bug report. Free text is where personal data gets in.
- Page addresses as origin and path, without query strings, which can carry tokens and emails. The same for addresses inside error messages.
- Device details in buckets: a GPU tier, not the renderer string. Positions are fine: they're places in your game's world, not the player's.
- A setting that turns it off, saved on the device, and stops sending at once.
Say on your privacy page what you collect and why, and if you store an ID, say that too.
The same with the Glitchless SDK
The Glitchless browser SDK (npm install glitchless) does what the helper above does. It times every frame and records the device and GPU tier on its own, and instrumentThree adds draw calls and what each frame sends to the GPU. Every 5 seconds it summarises frame times into a sketch that merges across players, so its percentiles cover all of them, and it sends a gzipped batch every 15 seconds and one with sendBeacon when the page is hidden. The calls match the sections above:
glitchless.js
import * as THREE from 'three';
import { Glitchless, instrumentThree } from 'glitchless';
const glitchless = Glitchless.init({
service: 'my-game',
version: '1.4.0',
intake: 'https://api.glitchless.gg',
clientToken: 'gl_pub_...',
privacy: 'install', // a random player ID for retention; leave it out to stay fully anonymous
});
instrumentThree(glitchless, { renderer, scene, camera, THREE });
// React Three Fiber:
// <Canvas onCreated={({ gl, scene, camera }) => instrumentThree(glitchless, { renderer: gl, scene, camera, THREE })}>
glitchless.levelStart('forest-2', { difficulty: 'hard' });
glitchless.levelComplete('forest-2', { durationS: 94, score: 1200 });
glitchless.levelFail('forest-2', { cause: 'fell' });
glitchless.levelAbandon('forest-2'); // closing the tab mid-level sends one by itself
glitchless.track('boss_end', { boss: 'golem', won: true, tries: 3 });
glitchless.count('coin', { source: 'chest' }); // many a second: added up and sent every 5 s
glitchless.track('purchase', { amount: 4.99, currency: 'USD', item: 'skin_pack' });
Anonymous is the default: no player ID and nothing kept between visits. privacy: 'install' adds only a random ID in localStorage, which retention needs; glitchless.resetInstallId() forgets it, and glitchless.setEnabled(false) turns everything off for a player who asks. Attributes are strings, numbers or booleans, at most 12 per event, and strings are cut to 64 characters.
What it costs a game per frame, per batch and in download size is measured in What the Glitchless SDK costs a web game.
PostHog, GameAnalytics and Sentry
You don't have to build a collector, and the hosted tools differ in what they're built for. Any of them can take the events above.
| Tool | Good for | Not built for |
|---|---|---|
| PostHog | Product analytics across your website and your game: funnels, retention, feature flags and A/B experiments, session replay (recording a canvas is opt-in, at 4 frames a second by default) and error tracking. posthog.capture('boss_end', { boss: 'golem' }) sends an event, and events stay anonymous until you call identify. | Frame times and stutters: you'd send them yourself as event properties. |
| GameAnalytics | Games, mobile free-to-play above all: progression events (start, fail and complete, up to three levels deep), design events, real-money purchases and in-game currency flows, retention, funnels, A/B tests and remote configs, with an HTML5 JavaScript SDK and a free plan with no cap on monthly players. | Finding stutters, or grouping browser errors by stack: its error events are a severity and a message you send yourself. |
| Sentry | Errors: it catches uncaught errors and unhandled rejections by default, with breadcrumbs, source maps, releases and session replay, and its tracing marks long tasks and long animation frames. | Gameplay analytics (levels, funnels, retention) and per-frame timing. |
| Glitchless | Frame times with each hitch's likely cause, errors with the game events before them, levels, your own events and funnels, retention in install mode and purchases, all readable by your coding agent over MCP. | Feature flags, A/B experiments and your website's marketing numbers. |
They work together. Send every event through your own track() and forward it to each tool you use: PostHog or GameAnalytics for the business side (where players come from, experiments, monetisation), Sentry if your studio already runs it for its servers, and a game-specific tool for frame times and play. Each extra SDK is more script to load and another stream of requests, so check that each one batches and none does work every frame.
Two privacy notes: PostHog keeps an anonymous ID in a cookie and localStorage by default (persistence: 'memory' keeps nothing), and GameAnalytics' SDK makes its own user ID. Mention either on your privacy page. In more detail: Glitchless vs PostHog, Glitchless vs GameAnalytics and Glitchless vs Sentry.
From events to answers, for you and your agent
Sending events is the easy half; reading them is where the time goes. In Glitchless, the dashboard's Events page counts each event over time, breaks it down by each attribute (boss fights by boss, plays by mode) and builds funnels between events (shop_open to purchase). The level funnel shows where players quit, flags a level far fewer sessions get through than the rest as a wall, and counts rage quits. In install mode the Players page shows day 1, 7 and 30 retention, and purchases add up into revenue per player and by item.
Your coding agent reads the same data over MCP: glitchless_events for your own events and funnels, glitchless_player_funnel for levels and retention. It can add the calls to your game and design the game's own dashboard with glitchless_set_dashboard, with frame times and hitch causes from every player's session next to the play.
Ask your coding agent
Add analytics to my Three.js game with Glitchless: level start, complete, fail and abandon, the moments that answer my questions as track() events with a few attributes, and purchases once the store confirms them. Use privacy 'install' so retention works. Then design a dashboard with glitchless_set_dashboard that shows where players quit, which level is a wall, and how the game runs by GPU tier.
Frequently asked questions
What should a game track that web analytics doesn't?
Web analytics counts pages, clicks and referrers. A three.js game is one page and one canvas, so none of that says what happened in the game. Send the game's own events instead: levels, deaths, choices, purchases and frame times.
Should I measure FPS or frame time?
Frame time, as the typical frame, the 95th percentile and the worst. Average FPS hides freezes: 5 seconds at 60 fps with one 300 ms freeze still averages about 57 fps, and the freeze is what the player remembers.
Can I measure retention without accounts or cookies?
Yes, with a random ID kept in localStorage and tied to no account. Without any stored ID, every visit looks like a new player. It reads a little low: cleared storage, private windows and other browsers make new IDs, and Safari deletes script-writable storage after seven days of Safari use without the player interacting with the site.
Will analytics slow my game down?
Not if nothing heavy runs per frame. Push one number per frame, summarise every few seconds, and send one request every 15 seconds or so. Turning events into JSON and sending them never belongs in the render loop.
Which analytics tool should I use for a web game?
It depends on the question. PostHog suits product analytics across a website and a game, with feature flags and experiments; GameAnalytics suits free-to-play games and their monetisation numbers; Sentry suits errors across a studio's code. For frame times, stutter causes and errors next to gameplay, use a game-specific tool such as Glitchless. They don't exclude each other: you can send the same events to two.
Sources
- MDN: Window.requestAnimationFrame()
- MDN: Navigator.sendBeacon()
- MDN: Document visibilitychange event
- MDN: RequestInit keepalive
- W3C Beacon: CORS-safelisted content types
- MDN: WEBGL_debug_renderer_info
- WebKit: Full Third-Party Cookie Blocking and More (7-day cap on script-writable storage)
- three.js docs: WebGLRenderer.setAnimationLoop
- React Three Fiber: Hooks (useFrame, useThree)
- React Three Fiber: Canvas (onCreated)
- PostHog: JavaScript usage
- PostHog: JavaScript configuration
- PostHog: Anonymous vs identified events
- PostHog: Canvas recording
- PostHog: Error tracking for the web
- PostHog: Experiments
- GameAnalytics: JavaScript SDK
- GameAnalytics: JavaScript SDK event tracking
- GameAnalytics pricing
- Sentry: JavaScript SDK
- Sentry: GlobalHandlers integration
- Sentry: Automatic instrumentation (long tasks, long animation frames)
- Glitchless browser SDK (README)
Something wrong or out of date? Email support@glitchless.gg and we'll fix it. Glitchless isn't affiliated with the three.js authors, Poimandres (React Three Fiber), PostHog, Sentry, GameAnalytics or Apple; product names are their owners' trademarks.