Analytics and telemetry for web games
Analytics help you understand what players do, where they struggle, and what keeps them engaged. This tutorial shows what to track and how.
1) What to track
Engagement metrics:
- Session starts and duration
- Levels started and completed
- Features used
- Retention (return visits)
Performance metrics:
- Load time
- Frame rate
- Memory usage
- Errors and crashes
Conversion metrics:
- Tutorial completion
- First purchase
- Social shares
2) Simple event tracking
js
class Analytics {
constructor(endpoint) {
this.endpoint = endpoint
this.sessionId = crypto.randomUUID()
this.queue = []
this.flushInterval = 30000 // 30 seconds
setInterval(() => this.flush(), this.flushInterval)
// Flush when the page is being hidden, not on beforeunload.
// beforeunload/unload are unreliable (often don't fire on mobile,
// and block the back/forward cache). visibilitychange + pagehide
// are the recommended events for end-of-session sends.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') this.flush()
})
window.addEventListener('pagehide', () => this.flush())
}
track(event, data = {}) {
this.queue.push({
event,
data,
sessionId: this.sessionId,
timestamp: Date.now(),
url: location.href,
})
// Flush immediately for important events
if (event === 'error' || event === 'purchase') {
this.flush()
}
}
async flush() {
if (this.queue.length === 0) return
const events = [...this.queue]
this.queue = []
try {
await fetch(this.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ events }),
keepalive: true, // Important for beforeunload
})
} catch {
// Put events back in queue
this.queue.unshift(...events)
}
}
}
const analytics = new Analytics('/api/analytics')3) Session tracking
js
// Track session start
analytics.track('session_start', {
referrer: document.referrer,
screen: `${screen.width}x${screen.height}`,
devicePixelRatio: window.devicePixelRatio,
userAgent: navigator.userAgent,
})
// Track session end
let sessionStart = Date.now()
// Send session_end when the page is hidden, not on beforeunload.
// beforeunload is unreliable on mobile and breaks the bfcache.
function recordSessionEnd() {
analytics.track('session_end', {
duration: Date.now() - sessionStart,
})
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') recordSessionEnd()
})
window.addEventListener('pagehide', recordSessionEnd)
// Track visibility changes
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
analytics.track('tab_hidden')
} else {
analytics.track('tab_visible')
}
})4) Game-specific events
js
// Level tracking
function onLevelStart(levelId) {
analytics.track('level_start', { levelId })
}
function onLevelComplete(levelId, score, time) {
analytics.track('level_complete', {
levelId,
score,
timeSeconds: time,
})
}
function onLevelFail(levelId, reason) {
analytics.track('level_fail', {
levelId,
reason, // 'death', 'timeout', 'quit'
})
}
// Achievement tracking
function onAchievementUnlocked(achievementId) {
analytics.track('achievement', { achievementId })
}
// Tutorial tracking
function onTutorialStep(step, skipped = false) {
analytics.track('tutorial', { step, skipped })
}5) Performance monitoring
js
class PerformanceMonitor {
constructor(analytics) {
this.analytics = analytics
this.frameTimes = []
this.lastFrame = performance.now()
}
recordFrame() {
const now = performance.now()
this.frameTimes.push(now - this.lastFrame)
this.lastFrame = now
// Keep last 60 frames
if (this.frameTimes.length > 60) {
this.frameTimes.shift()
}
}
getAverageFPS() {
if (this.frameTimes.length === 0) return 0
const avgFrameTime = this.frameTimes.reduce((a, b) => a + b) / this.frameTimes.length
return 1000 / avgFrameTime
}
reportPerformance() {
const fps = this.getAverageFPS()
const memory = performance.memory?.usedJSHeapSize
this.analytics.track('performance', {
avgFPS: Math.round(fps),
memoryMB: memory ? Math.round(memory / 1024 / 1024) : null,
})
}
}
// Report every minute
const perfMonitor = new PerformanceMonitor(analytics)
setInterval(() => perfMonitor.reportPerformance(), 60000)Attribute jank with Long Animation Frames
The FPS counter above tells you when frames are dropping, but not why. The Long Animation Frames API (LoAF), which shipped in Chrome and Edge 123, fills that gap. It flags any frame that takes longer than 50ms and breaks down which scripts caused the delay, so you can attribute jank to specific code instead of guessing.
js
if (PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
analytics.track('long_frame', {
durationMs: Math.round(entry.duration),
blockingMs: Math.round(entry.blockingDuration),
// scripts[] names the source URLs that ate the frame
scripts: entry.scripts?.map((s) => s.sourceURL),
})
}
})
observer.observe({ type: 'long-animation-frame', buffered: true })
}LoAF is Chromium-only for now (no Firefox or Safari), so feature-detect before using it and keep the FPS counter as your cross-browser baseline.
6) Error tracking
js
window.addEventListener('error', (event) => {
analytics.track('error', {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error?.stack,
})
})
window.addEventListener('unhandledrejection', (event) => {
analytics.track('error', {
message: event.reason?.message || String(event.reason),
type: 'unhandledrejection',
stack: event.reason?.stack,
})
})
// Custom error tracking
function trackGameError(context, error) {
analytics.track('game_error', {
context,
message: error.message,
stack: error.stack,
})
}7) Load time tracking
js
// Track initial load
window.addEventListener('load', () => {
const timing = performance.timing
const loadTime = timing.loadEventEnd - timing.navigationStart
const domReady = timing.domContentLoadedEventEnd - timing.navigationStart
analytics.track('page_load', {
totalMs: loadTime,
domReadyMs: domReady,
})
})
// Track game-specific load phases
async function loadGame() {
const start = performance.now()
await loadCriticalAssets()
const criticalTime = performance.now() - start
analytics.track('load_critical', { ms: Math.round(criticalTime) })
await loadGameAssets()
const totalTime = performance.now() - start
analytics.track('load_complete', { ms: Math.round(totalTime) })
}8) Funnel tracking
Track player progression through key flows:
js
class FunnelTracker {
constructor(analytics, funnelName) {
this.analytics = analytics
this.funnelName = funnelName
this.startTime = Date.now()
}
step(stepName) {
this.analytics.track('funnel_step', {
funnel: this.funnelName,
step: stepName,
elapsedMs: Date.now() - this.startTime,
})
}
complete() {
this.analytics.track('funnel_complete', {
funnel: this.funnelName,
totalMs: Date.now() - this.startTime,
})
}
abandon(reason) {
this.analytics.track('funnel_abandon', {
funnel: this.funnelName,
reason,
elapsedMs: Date.now() - this.startTime,
})
}
}
// Usage
const onboarding = new FunnelTracker(analytics, 'onboarding')
onboarding.step('welcome_shown')
// ... player clicks continue
onboarding.step('name_entered')
// ... player completes tutorial
onboarding.complete()9) A/B testing support
js
class ABTest {
constructor(testName, variants) {
this.testName = testName
this.variants = variants
// Get or assign variant
const stored = localStorage.getItem(`ab_${testName}`)
if (stored && variants.includes(stored)) {
this.variant = stored
} else {
this.variant = variants[Math.floor(Math.random() * variants.length)]
localStorage.setItem(`ab_${testName}`, this.variant)
}
// Track assignment
analytics.track('ab_assignment', {
test: testName,
variant: this.variant,
})
}
getVariant() {
return this.variant
}
trackConversion(metric) {
analytics.track('ab_conversion', {
test: this.testName,
variant: this.variant,
metric,
})
}
}
// Usage
const difficultyTest = new ABTest('difficulty', ['easy', 'normal', 'hard'])
game.difficulty = difficultyTest.getVariant()
// When player completes level
difficultyTest.trackConversion('level_complete')10) Privacy considerations
js
class PrivacyAwareAnalytics extends Analytics {
constructor(endpoint) {
super(endpoint)
this.enabled = this.checkConsent()
}
checkConsent() {
return localStorage.getItem('analytics_consent') === 'true'
}
setConsent(enabled) {
localStorage.setItem('analytics_consent', enabled ? 'true' : 'false')
this.enabled = enabled
if (enabled) {
this.track('consent_granted')
}
}
track(event, data = {}) {
if (!this.enabled) return
// Strip PII
const sanitized = { ...data }
delete sanitized.email
delete sanitized.name
delete sanitized.ip
super.track(event, sanitized)
}
}
// Show consent dialog
function showConsentDialog() {
const dialog = document.createElement('div')
dialog.innerHTML = `
<p>We use analytics to improve the game. Is that okay?</p>
<button id="accept">Accept</button>
<button id="decline">Decline</button>
`
document.body.appendChild(dialog)
dialog.querySelector('#accept').onclick = () => {
analytics.setConsent(true)
dialog.remove()
}
dialog.querySelector('#decline').onclick = () => {
analytics.setConsent(false)
dialog.remove()
}
}Third-party alternatives
If you don't want to build your own:
- Plausible — Privacy-focused, simple
- Amplitude — Product analytics, funnels
- Mixpanel — Event tracking, user journeys
- Sentry — Error tracking specifically
js
// Example: Plausible
const script = document.createElement('script')
script.defer = true
script.dataset.domain = 'yourgame.com'
script.src = 'https://plausible.io/js/plausible.js'
document.head.appendChild(script)
// Track custom events
window.plausible('level_complete', { props: { level: '1' } })Related
- Ship a web game that loads fast
- IndexedDB game saves
- For creators
- How to Launch Your Game on itch.io — itch.io analytics and tracking downloads
- Steam Next Fest Strategy — measuring wishlists and demo conversion rates
External Resources
- Plausible Analytics — privacy-friendly, lightweight analytics
- PostHog — open-source product analytics with event tracking
- Sentry — error monitoring and crash reporting for web apps