Skip to content

React ​

sh
npm install @walangstudio/badgetrip-core @walangstudio/badgetrip-react @walangstudio/badgetrip-assets

This uses the engine from Getting started.

Wrap your app ​

tsx
// main.tsx
import { BadgetripProvider } from '@walangstudio/badgetrip-react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import { engine } from './badges';

createRoot(document.getElementById('root')!).render(
  <BadgetripProvider engine={engine}>
    <App />
  </BadgetripProvider>,
);

Show the badges ​

tsx
// Trophies.tsx
import { AchievementBadge, useAchievementCatalog } from '@walangstudio/badgetrip-react';

export function Trophies({ userId }: { userId: string }) {
  const badges = useAchievementCatalog(userId);
  const points = badges.reduce((sum, b) => sum + (b.unlocked ? b.points : 0), 0);

  return (
    <section>
      <h2>Achievements ({points} pts)</h2>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 16 }}>
        {badges.map((b) => (
          <AchievementBadge key={b.code} achievement={b} />
        ))}
      </div>
    </section>
  );
}

Locked badges are grayed out with a progress bar under them. Hidden ones show a question-mark icon and the name "Hidden achievement" until earned.

Record something ​

Get the engine from useBadgetrip(), not from your import. Events sent through the hook re-render every component that reads badges; events sent through the raw import don't.

tsx
// LogHabit.tsx
import { useBadgetrip } from '@walangstudio/badgetrip-react';

export function LogHabit({ userId }: { userId: string }) {
  const engine = useBadgetrip();

  async function log() {
    const { unlocked } = await engine.emit({
      id: crypto.randomUUID(),
      actor: userId,
      type: 'habit.done',
      ts: Date.now(),
      payload: {},
    });
    if (unlocked.length) alert(`Unlocked: ${unlocked.join(', ')}`);
  }

  return <button onClick={log}>Done for today</button>;
}

Swap the alert for your toast library of choice.

Other hooks ​

useScore(userId, 'xp'), useStreak(userId, 'daily'), useLeaderboard(code), useTier(userId, code) and useAchievementProgress(userId, code) all work the same way: they return the current value and update after every emit.

A query that fails (say, a leaderboard code that doesn't exist) throws during render, so put an error boundary around the part of the page that uses it.

Your own look ​

AchievementBadge is deliberately plain. To build your own, use useAchievementIcon(badge) for the image (it already handles locked and reduced-motion states) and render the rest however you want:

tsx
import { type AchievementView } from '@walangstudio/badgetrip-core';
import { useAchievementIcon } from '@walangstudio/badgetrip-react';

function Pill({ badge }: { badge: AchievementView }) {
  const icon = useAchievementIcon(badge);
  return (
    <span className={badge.unlocked ? 'pill' : 'pill locked'}>
      <img src={icon.src} alt="" width={20} height={20} /> {badge.name}
    </span>
  );
}

To change the icons themselves, wrap the tree in <IconProvider icons={createIconResolver({ ... })}>, with createIconResolver from @walangstudio/badgetrip-assets. See Getting started.

Celebrate unlocks ​

Render <UnlockNotifier actor={userId} /> once inside the provider, and every unlock pops up on top of the page. Toasts, modals, fullscreen confetti and sound are all configurable per achievement; see Unlock celebrations. For your own celebration UI, useUnlocks() gives you the queue instead.

Themes ​

Give the provider a theme, and badges, <UnlockNotifier> and useUnlocks all follow it. Pass a different one to switch; nothing re-mounts. Keep the theme in state next to the provider, so main.tsx from above becomes:

tsx
// main.tsx
import { themes } from '@walangstudio/badgetrip-assets';
import { BadgetripProvider, UnlockNotifier } from '@walangstudio/badgetrip-react';
import { useState } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import { engine } from './badges';

const userId = 'ana'; // your signed-in user

function Root() {
  const [theme, setTheme] = useState(themes.classic);
  return (
    <BadgetripProvider engine={engine} theme={theme}>
      <button type="button" onClick={() => setTheme(themes.arcade)}>Arcade</button>
      <UnlockNotifier actor={userId} />
      <App />
    </BadgetripProvider>
  );
}

createRoot(document.getElementById('root')!).render(<Root />);

celebrations or icons passed to <UnlockNotifier>, and an <IconProvider>, are used instead of the theme's.

useTheme() reads the current theme. The themes guide covers making your own.

MIT licensed