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
- Admin defines tiers (three are seeded by default: Bronze, Silver, Gold) with
tier_order,promote_count,demote_count,cohort_size. - 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) andleague_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.
- The client SDK reads the user's current cohort, live rank, and full cohort roster.
Default tiers
| Tier | tier_order | promote_count | demote_count | cohort_size |
|---|---|---|---|---|
| Bronze | 0 | 5 | 0 | 30 |
| Silver | 1 | 5 | 5 | 30 |
| Gold | 2 | 0 | 5 | 30 |
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
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_countmembers move up a tier (capped at the highest active tier — top tier promotions are no-ops by settingpromote_count = 0). - Demote: the bottom
league.demote_countmembers 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_name | properties |
|---|---|
league_promoted | from_league_id, from_tier_order, to_league_id, to_tier_order, cohort_id, final_rank, week_start |
league_demoted | from_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
| Method | Path | Description |
|---|---|---|
GET | /admin/projects/:projectId/leagues | List leagues, ordered by tier_order |
POST | /admin/projects/:projectId/leagues | Create a league (name, tier_order, …) |
PATCH | /admin/projects/:projectId/leagues/:leagueId | Update name / promote / demote / cohort_size |
GET | /admin/projects/:projectId/leagues/:leagueId/cohorts/current | List 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
| Method | Path | Description |
|---|---|---|
GET | /client/leagues/me | Current cohort + tier name + live rank + score |
GET | /client/leagues/me/cohort | Cohort with all members, anonymised (display_name only) |
Example
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_awardedevents. 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 ofcohort_size.