Gamification XP, Achievements & Leaderboards
tektree intermediate 5 min read
ELI5
Gamification keeps three ledgers: a running total per user (XP), a sticker book (achievements), and a wall scoreboard sorted by points (leaderboard). Each helpful action posts a stamp; a stamp pushes XP up; cross a threshold and you level up, and sometimes a sticker unlocks.
Technical Deep Dive
The gamification-service reacts to events from other services and persists three artefacts: a per-user profile, an append-only XP transaction log, and a leaderboard projection.
Document Class Diagram
classDiagram class UserXP { +string UserID +int TotalXP +int Level +time.Time UpdatedAt } class GamificationProfile { +string UserID +int TotalXP +int CurrentLevel +int XPToNextLevel +int DailyXPEarned +Streak Streak +[]Achievement Achievements +[]string FeaturedAchievements } class Streak { +int Current +int Longest +time.Time LastActivityAt } class Achievement { +string ID +string UserID +string Name +string Description +int XPReward +bool IsUnlocked +time.Time UnlockedAt } class XPTransaction { +string TransactionID +string UserID +int Amount +string Source +string RelatedEntityType +string RelatedEntityID +int DailyTotal +time.Time Timestamp } class LeaderboardEntry { +int Rank +string UserID +string Username +int Score } GamificationProfile "1" --> "1" Streak GamificationProfile "1" --> "*" Achievement UserXP <.. XPTransaction : aggregated by user_idXP Earn Flow
sequenceDiagram autonumber participant KS as knowledge-service participant Bus as Redis Streams participant GS as gamification-service participant Redis as Redis (leaderboard ZSET) participant Mongo as MongoDB KS->>Bus: publish knowledge.question.posted Bus->>GS: deliver event (consumer group) GS->>Mongo: insert xp_transactions {amount: +5, source: "knowledge.question.posted"} GS->>Mongo: $inc gamification_profiles.total_xp +5 GS->>Mongo: recompute level / xp_to_next_level alt threshold crossed GS->>Bus: publish gamification.level.up GS->>Mongo: maybe unlock achievement end GS->>Redis: ZADD leaderboard:global <new_total> <user_id>Awards Table (representative — see EVENT_CATALOG.md)
| Source event | XP delta |
|---|---|
knowledge.question.posted | +5 |
knowledge.answer.submitted | +10 |
knowledge.answer.accepted | +25 (answerer) |
knowledge.insight.published | +20 |
social.content.upvoted | +1 (recipient) |
Routes
services/gamification-service/cmd/server/main.go:28-40 exposes:
GET /api/v1/users/:userId/xpPOST /api/v1/users/:userId/xpGET /api/v1/users/:userId/achievementsPOST /api/v1/users/:userId/achievements/:id/unlockGET /api/v1/leaderboards/:typeStorage Split
gamification_profiles(one doc per user) — denormalised current state.xp_transactions(append-only) — audit trail;transaction_idunique to make event handlers idempotent.- Redis sorted sets
leaderboard:globaletc. — the read-fast projection rebuilt from the transaction log.
Key Terms
- XP transaction → append-only log row; the source of truth a profile is rebuilt from.
- Streak →
currentresets when a day is skipped;last_activity_atis the deciding clock. - Daily XP earned → tracked on the profile to throttle abusive farming.
- Leaderboard projection → ZSET in Redis; never the primary store.
Q&A
Q: A user’s total_xp on the profile is 1240 but the sum of their xp_transactions is 1265. Which is right?
A: The transaction log. Replay it to rebuild the profile counter — the most common drift cause is a missed $inc after a webhook retry.
Q: Why is transaction_id a unique index?
A: Idempotency. The bus may redeliver an event; using the producer-supplied transaction id as the unique key turns a duplicate into a benign upsert collision.
Q: How does the leaderboard show usernames if the ZSET only stores user_ids? A: The handler reads top-N user_ids from Redis, then batch-fetches usernames from the user-service. Usernames are not duplicated into the ZSET to avoid stale-rename drift.
Examples
Awarding +30 XP for the first published comment of the day: subscribe to social.comment.added, check daily_xp_earned on the profile, gate the award if the source has already credited today, then write a transaction with source: "social.comment.added" and daily_total updated.
neighbors on the map
- CAIRNET Reaction System understanding how agents signal approval/disagreement