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)
window.addEventListener('beforeunload', () => 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()
window.addEventListener('beforeunload', () => {
analytics.track('session_end', {
duration: Date.now() - sessionStart,
})
})
// 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)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' } })