triggair

Blog / competition

Competitive leagues with promotion and relegation

3 min read

Competitive leagues with promotion and relegation cover art

A single global leaderboard is a motivation killer. The top 10 fight over the crown, and everyone from rank 200 to rank 20,000 stares at a number they'll never move. Most of your players aren't bad, they're just invisible. What they need isn't a bigger board, it's a smaller one: a division of people at their exact skill level, with a real stake each week.

That's what leagues do. Players get sorted into tiered divisions sitting on top of a leaderboard you already submit to. Each season, the top of every division promotes up and the bottom relegates down. Bronze feels as tense as Diamond, because in your bracket you're always near a line that matters.

Joining a league

A league is attached to one of your leaderboards by key. When a player joins, they're dropped into the lowest division. Everyone climbs from the bottom, which keeps new brackets fair and gives every account somewhere to go.

ts
// Call this once when the player enters the competitive mode.
// Re-joining is safe; they stay where they are.
const { joined } = await tg.leagues.join('ladder');
if (joined) {
  console.log('You\'re in this season\'s ladder. Starting in the lowest division.');
}

Submitting a round score

Leagues don't have their own scoring API; they ride on the leaderboard. Every round the player finishes, submit the score the normal way. The league reads from that board when it ranks divisions and when the season advances, so your existing submit call is all the plumbing you need.

ts
// Player just finished a run. Feed the score to the board the league sits on.
async function endRound(score: number) {
  await tg.leaderboards.submit('ladder', score);
  // That's it - the league standings update off this board.
}

Note that promotion and relegation happen server-side at season advance. You never compute cutoffs on the client; you just keep submitting scores and read the result.

Rendering the player's division and zone

The payoff is tg.leagues.me(). It tells you the player's division_name, their rank inside that division, the total members, and (the important part) their zone: 'promoting', 'safe', or 'relegating'. That one field is your entire UI tension. Color the card green when they're climbing, red when they're about to drop.

ts
const me = await tg.leagues.me('ladder');

const zoneLabel = {
  promoting: '▲ Promotion zone - hold your spot!',
  safe: '- Safe',
  relegating: '▼ Relegation zone - climb or drop!',
}[me.zone];

render(`
  <div class="league-card zone-${me.zone}">
    <h3>${me.division_name}</h3>
    <p>Rank ${me.rank} of ${me.members}</p>
    <p class="zone">${zoneLabel}</p>
  </div>
`);

To show the bracket the player is fighting inside, pull the division standings with divisionTop. Pass the league key and a tier to render the ladder your player actually sees: the handful of names directly above and below them.

ts
// Standings for a specific tier (e.g. tier 1 = top division).
const standings = await tg.leagues.divisionTop('ladder', me.division);
for (const row of standings) {
  console.log(`${row.rank}. ${row.player_id} - ${row.score}`);
}

Gotchas

  • Don't fake the cutoffs on the client. The zone field is authoritative and already accounts for how many slots promote or relegate this season. Reading it beats re-deriving "top 3 go up" yourself, which breaks the moment you retune the season.
  • Join is idempotent-friendly. Calling join again returns the player's current placement rather than resetting them, so it's safe to call on every entry into competitive mode.
  • One board, one league identity. Since the league rides your leaderboard, submit real round scores to that board only. Routing unrelated scores through it will scramble the standings.
  • Errors carry an agentHint. If a join or submit fails, the typed code plus agentHint tell you (or your AI copilot) exactly what to fix.

Tiered divisions turn one demotivating global ranking into thousands of tight, personal races. Join players in, keep submitting round scores, and let the zone field do the emotional heavy lifting.

Build this into your game.

Everything above is one import and a publishable key away. Start with the quickstart, or read the guide for the exact feature.

Keep reading