Achievement System
The achievement system is a data-driven engine — admins create achievements and rules via the admin panel with zero code changes. When users perform actions in the app, events are published to NATS JetStream, and a background worker evaluates achievement progress.
Architecture
User Action (login, trade, deposit, etc.)
│
▼
UseCase fires SendEvent() ──► NATS JetStream
stream: ACHIEVEMENTS
subject: achievements.events.<userID>
│
▼
Achievement Worker
(durable pull consumer)
│
▼
CheckAchievementActivity()
├── calculateSignalData() ──► DB queries for all metrics
├── loadActiveAchievements() ──► DB: active, non-expired
├── evalProgress() ──► comparator-based evaluation
├── upsertUserAchievement() ──► update progress %
├── grantRewards() ──► apply consumable rewards
└── sendNotification() ──► in-app notificationEvents
Every significant user action fires an achievement event. The achievement worker recalculates ALL metrics from the database on each event — events are triggers, not data carriers.
All Event Types
| Event | Constant | Fired When | Source File |
|---|---|---|---|
match_close | EventMatchClose | Match finishes and rewards distributed | game_usecase_close.go |
position_create | EventPositionCreate | Player opens a new position | game_usecase.go |
position_close | EventPositionClose | Position closed (manual, SL/TP, liquidation) | game_usecase_calc.go |
position_updated | EventPositionUpdated | Player updates SL/TP on position | game_usecase.go |
match_join | EventMatchJoin | Player joins a match room | game_usecase.go |
match_start | EventMatchStart | Match transitions to “open” status | game_usecase.go |
match_canceled | EventMatchCanceled | Admin cancels a match | game_usecase.go |
login | EventLogin | User logs in (email OTP or password) | authentication_usecase.go |
profile_update | EventProfileUpdate | User updates profile (nickname/avatar) | authentication_usecase.go |
deposit | EventDeposit | Funds deposited to wallet | wallet_usecase.go |
withdrawal | EventWithdrawal | Withdrawal request created | wallet_usecase.go |
level_up | EventLevelUp | User gains XP (via consumable or match) | consumable_usecase.go |
friend_request_sent | EventFriendRequestSent | User sends a friend request | friendship_usecase.go |
friend_request_accepted | EventFriendRequestAccepted | Friend request accepted | friendship_usecase.go |
store_purchase | EventStorePurchase | User purchases a store item | store_usecase.go |
Metrics
The achievement engine computes 13 metrics from the database at evaluation time. Adding a new achievement rule using any of these metrics requires NO code changes — just create it in the admin panel.
| Metric | Type | Description | DB Source |
|---|---|---|---|
matches | uint64 | Total matches played | GameMatchParticipant count |
wins | uint64 | Total matches won | GameMatch where winner_id = userID |
losses | uint64 | Total matches lost | matches - wins |
positions | uint64 | Total positions opened | Sum of positions across all participations |
wins_in_row | int64 | Current consecutive win streak | Last 20 closed matches |
total_volume | float64 | Cumulative position size | Sum of GameMatchPosition.Size |
total_profit | float64 | Cumulative realized PnL | Sum of GameMatchPosition.Profit |
invite | int64 | Accepted friend count | Friendship where status=accepted |
level | uint8 | Current user level | User.Level |
deposits | uint64 | Total deposit transactions | Transaction where type=deposit |
withdrawals | uint64 | Total withdrawal transactions | Transaction where type=withdraw |
profile_completed | float64 | 1.0 if nickname+avatar set, 0 otherwise | User.NickName + User.Avatar |
trades_closed | uint64 | Total closed positions | GameMatchPosition where status=closed |
Rule Comparators
Each achievement rule specifies a comparator that determines how progress is calculated:
| Comparator | Behavior | Example |
|---|---|---|
gte | Progress = current / target (0-100%). Completes when current >= target | ”Win 5 matches” → 3/5 = 60% |
gt | Completes when current > target. Shows progress ratio until then | ”Score above 1000” |
lte | Binary: 100% if current <= target, 0% otherwise | ”Keep losses under 5” |
lt | Binary: 100% if current < target, 0% otherwise | ”Fewer than 3 liquidations” |
eq | Binary: 100% only when current == target exactly | ”Reach exactly level 10” |
bool | Binary: 100% if current != 0, 0% if current == 0 | ”Complete your profile” |
Weighted Multi-Rule Achievements
Achievements can have multiple rules with weights that sum to 100:
Achievement: "Trading Master"
├── Rule 1: wins gte 10, weight 50 → 50% of progress
├── Rule 2: total_profit gte 500, weight 30 → 30% of progress
└── Rule 3: trades_closed gte 100, weight 20 → 20% of progress
Total progress = sum(each rule's progress × weight)
Completed when total >= 100Reward Disbursement
When an achievement is completed for the first time:
- The
rewardsedge is loaded (links toConsumableitems) - Each consumable is applied to the user via
ApplyConsumable - An in-app notification is sent (
ACHIEVEMENT_UNLOCKED) - The
UserAchievementrecord is markedis_completed = truewithcompleted_attimestamp
A double-grant guard ensures rewards are only granted once — if CheckAchievementActivity runs again for an already-completed achievement, it skips reward granting and notification.
Worker Deployment
The achievement worker is a separate binary:
go run . achievementIt runs a NATS JetStream durable pull consumer on stream ACHIEVEMENTS with subjects achievements.events.>. Messages are processed by a semaphore-bounded goroutine pool (max 50 concurrent workers).
Scaling: Run multiple instances of the achievement worker — JetStream automatically load-balances messages across consumers with the same durable name.
Creating Achievements (Admin Panel)
Step 1: Create Achievement Rules
In the admin panel, navigate to Achievement Rules and create rules:
| Field | Value |
|---|---|
| Name | ”Win 5 matches” |
| Field | wins (dropdown) |
| Comparator | gte (dropdown) |
| Target | 5 |
| Weight | 100 |
Step 2: Create Achievement
Navigate to Achievements and create:
| Field | Value |
|---|---|
| Name | ”Rising Star” |
| Key | rising_star (unique slug) |
| Description | ”Win your first 5 matches” |
| Avatar | Upload badge image |
| Is Active | true |
| Rules | Select the rule(s) created in step 1 |
| Rewards | Select consumable reward(s) (optional) |
Step 3: Test
- Log in as a test user
- Play and win 5 matches
- Check achievement progress via
UserAchievementquery - On 5th win, the achievement completes automatically
No deployment or code changes needed.
GraphQL API
Query User Achievements
query {
profile {
achievements {
edges {
node {
id
progress
isCompleted
completedAt
achievement {
name
key
avatar
description
}
}
}
}
}
}Admin: Seed Permissions
mutation {
seedPermissions
seedAchievements
}Seed Achievements (Built-in)
The following achievements are seeded via seedAchievements mutation:
| Achievement | Rules | Reward |
|---|---|---|
| First Win | wins >= 1 | 50 XP |
| Win Streak | wins_in_row >= 3 | 100 XP |
| Trading Veteran | trades_closed >= 50 | — |
| High Roller | total_volume >= 10000 | — |
| Profile Complete | profile_completed = true (bool) | 25 XP |
| Champion | wins >= 10 (w:50) + total_profit >= 500 (w:50) | — |
| First Deposit | deposits >= 1 | — |
| Social Butterfly | invite >= 3 | — |