---
title: "Gamification"
url: https://docs.growamal.com/en/guides/gamification/
language: en
---

# 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.

> **Note:** 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.

```bash
curl https://localhost:3000/api/students/42/gamification/profile \
  -H "Authorization: Bearer <accessToken>"
```

```json
{
  "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.

> **Warning:** 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).

```bash
curl "https://localhost:3000/api/leaderboards/class/cls_001?view=by_mastery_events&windowDays=14" \
  -H "Authorization: Bearer <accessToken>"
```

```json
{
  "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`.

```bash
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" }'
```

```json
{
  "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.

```bash
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.

```bash
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" }'
```

```json
{
  "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.

## Related guides

- [Student Profiles](https://docs.growamal.com/en/guides/student-profiles/): the profile resolution that feeds profile-based badge rules
- [RTI Decisions](https://docs.growamal.com/en/guides/rti-decisions/): a source of `bundle_completion` and `bundle_step_goal_met` events
- [Progress Monitoring](https://docs.growamal.com/en/guides/progress-monitoring/): a source of `cbm_trend_above_goal` events
- [Skill-Status Engine](https://docs.growamal.com/en/guides/skill-status-engine/): a source of `skill_status_transition` and `domain_status_escalation` events
- [Language Safety](https://docs.growamal.com/en/guides/language-safety/): the layer that filters leaderboard framing labels for growth language
