Arrows Leaderboard Feature
A fun engagement feature tracking total arrows shot globally with milestone badges. Users can compete on how many arrows they’ve shot, with time-filtered leaderboards and achievement milestones.
Overview
Engagement Hook: “You’ve shot 1,234 arrows - You’re #47 globally!”
The arrows leaderboard provides:
- Global ranking by total arrows shot
- Time period filters (weekly, monthly, seasonal, all-time)
- Milestone achievement badges
- Streak tracking (consecutive shooting days)
Data Model
UserArrowStats
Firestore Collection: user_arrow_stats/{userId}
data class UserArrowStats(
val userId: String,
val displayName: String,
// Primary engagement metrics
val totalArrowsShot: Int,
val roundsCompleted: Int,
// Cumulative scoring stats
val totalScore: Int,
val totalXCount: Int,
val totalTenCount: Int,
val totalNineCount: Int,
val totalMissCount: Int,
// Derived stats
val averagePerArrow: Double,
val xPercentage: Double,
// Time period keys
val weekKey: String, // "2025-W52"
val monthKey: String, // "2025-12"
val seasonKey: String, // "2025-INDOOR"
// Time period aggregates
val weeklyArrows: Int,
val monthlyArrows: Int,
val seasonalArrows: Int,
// Streak tracking
val currentStreak: Int, // Consecutive days
val longestStreak: Int,
val lastShotDate: String, // ISO date: "2025-12-29"
// Ranking (client-side after query)
val rank: Int
)Milestone Badges
Achievement milestones for gamification:
| Milestone | Threshold | Icon | Description |
|---|---|---|---|
| First Arrow | 1 | arrow.right | Welcome! |
| Getting Started | 100 | target | On your way |
| Hundred Club | 500 | flame.fill | Building momentum |
| Consistent Archer | 1,000 | star.fill | Dedication showing |
| Dedicated | 2,500 | medal.fill | Serious archer |
| Committed | 5,000 | trophy.fill | Major milestone |
| True Archer | 10,000 | crown.fill | Elite status |
| Master Archer | 25,000 | sparkles | Exceptional |
| Legendary | 50,000 | bolt.fill | Legendary archer |
| Elite | 100,000 | diamond.fill | Top tier |
enum class ArrowMilestone(
val threshold: Int,
val displayName: String,
val emoji: String
)Time Period Filters
| Filter | Key Format | Example | Use Case |
|---|---|---|---|
| All Time | N/A | - | Lifetime totals |
| Weekly | YYYY-WNN | 2025-W52 | Current week competition |
| Monthly | YYYY-MM | 2025-12 | Month challenge |
| Seasonal | YYYY-SEASON | 2025-INDOOR | Season tracking |
Season Definition
- Indoor: November through March
- Outdoor: April through October
- Year assigned to season start (Nov 2024 → 2024-INDOOR)
Service Layer
ArrowStatsService
class ArrowStatsService(
private val dataSource: FirebaseArrowStatsDataSource
) {
// Get global leaderboard
suspend fun getArrowsLeaderboard(
timePeriod: TimePeriod = TimePeriod.ALL_TIME,
limit: Int = 50
): Result<List<UserArrowStats>>
// Real-time leaderboard updates
fun observeArrowsLeaderboard(
timePeriod: TimePeriod,
limit: Int
): Flow<List<UserArrowStats>>
// Get user's stats
suspend fun getUserStats(userId: String): Result<UserArrowStats?>
// Get user's rank
suspend fun getUserRank(
userId: String,
timePeriod: TimePeriod
): Result<Int?>
// Update display name
suspend fun updateDisplayName(
userId: String,
newDisplayName: String
): Result<Unit>
}Integration with Round Publishing
Arrow stats are automatically updated when a round is published:
class RoundPublishingService {
suspend fun publishRound(round: Round): Result<PublishedRound> {
// ... publish round logic ...
// Update arrow stats
arrowStatsDataSource?.incrementArrowCount(
userId = currentUserId,
arrowCount = round.totalArrowCount,
round = round
)
return Result.success(publishedRound)
}
}Stats Updated
On each publish:
totalArrowsShot += round.arrowCountroundsCompleted += 1totalScore += round.scoretotalXCount += round.xCountweeklyArrows,monthlyArrows,seasonalArrows(if matching current period)currentStreak(if consecutive day)averagePerArrow,xPercentage(recalculated)
UI Components
Android: ArrowsLeaderboardScreen
@Composable
fun ArrowsLeaderboardScreen(
timePeriod: TimePeriod,
onTimePeriodChange: (TimePeriod) -> Unit
)Features:
- Time period selector (chips)
- Scrollable leaderboard with rank, avatar, name, arrows
- Current user highlight
- Milestone badge display
- Pull-to-refresh
iOS: ArrowsLeaderboardView
struct ArrowsLeaderboardView: View {
@State var timePeriod: TimePeriod = .allTime
}Equivalent iOS SwiftUI implementation with same features.
LeaderboardBrowserScreen
Groups leaderboard conditions by Indoor/Outdoor:
┌─────────────────────────────────────┐
│ Indoor │
│ ├─ 18m 40cm Indoor 5-Ring │
│ └─ 18m 40cm Vegas 2-Ring │
│ │
│ Outdoor │
│ ├─ 30m 80cm Standard │
│ ├─ 50m 80cm Standard │
│ ├─ 70m 122cm Standard │
│ └─ 90m 122cm Standard │
└─────────────────────────────────────┘
Firestore Indexes
{
"indexes": [
{
"collectionGroup": "user_arrow_stats",
"fields": [
{ "fieldPath": "totalArrowsShot", "order": "DESCENDING" }
]
},
{
"collectionGroup": "user_arrow_stats",
"fields": [
{ "fieldPath": "weekKey", "order": "ASCENDING" },
{ "fieldPath": "weeklyArrows", "order": "DESCENDING" }
]
},
{
"collectionGroup": "user_arrow_stats",
"fields": [
{ "fieldPath": "monthKey", "order": "ASCENDING" },
{ "fieldPath": "monthlyArrows", "order": "DESCENDING" }
]
},
{
"collectionGroup": "user_arrow_stats",
"fields": [
{ "fieldPath": "seasonKey", "order": "ASCENDING" },
{ "fieldPath": "seasonalArrows", "order": "DESCENDING" }
]
}
]
}Security Rules
match /user_arrow_stats/{userId} {
// READ: Any authenticated user (for leaderboard display)
allow read: if request.auth != null;
// CREATE/UPDATE: Only owner can write
allow create, update: if request.auth != null &&
request.auth.uid == userId;
// DELETE: Only owner can delete
allow delete: if request.auth != null &&
request.auth.uid == userId;
}Display Formatting
Arrow Count Formatting
val formattedTotalArrows: String
get() = when {
totalArrowsShot >= 1_000_000 -> "${totalArrowsShot / 1_000_000}M+"
totalArrowsShot >= 10_000 -> "${totalArrowsShot / 1_000}K+"
totalArrowsShot >= 1_000 -> String.format("%.1fK", totalArrowsShot / 1000.0)
else -> totalArrowsShot.toString()
}| Count | Display |
|---|---|
| 500 | ”500” |
| 1,234 | ”1.2K” |
| 15,678 | ”15K+“ |
| 1,500,000 | ”1M+“ |
Ranking Display
| Rank | Display |
|---|---|
| 1 | Gold medal icon |
| 2 | Silver medal icon |
| 3 | Bronze medal icon |
| 4-10 | Blue highlight |
| 11+ | Standard row |
| Current user | Always highlighted |
Streak Tracking
Consecutive Days
currentStreak: Days in current streaklongestStreak: All-time bestlastShotDate: ISO date of last activity
Update Logic
fun updateStreak(today: String, lastDate: String, currentStreak: Int): Int {
val yesterday = today.minusDays(1)
return when (lastDate) {
today -> currentStreak // Same day, no change
yesterday -> currentStreak + 1 // Consecutive!
else -> 1 // Streak broken, start fresh
}
}Future Enhancements
- Weekly Challenges: Time-limited arrow shooting goals
- Club/Team Leaderboards: Group competitions
- Achievement Notifications: Push on milestone reached
- Historical Charts: Arrows over time visualization
- Social Sharing: Share milestone achievements
Related Documentation
- 2025-12-29-phase3-verification-system - Session notes
- verification-system - Verification architecture
- leaderboard-phase1-foundation - Score leaderboard