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
ScoringSessionProtocolconformance 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
| Component | File | Description |
|---|---|---|
| ViewModel | ViewModels/TournamentScoringViewModel.swift | Core scoring logic (933 lines) |
| Main View | Views/TournamentScoring/TournamentScoringView.swift | Scoring UI (552 lines) |
| Tabs | Views/TournamentScoring/ParticipantTabsView.swift | Participant selection (175 lines) |
| Leaderboard | Views/TournamentScoring/LeaderboardView.swift | Rankings display (361 lines) |
| Results | Views/TournamentScoring/TournamentResultsView.swift | Final results (444 lines) |
| Tests | TournamentScoringViewModelTests.swift | Unit 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:
- Total Score (highest wins)
- X-count (number of X’s - inner 10)
- 10-count (number of 10’s including X’s)
- Ends Completed (more ends = higher rank)
- 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:
-
Local Round Database (per-arrow, offline-first)
saveArrowScoreToLocalRound()on each score entrycompleteEndInLocalRound()on end completion- Supports offline scoring
-
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
| Feature | iOS | Android |
|---|---|---|
| ViewModel | TournamentScoringViewModel | TournamentDetailsViewModel |
| State | ParticipantScoreState | ParticipantScoringState |
| Leaderboard | LeaderboardDisplayEntry | LeaderboardEntry |
| Tie-breaking | Score → X → 10 → Ends → ID | Score → X → 10 → Ends → ID |
| Sync | Per-end to Firebase | Per-end to Firebase |
Shared Patterns
- KMP
TournamentParticipantmodel - KMP
ScoringSystemenum - KMP
ArrowEntrystructure - Firebase document structure
Test Coverage
TournamentScoringViewModelTests
| Test Category | Tests | Description |
|---|---|---|
| Score Entry | 5 | Arrow entry, X/10 counts |
| Leaderboard | 4 | Tie-breaking rules, ranking |
| Participant Switching | 3 | Manual/auto switching |
| End Completion | 4 | End flow, round completion |
| Security | 2 | Scoreable participants guard |
Related Documentation
- Guest Archers Guide - Adding guest participants
- Tournament List Filtering - Phase 6a
- iOS Troubleshooting - Common issues
- KMP iOS Patterns - Integration patterns
Last Updated: 2025-12-04 Status: Phase 6d complete