Gamification Module Guide¶
Last reviewed: 2026-06-23
This guide is a how-to/reference for maintainers of the Gamification surface in Project NEXUS: XP and levels, badges and achievements, leaderboards, challenges, and the NEXUS score. It documents the data model, tenant scoping, the XP-award idempotency / anti-abuse mechanism, failure modes, and the regression tests that protect this surface.
Gamification is engagement scaffolding. It awards experience points (XP), grants badges, ranks members on leaderboards, and computes a composite "NEXUS score". It must never be a path to creating time credits — XP and badges are reputational only and are independent of the wallet ledger.
Audience & supported workflows¶
Use this guide when changing XP values, badge rules, level thresholds, leaderboard queries, challenges, or the NEXUS score.
Supported workflows:
- XP & levels — members earn XP for platform actions and level up across named tiers.
- Badges & achievements — milestone badges (quantity-based) plus quality badges (reliability, reciprocity, mentoring, etc.) are auto-granted when thresholds are met.
- Leaderboards — per-tenant rankings across nine metrics over weekly / monthly / all-time windows.
- Challenges — time-boxed action goals that grant XP and optionally a badge on completion.
- NEXUS score — a composite 1000-point reputation score across six weighted dimensions.
- Daily rewards & streaks — a once-per-day claimable XP reward with streak tracking (delegated to
DailyRewardService).
Tenant & feature-gate rules¶
- Tenant scoping is mandatory. XP, badges, leaderboards, challenges, and scores are all per-tenant. Eloquent models (
UserXpLog,UserBadge,Challenge,UserChallengeProgress) use theHasTenantScopetrait; raw-SQL paths (leaderboards, several badge checks) filter ontenant_idexplicitly.GamificationService::getLeaderboard()defaults the tenant toTenantContext::getId()precisely because an unscoped XP aggregate would leak users across every tenant on the platform. - Feature gate:
gamification. Registered inapp/Services/TenantFeatureConfig.php(default on). The React frontend wraps the Leaderboard, Achievements, and NEXUS score routes in<FeatureGate feature="gamification">(seereact-frontend/src/App.tsx); disabled tenants get a "coming soon" fallback. - Note: the
/v2/gamification/*API routes are not individually wrapped in afeature:route middleware — the gate is enforced in the frontend and in navigation config. Treat the feature flag as a UI/navigation gate, not a hard API authorization boundary. Side-effect awards (XP/badges) still fire from the underlying action events regardless of the flag.
Key code & data locations¶
Routes are defined in routes/api.php. Do not copy the endpoint table here — read the route file or the API reference for the live list. Primary entry points:
| Concern | Route prefix | Controller |
|---|---|---|
| Profile, badges, leaderboard, challenges, NEXUS score, daily reward, shop, seasons | /v2/gamification/* |
App\Http\Controllers\Api\GamificationV2Controller |
| Legacy gamification (leaderboard, streaks, achievements, summary, showcase) | /leaderboard, /achievements, /gamification/*, /streaks, /daily-reward/* |
App\Http\Controllers\Api\GamificationController |
| NEXUS score read / recalculate | /nexus-score, /nexus-score/recalculate |
App\Http\Controllers\Api\GamificationController |
| Admin badge/campaign config, recheck, bulk award | /v2/admin/gamification/*, /v2/admin/users/{id}/badges |
App\Http\Controllers\Api\AdminGamificationController, AdminUsersController |
| Group challenges | /v2/groups/{id}/challenges |
App\Http\Controllers\Api\GroupChallengeController |
Services:
app/Services/GamificationService.php— the core: XP values, level thresholds,awardXP(),awardBadge(),runAllBadgeChecks(), leaderboard helper, daily reward delegation. XP and level constants live here (XP_VALUES,LEVEL_THRESHOLDS, plus the V2 named-level and simplified-XP variants).app/Services/BadgeDefinitionService.php— DB-backed badge definitions (seeded by migration);GamificationServicefalls back to its static badge array when the table is unseeded.app/Services/BadgeService.php,BadgeCollectionService.php— badge enrichment and curated collections.app/Services/LeaderboardService.php— the multi-metric leaderboard with versioned cache invalidation.app/Services/LeaderboardSeasonService.php— time-boxed leaderboard seasons.app/Services/ChallengeService.php(+ChallengeCategoryService,ChallengeTemplateService,ChallengeOutcomeService,GroupChallengeService) — challenge CRUD and claim/completion.app/Services/NexusScoreService.php+NexusScoreCacheService.php— composite score calculation and its 1-hour cache.app/Services/GamificationRealtimeService.php— Pusher broadcasts (badge-earned,xp-gained,level-up) on the per-user channel.app/Services/GamificationEmailService.php— milestone emails (badge earned, level up).app/Services/Achievement*Service.php— achievement analytics, campaigns, and unlockables.
Listeners (where XP is actually awarded):
app/Listeners/UpdateWalletBalance.php— onTransactionCompleted, awardssend_credits/receive_creditsXP and runs badge checks for both parties. Queued,$tries = 1, with a one-time cache claim.app/Listeners/AwardXpOnVolLogApproved.php— on a vol-logpending → approvedtransition, awardsvolunteer_hourXP (20 XP/hour) and re-checks badges.
Models / tables:
user_xp_log(App\Models\UserXpLog) — append-only XP ledger;UPDATED_ATdisabled. Carriestenant_id,user_id,xp_amount,action,description,source_reference.users.xp,users.level,users.points— denormalised running totals (XP is incremented atomically; level recalculated from thresholds).users.show_on_leaderboardopts a member out of leaderboards.user_badges(App\Models\UserBadge) — earned badges; unique on(tenant_id, user_id, badge_key).challenges,user_challenge_progress,nexus_score_cache,user_streaks, plus the badge-definition / collection / season / campaign tables seeded by the gamification migrations.
XP & levels¶
GamificationService::XP_VALUES maps an action key to an XP amount (e.g. complete_transaction = 25, volunteer_hour = 20, create_listing = 15, send_credits = 10, receive_credits = 5). A simplified XP_VALUES_V2 set exists for the gamification redesign.
awardXP($userId, $amount, $action, $description = '', $reference = null):
- Returns immediately if
$amount <= 0. - Inside a DB transaction, writes a
user_xp_logrow and atomically incrementsusers.xp. - For declared one-time actions (currently
complete_profile), it locks the user row and skips if a log row for that action already exists. - After commit, invalidates the tenant's leaderboard cache, broadcasts an
xp-gainedevent, then callscheckLevelUp().
Levels are derived from cumulative XP. LEVEL_THRESHOLDS defines the 25-level V1 ladder; LEVEL_THRESHOLDS_V2 defines 10 named tiers (Newcomer → Explorer → Contributor → … → Legend). getLevelName() maps any level to a named tier. checkLevelUp() recalculates the level, persists it, notifies the member (in their preferred_language via LocaleContext), broadcasts level-up, awards milestone bonus XP at specific levels only — 5, 10, 15, 20, 25, 30, 50, 100 (bonuses of 50/100/150/200/300/400/500/1000 XP respectively; the ladder is not every-5 past level 30), and grants level badges.
Badges & achievements¶
Badge definitions are DB-backed (BadgeDefinitionService::getEnabledBadges()) with a static fallback array in GamificationService::getStaticBadgeDefinitions() for pre-seed safety. Each definition has a key, name, icon, type, and threshold.
runAllBadgeChecks($userId) runs every check. Quantity badges count an activity and grant tiered badges (volunteer hours, offers/requests, credits earned/spent, transactions, helped-people diversity, connections, messages, reviews given, 5-star reviews received, events attended/hosted, groups joined/created, posts, likes received, profile completion, membership age). Quality badges (gamification redesign) reward behaviour, not volume:
- Reliability — completed transactions with a cancellation rate under a configured ceiling.
- Bridge builder — trading across multiple distinct listing categories.
- Mentor — being a partner's first-ever completed transaction.
- Reciprocity — a healthy earn/spend ratio (core timebanking value).
- Community champion — sustained multi-category activity over consecutive months.
Awarding a badge (awardBadge()) is idempotent (see below), creates a recipient-locale notification + push, broadcasts badge-earned, records a feed activity, sends a milestone email, and awards earn_badge XP.
Leaderboards¶
LeaderboardService supports nine metrics — credits_earned, credits_spent, vol_hours, badges, xp, connections, reviews, posts, streak — over three windows: all_time, monthly (30 days), weekly (7 days). Queries are tenant-scoped, restricted to is_approved = 1, and honour show_on_leaderboard.
Results are cached for 60 seconds (CACHE_TTL_SECONDS). Cache keys are versioned via a per-tenant counter so invalidation is a single Cache::increment("leaderboard:{tenant}:version") — avoiding Cache::tags(), which is unsupported on the file/database cache stores. awardXP() calls LeaderboardService::invalidate($tenantId) after every award. The is_current_user flag is applied per-request after the cache fetch, so the cached dataset stays shareable across users.
The simpler GamificationService::getLeaderboard() is an XP-only ranking used by some profile surfaces; LeaderboardSeasonService adds time-boxed seasons.
Challenges¶
ChallengeService manages time-boxed action goals. A challenge has a challenge_type (e.g. weekly), action_type, target_count, category, xp_reward, and optional badge_reward, with start_date / end window and status. Member progress is tracked in user_challenge_progress; completing a challenge grants its XP (and badge) reward. Group challenges are handled by GroupChallengeService under /v2/groups/{id}/challenges. Challenge creation/management is exposed to admins via AdminGamificationController.
NEXUS score¶
NexusScoreService computes a composite 1000-point reputation score across six weighted dimensions: Community Engagement (250), Contribution Quality (200), Volunteer Hours (200), Platform Activity (150), Badges & Achievements (100), and Social Impact (100). It is tenant-scoped and returns both a total and a per-dimension breakdown. NexusScoreCacheService caches results for 1 hour in nexus_score_cache (keyed on user_id + tenant_id); recalculateAll($tenantId) re-scores every approved member in a tenant.
XP-award idempotency & anti-abuse¶
Duplicate XP is the main abuse/regression risk. The platform defends against it with two independent layers plus per-action one-time guards.
Layer 1 — short-lived cache claim (queue re-delivery)¶
UpdateWalletBalance is queued with public int $tries = 1 (no automatic retries, because XP has no natural retry-safe dedup beyond what is described here). At the top of handle() it makes a one-time claim:
$claimKey = 'wallet_xp:done:' . $event->tenantId . ':' . ($event->transaction->id ?? 0);
if (!Cache::add($claimKey, 1, now()->addHour())) {
// duplicate delivery suppressed
return;
}
Cache::add() is atomic insert-if-absent — a second delivery of the same TransactionCompleted within the hour short-circuits before any XP is awarded.
Layer 2 — permanent DB-level idempotency key (the real backstop)¶
The cache claim only lasts an hour. The durable guard is a unique index on user_xp_log:
- Migration
database/migrations/2026_06_16_120000_add_idempotency_to_user_xp_log.phpadds a nullablesource_referencecolumn and a unique indexuniq_user_xp_log_refon(tenant_id, user_id, action, source_reference). - MySQL/MariaDB allow multiple NULLs in a unique index, so legacy reference-less awards are unaffected — only callers that pass a
source_referencebecome idempotent at the database level. UpdateWalletBalancepasses the transaction id as the reference for both the sender'ssend_creditsand the receiver'sreceive_creditsawards. A re-award attempt for the same(tenant, user, action, transaction)raisesUniqueConstraintViolationException, whichawardXP()catches and treats as a silent idempotent no-op (it does not log it as an error or re-increment XP).
This is the mechanism to verify when touching this surface: the unique index is the source of truth; the cache claim is an optimisation that avoids the wasted work, not the correctness guarantee.
Other anti-duplication guards¶
- One-time actions (
complete_profile):awardXP()locks the user row and checks for an existing log row before awarding — serialising concurrent duplicate one-time awards. - Badges:
awardBadge()checks for an existingUserBadgeand also catchesUniqueConstraintViolationException, backed by the(tenant_id, user_id, badge_key)unique index (migrations/2026_03_28_fix_user_badges_unique_index.sql). Badge checks are therefore safe to call repeatedly. - Volunteer XP:
AwardXpOnVolLogApprovedonly acts on a genuinepending → approvedtransition and does aLIKE '%[vol_log:N]%'pre-check on the description (bracketed token to avoidvol_log:1matchingvol_log:11).
Reputational only: XP and badges never mint time credits. They are derived from completed actions; they never feed back into the wallet ledger. Keep it that way — adding an XP→credit path would turn a reputational system into a money-printing one.
Failure modes & recovery¶
- Side effects must never break the parent action. Every broadcast, email, feed-activity, and badge-check call in
GamificationService/ the listeners is wrapped in try/catch and logged atdebug/warning. A Pusher outage or email failure degrades gamification silently; it never rolls back a transaction or vol-log. - Queue not running. XP/badge awards on transactions are dispatched via the queue (
UpdateWalletBalance implements ShouldQueue). If the worker is down, awards are delayed, not lost — they fire when the queue drains. Because$tries = 1, a job that genuinely fails mid-run is not retried; the DB idempotency key means a manual replay is still safe. - Tenant context on the queue. The listener re-pins
TenantContext::setById($event->tenantId)and restores it infinally— queue workers boot once and do not carry request tenant context. - Stale leaderboard. Rankings are eventually consistent (60s TTL + version-bump invalidation). If a ranking looks wrong, confirm the per-tenant version counter is incrementing on awards; a flat counter points at a cache-store problem.
- Stale / missing NEXUS score. Scores are cached 1 hour. Force a refresh with
POST /nexus-score/recalculateorNexusScoreService::recalculateAll($tenantId). The cache layer degrades to live calculation whennexus_score_cacheis absent. - Unseeded badge definitions. If
BadgeDefinitionServiceis not seeded,GamificationServicetransparently falls back to its static badge array — no crash, but admin-configured badge tuning won't apply until the seed migration runs.
Test commands & key regression tests¶
# All gamification PHP tests
vendor/bin/phpunit --filter 'Gamification|Badge|Leaderboard|NexusScore|Challenge|Xp'
# Targeted high-value cases
vendor/bin/phpunit tests/Laravel/Unit/Listeners/UpdateWalletBalanceTest.php
vendor/bin/phpunit tests/Laravel/Integration/GamificationFlowTest.php
vendor/bin/phpunit tests/Laravel/Unit/Services/LeaderboardServiceTest.php
vendor/bin/phpunit tests/Laravel/Unit/Services/NexusScoreServiceTest.php
Important regression tests:
tests/Laravel/Unit/Listeners/UpdateWalletBalanceTest.php— the listener awards XP + runs badge checks, implementsShouldQueue, and swallows exceptions without breaking the parent flow.tests/Laravel/Integration/GamificationFlowTest.php— end-to-end: XP awarded for listing creation and exchange completion, badge checks after activity, level calculation from XP (including beyond the max defined level), leaderboard ranking, and that daily reward cannot be claimed twice.tests/Laravel/Unit/Models/UserXpLogTest.php— model contract: table name,UPDATED_ATdisabled, fillable includessource_reference, and the tenant scope is applied.tests/Laravel/Unit/Services/LeaderboardServiceTest.php/NexusScoreServiceTest.php/GamificationServiceTest.php— metric/period handling, score dimensions, and XP/level constant invariants.tests/Laravel/Feature/Controllers/Gamification{,V2}ControllerTest.php,AdminGamificationControllerTest.php— endpoint behaviour and admin badge/campaign management.
When adding a new XP source, prefer passing a stable source_reference so the uniq_user_xp_log_ref index makes the award idempotent, and add a test that asserts a second award for the same reference is a no-op.