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
| Method | Path | Who can call |
|---|---|---|
GET | /api/students/{id}/gamification/profile | Student-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}/rescind | Teacher (ClassTeacher-scoped) |
GET | /api/admin/gamification/audit | Admin only |
POST | /api/admin/gamification/replay | Admin 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:
| Kind | Description |
|---|---|
subskill_state_up | A sub-skill moves to a higher state in the SSE pipeline |
cbm_trend_above_goal | A CBM fluency trend crosses above the aim line |
bundle_step_goal_met | An individual step within a bundle plan is completed |
band_progression | The student advances one or more proficiency bands on an item set |
skill_status_transition | A skill-level status transitions to a more positive state |
domain_status_escalation | A domain-level status transitions to a more positive state |
bundle_completion | The full bundle plan is marked complete |
scaffold_tier_step_up | The 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.
| Tier | Reached when cumulative count is |
|---|---|
| 1 | below 5 (starting tier) |
| 2 | 5 or more |
| 3 | 15 or more |
| 4 | 35 or more |
| 5 | 75 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
MasteryEventSourceKindenum has exactly 8 values.xp_transaction.masteryEventIdis NOT NULL on every credit row;student_badge.triggeringMasteryEventIdis 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_balanceis 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, andstudent_badge_rescindtables are append-only by application convention and CI lint. No PostgresBEFOREtrigger exists in Wave 1; enforcement is at the application layer. - No free text (B.3). The rescind body accepts only the controlled
overrideCodefield. Noreason,note, orcommentfield exists on any gamification write path. - No student leaderboard (SPEC-GAM-07). Student tokens receive
403on 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.
organizationIdis denormalized and indexed on every table; a cross-org id returns404. - Halt sentinel. A
do_not_decide_yetorpartial_domain_profilesignal from the upstream engine short-circuits the orchestrator entirely; no mastery event, XP credit, badge, or streak tick is written.
Related guides
- Student Profiles: the profile resolution that feeds profile-based badge rules
- RTI Decisions: a source of
bundle_completionandbundle_step_goal_metevents - Progress Monitoring: a source of
cbm_trend_above_goalevents - Skill-Status Engine: a source of
skill_status_transitionanddomain_status_escalationevents - Language Safety: the layer that filters leaderboard framing labels for growth language