Skip to content

Achievement

Try the interactive example ↓

A rule-driven achievement engine: admins define achievements with typed rules (counters, thresholds, streaks, accumulators, collections, composites, time-limited), clients post events, and the engine evaluates progress, awards points, cascades prerequisite unlocks, and streams live progress over WebSocket.

When to reach for it: badge walls, fitness and learning milestones, seasonal medals, engagement awards ("first post"), SaaS adoption gamification ("first 1,000 API requests") - recognition for what already happened.

When not to: assigned multi-step arcs with acceptance and turn-in belong to Quest; raw score rankings belong to Leaderboard - the built-in achievement board only ranks achievement points.

Definitions, user progress, earned points, grant/revoke counters and retry keys are durable PostgreSQL state managed by Rent. Progress and awards commit together. Timeline and WebSocket/PubSub delivery remain separate Redis operations; they are not part of the database transaction. The leaderboard publishes the canonical points total from PostgreSQL, including when revocation keeps or removes points.

Concepts

  • Definitions are admin-only and write-once - define, disable, grant, and revoke require a platform admin token. There is no update, delete, or re-enable: retiring an achievement means disabling it.
  • Rules decide progress - each definition carries one rule (possibly a composite of nested rules). The seven rule types have their own page: Rule types.
  • Events drive everything - POST /events matches an event against every active achievement of the acting user, which defaults to the caller; only admins may submit for someone else.
  • States - locked -> available -> in_progress -> completed (or expired for overdue time-limited rules). An achievement with prerequisites starts locked and becomes available automatically when the prerequisites complete.
  • Points feed a leaderboard - completion awards the definition's points to the tenant's achievement-points board. The rewards payload (items, currency, titles) is stored on the definition but not applied; points are the only reward the engine acts on today.
  • Hidden achievements are excluded from discovery listings by default, but this is UI-level concealment, not authorization: any caller may pass include_hidden, and a user's own profile lists them before unlock.
  • Dedupe keys make event submission idempotent per user for the dedupe TTL; a replay succeeds but processes nothing.

Define achievements (requires a platform admin token)

snug achievement define --id first-strike --name "First Strike" \
  --description "Defeat three enemies" --points 100 \
  --category combat --tier bronze \
  --rules '{"type":"counter","event":"enemy.defeated","target":3}'

snug achievement define --id high-score --name "High Score" \
  --description "Reach a score of 1000" --points 150 \
  --category combat --tier silver \
  --rules '{"type":"threshold","metric":"score","value":1000,"comparison":"gte"}'

The response reports the initial_state every user will start in - available, or locked when --prerequisite is set. Reusing an id fails with 409 achievement_exists; a non-admin caller gets 403 FORBIDDEN.

Reading a definition back (snug achievement get --id first-strike) returns only the public card - id, name, description, points, xp, category, tier, hidden. Rules, prerequisites, rewards, and metadata are write-only through the API, so keep your definitions in version control.

Drive events

snug achievement event --event-type enemy.defeated --event-data '{"enemy":"goblin"}'
snug achievement event --event-type enemy.defeated --user-id player123   # admin only
snug achievement batch --events '[{"event_type":"enemy.defeated","event_data":{}}]'

Over HTTP the same submission is POST /api/v1/achievements/events; the enveloped response lists every achievement the event advanced (captured live, trimmed):

{
  "status": 200,
  "msg": "OK",
  "data": {
    "event_id": "KvqFNEysERvC",
    "processed_achievements": [
      {
        "achievement_id": "docs4-marathon",
        "previous_state": "available",
        "new_state": "in_progress",
        "progress": { "type": "accumulator", "current": 5.0, "target": 42.0, "percentage": 11.904762 },
        "completed": false
      }, ...
    ],
    "unlocked_achievements": [],
    "webhooks_triggered": 0,
    "leaderboard_updated": false
  }
}

Two fields are easy to misread:

  • A completion appears in processed_achievements with completed: true plus points_awarded and xp_awarded. unlocked_achievements is something else: achievements whose prerequisites this completion just satisfied (locked became available).
  • Replaying a --dedupe-key the user already submitted returns 200 with empty arrays - a silent no-op, not an error. Failed processing releases the key so a retry works.

Batches run per event in order and are capped (100 events by default); an oversized batch fails with 400 events: length is greater than 100. A non-admin submitting for another user gets 403 FORBIDDEN.

Track progress

snug achievement in-progress --user-id player123   # progress + next_milestone
snug achievement user --user-id player123          # full profile with stats
snug achievement leaderboard --limit 10
snug achievement stats --id first-strike

Profile and in-progress queries are self-or-admin; another user's profile is 403 FORBIDDEN. The profile aggregates totals and per-category stats:

{ "total_points": 250, "total_xp": 0, "completed_count": 2,
  "in_progress_count": 1, "completion_percentage": 50.0 }

The profile's achievements, the in-progress list, and the leaderboard are lists on the shared contract; the profile's stats and categories always cover every achievement, whatever page is shown. The profile filters on id, state, category, tier, points, started_at, and completed_at (sorted by name by default); in-progress sorts by name or time_remaining; the leaderboard ranks best first and pages by position, so it refuses total.

Per-achievement stats reports attempted/completed counts, completion rate, and a state distribution. The leaderboard ranks total achievement points and includes the caller's own user_rank (tier_breakdown in its entries is currently always empty).

Listing and discovery (snug achievement list --filter 'category[eq]=combat' --filter 'tier[eq]=bronze') speaks the standard list contract: q over name and description; filters on category and tier (eq, ne, in), on points and created_at (eq, ne, in, gt, gte, lt, lte), and on disabled; and one sort key out of created_at (the default, newest first), name, points, or xp. Hidden achievements are left out unless --include-hidden is set.

Grant, revoke, disable (requires a platform admin token)

snug achievement grant --id first-strike --user-id player123 --reason "Compensation"
snug achievement revoke --id first-strike --user-id player123 --reason "Cheating" --remove-points true
snug achievement disable --id first-strike --reason "Season ended" --hide-from-ui

Grant forces completion and awards points. Revoke resets the user's state and progress; --remove-points (default true; pass false to keep them) also takes the points back and resyncs the leaderboard. Disable stops all further progress (verified: events that previously advanced the achievement process nothing afterwards), and --hide-from-ui additionally marks it hidden; disabled definitions stay readable and stay in users' profiles.

Live progress over WebSocket

GET /api/v1/achievements/users/{user_id}/live upgrades to a WebSocket that streams progress frames as events process (self-or-admin; a cross-user handshake is rejected with 403). The CLI wraps it as snug achievement watch --user-id docs-wave; captured live:

{"type":"pubsub_message","channel":"achievements.docs-wave.live",
 "payload":{"type":"achievement_progress","achievement_id":"docs4-secret-room",
   "previous_state":"available","new_state":"completed","completed":true,
   "points_awarded":50, "progress":{"type":"counter","current":1,"target":1,
   "percentage":100.0}, ...}}

A second frame announces the completion event itself; the full message protocol is in the API reference.

Limits and configuration

Batch size (100 events), dedupe TTL (1 hour), rules per achievement (10), and the expiry-sweep interval for time-limited rules (60 s) are tunable via the ACHIEVEMENT_* variables.

Reference

  • Achievements API - every endpoint, callable
  • Rule types - all seven rule types with live-verified progress payloads
  • Related: Quest for assigned objectives with turn-in, Leaderboard for general-purpose rankings

Interactive example

Recognize milestones as activity arrives. Track progress toward rules and prerequisites, then award achievement points when they are met.

An illustrative example that runs in your browser. No account or API calls required.