Published Rounds Architecture
This document describes the architecture for publishing practice rounds to the global leaderboard, enabling users to share their scores beyond tournaments.
Overview
The Published Rounds system allows users to share completed practice rounds on the global leaderboard. It bridges local practice data with the cloud-based leaderboard, maintaining user control through visibility settings.
┌─────────────────┐ ┌──────────────────────┐ ┌─────────────────┐
│ Local Round │────▶│ Publishing Service │────▶│ Firestore │
│ (Room/KMP) │ │ (Eligibility Check) │ │ (Dual Write) │
└─────────────────┘ └──────────────────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ publishedRounds│
│ + leaderboards │
└─────────────────┘
Data Flow
Publishing Flow
- User Initiates: User taps “Publish” on a completed round
- Eligibility Check: Service validates round can be published
- Visibility Selection: User chooses Public/Authenticated/Private
- Atomic Write: Batch write to Firestore (publishedRounds + leaderboards)
- Local Reference: Store PublishedRoundReference locally
- UI Update: Show “Published” indicator on round
Unpublishing Flow
- User Initiates: User taps “Unpublish” from My Published Rounds
- Firestore Delete: Remove from publishedRounds collection
- Leaderboard Update: Remove corresponding leaderboard entry
- Local Cleanup: Remove PublishedRoundReference
Eligibility Rules
A round can only be published if:
| Condition | Requirement |
|---|---|
| Status | Must be COMPLETED |
| Type | Must NOT be a tournament round |
| Score | Must have valid score data |
| Already Published | Must not already be published |
fun canPublish(round: Round): Boolean {
return round.status == RoundStatus.COMPLETED &&
round.tournamentId == null &&
round.totalScore > 0 &&
!isAlreadyPublished(round.id)
}Firestore Schema
publishedRounds Collection
interface PublishedRound {
// Identity
userId: string; // Firebase Auth UID
localRoundId: number; // Local Room database ID
displayName: string; // User's display name at publish time
// Score Data
score: number; // Total score
xCount: number; // Number of X's
tenCount: number; // Number of 10's
arrowCount: number; // Total arrows shot
averagePerArrow: number; // Calculated average
maxPossibleScore: number; // Max possible for this round type
// Round Configuration
conditionKey: string; // e.g., "18m-40cm-STANDARD_10_RING"
distance: string; // e.g., "EIGHTEEN_METERS"
targetSize: string; // e.g., "FORTY_CM"
scoringSystem: string; // e.g., "STANDARD_10_RING"
roundName: string; // User-provided round name
numEnds: number; // Number of ends
numArrows: number; // Arrows per end
// Metadata
scoredAt: Timestamp; // When round was completed
publishedAt: Timestamp; // When published
createdAt: Timestamp;
updatedAt: Timestamp;
// Status & Visibility
status: PublishedRoundStatus;
visibility: Visibility;
verificationLevel: VerificationLevel;
}Enums
type PublishedRoundStatus =
| "PENDING" // Awaiting moderation (future)
| "PUBLISHED" // Live on leaderboard
| "REJECTED" // Failed moderation (future)
| "WITHDRAWN"; // User unpublished
type Visibility =
| "PUBLIC" // Anyone can see
| "AUTHENTICATED_ONLY" // Signed-in users only
| "PRIVATE"; // Only owner can see
type VerificationLevel =
| "SELF_REPORTED" // Default for practice rounds
| "IMAGE_RECOGNITION" // Camera scoring (future)
| "WITNESS_VERIFIED" // Peer verification (Phase 3)
| "TOURNAMENT"; // Tournament-sourced scoresDual Write Pattern
Published rounds write to two collections atomically:
suspend fun publishRound(round: Round, visibility: Visibility): Result<Unit> {
val batch = firestore.batch()
// 1. Create refs to get IDs upfront
val publishedRef = firestore.collection("publishedRounds").document()
val leaderboardRef = firestore.collection("leaderboards").document()
// 2. Build publishedRound with leaderboard ID
val publishedRound = buildPublishedRound(
round = round,
leaderboardEntryId = leaderboardRef.id,
visibility = visibility
)
// 3. Build leaderboard entry
val leaderboardEntry = buildLeaderboardEntry(
round = round,
publishedRoundId = publishedRef.id,
visibility = visibility
)
// 4. Atomic batch write
batch.set(publishedRef, publishedRound)
batch.set(leaderboardRef, leaderboardEntry)
return try {
batch.commit().await()
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}Why Dual Write?
| Collection | Purpose |
|---|---|
publishedRounds | Complete round data, user’s published history |
leaderboards | Optimized for leaderboard queries (by condition, time period) |
The leaderboards collection is denormalized for fast reads:
- Indexed by
conditionKeyfor filtering - Indexed by time keys (
weekKey,monthKey,seasonKey) for time-based leaderboards - Contains only data needed for leaderboard display
Local Reference Tracking
The app maintains local references to published rounds:
@Entity(tableName = "published_round_reference")
data class PublishedRoundReference(
@PrimaryKey
val localRoundId: Long,
val firestoreId: String,
val leaderboardEntryId: String,
val publishedAt: Long
)This enables:
- Showing “Published” indicator on rounds
- Preventing duplicate publishing
- Quick lookup for unpublishing
Security Rules
Create Validation
allow create: if request.auth != null &&
request.auth.uid == request.resource.data.userId &&
request.resource.data.score is int &&
request.resource.data.arrowCount is int &&
isValidVisibility(request.resource.data.visibility) &&
isValidPublishedRoundStatus(request.resource.data.status) &&
isValidVerificationLevel(request.resource.data.verificationLevel);Read Access (Visibility-Based)
allow read: if
resource.data.visibility == 'PUBLIC' ||
(request.auth != null && resource.data.visibility == 'AUTHENTICATED_ONLY') ||
(request.auth != null && request.auth.uid == resource.data.userId);Update Restrictions
Only owner can update, and only visibility/status fields:
allow update: if request.auth != null &&
request.auth.uid == resource.data.userId &&
request.resource.data.diff(resource.data)
.affectedKeys()
.hasOnly(['visibility', 'status', 'updatedAt']);Platform Implementation
Android
class RoundPublishingService(
private val firebaseDataSource: FirebasePublishingDataSource,
private val publishedRoundDao: PublishedRoundReferenceDao
) {
suspend fun publishRound(
round: RoundWithDetails,
visibility: Visibility
): Result<Unit>
suspend fun unpublishRound(localRoundId: Long): Result<Unit>
suspend fun getMyPublishedRounds(): Flow<List<PublishedRound>>
fun canPublish(round: Round): Boolean
}iOS
protocol RoundPublishingRepositoryProtocol {
func publishRound(_ round: RoundWithDetails, visibility: Visibility) async throws
func unpublishRound(localRoundId: Int32) async throws
func getMyPublishedRounds() async throws -> [PublishedRound]
func canPublish(_ round: Round) -> Bool
}
class RoundPublishingRepositoryBridge: RoundPublishingRepositoryProtocol {
// Firebase Firestore implementation
}UI Components
Android
PublishRoundDialog- Bottom sheet with visibility optionsMyPublishedRoundsScreen- List with unpublish action- Publish button in
RoundDetailsScreentoolbar
iOS
PublishRoundSheet- SwiftUI sheet with visibility pickerMyPublishedRoundsView- List with swipe-to-unpublish- Publish button in
RoundDetailViewtoolbar
Testing
See Firestore Rules Testing Guide for:
- Create validation tests
- Visibility-based read access tests
- Update restriction tests
Related Documentation
- Verification System - Peer verification of scores
- Global Admin System - Admin moderation
- Leaderboard Settings - User preferences