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 (the migration seeds three: Bronze, Silver, Gold) with
tier_order,promote_count,demote_count,cohort_size. - Every Monday at 00:00 UTC the
league-rolloverworkflow 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) andleague_demoted(bottom N) events intoengagement_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(statusactive) and the membership rows.
- 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
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
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_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 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_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 / your own engagement_events subscriber 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.
Database tables
| Table | Purpose |
|---|---|
leagues | Tier definitions: name, tier_order, promote_count, demote_count, cohort_size |
league_cohorts | One row per league per ISO week (Monday). Status active or closed. |
league_memberships | Per-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_awardedevents. Future: per-leaguescore_sourcecolumn 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_sizeis preserved on the schema and thechunkIntoCohortshelper is exported, but partitioning ships in a follow-up that relaxes the unique key to(league_id, week_start, cohort_index).