triggair

Blog / retention

Quests and a battle-pass season

3 min read

Quests and a battle-pass season cover art

The difference between a game people play once and a game people come back to is usually a season structure: a reason to log in today, and a track that rewards the logging-in over weeks. Daily and weekly quests give you the "today," a battle pass gives you the "over weeks." Built by hand, this is a nightmare of reset timers, XP curves, tier tables, and reward accounting that has to be right or players revolt.

Triggair gives you quests and a battle pass as configured, server-authoritative systems. You define the season in the dashboard; your game just feeds it the progress it already produces.

Drive progress from gameplay you already have

Quests and battle-pass XP ride on the same signal: things the player does. If you're already tracking stats, you're already driving progression, so you don't report to quests separately.

ts
// The player finished a match with 2 wins and 340 damage.
await tg.stats.update([
  { key: 'matches_won', op: 'increment', value: 2 },
  { key: 'damage_dealt', op: 'increment', value: 340 },
]);

A quest configured as "win 3 matches today" watches matches_won; the battle pass grants XP off the same activity. One update call, and both the daily goals and the season track advance, server-side, so it's consistent across every device the player owns.

Read quest state and the pass tier

Reading is for display. You show players where they are; you never compute whether a goal is complete or how much XP a tier costs. The server owns that. Method names here are lighter, so treat these shapes as the intent rather than exact signatures.

ts
// Current daily/weekly quests with their progress.
const quests = await tg.quests.list();
// each ~ { key, description, progress, target, done, reward }

// Where the player sits on the season track (pass the season key).
const pass = await tg.battlePass.get('season_1');
// { season_key, name, state, bp, tier, has_premium, claimed_free, claimed_premium,
//   tiers: [{ tier, bp_required }] }

Render progress / target as a bar, show the current tier and how far to the next, and mark which tiers the player has already collected via claimed_free / claimed_premium. The free-versus-premium split is just the has_premium flag on the player's pass: the same tier can hold a free reward everyone gets and a premium reward unlocked by the paid track.

Rewards escrow to the inbox

This is the part that keeps a live game honest: completing a quest or crossing a tier doesn't dump items into your game state. It escrows the reward to the inbox, and the player claims it, exactly once, on the server.

ts
const items = await tg.inbox.list();
for (const item of items) {
  const reward = await tg.inbox.claim(item.id);  // exactly-once
  applyReward(reward);                            // grant only after claim
}

Because the payout is server-authoritative and settled through the inbox, a player can't double-claim a tier by reinstalling, and a crash mid-reward doesn't cost them anything. The reward is owed the moment the quest completes, and claim settles it when they're ready.

Gotchas

  • Don't invent your own reset clock. Daily and weekly windows are part of the season configured in the dashboard. Read quest state; don't compute "is it a new day?" on the client. Device clocks lie and timezones bite.
  • Progress is a delta, not a total. stats.update with increment adds to the server's number. Send what just happened, not the running total, or you'll double-count.
  • Free and premium share the ladder. Premium is a flag that unlocks the premium column of the same tiers: you don't build a separate track, you reveal a second reward per tier.
  • Tune without shipping. Season length, quest targets, and XP curves live in config, so you can rebalance a live season with no app-store release.

Feed the season the gameplay you already track, read state only to display it, and let the inbox settle every reward. That's the retention backbone of a live game without a single reset timer in your code.

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