Skip to content

Reward System

Arnel Robles edited this page Oct 1, 2026 · 1 revision

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.

Config

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.

The player

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 }
// }

Levels

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.

addXP

const { leveledUp, newLevel, xpGained } = await rewards.addXP(120, 'Daily goal');
  • amount must be a finite number. Anything else throws a TypeError and changes nothing.
  • A negative amount removes XP. XP stops at zero, and xpGained reports what was removed.
  • Levels are never taken back. Losing XP leaves the level where it was.
  • It saves, then emits xp_gained, then level_up if the level rose.
  • It does not apply the streak multiplier and does not update the streak. recordGame does both.

recordGame

await rewards.recordGame('quiz', { score: 80, correct: 8 });

In order:

  1. Updates the daily streak if the calendar day changed.
  2. Adds one to stats.quiz.totalPlays and totalGamesPlayed.
  3. Raises stats.quiz.highScore and emits new_high_score if score beat it.
  4. Adds every other numeric key in the result onto the stat of the same name.
  5. Awards XP: floor((10 + floor(score / 5)) * streakMultiplier). A zero, missing or negative score earns the base 10.
  6. Checks achievements.
  7. Saves once.

It throws a TypeError, before changing anything, when:

  • the game name is not a non-empty string, or is __proto__, constructor or prototype
  • the result is not a plain object
  • score is present and not a finite number

Achievements

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.
  • xp is optional and counts as 0 when left out.
  • icon is rendered as HTML by GameUI. 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.

Streaks

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: current goes up by one, and longest follows
  • any later day: current resets 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.

Saving

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.

Clone this wiki locally