Progression System — Design Specification
Status: Draft Last updated: 2026-03-27 Supersedes: Individual challenge/achievement specs; Elo tier definitions
Philosophy
A player who shows up, plays poorly, and goes home should feel BETTER about their tennis journey than when they arrived.
Progression in TwoMore is not about climbing a ladder. It's about growing as a tennis player and a community member. The system mirrors real tennis: your body remembers even after a break, improvement comes from consistency not intensity, and the best part of tennis is the people you play with.
Core Principles
- Losing is invisible, winning is spectacular — Elo adjustments happen internally; UI shows growth, not decline
- Tiers are permanent badges — once earned, never visibly revoked (90-day grace period)
- One visual, hidden math — the 5-petal radar IS the progression. XP, caps, multipliers are invisible
- At least one petal always grows — showing up = Activity grew. Playing with someone = Social grew. No all-negative sessions.
- "Welcome back" over "you missed it" — returning from a gap is celebrated, never punished
- Tennis first, gamification second — if the system ever makes someone feel bad about playing tennis, it has failed
The 5-Petal Radar
Every player has a radar chart with 5 dimensions, each 0-100:
| Petal | Korean | What it measures | How it grows |
|---|---|---|---|
| Performance | 경기력 | Elo relative to tier, win rate, competitive results | Winning matches, improving Elo |
| Activity | 활동량 | Sessions attended, matches played, hours on court | Showing up and playing |
| Social | 소셜 | Unique partners, new connections, games as guest | Meeting new people, playing with different partners |
| Growth | 성장 | Elo improvement rate, tier advancement, practice | Getting better over time |
| Contribution | 공헌 | Hosting sessions, organizing, inviting members | Giving back to the community |
Petal Calculation
Each petal is calculated as a percentile within the user's tier cohort (not globally). A Bronze player with 10 unique partners scores higher on Social than a Diamond player with 10 partners, because the expected baseline is different per tier.
petalScore = min(100, (userMetric / tierExpectedMetric) * 60)The 60 baseline means matching tier expectations puts you at 60/100 — above average but with room to grow. Exceeding 1.67x expectations maxes the petal.
Petal Fade (Inactivity)
Petals fade at 5% per month of inactivity, with a floor at 40% of peak. A 6-month break results in petals at ~70% of peak. Petals never reach zero.
fadedScore = max(peakScore * 0.4, peakScore * (0.95 ^ monthsInactive))Petals only fade when not playing. One session in a month resets the fade timer for that month.
Petal Growth Rules
Asymmetric growth/shrink: Petals grow at normal rate but shrink at 1/3 rate. A bad month reduces petals by 1/3 of what a good month would add. This ensures the radar mostly grows over time.
Tier System
Rating Ranges (calibrated to USTA NTRP / UTR)
| Tier | Elo Range | NTRP | UTR | Korean Description | 동호회 Division |
|---|---|---|---|---|---|
| Bronze | < 1000 | 2.0-2.5 | 1-3 | 기본 스트로크를 연습 중이며, 느린 랠리가 가능해요 | D조 / 입문 |
| Silver | 1000-1199 | 2.5-3.0 | 3-4.5 | 중간 속도의 랠리를 유지하고, 기본 서브가 들어가요 | C~D조 / 초급 |
| Gold | 1200-1399 | 3.0-3.5 | 4.5-6 | 안정적인 스트로크와 방향 조절이 가능해요 | C조 / 중급 |
| Platinum | 1400-1599 | 3.5-4.0 | 6-7.5 | 스핀과 파워를 활용하고, 다양한 샷으로 압박할 수 있어요 | B~C조 / 중상급 |
| Diamond | 1600-1799 | 4.0-4.5 | 7.5-9 | 강력한 무기 샷을 보유하고, 경기 흐름을 주도해요 | B조 / 상급 |
| Master | 1800+ | 4.5-5.0+ | 9-11+ | 모든 샷에 높은 완성도를 갖추고 전략적으로 경기해요 | A조 / 최상급 |
Tier Persistence
Once earned, a tier badge is never visibly revoked. Internal Elo adjusts normally, but the displayed tier only drops after 90 consecutive days below the threshold. This prevents the anxiety of "I just got demoted" after one bad session.
displayTier = max(currentEloTier, highWatermarkTier if < 90 days since last at that tier)Onboarding Elo Seeds
| Self-rated Level | Seed Elo | Starting Tier |
|---|---|---|
| 입문 (Beginner) | 1000 | Silver (floor) |
| 중급 (Intermediate) | 1300 | Gold (mid) |
| 상급 (Advanced) | 1700 | Diamond (mid) |
| 선수출신 (Former competitive) | 1900 | Master (floor) |
Adaptive Monthly Challenges
Target Formula
target = round(baseTarget × tierMultiplier × activityMultiplier × seasonMultiplier)
target = clamp(target, baseTarget × 0.5, baseTarget × 2.0)
target = max(target, 1) // minimum 1Tier Multiplier
Applied by challenge type. Win targets are always 1.0 because Elo matchmaking already calibrates difficulty.
| Tier | Growth Challenges | Activity Challenges | Win Challenges | Social Challenges |
|---|---|---|---|---|
| Bronze | 1.2 (easier to grow) | 0.8 (lower expectations) | 1.0 | 1.0 |
| Silver | 1.1 | 0.9 | 1.0 | 1.0 |
| Gold | 1.0 (baseline) | 1.0 | 1.0 | 1.0 |
| Platinum | 0.85 | 1.1 | 1.0 | 1.0 |
| Diamond | 0.7 (harder to grow) | 1.2 | 1.0 | 1.0 |
| Master | 0.6 | 1.3 | 1.0 | 1.0 |
Activity Multiplier
Based on trailing 3-month session average, clamped 0.6-1.5:
activityMultiplier = clamp(avgSessionsPerMonth / 10, 0.6, 1.5)| Trailing Avg | Multiplier |
|---|---|
| ≤4 sessions/month | 0.6 |
| 6 | 0.6 |
| 8 | 0.8 |
| 10 (baseline) | 1.0 |
| 12 | 1.2 |
| ≥15 | 1.5 (cap) |
Season Multiplier (Korean Climate)
| Month | Multiplier | Reason |
|---|---|---|
| 3-5 (Spring) | 1.0 | Peak outdoor season |
| 6 | 0.95 | Pre-monsoon |
| 7-8 | 0.7 | 장마 + heat |
| 9-10 | 1.0 | Fall peak |
| 11 | 0.9 | Getting cold |
| 12-2 | 0.75 | Winter (indoor only) |
Challenge Categories (4 → 5 petals)
Each monthly challenge maps to one of the 5 radar petals:
| Category | Petal | Rally (Tier 1) | Ace (Tier 2) | Champion (Tier 3) |
|---|---|---|---|---|
| Matches played | 활동량 | 4 | 15 | 40 |
| Wins | 경기력 | 2 | 8 | 25 |
| Win streak | 경기력 | 2 | 3 | 5 |
| Practice sessions | 성장 | 1 | 3 | 8 |
| New partners | 소셜 | 2 | 5 | 10 |
| Host/organize | 공헌 | 1 | 3 | 5 |
| Court visits | 활동량 | 1 | 3 | 6 |
| Public meet joins | 소셜 | 1 | 3 | 5 |
All base targets are modified by the adaptive formula before display.
Comeback System
Detection
A "comeback" is triggered when a user plays their first session after 14+ days of inactivity.
3-Session Ramp
| Session | XP Bonus | Target Multiplier | Message |
|---|---|---|---|
| 1st back | +50% | 0.5x | "돌아오셨군요! 코트가 기다렸어요" |
| 2nd | +25% | 0.7x | "벌써 감 돌아오고 있죠?" |
| 3rd | Normal | Normal | "완전히 돌아왔어요!" |
What's Preserved vs Changed
| Metric | Status | Reasoning |
|---|---|---|
| Elo rating | Preserved exactly | Skill doesn't vanish overnight |
| Tier badge | Preserved (90-day grace) | Earned status stays |
| Achievements | 100% preserved | Lifetime milestones never reset |
| Challenge streaks | Broken but not displayed | Don't show "streak lost" |
| Club membership | Preserved | Unless admin removed |
| Radar petals | Faded but visible | Shows where you were, implies you'll return |
Comeback Achievement
"컴백" (Comeback): Play a session after 30+ days of inactivity. Bronze badge. Reframe the gap as a story chapter, not a failure.
Anti-Patterns
- ❌ "You haven't played in X days" push notification
- ❌ Declining Elo graph shown on return
- ❌ "You missed 3 months of challenges" message
- ❌ Streak loss animation
- ✅ "Your club has a session Saturday — 6/8 spots" (future-focused)
- ✅ "You've played 47 total sessions. Ready for #48?" (continuation framing)
Session XP System
XP Sources
| Action | Base XP | Session Cap | Radar Petal |
|---|---|---|---|
| Attend a session | 50 | — | 활동량 |
| Play a match | 30 | 120 (4 matches) | 경기력 |
| Win a match | 20 | 80 (4 wins) | 경기력 |
| Close match (within 3 games) | 15 bonus | 30 | 경기력 |
| Meet a new unique partner | 25 | 75 (3 new) | 소셜 |
| Host a session | 40 | 40/day | 공헌 |
| Invite a member (they join) | 50 | 100/month | 공헌 |
| Log a practice note | 15 | 15 | 성장 |
| Complete a monthly challenge | 100 | per challenge | 성장 |
| Comeback session (14+ day gap) | 30 bonus | once | 활동량 |
Session XP Cap
MAX_SESSION_XP = 200
OVERFLOW_RATE = 0.25After 200 XP in a single session, additional XP is earned at 25% rate. This prevents marathon sessions from dominating monthly progress.
Weekly Soft Cap
WEEKLY_BASE_SESSIONS = 4
if (sessionsThisWeek > WEEKLY_BASE_SESSIONS) {
xpMultiplier = 0.5
message = "이번 주 충분히 열심히 했어요! 쉬는 것도 실력이에요 💪"
}Honeymoon Period
First 30 days after joining a club: 2x XP on all actions within that club. Creates rapid petal growth and immediate sense of belonging.
Club Challenges (Collaborative)
Monthly club-wide goals that every member contributes to.
Structure
club_challenges table:
id, club_id, month, year,
challenge_type ('matches' | 'sessions' | 'members' | 'custom'),
target (number),
current (number, denormalized),
completed_at (nullable timestamp)Default Monthly Club Challenges
Auto-generated based on club size:
| Club Size | Challenge | Target Formula |
|---|---|---|
| Any | Total sets played | members × 5 |
| Any | Total sessions held | 4 |
| 8+ members | Unique players this month | members × 0.8 |
| 10+ members | New member joined | 1 |
Display
On club dashboard: "우리 클럽 이번 달" (Our Club This Month)
- Progress bar: "42/50 세트 (84%)"
- Individual contribution: "나의 기여: 8세트"
- Member avatars of top contributors
"Distance to Next" Framing
Absolute positions are hidden. Only distance to the next meaningful milestone is shown:
| Screen | Bad (absolute) | Good (distance to next) |
|---|---|---|
| Ranking | "#47 out of 50" | "3승이면 클럽 순위 3단계 올라가요" |
| Elo | "1,247 (Gold)" | "골드까지 153 ELO 남았어요" → "플래티넘까지 153 ELO" |
| Challenges | "2/9 completed" | "다음 챌린지까지 1경기 남았어요" |
| Achievements | "12/59 unlocked" | "'Rally Sweep'까지 챌린지 1개 남았어요" |
Leaderboard Rules
- Show top 10 + user's position ± 3 neighbors
- Only show upward movement: "김민수 3단계 상승 ↑"
- Never show downward movement publicly
- User can see their own Elo change (positive in green, negative in neutral gray — not red)
Post-Session Summary Screen
Shown after a session ends. Single screen, swipeable cards:
Card 1: Today's Highlights
오늘의 테니스 ✓
🎾 3경기 | 2승 1패
📈 Elo 1,423 → 1,438 (+15)
🤝 새로운 파트너: 이수진
⏱ 코트 타임: 2시간 15분Card 2: Monthly Progress
이번 달 진행률
경기력 ████████░░ 80%
활동량 ██████░░░░ 60%
소셜 ████░░░░░░ 40%
성장 ██████░░░░ 60%
공헌 ██░░░░░░░░ 20%Card 3: Club Challenge
우리 클럽 이번 달
42/50 세트 (84%) — 거의 다 왔어요!
나의 기여: 8세트Monthly Retrospective ("이번 달의 나")
Auto-generated at month end. Shareable card:
- Activity: "이번 달 12세션 참가 (지난달 대비 +3)"
- Match record: "8승 4패 (승률 67%) | Elo +35"
- Social: "새로운 파트너 4명 | 총 23명"
- Highlight: "이번 달 최고의 순간: 3세트 타이브레이크 승리"
- Radar: This month vs last month overlay
- Next month: "다음 달 목표: 활동량 레벨업에 도전해 보세요"
Friendly Match Designation
Club admins can mark sessions as "친선전" (Friendly Match):
- No Elo change
- Full XP for 활동량 and 소셜 petals
- Does NOT count toward 경기력 petal
- Shown with a 🤝 badge on the session card
- Important for: new member integration, cross-club meetups, practice sessions
출석 체크 (Weekly Attendance Calendar)
Korean-market expected pattern. Weekly (not daily) rhythm:
- 4-week attendance calendar visible on profile
- Mark session attendance each week
- Complete 3/4 weeks = monthly attendance reward (achievement progress)
- Complete 4/4 weeks = bonus achievement
- Missing a week: no guilt, just a blank square
- Calendar resets monthly, no streaks that can "break"
DB Changes Required
New Tables
-- Player progression snapshot (monthly)
CREATE TABLE player_monthly_stats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES profiles(id),
month INT NOT NULL, -- 1-12
year INT NOT NULL,
sessions_attended INT DEFAULT 0,
matches_played INT DEFAULT 0,
matches_won INT DEFAULT 0,
unique_partners INT DEFAULT 0,
sessions_hosted INT DEFAULT 0,
elo_start INT,
elo_end INT,
petal_performance SMALLINT DEFAULT 0, -- 0-100
petal_activity SMALLINT DEFAULT 0,
petal_social SMALLINT DEFAULT 0,
petal_growth SMALLINT DEFAULT 0,
petal_contribution SMALLINT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(user_id, month, year)
);
-- Club challenges (collaborative monthly goals)
CREATE TABLE club_challenges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
club_id UUID NOT NULL REFERENCES clubs(id),
month INT NOT NULL,
year INT NOT NULL,
challenge_type TEXT NOT NULL, -- 'matches' | 'sessions' | 'members'
target INT NOT NULL,
current INT DEFAULT 0,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(club_id, month, year, challenge_type)
);
-- Club challenge contributions (individual member contribution)
CREATE TABLE club_challenge_contributions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
club_challenge_id UUID NOT NULL REFERENCES club_challenges(id),
user_id UUID NOT NULL REFERENCES profiles(id),
contribution INT DEFAULT 0,
updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(club_challenge_id, user_id)
);Modified Tables
-- profiles: add display_tier (persisted tier badge)
ALTER TABLE profiles ADD COLUMN display_tier TEXT DEFAULT 'bronze';
ALTER TABLE profiles ADD COLUMN display_tier_expires_at TIMESTAMPTZ;
ALTER TABLE profiles ADD COLUMN last_session_at TIMESTAMPTZ;
ALTER TABLE profiles ADD COLUMN comeback_sessions_remaining SMALLINT DEFAULT 0;Implementation Phases
Phase 1: Foundation (this sprint)
- DB migration for new tables
- Adaptive challenge formula in domain layer
- Floor protection: tier persistence, petal fade formula
- Comeback detection + 3-session ramp
- "Distance to next" framing on ranking/profile screens
- Friendly match designation on sessions
Phase 2: Radar + Summary
- 5-petal radar chart component (Reanimated SVG)
- Post-session summary screen
- Monthly stats aggregation (cron or Edge Function)
- Radar display on profile screen
Phase 3: Club Challenges
- Club challenges tables + hex stack
- Auto-generation at month start
- Dashboard integration
- Individual contribution tracking
Phase 4: Polish
- Monthly retrospective card (shareable)
- 출석 체크 calendar on profile
- Honeymoon 2x XP for new club members
- Celebration animations for tier thresholds (Wii Sports "Going Pro" moment)
Key Files to Create/Modify
Domain Layer
src/domain/entities/progression.entity.ts— radar, XP, adaptive formula typessrc/domain/rules/progression.rules.ts— petal calculation, adaptive targets, comeback logic- Modify
src/domain/entities/challenge.entity.ts— add adaptive multipliers - Modify
src/domain/entities/profile.entity.ts— add display_tier, last_session_at
Ports
src/ports/repositories/progression.repository.port.ts— monthly stats, club challenges
Adapters
src/adapters/supabase/progression.supabase.tssrc/adapters/mock/progression.mock.ts- Migration:
supabase/migrations/00043_progression_system.sql
Presentation
src/presentation/components/ui/radar-chart.tsx— 5-petal radar (Reanimated SVG)src/presentation/components/sessions/session-summary.tsx— post-session screensrc/presentation/components/clubs/club-challenge-card.tsxsrc/presentation/components/profile/weekly-attendance.tsx- Modify
src/presentation/screens/profile/profile-screen.tsx— radar + attendance - Modify
src/presentation/screens/ranking/ranking-view.tsx— distance-to-next - Modify
src/presentation/screens/clubs/club-dashboard-screen.tsx— club challenges