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 (three are seeded by default: Bronze, Silver, Gold) with tier_order, promote_count, demote_count, cohort_size.
  2. Every Monday at 00:00 UTC the weekly rollover fires. For every active project it:
    • Ranks last week's cohorts by score (XP earned in the week).
    • Closes the previous week's cohorts.
    • Emits league_promoted (top N) and league_demoted (bottom N) 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.
    • Opens new cohorts for the new week.
  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

The league score is XP earned in the cohort's week: the rollover sums every xp_awarded engagement event whose timestamp falls in [week_start, week_start + 7d). The XP amount is read from the event's amount property (the convention emitted by the XP evaluator).

Weekly cycle

Mon 00:00 UTC                                        Mon 00:00 UTC
     │                                                    │
     │  ── Active week ───────────────────────────────►   │
     │  cohort status: active                             │
     │  member scores live, computed at read time         │
     │                                                    │
     │                                  ▼   rollover   ▼
     │                                  - rank cohorts
     │                                  - emit events
     │                                  - close + reassign

During the week, the source of truth for "current rank" is the live engagement-event stream — member scores are only materialised at the week boundary by the rollover. 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 stable 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 rollover clamps so a single member never receives BOTH a league_promoted AND a league_demoted event in the same week.

Events emitted

The rollover emits these 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, or your own engagement-event handler 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.

Behavior to know

  • Promote / demote runs at the Monday boundary only. Tier changes happen during the weekly rollover; rank shifts mid-week are not reflected until the next Monday.
  • Cohorts are randomly assembled within a tier. No anti-collusion or alt-account detection — design your XP economy so coordinated farming isn't worth more than legitimate play.
  • Score source is xp_awarded events. League rank is driven by XP. Award XP for whatever in-app behavior you want to drive (steps, sessions, custom actions); leagues will rank on the resulting XP.
  • New users join the next Monday rollover. A user who registers Tuesday is placed into a cohort at the following Monday 00:00 UTC rollover.
  • One cohort per league per week. The schema enforces UNIQUE (league_id, week_start), so all members of a tier compete in a single cohort each week regardless of cohort_size.

On this page