Skip to main content

Event-Based Tracking

Event-based tracking uses semantic events to update achievements. Instead of updating metrics directly, you emit events that represent business actions (like "userScored" or "levelUp"), which are automatically mapped to metric updates.

This pattern is framework-agnostic and provides better separation of concerns - ideal for larger applications or multi-framework projects.

Overview

With event-based tracking, you create an AchievementEngine instance outside of React and configure event mappings. Your application code emits semantic events, and the engine automatically updates the appropriate metrics based on your mapping configuration.

The engine is framework-agnostic - the same instance can be used in React, Vue, Angular, or vanilla JavaScript.

Setup

Creating the Engine

First, create an AchievementEngine instance outside of your React components:

import { AchievementEngine } from 'react-achievements';

const achievements = {
score: {
100: { title: 'Century!', description: 'Score 100 points', icon: '🏆' },
500: { title: 'High Scorer!', icon: '⭐' },
},
level: {
5: { title: 'Leveling Up', icon: '📈' },
10: { title: 'Double Digits', icon: '🔟' }
}
};

const eventMapping = {
'userScored': (data) => ({ score: data.points }),
'userLeveledUp': (data) => ({ level: data.level }),
'tutorialCompleted': () => ({ completedTutorial: true })
};

const engine = new AchievementEngine({
achievements,
eventMapping,
storage: 'local' // 'local', 'memory', 'indexedDB', or custom
});

Provider Integration

Pass the engine to AchievementProvider:

import { AchievementProvider } from 'react-achievements';

function App() {
return (
<AchievementProvider engine={engine} useBuiltInUI={true}>
<YourApp />
</AchievementProvider>
);
}

Using the Hook

Access the engine in your components with useAchievementEngine():

import { useAchievementEngine } from 'react-achievements';

function Game() {
const engine = useAchievementEngine();

const handleAction = () => {
// Emit semantic events
engine.emit('userScored', { points: 100 });
};

return <button onClick={handleAction}>Score Points</button>;
}

Event Mapping

Event mapping is the core concept that makes event-based tracking powerful. It defines how your semantic events translate into metric updates.

Direct String Mapping

The simplest form maps an event name directly to a metric name:

const eventMapping = {
'enemy:defeated': 'enemiesDefeated',
'item:collected': 'itemsCollected'
};

// Usage
engine.emit('enemy:defeated', 10); // Sets enemiesDefeated to 10
engine.emit('item:collected', 5); // Sets itemsCollected to 5

MetricUpdater Functions

For more complex logic, use functions to transform event data into metric updates:

import type { MetricUpdater } from 'react-achievements';

const eventMapping = {
// Increment level, reset experience
'player:levelup': (eventData, currentMetrics) => {
const currentLevel = currentMetrics.level || 0;
return {
level: currentLevel + 1,
experience: 0 // Reset experience on level up
};
},

// Add experience points
'player:gain_exp': (eventData, currentMetrics) => {
const currentExp = currentMetrics.experience || 0;
return {
experience: currentExp + eventData.amount
};
}
};

// Usage
engine.emit('player:levelup');
engine.emit('player:gain_exp', { amount: 50 });

MetricUpdater Function Signature:

type MetricUpdater = (
eventData: any,
currentMetrics: Record<string, any>
) => Record<string, any>;

Multi-Metric Events

A single event can update multiple metrics:

const eventMapping = {
'boss:defeated': (data, currentMetrics) => ({
score: (currentMetrics.score || 0) + data.scoreGained,
level: (currentMetrics.level || 1) + 1,
bossesDefeated: (currentMetrics.bossesDefeated || 0) + 1,
lastBossName: data.bossName
})
};

// Usage - one event updates four metrics
engine.emit('boss:defeated', {
scoreGained: 250,
bossName: 'Dragon King'
});

Emitting Events

Basic Event Emission

Emit events using engine.emit(eventName, data):

function GameComponent() {
const engine = useAchievementEngine();

const handleUserScored = () => {
engine.emit('userScored', { points: 100 });
};

const handleLevelUp = () => {
engine.emit('userLeveledUp', { level: 5 });
};

const handleSimpleEvent = () => {
engine.emit('tutorialCompleted');
};

return (
<div>
<button onClick={handleUserScored}>Score 100</button>
<button onClick={handleLevelUp}>Level Up</button>
<button onClick={handleSimpleEvent}>Complete Tutorial</button>
</div>
);
}

Event Data Patterns

Events can carry simple values or complex objects:

// Simple value
engine.emit('buttonClicked', true);

// Object with multiple properties
engine.emit('userScored', {
points: 100,
multiplier: 2,
timestamp: Date.now()
});

// No data (boolean achievements)
engine.emit('tutorialCompleted');

// Array data
engine.emit('itemsCollected', ['sword', 'shield', 'potion']);

TypeScript Type Safety

Define event types for type-safe event emission:

interface GameEvents {
'userScored': { points: number; multiplier?: number };
'userLeveledUp': { level: number };
'boss:defeated': { scoreGained: number; bossName: string };
'tutorialCompleted': void;
}

// Type-safe event mapping
const eventMapping: Record<keyof GameEvents, MetricUpdater | string> = {
'userScored': (data) => ({ score: data.points * (data.multiplier || 1) }),
'userLeveledUp': (data) => ({ level: data.level }),
'boss:defeated': (data) => ({
score: data.scoreGained,
lastBoss: data.bossName
}),
'tutorialCompleted': () => ({ completedTutorial: true })
};

Listening to Engine Events

The engine emits built-in events that you can listen to for notifications, analytics, or custom logic.

Built-in Events

Four core events are available:

  • achievement:unlocked - Fired when an achievement is unlocked
  • metric:updated - Fired when a metric value changes
  • state:changed - Fired after any state change (metrics or achievements)
  • error - Fired when an error occurs

Subscribing to Events

Use engine.on() to subscribe to events:

import { useAchievementEngine } from 'react-achievements';
import { useEffect } from 'react';

function NotificationHandler() {
const engine = useAchievementEngine();

useEffect(() => {
// Listen for achievement unlocks
const unsubscribe = engine.on('achievement:unlocked', (event) => {
console.log(`🎉 ${event.achievementTitle}`);
console.log(event.achievementDescription);

// Custom notification logic
// event contains: achievementId, achievementTitle, achievementDescription, achievementIconKey, timestamp
});

// Cleanup on unmount
return () => unsubscribe();
}, [engine]);

return null;
}

Event Payloads:

interface AchievementUnlockedEvent {
achievementId: string;
achievementTitle: string;
achievementDescription: string;
achievementIconKey?: string;
timestamp: number;
}

interface MetricUpdatedEvent {
metric: string;
oldValue: any;
newValue: any;
}

interface StateChangedEvent {
metrics: Record<string, any>;
unlockedAchievements: string[];
}

interface ErrorEvent {
error: Error;
context?: string;
}

Multiple Event Listeners

Subscribe to multiple events:

useEffect(() => {
const unsubscribeUnlocked = engine.on('achievement:unlocked', (event) => {
console.log('Achievement unlocked:', event.achievementTitle);
});

const unsubscribeMetric = engine.on('metric:updated', (event) => {
console.log(`${event.metric}: ${event.oldValue}${event.newValue}`);
});

const unsubscribeState = engine.on('state:changed', (event) => {
console.log('State changed:', event);
});

return () => {
unsubscribeUnlocked();
unsubscribeMetric();
unsubscribeState();
};
}, [engine]);

One-Time Listeners

Use engine.once() to listen for an event just once:

useEffect(() => {
// Trigger confetti only on first achievement
engine.once('achievement:unlocked', (event) => {
console.log('First achievement unlocked!', event.achievementTitle);
// Show special first-time celebration
});
}, [engine]);

React Integration

Emitting from Components

Emit events in response to user actions:

function GameControls() {
const engine = useAchievementEngine();
const [score, setScore] = useState(0);

const handleScorePoints = (points: number) => {
const newScore = score + points;
setScore(newScore);

// Emit event instead of direct metric update
engine.emit('userScored', { points: newScore });
};

return (
<div>
<p>Score: {score}</p>
<button onClick={() => handleScorePoints(100)}>+100 Points</button>
<button onClick={() => handleScorePoints(500)}>+500 Points</button>
</div>
);
}

Emitting from Effects

Emit events when React state changes:

import { useEffect } from 'react';

function PlayerProgress({ player }) {
const engine = useAchievementEngine();

// Track level changes
useEffect(() => {
if (player.level > 1) {
engine.emit('userLeveledUp', { level: player.level });
}
}, [player.level, engine]);

// Track game completion
useEffect(() => {
if (player.gameComplete) {
engine.emit('gameCompleted', {
difficulty: player.difficulty,
timeElapsed: player.timeElapsed
});
}
}, [player.gameComplete, engine]);

return <div>Level: {player.level}</div>;
}

Using with React State

Synchronize events with your application state:

function Game() {
const engine = useAchievementEngine();
const [gameState, setGameState] = useState({
score: 0,
level: 1,
enemiesDefeated: 0
});

const defeatEnemy = () => {
setGameState(prev => {
const newState = {
...prev,
enemiesDefeated: prev.enemiesDefeated + 1,
score: prev.score + 10
};

// Emit event with new state
engine.emit('enemyDefeated', {
totalEnemies: newState.enemiesDefeated,
scoreGained: 10
});

return newState;
});
};

return (
<div>
<p>Score: {gameState.score}</p>
<p>Enemies Defeated: {gameState.enemiesDefeated}</p>
<button onClick={defeatEnemy}>Defeat Enemy</button>
</div>
);
}

Framework-Agnostic Usage

One of the key benefits of event-based tracking is that the AchievementEngine works outside React.

Vanilla JavaScript

Use the engine in plain JavaScript:

<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/achievements-engine"></script>
</head>
<body>
<button id="scoreBtn">Score 100 Points</button>

<script>
const engine = new AchievementEngine({
achievements: {
score: {
100: { title: 'Century!', description: 'Score 100 points', icon: '🏆' }
}
},
eventMapping: {
'userScored': (data) => ({ score: data.points })
},
storage: 'local'
});

// Listen for achievements
engine.on('achievement:unlocked', (event) => {
alert(`Achievement Unlocked: ${event.achievementTitle}`);
});

// Emit events from vanilla JS
document.getElementById('scoreBtn').addEventListener('click', () => {
engine.emit('userScored', { points: 100 });
});
</script>
</body>
</html>

Vue Integration

Share the same engine with Vue:

<template>
<div>
<button @click="scorePoints">Score 100 Points</button>
</div>
</template>

<script>
import { engine } from './achievementEngine';

export default {
methods: {
scorePoints() {
engine.emit('userScored', { points: 100 });
}
},

mounted() {
this.unsubscribe = engine.on('achievement:unlocked', (event) => {
console.log('Achievement unlocked:', event.achievementTitle);
});
},

beforeUnmount() {
this.unsubscribe();
}
}
</script>

Angular Integration

Use the engine in Angular services:

import { Injectable } from '@angular/core';
import { engine } from './achievementEngine';

@Injectable({
providedIn: 'root'
})
export class AchievementService {
private unsubscribe: () => void;

constructor() {
this.unsubscribe = engine.on('achievement:unlocked', (event) => {
console.log('Achievement unlocked:', event.achievementTitle);
});
}

scorePoints(points: number) {
engine.emit('userScored', { points });
}

ngOnDestroy() {
this.unsubscribe();
}
}

Complete Example

Here's a full working example combining event mapping, event emission, and event listening:

achievementEngine.ts
import { AchievementEngine } from 'react-achievements';

// Achievement configuration
export const gameAchievements = {
score: {
100: { title: 'Century!', description: 'Score 100 points', icon: '🏆' },
500: { title: 'High Scorer!', description: 'Score 500 points', icon: '⭐' },
1000: { title: 'Master!', description: 'Score 1000 points', icon: '💎' }
},
level: {
5: { title: 'Leveling Up', description: 'Reach level 5', icon: '📈' },
10: { title: 'Double Digits', description: 'Reach level 10', icon: '🔟' }
},
enemiesDefeated: {
10: { title: 'Novice Slayer', description: 'Defeat 10 enemies', icon: '⚔️' },
50: { title: 'Expert Slayer', description: 'Defeat 50 enemies', icon: '🗡️' }
},
completedTutorial: {
true: { title: 'Tutorial Master', description: 'Complete the tutorial', icon: '📚' }
}
};

// Event mapping
export const eventMapping = {
'userScored': (data) => ({ score: data.points }),

'userLeveledUp': (data) => ({ level: data.level }),

'enemyDefeated': (data, currentMetrics) => ({
enemiesDefeated: (currentMetrics.enemiesDefeated || 0) + 1
}),

'tutorialCompleted': () => ({ completedTutorial: true }),

// Multi-metric event
'bossDefeated': (data, currentMetrics) => ({
score: (currentMetrics.score || 0) + data.scoreGained,
level: (currentMetrics.level || 1) + 1,
enemiesDefeated: (currentMetrics.enemiesDefeated || 0) + 1
})
};

// Create engine instance
export const engine = new AchievementEngine({
achievements: gameAchievements,
eventMapping,
storage: 'local'
});

// Listen for achievement unlocks
engine.on('achievement:unlocked', (event) => {
console.log(`🎉 ${event.achievementTitle}`);
console.log(event.achievementDescription);
});
App.tsx
import { AchievementProvider } from 'react-achievements';
import { engine } from './achievementEngine';
import Game from './Game';

function App() {
return (
<AchievementProvider engine={engine} useBuiltInUI={true}>
<Game />
</AchievementProvider>
);
}

export default App;
Game.tsx
import { useAchievementEngine } from 'react-achievements';
import { useState } from 'react';

function Game() {
const engine = useAchievementEngine();
const [currentScore, setCurrentScore] = useState(0);
const [currentLevel, setCurrentLevel] = useState(1);

const handleScorePoints = (points: number) => {
const newScore = currentScore + points;
setCurrentScore(newScore);

// Emit semantic event
engine.emit('userScored', { points: newScore });
};

const handleLevelUp = () => {
const newLevel = currentLevel + 1;
setCurrentLevel(newLevel);

engine.emit('userLeveledUp', { level: newLevel });
};

const handleDefeatEnemy = () => {
engine.emit('enemyDefeated');
};

const handleBossDefeat = () => {
const scoreGained = 250;
const newScore = currentScore + scoreGained;
const newLevel = currentLevel + 1;

setCurrentScore(newScore);
setCurrentLevel(newLevel);

// Single event updates multiple metrics
engine.emit('bossDefeated', { scoreGained });
};

return (
<div>
<h1>Game</h1>
<p>Score: {currentScore}</p>
<p>Level: {currentLevel}</p>

<div>
<button onClick={() => handleScorePoints(100)}>+100 Points</button>
<button onClick={() => handleScorePoints(500)}>+500 Points</button>
<button onClick={handleLevelUp}>Level Up</button>
<button onClick={handleDefeatEnemy}>Defeat Enemy</button>
<button onClick={handleBossDefeat}>Defeat Boss (Multi-Metric)</button>
<button onClick={() => engine.emit('tutorialCompleted')}>
Complete Tutorial
</button>
</div>
</div>
);
}

export default Game;

When to Use Event-Based Tracking

Best For

  • Multi-framework projects - Share the same engine across React, Vue, Angular
  • Large applications - Better separation of concerns for complex business logic
  • Semantic event names - "userScored" is clearer than "score: 100"
  • Testability - Events are easier to mock and test than direct metric updates
  • Domain-driven design - Events represent business actions

Advantages

  • Framework agnostic - Use the same engine anywhere
  • Semantic events - Better developer experience with meaningful event names
  • Event mapping - One event can update multiple metrics automatically
  • Easier testing - Test event emission separately from achievement logic
  • Separation of concerns - Business logic decoupled from achievement tracking
  • Event listeners - React to achievements with custom logic

Trade-offs

  • More setup code - Creating engine and event mapping requires additional configuration
  • Additional concept - Need to understand event-driven architecture
  • Slight learning curve - More concepts than direct updates (engine, mapping, events)

Migration from Direct Updates

You can migrate from direct updates to event-based tracking incrementally:

Before (Direct Updates)

// Old pattern
<AchievementProvider achievements={config}>
<App />
</AchievementProvider>

// In component
const { track } = useSimpleAchievements();
track('score', 100);
track('completedTutorial', true);

After (Event-Based)

// New pattern - create engine
const engine = new AchievementEngine({
achievements: config,
eventMapping: {
'userScored': (data) => ({ score: data.points }),
'tutorialCompleted': () => ({ completedTutorial: true })
}
});

<AchievementProvider engine={engine}>
<App />
</AchievementProvider>

// In component
const engine = useAchievementEngine();
engine.emit('userScored', { points: 100 });
engine.emit('tutorialCompleted');

Can Both Patterns Coexist?

No - The provider enforces one pattern per app. However, you can:

  • Use different patterns in different applications
  • Migrate one component at a time by creating a new provider with the engine pattern
  • The engine created internally by the old pattern can emit events, but doesn't use event mapping

Comparison with Direct Updates

AspectDirect UpdatesEvent-Based Tracking
SetupPass achievements to providerCreate engine, pass to provider
APItrack('score', 100)engine.emit('userScored', { points: 100 })
SemanticsDirect metricsBusiness events
FrameworkReact-onlyFramework-agnostic
TestingTest with componentsTest events separately
Multi-MetricCall trackMultiple()One event with mapping
Event ListenersNot availableengine.on() available
Best ForSimple React appsLarge/multi-framework apps

Want to try direct updates? See the Direct Updates Guide

Next Steps