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

  1. User Initiates: User taps “Publish” on a completed round
  2. Eligibility Check: Service validates round can be published
  3. Visibility Selection: User chooses Public/Authenticated/Private
  4. Atomic Write: Batch write to Firestore (publishedRounds + leaderboards)
  5. Local Reference: Store PublishedRoundReference locally
  6. UI Update: Show “Published” indicator on round

Unpublishing Flow

  1. User Initiates: User taps “Unpublish” from My Published Rounds
  2. Firestore Delete: Remove from publishedRounds collection
  3. Leaderboard Update: Remove corresponding leaderboard entry
  4. Local Cleanup: Remove PublishedRoundReference

Eligibility Rules

A round can only be published if:

ConditionRequirement
StatusMust be COMPLETED
TypeMust NOT be a tournament round
ScoreMust have valid score data
Already PublishedMust 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 scores

Dual 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?

CollectionPurpose
publishedRoundsComplete round data, user’s published history
leaderboardsOptimized for leaderboard queries (by condition, time period)

The leaderboards collection is denormalized for fast reads:

  • Indexed by conditionKey for 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 options
  • MyPublishedRoundsScreen - List with unpublish action
  • Publish button in RoundDetailsScreen toolbar

iOS

  • PublishRoundSheet - SwiftUI sheet with visibility picker
  • MyPublishedRoundsView - List with swipe-to-unpublish
  • Publish button in RoundDetailView toolbar

Testing

See Firestore Rules Testing Guide for:

  • Create validation tests
  • Visibility-based read access tests
  • Update restriction tests