Amba

Leagues

Duolingo-style weekly cohort leagues with auto promote/demote at week boundary.

Leagues group your users into weekly cohorts at a tier (Bronze / Silver / Gold / ...) and auto-promote the top performers and demote the bottom performers each Monday.

If leaderboards rank everyone in one big list, leagues split your audience into ~30-user cohorts at the user's tier, give them a week to compete, then shuffle the leaders up a tier and the laggards down. It's the loop that keeps Duolingo's daily active users coming back.

How it works

  1. Admin defines tiers (the migration seeds three: Bronze, Silver, Gold) with tier_order, promote_count, demote_count, cohort_size.
  2. Every Monday at 00:00 UTC the league-rollover workflow fires. For every active project it:
    • Ranks last week's cohorts by score (XP earned in the week).
    • Marks the cohorts closed.
    • Emits league_promoted (top N) and league_demoted (bottom N) events into engagement_events.
    • Assembles new-week assignments: top N move up a tier, bottom N move down, the middle stays put. New users default to Bronze.
    • Inserts new league_cohorts (status active) and the membership rows.
  3. The client SDK reads the user's current cohort, live rank, and full cohort roster.

Default tiers

Tiertier_orderpromote_countdemote_countcohort_size
Bronze05030
Silver15530
Gold20530

The bottom tier has demote_count = 0 (you can't be demoted out of Bronze). The top tier has promote_count = 0 (you can't be promoted out of Gold). Add a Diamond tier later by POST /admin/leagues with tier_order: 3.

Score input

For v1, the league score is XP earned in the cohort's week: the workflow sums engagement_events rows where event_name = 'xp_awarded' and occurred_at falls in [week_start, week_start + 7d). The XP amount comes from properties->>'amount' (the convention used by xpEvaluation).

Configurable score sources (steps, sessions, custom events) are deferred — see the bottom of this page.

Weekly cycle

Mon 00:00 UTC                                        Mon 00:00 UTC
     │                                                    │
     │  ── Active week ───────────────────────────────►   │
     │  cohorts.status = 'active'                         │
     │  memberships.score = 0 (denormalised at boundary)  │
     │                                                    │
     │                                  ▼ league-rollover ▼
     │                                  - rank cohorts
     │                                  - emit events
     │                                  - close + reassign

During the week the source of truth for "current rank" is engagement_events — the score column on league_memberships is only materialised at week boundary by the rollover workflow. Live rank is computed at read time by the /client/leagues/me endpoint.

Promote/demote rules

For each cohort, after ranking by score DESC (ties broken by app_user_id ASC):

  • Promote: the top league.promote_count members move up a tier (capped at the highest active tier — top tier promotions are no-ops by setting promote_count = 0).
  • Demote: the bottom league.demote_count members move down a tier (floored at the lowest tier).
  • Stay: every member between those two slices keeps their current tier.

If promote_count + demote_count > cohort_size, the workflow clamps so a single member never receives BOTH a league_promoted AND a league_demoted event in the same week.

Events emitted

The rollover workflow emits these events into engagement_events for every promotion/demotion:

event_nameproperties
league_promotedfrom_league_id, from_tier_order, to_league_id, to_tier_order, cohort_id, final_rank, week_start
league_demotedfrom_league_id, from_tier_order, to_league_id, to_tier_order, cohort_id, final_rank, week_start

Subscribe via push campaigns / segments / your own engagement_events subscriber to celebrate promotions or coach demotions.

Admin API reference

MethodPathDescription
GET/admin/projects/:projectId/leaguesList leagues, ordered by tier_order
POST/admin/projects/:projectId/leaguesCreate a league (name, tier_order, …)
PATCH/admin/projects/:projectId/leagues/:leagueIdUpdate name / promote / demote / cohort_size
GET/admin/projects/:projectId/leagues/:leagueId/cohorts/currentList active cohorts with member counts

tier_order is intentionally NOT updatable on this PR — changing a tier mid-week would require re-cohorting every active membership.

Client API reference

MethodPathDescription
GET/client/leagues/meCurrent cohort + tier name + live rank + score
GET/client/leagues/me/cohortCohort with all members, anonymised (display_name only)

Example

curl -H "X-Api-Key: $AMBA_KEY" -H "Authorization: Bearer $SESSION" \
  https://api.amba.dev/v1/client/leagues/me
{
  "data": {
    "cohort": {
      "id": "co_5f3a…",
      "league_id": "lg_silver",
      "week_start": "2026-05-04",
      "status": "active"
    },
    "league": {
      "id": "lg_silver",
      "name": "Silver",
      "tier_order": 1
    },
    "rank": 2,
    "score": 50,
    "member_count": 2
  }
}
curl -H "X-Api-Key: $AMBA_KEY" -H "Authorization: Bearer $SESSION" \
  https://api.amba.dev/v1/client/leagues/me/cohort
{
  "data": {
    "cohort": { "id": "co_5f3a…", "week_start": "2026-05-04", "status": "active" },
    "league": { "name": "Silver", "tier_order": 1 },
    "members": [
      { "display_name": "Bob", "score": 100, "rank": 1 },
      { "display_name": "Alice", "score": 50, "rank": 2 }
    ]
  }
}

Members in /me/cohort are anonymised: only display_name, score, rank are returned. No app_user_id leaks to other clients.

Database tables

TablePurpose
leaguesTier definitions: name, tier_order, promote_count, demote_count, cohort_size
league_cohortsOne row per league per ISO week (Monday). Status active or closed.
league_membershipsPer-user-per-cohort row. score and final_rank are materialised at week boundary.

Tenant DBs hold no project_id column on these tables — isolation is at the database level.

Deferred for v1.1

The following are explicit non-goals for v1 and are left as follow-ups:

  • Mid-week tier reassignment. Promote/demote happens only at the Monday boundary.
  • Anti-collusion / smurf detection. Cohorts are random within a tier. We don't detect coordinated XP farming or alt-accounts.
  • Configurable score sources. v1 hardcodes xp_awarded events. Future: per-league score_source column accepting custom event names or computed metrics.
  • Mid-week join. New users register in the next Monday rollover, not immediately.
  • True ~30-user cohort partitioning. The migration's UNIQUE (league_id, week_start) allows only ONE cohort per league per week in v1; cohort_size is preserved on the schema and the chunkIntoCohorts helper is exported, but partitioning ships in a follow-up that relaxes the unique key to (league_id, week_start, cohort_index).

On this page