Skip to content

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

  1. Losing is invisible, winning is spectacular — Elo adjustments happen internally; UI shows growth, not decline
  2. Tiers are permanent badges — once earned, never visibly revoked (90-day grace period)
  3. One visual, hidden math — the 5-petal radar IS the progression. XP, caps, multipliers are invisible
  4. At least one petal always grows — showing up = Activity grew. Playing with someone = Social grew. No all-negative sessions.
  5. "Welcome back" over "you missed it" — returning from a gap is celebrated, never punished
  6. 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:

PetalKoreanWhat it measuresHow it grows
Performance경기력Elo relative to tier, win rate, competitive resultsWinning matches, improving Elo
Activity활동량Sessions attended, matches played, hours on courtShowing up and playing
Social소셜Unique partners, new connections, games as guestMeeting new people, playing with different partners
Growth성장Elo improvement rate, tier advancement, practiceGetting better over time
Contribution공헌Hosting sessions, organizing, inviting membersGiving 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)

TierElo RangeNTRPUTRKorean Description동호회 Division
Bronze< 10002.0-2.51-3기본 스트로크를 연습 중이며, 느린 랠리가 가능해요D조 / 입문
Silver1000-11992.5-3.03-4.5중간 속도의 랠리를 유지하고, 기본 서브가 들어가요C~D조 / 초급
Gold1200-13993.0-3.54.5-6안정적인 스트로크와 방향 조절이 가능해요C조 / 중급
Platinum1400-15993.5-4.06-7.5스핀과 파워를 활용하고, 다양한 샷으로 압박할 수 있어요B~C조 / 중상급
Diamond1600-17994.0-4.57.5-9강력한 무기 샷을 보유하고, 경기 흐름을 주도해요B조 / 상급
Master1800+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 LevelSeed EloStarting Tier
입문 (Beginner)1000Silver (floor)
중급 (Intermediate)1300Gold (mid)
상급 (Advanced)1700Diamond (mid)
선수출신 (Former competitive)1900Master (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 1

Tier Multiplier

Applied by challenge type. Win targets are always 1.0 because Elo matchmaking already calibrates difficulty.

TierGrowth ChallengesActivity ChallengesWin ChallengesSocial Challenges
Bronze1.2 (easier to grow)0.8 (lower expectations)1.01.0
Silver1.10.91.01.0
Gold1.0 (baseline)1.01.01.0
Platinum0.851.11.01.0
Diamond0.7 (harder to grow)1.21.01.0
Master0.61.31.01.0

Activity Multiplier

Based on trailing 3-month session average, clamped 0.6-1.5:

activityMultiplier = clamp(avgSessionsPerMonth / 10, 0.6, 1.5)
Trailing AvgMultiplier
≤4 sessions/month0.6
60.6
80.8
10 (baseline)1.0
121.2
≥151.5 (cap)

Season Multiplier (Korean Climate)

MonthMultiplierReason
3-5 (Spring)1.0Peak outdoor season
60.95Pre-monsoon
7-80.7장마 + heat
9-101.0Fall peak
110.9Getting cold
12-20.75Winter (indoor only)

Challenge Categories (4 → 5 petals)

Each monthly challenge maps to one of the 5 radar petals:

CategoryPetalRally (Tier 1)Ace (Tier 2)Champion (Tier 3)
Matches played활동량41540
Wins경기력2825
Win streak경기력235
Practice sessions성장138
New partners소셜2510
Host/organize공헌135
Court visits활동량136
Public meet joins소셜135

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

SessionXP BonusTarget MultiplierMessage
1st back+50%0.5x"돌아오셨군요! 코트가 기다렸어요"
2nd+25%0.7x"벌써 감 돌아오고 있죠?"
3rdNormalNormal"완전히 돌아왔어요!"

What's Preserved vs Changed

MetricStatusReasoning
Elo ratingPreserved exactlySkill doesn't vanish overnight
Tier badgePreserved (90-day grace)Earned status stays
Achievements100% preservedLifetime milestones never reset
Challenge streaksBroken but not displayedDon't show "streak lost"
Club membershipPreservedUnless admin removed
Radar petalsFaded but visibleShows 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

ActionBase XPSession CapRadar Petal
Attend a session50활동량
Play a match30120 (4 matches)경기력
Win a match2080 (4 wins)경기력
Close match (within 3 games)15 bonus30경기력
Meet a new unique partner2575 (3 new)소셜
Host a session4040/day공헌
Invite a member (they join)50100/month공헌
Log a practice note1515성장
Complete a monthly challenge100per challenge성장
Comeback session (14+ day gap)30 bonusonce활동량

Session XP Cap

MAX_SESSION_XP = 200
OVERFLOW_RATE = 0.25

After 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 SizeChallengeTarget Formula
AnyTotal sets playedmembers × 5
AnyTotal sessions held4
8+ membersUnique players this monthmembers × 0.8
10+ membersNew member joined1

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:

ScreenBad (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:

  1. Activity: "이번 달 12세션 참가 (지난달 대비 +3)"
  2. Match record: "8승 4패 (승률 67%) | Elo +35"
  3. Social: "새로운 파트너 4명 | 총 23명"
  4. Highlight: "이번 달 최고의 순간: 3세트 타이브레이크 승리"
  5. Radar: This month vs last month overlay
  6. 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

sql
-- 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

sql
-- 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 types
  • src/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.ts
  • src/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 screen
  • src/presentation/components/clubs/club-challenge-card.tsx
  • src/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

Markdown remains the source of truth. Run yarn docs:check before handoff.