Multi-Archer Tournament Scoring Guide

Phase: 6d Status: Complete PRs: #349, #350


Overview

Phase 6d implements multi-archer tournament scoring for iOS, enabling users to score for themselves and their guests during tournament sessions. The implementation includes per-participant state management, real-time leaderboard calculations with archery tie-breaking rules, and dual-write persistence (Firebase + local Round database).

Key Features:

  • Per-participant scoring state with ParticipantScoreState
  • Participant switching via horizontal scrollable tabs
  • Real-time leaderboard with archery tie-breaking (Score X-count 10-count ID)
  • Dual input modes (Buttons and Target)
  • Firebase Firestore sync on end completion
  • Local Round database persistence for offline-first support
  • ScoringSessionProtocol conformance for unified scoring view

Architecture

Component Structure

TournamentDetailView
└── TournamentScoringView
    ├── TournamentScoringViewModel
    │   ├── TournamentRepositoryProtocol (Firebase)
    │   ├── RoundRepositoryBridge (Local storage)
    │   └── ScoringSessionProtocol conformance
    ├── ParticipantTabsView
    │   └── ParticipantTab (per participant)
    ├── Progress Header
    ├── Score Display Area
    ├── Score Entry (Buttons/Target)
    └── LeaderboardView (modal overlay)

Data Flow

User taps score
    │
    ▼
TournamentScoringViewModel.enterScore()
    │
    ├─► ParticipantScoreState updated
    │   (currentEndArrows, xCount, tenCount)
    │
    ├─► saveArrowScoreToLocalRound()
    │   (offline-first persistence)
    │
    ├─► If end complete: completeEndForParticipant()
    │   ├─► Update totals
    │   ├─► completeEndInLocalRound()
    │   ├─► syncEndCompletionToFirebase()
    │   └─► Auto-switch to next participant
    │
    └─► updateLeaderboard()
        (real-time ranking recalculation)

Key Files

ComponentFileDescription
ViewModelViewModels/TournamentScoringViewModel.swiftCore scoring logic (933 lines)
Main ViewViews/TournamentScoring/TournamentScoringView.swiftScoring UI (552 lines)
TabsViews/TournamentScoring/ParticipantTabsView.swiftParticipant selection (175 lines)
LeaderboardViews/TournamentScoring/LeaderboardView.swiftRankings display (361 lines)
ResultsViews/TournamentScoring/TournamentResultsView.swiftFinal results (444 lines)
TestsTournamentScoringViewModelTests.swiftUnit tests

TournamentScoringViewModel

State Management

The ViewModel manages per-participant scoring state using a dictionary:

@MainActor
class TournamentScoringViewModel: ObservableObject, ScoringSessionProtocol {
 
    /// Per-participant scoring state
    @Published var participantStates: [String: ParticipantScoreState] = [:]
 
    /// Currently selected participant ID for scoring
    @Published var currentParticipantId: String = ""
 
    /// Current end number (1-based, shared across participants)
    @Published var currentEndNumber: Int32 = 1
 
    /// Leaderboard entries sorted by rank
    @Published var leaderboard: [LeaderboardDisplayEntry] = []
}

ParticipantScoreState

Tracks individual archer’s scoring progress:

struct ParticipantScoreState: Equatable {
    let participantId: String
    var currentEndArrows: [ArrowEntry] = []
    var completedEndsTotals: [Int32] = []
    var totalScore: Int32 = 0
    var endsCompleted: Int32 = 0
    var xCount: Int32 = 0
    var tenCount: Int32 = 0
 
    var currentEndTotal: Int32 {
        return currentEndArrows.reduce(0) { $0 + $1.score }
    }
 
    var runningTotal: Int32 {
        return totalScore + currentEndTotal
    }
}

Scoreable Participants Security

Only allows scoring for the current user and their guests:

/// Participants that can be scored by current user (LocalUser + their guests)
var scoreableParticipants: [TournamentParticipant] {
    guard let currentUserId = Auth.auth().currentUser?.uid else { return [] }
    return participants.filter { participant in
        participant.participantId == currentUserId ||
        (participant.guestParticipant && participant.addedBy == currentUserId)
    }
}

ScoringSessionProtocol Conformance

Enables use with the unified ScoringView (Phase 6e):

/// Scoring mode - tournament for multi-archer Firebase sessions
var scoringMode: ScoringMode {
    return .tournament
}
 
/// Convert TournamentParticipant to ScoringParticipantInfo for unified view
var scoringParticipants: [ScoringParticipantInfo] {
    return scoreableParticipants.map { participant in
        ScoringParticipantInfo(
            id: participant.participantId,
            displayName: participant.displayName.isEmpty ? participant.participantId : participant.displayName,
            isCurrentUser: participant.participantId == currentUserId,
            isGuest: participant.guestParticipant
        )
    }
}

Leaderboard Calculation

Tie-Breaking Rules

Archery tournaments use specific tie-breaking rules:

  1. Total Score (highest wins)
  2. X-count (number of X’s - inner 10)
  3. 10-count (number of 10’s including X’s)
  4. Ends Completed (more ends = higher rank)
  5. Participant ID (alphabetical fallback)
private func updateLeaderboard() {
    // Sort by archery tie-breaking rules
    entries.sort { a, b in
        if a.totalScore != b.totalScore { return a.totalScore > b.totalScore }
        if a.xCount != b.xCount { return a.xCount > b.xCount }
        if a.tenCount != b.tenCount { return a.tenCount > b.tenCount }
        if a.endsCompleted != b.endsCompleted { return a.endsCompleted > b.endsCompleted }
        return a.id < b.id
    }
 
    // Assign ranks and calculate score differences from first place
    let firstScore = entries.first?.totalScore ?? 0
    leaderboard = entries.enumerated().map { index, entry in
        // ... create LeaderboardDisplayEntry with rank and scoreDifferenceFromFirst
    }
}

LeaderboardDisplayEntry

struct LeaderboardDisplayEntry: Identifiable, Equatable {
    let id: String
    let rank: Int
    let displayName: String
    let totalScore: Int32
    let endsCompleted: Int32
    let xCount: Int32
    let tenCount: Int32
    let isCurrentUser: Bool
    let isActivelyScoring: Bool
    var scoreDifferenceFromFirst: Int32 = 0
}

ParticipantTabsView

Horizontal scrollable tabs for participant selection:

struct ParticipantTabsView: View {
    let participants: [TournamentParticipant]
    let selectedParticipantId: String
    let onParticipantSelected: (String) -> Void
 
    var body: some View {
        if participants.count > 1 {
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(spacing: 12) {
                    ForEach(participants, id: \.participantId) { participant in
                        ParticipantTab(
                            participant: participant,
                            isSelected: participant.participantId == selectedParticipantId,
                            onTap: { onParticipantSelected(participant.participantId) }
                        )
                    }
                }
                .padding(.horizontal)
            }
            .background(Color(.systemGray6))
        }
    }
}

Visual Indicators:

  • Active participant highlighted with blue border/background
  • “Active” badge shown on selected tab
  • Current score displayed per participant
  • Guest participants show “Guest” label when name is empty

Score Entry

Dual Input Modes

enum TournamentScoringInputMode: String, CaseIterable {
    case buttons = "Buttons"
    case target = "Target"
}

Toggle between modes with segmented picker:

Picker("Input Mode", selection: $inputMode) {
    ForEach(TournamentScoringInputMode.allCases, id: \.self) { mode in
        Text(mode.rawValue).tag(mode)
    }
}
.pickerStyle(.segmented)

Score Entry Flow

func enterScore(_ score: Int32, isX: Bool = false) async {
    guard canEnterScore else { return }
 
    // Create local round on first score entry (lazy initialization)
    if !hasCreatedLocalRound {
        hasCreatedLocalRound = true  // Prevent race condition
        await createLocalRoundForTournament()
    }
 
    // Update participant state
    let arrowEntry = ArrowEntry(score: score, isX: isX, placement: nil)
    state.currentEndArrows.append(arrowEntry)
 
    // Update X and 10 counts
    if isX {
        state.xCount += 1
        state.tenCount += 1  // X counts as 10
    } else if score == 10 {
        state.tenCount += 1
    }
 
    // Save to local Round database
    await saveArrowScoreToLocalRound(...)
 
    // Check if end is complete
    if state.currentEndArrows.count >= Int(arrowsPerEnd) {
        await completeEndForParticipant(currentParticipantId)
    }
 
    updateLeaderboard()
}

Persistence

Dual-Write Pattern

Scores are persisted to both Firebase and local Round database:

  1. Local Round Database (per-arrow, offline-first)

    • saveArrowScoreToLocalRound() on each score entry
    • completeEndInLocalRound() on end completion
    • Supports offline scoring
  2. Firebase Firestore (per-end, online sync)

    • syncEndCompletionToFirebase() on end completion
    • Updates participant document with totals
    • Only syncs for scoreable participants (security)

Firebase Sync

private func syncEndCompletionToFirebase(
    participantId: String,
    endNumber: Int32,
    endTotal: Int32
) async {
    // Security: Only sync for participants the current user can score for
    guard scoreableParticipants.contains(where: { $0.participantId == participantId }) else {
        return
    }
 
    try await db.collection("tournaments")
        .document(tournamentId)
        .collection("participants")
        .document(participantId)
        .updateData([
            "currentScore": state.totalScore,
            "endsCompleted": state.endsCompleted,
            "xCount": state.xCount,
            "tenCount": state.tenCount,
            "lastActiveAt": FieldValue.serverTimestamp()
        ])
}

Participant Switching

Automatic Switching

When a participant completes an end, the ViewModel automatically switches to the next participant who hasn’t completed the current end:

private func completeEndForParticipant(_ participantId: String) async {
    // ... update state and sync
 
    // Check if all participants have completed this end
    let allCompleted = scoreableParticipants.allSatisfy { participant in
        guard let pState = participantStates[participant.participantId] else { return false }
        return pState.endsCompleted >= currentEndNumber
    }
 
    if allCompleted {
        // Advance to next end
        if currentEndNumber < totalEnds {
            currentEndNumber += 1
            currentArrowNumber = 1
        } else {
            isRoundComplete = true
            await finalizeRound()
        }
    } else {
        // Switch to next participant who hasn't completed this end
        if let nextParticipant = scoreableParticipants.first(where: { ... }) {
            switchParticipant(to: nextParticipant.participantId)
        }
    }
}

Manual Switching

Security check ensures only authorized participants can be scored:

func switchParticipant(to participantId: String) {
    // Security: Only allow switching to scoreable participants
    guard scoreableParticipants.contains(where: { $0.participantId == participantId }) else {
        return
    }
 
    currentParticipantId = participantId
 
    // Update arrow number based on this participant's progress
    if let state = participantStates[participantId] {
        currentArrowNumber = Int32(state.currentEndArrows.count + 1)
    }
}

Platform Parity

iOS ↔ Android Alignment

FeatureiOSAndroid
ViewModelTournamentScoringViewModelTournamentDetailsViewModel
StateParticipantScoreStateParticipantScoringState
LeaderboardLeaderboardDisplayEntryLeaderboardEntry
Tie-breakingScore → X → 10 → Ends → IDScore → X → 10 → Ends → ID
SyncPer-end to FirebasePer-end to Firebase

Shared Patterns

  • KMP TournamentParticipant model
  • KMP ScoringSystem enum
  • KMP ArrowEntry structure
  • Firebase document structure

Test Coverage

TournamentScoringViewModelTests

Test CategoryTestsDescription
Score Entry5Arrow entry, X/10 counts
Leaderboard4Tie-breaking rules, ranking
Participant Switching3Manual/auto switching
End Completion4End flow, round completion
Security2Scoreable participants guard


Last Updated: 2025-12-04 Status: Phase 6d complete