CRUMB a card from devarno-cloud

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_id

XP 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 eventXP 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/xp
POST /api/v1/users/:userId/xp
GET /api/v1/users/:userId/achievements
POST /api/v1/users/:userId/achievements/:id/unlock
GET /api/v1/leaderboards/:type

Storage Split

  • gamification_profiles (one doc per user) — denormalised current state.
  • xp_transactions (append-only) — audit trail; transaction_id unique to make event handlers idempotent.
  • Redis sorted sets leaderboard:global etc. — 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.
  • Streakcurrent resets when a day is skipped; last_activity_at is 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