Repository navigation
Reward System
RewardSystem holds one player: XP, level, streak, achievements and per-game stats.
import { RewardSystem, DataService } from 'dopaminejs';
const rewards = new RewardSystem(new DataService(), {
achievements: { /* your own, see below */ }
});
const player = await rewards.init();init() loads the saved player, validates it, updates the daily streak and saves. Every other method needs it to have resolved first.
| Option | Meaning |
|---|---|
achievements |
Extra achievement definitions, keyed by id. An id that matches a built-in replaces it. |
events |
A kernel EventBus. Reward events are also emitted there. |
kernel |
A DopamineKernel. Its events bus is used. |
rewards.player;
// {
// name: 'Player',
// xp: 0,
// level: 1,
// totalGamesPlayed: 0,
// createdAt: 1790000000000,
// lastPlayedAt: 1790000000000,
// streak: { current: 1, longest: 1, lastPlayDate: '2026-10-01' },
// achievements: {}, // id: { unlockedAt, seen }
// stats: {} // gameName: { totalPlays, highScore, ...yours }
// }The total XP needed to reach level L is 50 * L * (L - 1).
| Level | Total XP |
|---|---|
| 2 | 100 |
| 3 | 300 |
| 4 | 600 |
| 5 | 1000 |
| 10 | 4500 |
| 20 | 19000 |
rewards.getXPForNextLevel();
// { total, needed, progress }-
total: the XP total that marks the next level. -
needed: XP still required, never below 0. -
progress: position inside the current level, 0 to 1. Use this for a progress bar.
const { leveledUp, newLevel, xpGained } = await rewards.addXP(120, 'Daily goal');-
amountmust be a finite number. Anything else throws aTypeErrorand changes nothing. - A negative amount removes XP. XP stops at zero, and
xpGainedreports what was removed. - Levels are never taken back. Losing XP leaves the level where it was.
- It saves, then emits
xp_gained, thenlevel_upif the level rose. - It does not apply the streak multiplier and does not update the streak.
recordGamedoes both.
await rewards.recordGame('quiz', { score: 80, correct: 8 });In order:
- Updates the daily streak if the calendar day changed.
- Adds one to
stats.quiz.totalPlaysandtotalGamesPlayed. - Raises
stats.quiz.highScoreand emitsnew_high_scoreifscorebeat it. - Adds every other numeric key in the result onto the stat of the same name.
- Awards XP:
floor((10 + floor(score / 5)) * streakMultiplier). A zero, missing or negative score earns the base 10. - Checks achievements.
- Saves once.
It throws a TypeError, before changing anything, when:
- the game name is not a non-empty string, or is
__proto__,constructororprototype - the result is not a plain object
-
scoreis present and not a finite number
Five are built in.
| Id | Name | Condition | XP |
|---|---|---|---|
first_game |
First Steps | 1 game played | 50 |
level_5 |
Rising Star | level 5 | 100 |
level_10 |
Expert Player | level 10 | 250 |
streak_3 |
Dedicated | 3 day streak | 100 |
streak_7 |
Unstoppable | 7 day streak | 300 |
Add your own in the config.
const rewards = new RewardSystem(new DataService(), {
achievements: {
perfect_quiz: {
name: 'Perfect',
description: 'Score 100 on a quiz',
icon: '<img src="/icons/perfect.svg" alt="">',
xp: 150,
check: (player, gameName, result) => gameName === 'quiz' && result.score === 100
}
}
});-
check(player, gameName, result)returns true when the achievement is earned. -
xpis optional and counts as 0 when left out. -
iconis rendered as HTML byGameUI. Never build it from user input. See Security. - Checks run inside
recordGame. A check that throws is logged and skipped, and the others still run.
await rewards.checkAchievements('quiz', { score: 100 }); // run the checks yourself
await rewards.unlockAchievement('perfect_quiz'); // true, or false if unknown or already unlocked
rewards.getUnlockedAchievements(); // definition + id + unlockedAt + seen
rewards.getUnseenAchievements();
await rewards.markAchievementsSeen(['perfect_quiz']);Unlocking pays the achievement's XP once and emits achievement_unlocked.
A streak counts consecutive local calendar days. The date is the user's local date, not UTC, so a user in Manila or Los Angeles does not lose a day at the UTC boundary. The test suite runs in Asia/Manila so a UTC assumption fails CI.
The streak is checked in init() and in recordGame():
- same day as
lastPlayDate: no change - the day after:
currentgoes up by one, andlongestfollows - any later day:
currentresets to 1
When the day changes, streak_updated fires with { current, longest }.
rewards.getStreakMultiplier();| Streak | Multiplier |
|---|---|
| 1 to 2 days | 1 |
| 3 to 4 days | 1.25 |
| 5 to 6 days | 1.5 |
| 7 days or more | 2 |
The multiplier applies to XP from recordGame.
Every mutation saves. recordGame collapses its saves into one write. If the storage write fails, save_failed fires with { key: 'player' } and the state stays in memory. Call rewards.save() to write by hand.
Start
Progression
Engine
Packages
Reference