Skip to content

Achievements ​

Define achievements as a keyed object. The key is the stable code, stored with every unlock, so rename the display name freely but never the key.

ts
import { createEngine, defineAchievements, rules } from '@walangstudio/badgetrip-core';

const achievements = defineAchievements({
  first_win: {
    name: 'First Win',
    description: 'Win a match',
    icon: 'trophy',
    points: 10,
    when: rules.count('match.won', 1),
  },

  // One definition, three unlocks: fan.bronze, fan.silver, fan.gold.
  fan: {
    name: 'Fan ({tier})',
    description: 'Get {n} reactions',
    icon: 'heart',
    when: rules.count('reaction.received'),
    tiers: { bronze: 10, silver: 50, gold: { at: 100, points: 50, icon: 'crown' } },
  },

  night_owl: {
    name: 'Night Owl',
    description: 'Played between midnight and 4am',
    lockedDescription: 'Some things only happen at night',
    hidden: true,
    icon: 'moon',
    when: rules.count('match.played', 1, { todBetween: [0, 4 * 3_600_000] }),
  },
});

const engine = createEngine({ /* stores, clock */ definitions: { achievements } });

defineAchievements returns plain AchievementDef[], so it mixes with hand-written defs and JSON config.

Fields ​

FieldDefaultMeaning
name, descriptionrequiredDisplay text. {tier} and {n} are filled per tier.
whenrequiredUnlock rule, built with rules.* or written as a Rule object.
rarity1 (tiers: 1, 2, 3, ... capped at 5)1-5, for sorting and styling.
points0Display value; sum unlocked points for an achievement score.
hiddenfalseConceal name, description, icon and progress until unlocked.
lockedDescriptiondescription ('' when hidden)Spoiler-free text shown while locked.
iconcategory, then 'trophy'Asset key. See Assets.
category-Free-form group; also an icon fallback.
celebration-Unlock celebration preset, such as 'modal' or 'epic'. See Celebrations.
tiers-{ name: threshold } or { name: { at, points?, rarity?, icon?, name?, description?, celebration? } }, ascending.
metadata-Anything else; opaque to the engine.

Any other field, on an achievement or a tier, throws achievement <code>: unknown option '<field>', so a typo fails at startup. Put extra data in metadata.

Rule builders ​

BuilderUnlocks when
rules.count(type, n, { where?, todBetween? })the actor has n events of type
rules.score(score, n)a score total reaches n
rules.streak(streak, n, { key?, of?, anyKey? })a streak reaches n
rules.unique(type, path, n)n distinct values at path
rules.groupCount(type, path, n, where?)one value at path repeats n times
rules.firstOfDay(type, between?)the first event of a day (optionally in a time window)
rules.rank(leaderboard, position)the actor holds a leaderboard position
rules.all(...), rules.any(...)every / some sub-rule holds

Leave n out only on a tiered achievement: each tier supplies it. See RULES.md for exact semantics.

Progress and the catalog ​

Every badge adapter shows a "3/5" count under locked multi-step achievements (showCount and formatCount; hide-count on the custom elements), and a notifier can pop up progress along the way; see Progress along the way.

ts
await engine.progress('u1', 'fan.silver'); // { current: 12, target: 50, percent: 24 }
await engine.catalog('u1');                // every achievement, ready to render

Progress is computed from the event stream and stores; nothing extra is persisted. An unlocked achievement always reports 100%, even if its streak later resets. For all, current/target count satisfied sub-rules and percent is the mean of their percents. For any, it is current: 0 or 1, target: 1, and the max percent. first-of-day has no standing progress.

catalog(actor) returns AchievementView[] in definition order: code, name, description, icon, category, rarity, points, series, unlocked, unlockedAt, concealed, progress. A hidden, locked achievement arrives with concealed: true, the name Hidden achievement, its lockedDescription, icon hidden, zero progress, and no series or category, so it cannot leak through the UI.

Retroactive unlocks ​

Achievements unlock during the actor's own emit. Two cases need a nudge: a definition added after the events already happened, and a rule other actors can satisfy for you (rank). catalog shows these at 100% but locked. Award them with:

ts
await engine.refresh('u1'); // ['newly_added_badge'] - stamped clock.now()

Call it on login or after deploying new achievements. first-of-day only unlocks during emit.

Assets ​

A theme bundles these icon settings with colors and celebrations; the options below are the same ones a theme's icons takes.

@walangstudio/badgetrip-assets ships 18 line icons (MIT, drawn for badgetrip), painted with currentColor:

trophy star medal crown flame shield bolt heart chat sprout target clock moon check lock hidden sparkle sparkle-animated

Achievements name an icon key; a resolver turns keys into renderable assets. Swap any asset without touching achievement definitions:

ts
import { createIconResolver } from '@walangstudio/badgetrip-assets';

const icons = createIconResolver({
  // Replace or add registry entries. A string aliases another key.
  icons: { trophy: { src: '/art/trophy.png' }, lava: 'flame' },
  // Per achievement code, or per series code to cover every tier.
  overrides: {
    first_win: { src: '/art/first-win.gif', still: '/art/first-win.png', animated: true },
    fan: { src: '/art/fan.svg' },
  },
  categories: { social: 'chat' },   // icon for achievements with no `icon`
  fallback: 'star',                 // default 'trophy'
  color: '#1f2937',                 // paint for the built-in SVGs: a color or a gradient
  tierColors: { bronze: '#b8733d' }, // merged over the default tints; false disables
});

icons.resolve(view);          // { src, still?, animated?, svg? }
icons.missing(achievements);  // unresolvable keys - check at startup to catch typos

Resolution order: overrides[code], overrides[series.code], icon, categories[category], fallback. A concealed achievement always resolves to hidden, so overrides cannot spoil it. Unknown keys fall through to the next step instead of failing.

Animated icons. Any src an <img> accepts works: GIF, APNG, animated WebP or SVG. Mark it animated: true and give a still frame. Every adapter's badge shows still while the achievement is locked and whenever the user prefers reduced motion. The built-in sparkle-animated shows the pattern. Without a still, the animation plays in every state, reduced motion included, so always ship one.

Tiers. The built-in icons are tinted per tier name (bronze, silver, gold, platinum, diamond). Image assets are never tinted.

Celebrations and secret mode ​

When an achievement unlocks, a notifier can celebrate it on top of the app: a toast in any corner or edge, a modal, or fullscreen with confetti and sound. Pick a preset per achievement with celebration, and configure the rest with createCelebrationResolver. For a Steam-style list, secret mode leaves hidden achievements out and shows "N hidden achievements remaining" instead: renderCatalog(views, { secret: true }), <badgetrip-catalog secret>, or splitConcealed(views) in your own UI. See the celebrations guide.

Frameworks ​

Every adapter binds to observe(engine) (or an @walangstudio/badgetrip-ipc remote), renders the same badge markup, and uses displayIcon for locked and reduced-motion still frames. Each package README has the full API.

TargetPackageEntry points
React@walangstudio/badgetrip-reactBadgetripProvider, useAchievementCatalog, AchievementBadge, IconProvider, UnlockNotifier, useUnlocks
React Native / Expo@walangstudio/badgetrip-react-nativesame hooks (including useUnlocks), native AchievementBadge
Vue@walangstudio/badgetrip-vueapp.use(createBadgetrip(engine, { icons })), useAchievementCatalog, AchievementBadge, UnlockNotifier, useUnlocks
Angular@walangstudio/badgetrip-angularprovideBadgetrip(engine, { icons, notifier }), BadgetripService.catalog(actor), .unlocks(), badgetrip-achievement-badge
htmx, SSR, vanilla, any framework@walangstudio/badgetrip-htmlrenderCatalog(views), defineBadgetripElements(observe(engine)), createNotifier(observed)
Electron, Workers, Tauri webview@walangstudio/badgetrip-ipcserveEngine(engine, transport), connectEngine(transport)

React ​

tsx
import { createIconResolver } from '@walangstudio/badgetrip-assets';
import {
  AchievementBadge,
  BadgetripProvider,
  IconProvider,
  useAchievementCatalog,
} from '@walangstudio/badgetrip-react';

function Trophies({ actor }: { actor: string }) {
  const list = useAchievementCatalog(actor);
  return list.map((a) => <AchievementBadge key={a.code} achievement={a} />);
}

<BadgetripProvider engine={engine}>
  <IconProvider icons={createIconResolver({ overrides: { first_step: 'crown' } })}>
    <Trophies actor="u1" />
  </IconProvider>
</BadgetripProvider>;

AchievementBadge renders the icon (grayscale while locked), name, description, and a <progress> bar while locked. It stretches to its grid cell so bars align. For a custom look, build your own with useAchievementIcon(view) (motion-aware asset) and useAchievementProgress(actor, code). IconProvider is optional: without it the built-in pack is used.

MIT licensed