Skip to Content

Gamification

The gamification module (WP-14-BE) ties every reward directly to a measured learning event. There is no XP for logging in, completing sessions, or spending time in the app. The only thing that can mint XP, unlock a badge, or advance a streak is a mastery_event row written by the measurement pipeline (V-8). This ensures that what the leaderboard reflects is growth, not engagement.

The student gamification profile carries only the student’s own XP, avatar, streak, and badges. No peer rank, no class totals, and no benchmark comparison ever reach a student-facing surface (SPEC-GAM-10). Student tokens receive 403 on every leaderboard route; there is no student leaderboard endpoint.

Routes

MethodPathWho can call
GET/api/students/{id}/gamification/profileStudent-self, teacher-of-class, parent, principal, admin
GET/api/leaderboards/class/{classId}Teacher, principal, admin (student token: 403)
GET/api/leaderboards/teacher/{teacherUserId}Teacher-self, principal, admin (student token: 403)
GET/api/leaderboards/school/{schoolId}Principal, admin only (teacher: 403)
POST/api/teachers/badges/{studentBadgeId}/rescindTeacher (ClassTeacher-scoped)
GET/api/admin/gamification/auditAdmin only
POST/api/admin/gamification/replayAdmin only

All student and class ids are positive integers. A cross-org or out-of-scope id returns 404 (existence non-disclosure). Every route requires a valid session token and the organizationId tenant context.

GET /api/students/{id}/gamification/profile

Returns the student’s own XP balance, avatar tier, streak state, and badge list. For adult callers (teacher, parent, principal, admin) each badge entry also carries studentBadgeId and status so that a teacher can drive the rescind dialog. For the student-self caller those two fields are absent; the student sees only the badge name and award date.

curl https://localhost:3000/api/students/42/gamification/profile \ -H "Authorization: Bearer <accessToken>"
{ "studentId": 42, "xpBalance": 340, "avatarTier": 3, "streak": { "current": 4, "longest": 11, "lastTickDate": "2026-06-26" }, "badges": [ { "code": "BADGE_JOURNEY_START", "nameAr": "بداية الرحلة", "descriptionAr": "...", "awardedAt": "2026-06-10T08:00:00.000Z" } ] }

xpBalance is always derived on read (a SUM over xp_transaction) and is never stored as a column (V-6). avatarTier is an integer 1 to 5. The studentBadgeId and status fields appear only for adult callers.

XP derivation

XP is never stored as a running balance. Every call that returns xpBalance computes it from the append-only xp_transaction ledger. Credits are written by the orchestrator after each mastery event, capped per session (engineering default: 200 XP per session; the cap is a versioned rule-set value, not a hard-coded constant).

The 8 closed MasteryEventSourceKind values that can mint XP are:

KindDescription
subskill_state_upA sub-skill moves to a higher state in the SSE pipeline
cbm_trend_above_goalA CBM fluency trend crosses above the aim line
bundle_step_goal_metAn individual step within a bundle plan is completed
band_progressionThe student advances one or more proficiency bands on an item set
skill_status_transitionA skill-level status transitions to a more positive state
domain_status_escalationA domain-level status transitions to a more positive state
bundle_completionThe full bundle plan is marked complete
scaffold_tier_step_upThe student moves to a less-supported scaffold tier within a bundle

No other event kind exists. The enum is closed; CI lint (lint:gamification-award-rule) rejects any call site that tries to mint XP outside this set. A do_not_decide_yet or partial_domain_profile halt signal short-circuits the orchestrator entirely; nothing is written.

Avatar tiers

The avatar advances through 5 cosmetic tiers based on cumulative mastery-event count. Tiers are cosmetic only; they carry no multiplier and no peer signal.

TierReached when cumulative count is
1below 5 (starting tier)
25 or more
315 or more
435 or more
575 or more

The thresholds are engineering working defaults (flagged for partner confirmation). Tier advancement queues a non-blocking celebration event for the frontend; the event is idempotent per (student, tier).

Streaks

A streak ticks once per calendar day on which the student earns at least one mastery event. Days with sessions but zero mastery events do not tick. The tick is idempotent within a calendar day; re-running the same day after a tick is a no-op.

Grace window. Weekends (Friday and Saturday) and school holidays do not count toward an absence gap. The system will not break a streak for a day that falls on a weekend or a configured holiday. The grace window for non-holiday, non-weekend no-event days defaults to 7 school days.

Break behavior. When the gap of non-weekend, non-holiday no-event days exceeds the grace window, the prior streak is broken. The day that exceeds the grace window starts a new streak at current = 1 (the break day itself is counted as day one of the new streak, not zero).

Streak milestones are at 5, 10, and 20 consecutive days and emit a notification event for the frontend.

Badges

15 badges are seeded from mastery events only. Each badge in the catalog has an awardRule that cites at least one MasteryEventSourceKind; a badge cannot be defined without a measurable learning trigger (V-8). The orchestrator evaluates the badge catalog after every mastery event and awards at most one new badge per evaluation to keep the celebration UI manageable.

For adult callers the badge DTO includes studentBadgeId (the database row id) and status (active or rescinded). These fields are absent in the student-self response.

Leaderboards

Three leaderboard scopes are available: class, teacher (multi-class roll-up), and school. All three are adult-only surfaces.

Student tokens receive 403 on every leaderboard route. There is no student-facing leaderboard, and no student-facing variant is planned (V-8 / Vision §5 hard anti-feature).

Class leaderboard (GET /api/leaderboards/class/{classId}) returns entries for all students in the class. The caller must be a ClassTeacher of that class, a principal of the school, or an admin. A different teacher’s class returns 404.

Teacher leaderboard (GET /api/leaderboards/teacher/{teacherUserId}) returns a multi-class roll-up across every class that teacher teaches, plus per-class totals. A teacher may only request their own teacherUserId; a principal or admin may request any teacher in their scope.

School leaderboard (GET /api/leaderboards/school/{schoolId}) is principal and admin only. A teacher token returns 403 regardless of whether that teacher belongs to the school.

All leaderboards read mastery_event directly. They never read xp_balance, session counts, or any engagement counter. The view query parameter selects by_mastery_events (default) or by_band_progression. The windowDays parameter (1 to 90, default 14) restricts the count to events within the window.

The response carries framing.topLabelAr and framing.bottomLabelAr: Arabic growth-language strings the frontend renders verbatim. These strings are language-safety filtered and never use comparative or ranking language (V-12).

curl "https://localhost:3000/api/leaderboards/class/cls_001?view=by_mastery_events&windowDays=14" \ -H "Authorization: Bearer <accessToken>"
{ "scope": "class", "view": "by_mastery_events", "windowDays": 14, "framing": { "topLabelAr": "...", "bottomLabelAr": "..." }, "entries": [ { "studentId": 42, "masteryEventCount": 18, "bandProgressionCount": 3, "sourceKindBreakdown": { "subskill_state_up": 12, "band_progression": 3, "bundle_step_goal_met": 3 } } ] }

POST /api/teachers/badges/{studentBadgeId}/rescind

A teacher can rescind a badge when it was awarded in error. The request body carries only overrideCode; there is no free-text field on this route (B.3).

The overrideCode must be Gam_Award_Invalid_05, the single gamification-rescind code from the 9-code OverrideCodeCatalog. The handler scope-pins the code to applicableScope = 'gamification_rescind' at runtime; a missing code or a code from a different scope (such as a bundle-exit code) returns 400. The teacher must be a ClassTeacher of the badge’s student; any other teacher returns 403. A badge that is already rescinded returns 409.

curl -X POST https://localhost:3000/api/teachers/badges/8124/rescind \ -H "Authorization: Bearer <accessToken>" \ -H "Content-Type: application/json" \ -d '{ "overrideCode": "Gam_Award_Invalid_05" }'
{ "studentBadgeId": 8124, "status": "rescinded", "compensatingXpTransactionId": 9201, "reversalDelta": -50 }

The rescind appends a compensating xp_transaction row (the reversal). Nothing is deleted; the full ledger including the original credit and the reversal remains visible in the audit route.

Admin: audit and replay

GET /api/admin/gamification/audit

Returns the raw mastery-event, XP-transaction, and badge streams for a student. Used by admins to inspect the full ledger before or after a replay. Accepts studentId (required), from, and to datetime filters.

curl "https://localhost:3000/api/admin/gamification/audit?studentId=42" \ -H "Authorization: Bearer <adminAccessToken>"

POST /api/admin/gamification/replay

Re-derives the student’s XP balance, badge set, and avatar tier from the immutable mastery_event stream as of a given point in time, then diffs the result against stored values (V-6). A non-zero diff means a stored value has drifted from the ledger; the response flags each dimension individually.

curl -X POST https://localhost:3000/api/admin/gamification/replay \ -H "Authorization: Bearer <adminAccessToken>" \ -H "Content-Type: application/json" \ -d '{ "studentId": 42, "asOf": "2026-06-27T00:00:00.000Z" }'
{ "studentId": 42, "asOf": "2026-06-27T00:00:00.000Z", "derived": { "xpBalance": 340, "badgeCodes": ["BADGE_JOURNEY_START"], "avatarTier": 3 }, "stored": { "xpBalance": 340, "badgeCodes": ["BADGE_JOURNEY_START"], "avatarTier": 3 }, "drift": { "xpBalance": false, "badges": false, "avatarTier": false }, "hasDrift": false }

hasDrift: true indicates the stored snapshot has diverged from the derivable stream. This is the V-6 audit signal; resolving it requires an admin investigation, not an automatic correction.

Invariants

  • V-8: every reward traces to a measured event. The MasteryEventSourceKind enum has exactly 8 values. xp_transaction.masteryEventId is NOT NULL on every credit row; student_badge.triggeringMasteryEventId is NOT NULL on every badge row. CI lint (lint:gamification-award-rule / AC-GAM-1) rejects any write path that circumvents these constraints.
  • V-6: XP balance is always derived. xp_balance is never stored as a column. Rule versions are pinned per credit row so a replay of the same inputs produces the same result.
  • Append-only (B.7). The mastery_event, xp_transaction, student_badge, and student_badge_rescind tables are append-only by application convention and CI lint. No Postgres BEFORE trigger exists in Wave 1; enforcement is at the application layer.
  • No free text (B.3). The rescind body accepts only the controlled overrideCode field. No reason, note, or comment field exists on any gamification write path.
  • No student leaderboard (SPEC-GAM-07). Student tokens receive 403 on all three leaderboard routes. This is a hard product rule, not a permission configuration.
  • No LLM (V-5). All gamification logic is deterministic and rule-written. No language model is involved at any step.
  • Tenant-isolated. organizationId is denormalized and indexed on every table; a cross-org id returns 404.
  • Halt sentinel. A do_not_decide_yet or partial_domain_profile signal from the upstream engine short-circuits the orchestrator entirely; no mastery event, XP credit, badge, or streak tick is written.