ADR-032: Progression gating for Campaign and World¶
Status: Accepted Date: 2026-04-24 Context: Pre-launch, we want new players to feel the Quick Round loop before they can open Campaign or World. Two drivers: (1) World is our strongest feature — players who encounter it cold, before they've calibrated on a few Quick Rounds, churn before the payoff lands; (2) each reactive World campaign costs ~$0.50 in AI generation (see project_monetization_roadmap memory), so tire-kickers who start a World and abandon it are a real cost line.
Decision¶
Gate both Campaign and World tabs inside /play behind games-played thresholds:
- Campaign (static arcs): unlock at 3 games played
- World (reactive arcs): unlock at 8 games played
Locked tabs stay visible with a teaser card: mode description, progress bar (N / threshold), a rotating featured campaign/world preview, and a CTA to keep playing. No Sync-score floor — we don't want to punish new players who are still calibrating. No invite-link bypass — even joiners who receive an invite must meet the threshold (decided explicitly to protect cost exposure on World).
Thresholds are enforced both client-side (/play renders <LockedModeTeaser/> instead of <CampaignBrowser/>) and server-side (POST /api/campaigns and POST /api/campaigns/join return 403 when games_played < threshold). Existing members are not re-gated — the join API only checks the threshold for new joins.
Rationale¶
- 3 games for Campaign is low enough to not feel punitive and high enough to prove the player came back at least once after their first session. Static campaigns are cheap to serve, so the gate is really about narrative pacing: campaigns land better when players already know what a Sync score feels like.
- 8 games for World is ~2-3 sessions of real play. High enough to filter casual clickers (the cost-protection goal), low enough that an engaged player hits it the same week.
- Visible-but-locked beats hidden-until-unlocked for motivation. The progress bar and rotating teaser create pull; hiding the mode entirely removes the reason to keep playing.
- Rotating daily teaser keeps return visits interesting without adding DB dependencies — a hand-curated array of 3-4 hero worlds, indexed by
floor(Date.now() / 1d) % length.
Alternatives Considered¶
- Sync-score floor instead of games-played (e.g., unlock World at 40 Sync). Rejected: punishes players who are still miscalibrated, and the gate should measure commitment, not performance. A player with a 30 Sync who has played 10 games has shown more signal than one with a 60 Sync who has played 2.
- Invite-link bypass for guests joining a host's World. Rejected by the user: the host has already paid the cost-exposure, but letting unvetted guests jump straight into a reactive World short-circuits the onboarding path we want every player to walk. Revisit if this blocks real team onboarding.
- Higher threshold for World (e.g., 15 games). Rejected: too high and curious players bounce before they get there; the whole point of making the tab visible is pull, and a distant finish line kills pull.
- Hide locked modes entirely. Rejected: removes the motivational pull that justifies the gate in the first place.
Discussion¶
The anchoring debate was what the gate should measure. Games-played won over Sync score because the stated goal is commitment, not competence — we want players who will stick with a campaign or World to completion, and return-visits are a better predictor than raw performance. The Sync score is also noisy in the first 5 games (confidence capping per ADR-006), so using it as a gate would effectively gate on early randomness.
The "no invite bypass" decision was the other sharp edge. The risk is real: a Beacon team host invites 4 new players to a World campaign, and 2 of them can't join because they have fewer than 8 games. The counter-argument — and the one that won — is that those 2 players are exactly the ones who benefit most from going through the Quick Round funnel first. Worth re-examining after first launch if it becomes a real friction point; easiest fix would be a per-campaign "bypass gate" flag the host can toggle.
Threshold values (3 and 8) are deliberately conservative for launch. Easier to lower than raise — if analytics show most players never reach 8 games, we can drop the World threshold to 5.
Consequences¶
- New players see Campaign and World tabs but can't enter them until they've played 3 and 8 Quick Rounds respectively.
- Server-side enforcement in two API routes prevents client manipulation.
- Existing users with
games_played >= 8are unaffected — they skip the gate entirely. - Invited joiners to a World must also meet the threshold; this may surface as friction when teams try to onboard new players into a World together.
- Thresholds are hardcoded constants — tuning them requires a code change. If we want to A/B test, promote to a config table (pattern exists via
sync_decay_config). - Rotating teasers are hardcoded in
LockedModeTeaser.tsx. If we want them DB-driven (e.g., "featured world of the day" marketing surface), that's a later step.
Key files:
- src/lib/progression/unlocks.ts — threshold constants + getUnlockState() helper
- src/components/progression/LockedModeTeaser.tsx — locked-state UI with rotating featured card
- src/app/play/page.tsx — renders teaser instead of CampaignBrowser when locked; fetches profile.games_played on mount
- src/app/api/campaigns/route.ts — server-side gate on campaign creation (POST)
- src/app/api/campaigns/join/route.ts — server-side gate on campaign join (POST), bypassed for existing members