system-design51 min read

How to Design a Live Sports Scoring & Stats Platform — A Senior+ Guide | Ayodhyya

How to Design a Live Sports Scoring & Stats Platform

Building a Real-Time ESPN/SofaScore from Data Ingestion to Millions of Concurrent WebSocket Connections
Senior+ System Design Guide 10,000+ Words Architecture & Code 2026 Edition

Table of Contents

1. Problem Overview & Scope

A live sports scoring platform delivers real-time scores, statistics, match events, and analytics to millions of concurrent users across web, mobile, and third-party integrations. Think ESPN, SofaScore, FlashScore, or FotMob — platforms that must update within 1-3 seconds of an actual on-field event, serve during peak events like the World Cup Final or Super Bowl, and handle everything from football to cricket to tennis under one roof.

The fundamental challenge is extreme read-heavy asymmetry combined with strict latency guarantees. A single goal event at the 89th minute of a Premier League match can trigger 50 million simultaneous notifications and WebSocket pushes within 2 seconds. The write path is tiny — one goal — but the fanout is enormous. Add multi-sport coverage (each with wildly different data models), betting data feeds that demand sub-second accuracy, fantasy sports point calculations that must be correct to the millisecond, and a global CDN infrastructure that delivers to every continent simultaneously.

Scale Numbers We're Designing For: 50M concurrent users at peak (World Cup Final), 200M daily active users, 500K+ matches per year across 50+ sports, 10-50 match events per second per live match, 1-3 second end-to-end latency from on-field event to user screen, 99.99% uptime during major sporting events.

Why This Problem Is Unique

  • Temporal burstiness: Traffic follows the sports calendar, not business hours. A Saturday afternoon with 10 Premier League matches running simultaneously generates 10x normal traffic.
  • Latency-sensitive but not real-time trading: 1-3 seconds is acceptable, but 30 seconds is useless. This is faster than social media but slower than financial markets.
  • Data provider dependency: Scores come from external providers (Sportradar, Opta, Stats Perform) with their own APIs, latencies, and outage patterns.
  • Multi-schema complexity: A football goal and a cricket wicket and a tennis ace are fundamentally different events with different metadata.
  • Global audience, regional compliance: GDPR in Europe, data residency requirements, gambling regulations for betting features.

2. Functional & Non-Functional Requirements

Functional Requirements

FeatureDescriptionPriority
Live Score UpdatesReal-time score display for all active matches across all sportsP0
Match Events FeedGoals, cards, substitutions, fouls, set pieces — sport-specific event streamsP0
Match StatisticsPossession, shots, passes, corners, and sport-specific aggregationsP0
Push NotificationsScore alerts, goal notifications, match start/end alertsP0
Fixtures & CalendarBrowse upcoming matches, filter by league/sport/dateP0
League StandingsTables with points, goal difference, form, and tiebreakersP1
Player ProfilesCareer stats, season stats, match-by-match breakdownsP1
Fantasy SportsPoints calculation, team management, leaderboardsP1
Betting OddsLive and pre-match odds from multiple bookmakersP1
Highlight ClipsVideo clips for key moments (goals, red cards, etc.)P2
Social FeedMatch-specific social media integration and fan reactionsP2
Multi-SportFootball, basketball, cricket, tennis, rugby, baseball, and moreP0

Non-Functional Requirements

RequirementTarget
End-to-end latency< 3 seconds from on-field event to client screen
Concurrent users50M on a single match, 200M daily active
Availability99.99% during major events (8.76 min downtime/year)
Throughput1M WebSocket messages/second during peak
Data accuracy99.99% (wrong scores destroy trust)
Global latency< 200ms API response for non-live data
Offline supportMobile apps show cached data when offline

3. High-Level Architecture

The system follows an event-driven architecture with a clear separation between the write path (data ingestion), the processing layer (enrichment, aggregation, fan-out), and the read path (serving to clients). Every component is independently scalable, and the entire system is designed for graceful degradation — if the betting feed goes down, scores still update.

graph TB subgraph "Data Providers" SP[Sportradar API] OA[Opta Feed] EA[ESPN API] PB[Custom Scrapers] end subgraph "Ingestion Layer" IG[Ingestion Gateway] V[Validation & Dedup] Q1[Kafka: raw-events] end subgraph "Processing Layer" EP[Event Processor] EN[Enrichment Service] AG[Aggregation Service] FF[Fan-Out Service] Q2[Kafka: processed-events] Q3[Kafka: match-state] end subgraph "Storage Layer" PG[(PostgreSQL)] RD[(Redis Cluster)] ES[(Elasticsearch)] S3[(S3 / Object Storage)] end subgraph "Serving Layer" API[REST API Gateway] WS[WebSocket Gateway] CDN[CloudFront CDN] GRPC[gRPC Internal] end subgraph "Client Layer" WEB[Web App] MOB[Mobile App] TV[Smart TV] THIRD[Third-Party APIs] end SP --> IG OA --> IG EA --> IG PB --> IG IG --> V V --> Q1 Q1 --> EP Q1 --> EN EP --> Q2 EN --> Q2 Q2 --> AG Q2 --> FF AG --> Q3 FF --> WS Q3 --> RD Q3 --> PG API --> PG API --> RD API --> ES WS --> RD CDN --> S3 API --> CDN WS --> WEB WS --> MOB API --> TV API --> THIRD

Key Architectural Decisions

  • Event sourcing for match state: The current score is derived from replaying events, not from overwrites. This provides a complete audit trail and enables historical replay.
  • Kafka as the backbone: All inter-service communication flows through Kafka topics, providing durability, ordering guarantees per partition, and natural backpressure.
  • Redis for hot match state: The current score, active match events, and statistics live in Redis with <1ms read latency. PostgreSQL is the durable source of truth.
  • WebSocket fan-out at the edge: Clients connect to regional WebSocket gateways. Fan-out happens at the gateway level using Redis Pub/Sub for cross-region replication.
  • Multi-provider ingestion: Data from multiple providers is merged with a priority system. If Sportradar sends a goal and Opta sends a goal within 5 seconds, we deduplicate. If they disagree, higher-priority provider wins.

4. Real-Time Score Ingestion Pipeline

The ingestion pipeline is the foundation of the entire system. Data arrives from external providers via their proprietary APIs — Sportradar's REST API pushes JSON events every 5-30 seconds per match, Opta provides an XML feed, and some smaller providers require periodic polling. The ingestion gateway normalizes, validates, deduplicates, and routes these events into Kafka.

Data Provider Integration

ProviderProtocolLatencyCoverageReliability
SportradarREST Push (Webhook)1-3sAll major sports99.95%
Opta (Stats Perform)XML Socket Feed1-5sFootball, tennis99.9%
ESPN APIREST (polling)5-15sUS sports focus99.8%
Custom ScrapersVarious10-30sNiche leagues90-95%
Provider Redundancy: Never rely on a single data provider. During the 2022 World Cup, Sportradar experienced a 47-minute outage affecting thousands of downstream services. Our ingestion layer must seamlessly failover to the secondary provider without any visible gap in scoring. We maintain at least two providers for every major sport, with a third for football (the highest-traffic sport).

Ingestion Gateway Implementation

C#
public class IngestionGateway : BackgroundService
{
    private readonly IEnumerable<IDataProvider> _providers;
    private readonly IKafkaProducer<string, RawMatchEvent> _producer;
    private readonly IMatchEventValidator _validator;
    private readonly IEventDeduplicator _deduplicator;
    private readonly ILogger<IngestionGateway> _logger;

    public IngestionGateway(
        IEnumerable<IDataProvider> providers,
        IKafkaProducer<string, RawMatchEvent> producer,
        IMatchEventValidator validator,
        IEventDeduplicator deduplicator,
        ILogger<IngestionGateway> logger)
    {
        _providers = providers;
        _producer = producer;
        _validator = validator;
        _deduplicator = deduplicator;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        var tasks = _providers.Select(p => ConsumeProviderAsync(p, ct));
        await Task.WhenAll(tasks);
    }

    private async Task ConsumeProviderAsync(
        IDataProvider provider, CancellationToken ct)
    {
        await foreach (var rawEvent in provider.SubscribeAsync(ct))
        {
            var sw = Stopwatch.StartNew();
            var normalized = provider.Normalize(rawEvent);
            var validationResult = _validator.Validate(normalized);
            if (!validationResult.IsValid)
            {
                _logger.LogWarning(
                    "Invalid event from {Provider}: {Errors}",
                    provider.Name, validationResult.Errors);
                continue;
            }
            if (await _deduplicator.IsDuplicateAsync(normalized))
            {
                _logger.LogDebug(
                    "Duplicate event {EventId} from {Provider}",
                    normalized.EventId, provider.Name);
                continue;
            }
            var kafkaMessage = new Message<string, RawMatchEvent>
            {
                Key = normalized.MatchId,
                Value = normalized
            };
            await _producer.ProduceAsync(
                "raw-match-events", kafkaMessage, ct);
            sw.Stop();
            _logger.LogInformation(
                "Ingested {EventType} for match {MatchId} from {Provider} in {Elapsed}ms",
                normalized.EventType, normalized.MatchId,
                provider.Name, sw.ElapsedMilliseconds);
        }
    }
}

Data Provider Interface

C#
public interface IDataProvider
{
    string Name { get; }
    SportType Sport { get; }
    int Priority { get; }
    IAsyncEnumerable<RawEvent> SubscribeAsync(CancellationToken ct);
    NormalizedMatchEvent Normalize(RawEvent raw);
}

public class SportradarProvider : IDataProvider
{
    private readonly HttpClient _httpClient;
    public string Name => "sportradar";
    public SportType Sport => SportType.All;
    public int Priority => 1;

    public async IAsyncEnumerable<RawEvent> SubscribeAsync(
        [EnumeratorCancellation] CancellationToken ct)
    {
        var request = new HttpRequestMessage(
            HttpMethod.Get,
            "https://api.sportradar.us/v1/events/stream");
        request.Headers.Add("Authorization", $"Bearer {_apiKey}");
        var response = await _httpClient.SendAsync(
            request, HttpCompletionOption.ResponseHeadersRead, ct);
        var stream = await response.Content.ReadAsStreamAsync(ct);
        using var reader = new StreamReader(stream);
        while (!reader.EndOfStream && !ct.IsCancellationRequested)
        {
            var line = await reader.ReadLineAsync(ct);
            if (string.IsNullOrEmpty(line)) continue;
            if (line.StartsWith("data:"))
            {
                var json = line.Substring(5).Trim();
                var evt = JsonSerializer.Deserialize<RawEvent>(json, _jsonOptions);
                if (evt != null) yield return evt;
            }
        }
    }

    public NormalizedMatchEvent Normalize(RawEvent raw)
    {
        return new NormalizedMatchEvent
        {
            EventId = $"sr-{raw.Id}",
            MatchId = raw.Match.Id,
            Provider = Name,
            Sport = SportType.Football,
            EventType = MapEventType(raw.Type),
            Timestamp = raw.Timestamp,
            Minute = raw.Period?.Clock?.Elapsed,
            TeamId = raw.Participant?.Id,
            PlayerId = raw球员?.Id,
            Metadata = JsonSerializer.SerializeToElement(raw)
        };
    }
}

Event Deduplication

When the same goal is reported by Sportradar and Opta within seconds of each other, we must emit only one canonical event. The deduplication uses a composite key of (match_id, event_type, approximate_minute, team_id) with a 10-second sliding window stored in Redis.

C#
public class RedisEventDeduplicator : IEventDeduplicator
{
    private readonly IDatabase _redis;
    private readonly TimeSpan _window = TimeSpan.FromSeconds(10);

    public async Task<bool> IsDuplicateAsync(NormalizedMatchEvent evt)
    {
        var key = $"dedup:{evt.MatchId}:" +
                  $"{evt.EventType}:" +
                  $"{evt.TeamId}:" +
                  $"{evt.Minute ?? 0}";
        var added = await _redis.StringSetAsync(
            key, evt.EventId, _window, When.NotExists);
        return !added;
    }
}

5. Match Event Modeling

Every action on the field — a goal, a yellow card, a substitution, a corner kick — is modeled as an immutable event. Events are the atomic building blocks of the system. The current match state (score, cards, lineup) is always derived by replaying events from the beginning. This event-sourced approach provides a complete audit trail, enables historical analysis, and makes it trivial to replay a match for debugging.

Core Event Model

C#
public abstract class MatchEvent
{
    public Guid EventId { get; init; }
    public string MatchId { get; init; }
    public string Provider { get; init; }
    public DateTimeOffset Timestamp { get; init; }
    public int? Minute { get; init; }
    public int? AddedTime { get; init; }
    public string Period { get; init; }
    public abstract EventType EventType { get; }
}

public class GoalEvent : MatchEvent
{
    public override EventType EventType => EventType.Goal;
    public string ScorerId { get; init; }
    public string ScorerName { get; init; }
    public string TeamId { get; init; }
    public GoalType GoalType { get; init; }
    public string? AssistId { get; init; }
    public string? AssistName { get; init; }
    public string? Description { get; init; }
}

public class CardEvent : MatchEvent
{
    public override EventType EventType =>
        CardColor == CardColor.Red ? EventType.RedCard : EventType.YellowCard;
    public string PlayerId { get; init; }
    public string PlayerName { get; init; }
    public string TeamId { get; init; }
    public CardColor CardColor { get; init; }
    public string? Reason { get; init; }
    public bool IsSecondYellow { get; init; }
}

public class SubstitutionEvent : MatchEvent
{
    public override EventType EventType => EventType.Substitution;
    public string PlayerOutId { get; init; }
    public string PlayerOutName { get; init; }
    public string PlayerInId { get; init; }
    public string PlayerInName { get; init; }
    public string TeamId { get; init; }
    public string? Reason { get; init; }
}

public class MatchState
{
    public string MatchId { get; init; }
    public int HomeScore { get; set; }
    public int AwayScore { get; set; }
    public MatchStatus Status { get; set; }
    public List<MatchEvent> Events { get; } = new();
    public Dictionary<string, int> TeamCards { get; } = new();
    public List<SubstitutionEvent> Substitutions { get; } = new();
    public DateTimeOffset LastUpdated { get; set; }
}

public enum EventType
{
    Goal, YellowCard, RedCard, Substitution,
    Penalty, Corner, FreeKick, ThrowIn,
    Offside, VAR, Injury, WaterBreak,
    MatchStart, HalfTime, FullTime, ExtraTimeStart,
    PenaltyShootout, MatchSuspended, MatchAbandoned
}

Event Processing Pipeline

flowchart LR A[Raw Event from Kafka] --> B{Validate} B -->|Invalid| C[Dead Letter Queue] B -->|Valid| D[Enrich] D --> E[Update Match State] E --> F[Write to Redis] E --> G[Persist to PostgreSQL] E --> H[Publish to processed-events] H --> I[Fan-Out to WebSocket Gateways] H --> J[Trigger Notifications] H --> K[Update Aggregations] H --> L[Update Fantasy Points]

6. Sport-Specific Schemas

Each sport has fundamentally different scoring rules, event types, and statistics. A unified schema that tries to represent everything becomes an unwieldy God object. Instead, we use a base event model with sport-specific extensions.

Football (Soccer)

EventData FieldsAggregations
GoalScorer, assist, minute, goal type (open play/penalty/free kick/header/own goal)Score, scorer stats, assist stats
CardPlayer, color, reason, second yellow flagTeam discipline, suspended players
SubstitutionPlayer in, player out, reasonLineup changes, squad utilization
VARDecision (goal awarded/denied), original call, review timeVAR accuracy stats
PossessionHome %, Away %, per-half breakdownRolling 5-minute possession
ShotsOn target, off target, blocked, by playerShot map, xG (expected goals)
PassesComplete, incomplete, by zone, key passesPass accuracy %, pass map

Basketball (NBA)

EventData FieldsAggregations
Field GoalPlayer, points (2/3), zone, assisted by, contestedFG%, 3P%, eFG%
Free ThrowPlayer, points, made/missed, fouled byFT%, FTA
ReboundPlayer, offensive/defensiveOREB, DREB, REB
AssistPlayer, to player, points generatedAST, AST/TO ratio
StealPlayer, from playerSTL, STL/TO
BlockPlayer, on player, shot typeBLK
TurnoverPlayer, type (bad pass, traveling, etc.)TO
FoulPlayer, type, team fouls per quarterTeam foul bonus tracking

Cricket

EventData FieldsAggregations
RunRuns (1-6), batsman, bowler, extras, balls since last wicketRun rate, required rate
WicketBatsman out, bowler, fielder, dismissal type, score at dismissalWickets, bowling figures
BoundaryBatsman, runs (4 or 6), bowler, area of groundBoundary count, scoring zones
OverBowler, runs conceded, wickets, extrasEconomy rate, bowling analysis
ExtrasType (wide, no-ball, bye, leg-bye), runsTotal extras per innings
PartnershipTwo batsman, runs scored, balls facedPartnership breakdown

Tennis

EventData FieldsAggregations
PointWinner, serve direction, shot type, rally lengthAces, double faults, winners
GameWinner, score, break of serve flagGames, breaks
SetWinner, score, tiebreak detailsSets won
ServeSpeed, placement, spin, first/secondServe speed, 1st serve %
Break PointPlayer, converted/ savedBP conversion rate
ChallengePlayer, line, outcome (successful/unsuccessful)Challenge accuracy
Schema Evolution: Sport-specific schemas evolve as governing bodies change rules. The NFL added new stats for tracking data (separation, route depth). The ICC changed DRS rules. We use a schema registry (Avro with Confluent Schema Registry) that supports backward-compatible evolution — new fields are optional, removed fields are deprecated but still decoded.

Unified Multi-Sport Event Store

C#
public class SportEventStore
{
    private readonly IDbConnection _db;

    public async Task<List<MatchEvent>> GetEventsAsync(
        string matchId, SportType? sportFilter = null)
    {
        var sql = @"
            SELECT event_id, match_id, sport, event_type,
                   event_data, minute, period, timestamp, provider
            FROM match_events
            WHERE match_id = @MatchId
            AND (@Sport IS NULL OR sport = @Sport)
            ORDER BY timestamp ASC";
        var events = await _db.QueryAsync<StoredEvent>(
            sql, new { MatchId = matchId, Sport = sportFilter });
        return events.Select(e =>
            DeserializeSportEvent(e.Sport, e)).ToList();
    }

    private MatchEvent DeserializeSportEvent(
        SportType sport, StoredEvent stored)
    {
        return sport switch
        {
            SportType.Football =>
                JsonSerializer.Deserialize<FootballEvent>(stored.EventData),
            SportType.Basketball =>
                JsonSerializer.Deserialize<BasketballEvent>(stored.EventData),
            SportType.Cricket =>
                JsonSerializer.Deserialize<CricketEvent>(stored.EventData),
            SportType.Tennis =>
                JsonSerializer.Deserialize<TennisEvent>(stored.EventData),
            _ => throw new NotSupportedException($"Sport {sport} not supported")
        };
    }
}

7. Live Match Timeline

The match timeline is the most viewed UI component — a chronological feed of all events for a single match. It must update in real-time, support rich media (player photos, event icons), and handle late-arriving data (e.g., a VAR decision reversing a goal 2 minutes after it was initially awarded).

Timeline Architecture

sequenceDiagram participant Client participant WS as WebSocket Gateway participant Redis participant Timeline as Timeline Service participant DB as PostgreSQL Client->>WS: Subscribe to match:matchId:timeline WS->>Redis: SUBSCRIBE match:matchId:timeline Redis-->>WS: Current timeline state WS-->>Client: Initial timeline snapshot Note over Timeline: New event arrives Timeline->>Redis: APPEND event to timeline Timeline->>DB: INSERT into match_events Timeline->>Redis: PUBLISH match:matchId:timeline Redis-->>WS: Event notification WS->>Client: type: timeline_event Note over Client: User scrolls to historical events Client->>WS: Request events before cursor WS->>DB: SELECT events WHERE timestamp less than cursor DB-->>WS: Historical events WS-->>Client: type: timeline_history

Timeline Data Structure in Redis

We use Redis Streams for timeline storage, which provides ordered, append-only semantics with consumer groups for multiple readers.

C#
public class MatchTimelineService
{
    private readonly IDatabase _redis;
    private readonly IConnectionMultiplexer _mux;

    private string StreamKey(string matchId) =>
        $"timeline:{matchId}";

    public async Task AddEventAsync(string matchId, MatchEvent evt)
    {
        var stream = _mux.GetStreamDatabase();
        var fields = new NameValueEntry[]
        {
            new("event_id", evt.EventId.ToString()),
            new("event_type", evt.EventType.ToString()),
            new("minute", evt.Minute?.ToString() ?? ""),
            new("team_id", evt.TeamId ?? ""),
            new("player_id", evt球员Id ?? ""),
            new("data", JsonSerializer.Serialize(evt)),
            new("timestamp", evt.Timestamp
                .ToUnixTimeMilliseconds().ToString())
        };
        await stream.AddAsync(StreamKey(matchId), "*", fields);
        await _redis.PublishAsync(
            Channel.Literal($"match:{matchId}:timeline"),
            JsonSerializer.Serialize(new
            {
                type = "timeline_event",
                matchId,
                event = evt
            }));
    }

    public async Task<List<TimelineEvent>> GetTimelineAsync(
        string matchId, int? before = null, int count = 50)
    {
        var stream = _mux.GetStreamDatabase();
        var startId = before?.ToString() ?? "0";
        var entries = await stream.RangeAsync(
            StreamKey(matchId), startId, "+",
            count: count, reverse: true);
        return entries.Select(MapToTimelineEvent).ToList();
    }
}

8. Push Notifications & Score Alerts

Push notifications are the most latency-sensitive output of the system. A "GOAL!" notification arriving 10 seconds after the user's team scores feels broken. We target sub-2-second delivery from event ingestion to device notification. This requires a dedicated notification pipeline that runs in parallel with the WebSocket fan-out.

Notification Architecture

flowchart TB E[Match Event] --> R{Notification Router} R -->|Score Alert| SA[Score Alert Service] R -->|Card Alert| CA[Card Alert Service] R -->|Match Start| MS[Match Start Service] R -->|Custom Alert| UA[User Alert Service] SA --> PB[Push Batcher] CA --> PB MS --> PB UA --> PB PB --> FCM[Firebase Cloud Messaging] PB --> APNS[Apple Push Notification Service] PB --> HMS[Huawei Mobile Services] PB --> WEB_PUSH[Web Push API] FCM --> DEVICE[Android Devices] APNS --> IOS[iOS Devices] HMS --> HUAWEI[Huawei Devices] WEB_PUSH --> BROWSER[Browsers]

Notification Batching

During a 5-minute spell where one team scores 3 goals, we don't send 3 separate notifications. The batching service groups notifications by user and match, creating a single rich notification: "Arsenal 3-0 Manchester United Goals: Saka (12'), Havertz (34'), Saka (41')".

C#
public class NotificationBatcher
{
    private readonly IDatabase _redis;
    private readonly TimeSpan _batchWindow = TimeSpan.FromSeconds(5);
    private readonly Dictionary<string, List<PendingNotification>>
        _pending = new();

    public async Task EnqueueNotificationAsync(
        PendingNotification notification)
    {
        var key = $"{notification.UserId}:{notification.MatchId}";
        lock (_pending)
        {
            if (!_pending.ContainsKey(key))
            {
                _pending[key] = new List<PendingNotification>();
                _ = ScheduleFlushAsync(key);
            }
            _pending[key].Add(notification);
        }
    }

    private async Task ScheduleFlushAsync(string key)
    {
        await Task.Delay(_batchWindow);
        List<PendingNotification> batch;
        lock (_pending)
        {
            if (!_pending.TryGetValue(key, out batch!)) return;
            _pending.Remove(key);
        }
        var merged = MergeNotifications(batch);
        await SendMergedNotificationAsync(merged);
    }

    private MergedNotification MergeNotifications(
        List<PendingNotification> notifications)
    {
        var first = notifications.First();
        var goals = notifications
            .Where(n => n.Type == NotificationType.Goal).ToList();
        if (goals.Count == 1)
            return new MergedNotification
            {
                Title = "GOAL!",
                Body = $"{goals[0].ScorerName} scores! {goals[0].MatchScore}",
                Data = goals[0].ToDictionary()
            };
        var scorers = string.Join(", ",
            goals.Select(g => g.ScorerName));
        return new MergedNotification
        {
            Title = $"{goals.Count} Goals!",
            Body = $"{scorers} - {goals.Last().MatchScore}",
            Data = new Dictionary<string, string>
            {
                ["matchId"] = first.MatchId,
                ["goalCount"] = goals.Count.ToString()
            }
        };
    }
}

User Alert Preferences

Alert TypeDefaultConfigurableBatchable
Goal scoredOnYesYes (5s window)
Red cardOnYesNo (immediate)
Half-timeOnYesNo
Full-timeOnYesNo
Starting lineupOffYesNo
SubstitutionOffYesNo
Pre-match (kickoff in 1h)OnYesNo
Custom score thresholdN/AYesNo

9. WebSocket Streaming to Clients

WebSockets are the primary real-time transport. At peak (World Cup Final), we maintain 50 million concurrent WebSocket connections. This requires a horizontally scaled WebSocket gateway fleet, efficient connection routing, and intelligent fan-out that avoids duplicating messages.

WebSocket Gateway Architecture

graph TB subgraph "Region: US-East" LB1[ALB / NLB] WS1[WS Node 1] WS2[WS Node 2] WS3[WS Node 3] PS1[PubSub Adapter] end subgraph "Region: EU-West" LB2[ALB / NLB] WS4[WS Node 4] WS5[WS Node 5] WS6[WS Node 6] PS2[PubSub Adapter] end subgraph "Region: AP-South" LB3[ALB / NLB] WS7[WS Node 7] WS8[WS Node 8] PS3[PubSub Adapter] end subgraph "Cross-Region" RD[(Redis Cluster)] KAFKA[Kafka] end CLIENT1[US Clients] --> LB1 CLIENT2[EU Clients] --> LB2 CLIENT3[APAC Clients] --> LB3 LB1 --> WS1 & WS2 & WS3 LB2 --> WS4 & WS5 & WS6 LB3 --> WS7 & WS8 WS1 & WS2 & WS3 --> PS1 WS4 & WS5 & WS6 --> PS2 WS7 & WS8 --> PS3 PS1 --> RD PS2 --> RD PS3 --> RD KAFKA --> PS1 & PS2 & PS3

WebSocket Gateway Implementation

C#
public class WebSocketGateway
{
    private readonly ConnectionPool _connections;
    private readonly ISubscriber _redisSub;

    public async Task HandleConnectionAsync(
        WebSocket socket, HttpContext context)
    {
        var userId = context.User.FindFirst("sub")?.Value;
        var connId = Guid.NewGuid().ToString();
        var connection = new ClientConnection
        {
            Id = connId,
            UserId = userId,
            Socket = socket,
            Subscriptions = new HashSet<string>(),
            ConnectedAt = DateTimeOffset.UtcNow
        };
        _connections.Add(connection);
        try
        {
            await _redisSub.SubscribeAsync(
                Channel.Literal("ws:fan-out"),
                async (channel, message) =>
                {
                    var evt = JsonSerializer
                        .Deserialize<FanOutMessage>(message.ToString());
                    if (connection.Subscriptions.Contains(evt.Channel))
                    {
                        var payload = JsonSerializer.Serialize(evt.Data);
                        var bytes = Encoding.UTF8.GetBytes(payload);
                        await socket.SendAsync(
                            new ArraySegment<byte>(bytes),
                            WebSocketMessageType.Text, true,
                            CancellationToken.None);
                    }
                });
            var buffer = new byte[4096];
            while (socket.State == WebSocketState.Open)
            {
                var result = await socket.ReceiveAsync(
                    new ArraySegment<byte>(buffer),
                    CancellationToken.None);
                if (result.MessageType == WebSocketMessageType.Close)
                {
                    await socket.CloseAsync(
                        WebSocketCloseStatus.NormalClosure,
                        "", CancellationToken.None);
                }
                else if (result.MessageType == WebSocketMessageType.Text)
                {
                    var msg = JsonSerializer.Deserialize<ClientMessage>(
                        Encoding.UTF8.GetString(buffer, 0, result.Count));
                    await HandleClientMessageAsync(connection, msg);
                }
            }
        }
        finally
        {
            _connections.Remove(connId);
        }
    }

    private async Task HandleClientMessageAsync(
        ClientConnection conn, ClientMessage msg)
    {
        switch (msg.Type)
        {
            case "subscribe":
                conn.Subscriptions.Add(msg.Channel);
                var snapshot = await GetSnapshotAsync(msg.Channel);
                if (snapshot != null) await SendMessageAsync(conn, snapshot);
                break;
            case "unsubscribe":
                conn.Subscriptions.Remove(msg.Channel);
                break;
            case "ping":
                await SendMessageAsync(conn,
                    new { type = "pong", ts = DateTimeOffset.UtcNow });
                break;
        }
    }
}

Fan-Out Strategy

ApproachProsConsWhen to Use
Redis Pub/SubSub-millisecond latency, simpleNo persistence, no backpressureReal-time push to connected clients
Kafka Consumer GroupsDurability, ordering, replayHigher latency (10-50ms)Cross-region replication, offline catch-up
Hybrid (Kafka to Redis)Best of bothAdded complexityProduction recommendation
Connection Limits: Each WebSocket gateway node maintains ~50K concurrent connections. With 50M connections at peak, we need ~1,000 gateway nodes. However, with connection sharing (users subscribe to multiple matches), actual unique connections may be 20-30M, requiring 400-600 nodes. Auto-scaling groups track connection count per node and scale out when utilization exceeds 70%.

Connection Efficiency: Channel Multiplexing

Instead of creating a separate connection per match per user, we multiplex all subscriptions over a single WebSocket connection. The client subscribes to channels like match:abc123:events, match:abc123:stats, league:premier-league:standings. The gateway maps channels to a single Redis Pub/Sub topic and filters messages per connection.

10. Match Statistics Aggregation

Match statistics — possession percentage, shots on target, pass accuracy, corners, fouls — are computed in real-time as events flow through the system. The aggregation service maintains running counters in Redis and periodically flushes snapshots to PostgreSQL for historical storage.

Football Statistics Model

C#
public class MatchStatistics
{
    public string MatchId { get; set; }
    public int HomePossessionPercent { get; set; }
    public int AwayPossessionPercent { get; set; }
    public List<PossessionPeriod> PossessionByPeriod { get; set; }
    public int HomeShotsOnTarget { get; set; }
    public int HomeShotsOffTarget { get; set; }
    public int AwayShotsOnTarget { get; set; }
    public int AwayShotsOffTarget { get; set; }
    public List<ShotEvent> ShotMap { get; set; }
    public int HomePassesCompleted { get; set; }
    public int HomePassesAttempted { get; set; }
    public int AwayPassesCompleted { get; set; }
    public int AwayPassesAttempted { get; set; }
    public int HomeCorners { get; set; }
    public int AwayCorners { get; set; }
    public int HomeFouls { get; set; }
    public int AwayFouls { get; set; }
    public int HomeOffsides { get; set; }
    public int AwayOffsides { get; set; }
    public int HomeYellowCards { get; set; }
    public int AwayYellowCards { get; set; }
    public int HomeRedCards { get; set; }
    public int AwayRedCards { get; set; }
    public decimal HomeXG { get; set; }
    public decimal AwayXG { get; set; }
    public int HomeDangerousAttacks { get; set; }
    public int AwayDangerousAttacks { get; set; }
    public DateTimeOffset LastUpdated { get; set; }
}

public class RealTimeStatsAggregator
{
    private readonly IDatabase _redis;

    public async Task UpdateStatsAsync(
        string matchId, MatchEvent evt)
    {
        var key = $"stats:{matchId}";
        switch (evt)
        {
            case GoalEvent goal:
                var field = goal.TeamId == await GetHomeTeamAsync(matchId)
                    ? "home_goals" : "away_goals";
                await _redis.HashIncrementAsync(key, field);
                break;
            case ShotEvent shot:
                await _redis.HashIncrementAsync(key,
                    $"_{shot.Side}_shots_{shot.OnTarget ? "on" : "off"}_target");
                break;
            case PassEvent pass:
                await _redis.HashIncrementAsync(key,
                    $"_{pass.Side}_passes_attempted");
                if (pass.Completed)
                    await _redis.HashIncrementAsync(key,
                        $"_{pass.Side}_passes_completed");
                break;
            case CardEvent card:
                await _redis.HashIncrementAsync(key,
                    $"_{card.Side}_{card.CardColor.ToString().ToLower()}_cards");
                break;
            case CornerEvent:
                await _redis.HashIncrementAsync(key,
                    $"_{evt.Side}_corners");
                break;
            case FoulEvent:
                await _redis.HashIncrementAsync(key,
                    $"_{evt.Side}_fouls");
                break;
        }
        await RecalculatePossessionAsync(matchId);
        await PublishStatsUpdateAsync(matchId);
    }

    private async Task RecalculatePossessionAsync(string matchId)
    {
        var key = $"stats:{matchId}";
        var home = int.Parse(await _redis.HashGetAsync(key,
            "home_possession_events"));
        var away = int.Parse(await _redis.HashGetAsync(key,
            "away_possession_events"));
        var total = home + away;
        if (total > 0)
        {
            var homePct = (int)Math.Round((double)home / total * 100);
            await _redis.HashSetAsync(key, new[] {
                new HashEntry("home_possession_pct", homePct),
                new HashEntry("away_possession_pct", 100 - homePct)
            });
        }
    }
}

xG (Expected Goals) Calculation

Expected Goals is the most important advanced metric in modern football analytics. It estimates the probability that a given shot results in a goal based on: shot location (distance and angle from goal), body part (foot, head), shot type (open play, free kick, penalty), and pre-shot events (through ball, cross, dribble). We use a pre-trained logistic regression model served as an ONNX model in the aggregation service.

C#
public class XGCalculator
{
    private readonly InferenceSession _model;

    public decimal CalculateXG(ShotEvent shot)
    {
        var features = new float[]
        {
            shot.DistanceFromGoal,
            shot.AngleFromGoal,
            shot.IsOneOnOne ? 1f : 0f,
            shot.IsHeader ? 1f : 0f,
            shot.IsVolley ? 1f : 0f,
            shot.IsFreeKick ? 1f : 0f,
            shot.IsPenalty ? 1f : 0f,
            shot.BodyPart == BodyPart.RightFoot ? 1f : 0f,
            shot.BodyPart == BodyPart.LeftFoot ? 1f : 0f,
            shot.BodyPart == BodyPart.Head ? 1f : 0f,
            shot.FirstTime ? 1f : 0f,
            shot.IsBigChance ? 1f : 0f,
        };
        var input = new NamedOnnxValue[]
        {
            NamedOnnxValue.CreateFromTensor(
                "features",
                new DenseTensor<float>(features,
                    new[] { 1, features.Length }))
        };
        var result = _model.Run(input);
        var probability = result.First().AsEnumerable<float>().First();
        return Math.Round((decimal)probability, 3);
    }
}

11. Historical Data & Analytics

Every match event, statistic, and state snapshot is persisted for historical analysis. This data powers head-to-head records, season statistics, historical trends, and data journalism. The storage strategy uses a tiered approach: hot data (current season) in PostgreSQL, warm data (1-3 years) in partitioned tables, and cold data (3+ years) compressed in S3 with Athena for ad-hoc queries.

Data Retention Strategy

Data TierAgeStorageAccess PatternRetention
HotCurrent seasonPostgreSQL (SSD)Real-time queriesIndefinite
Warm1-3 yearsPostgreSQL (HDD partitioned)Historical lookupsIndefinite
Cold3+ yearsS3 Parquet + AthenaAnalytics, data journalismIndefinite
Archive7+ yearsS3 GlacierCompliance only10 years

PostgreSQL Partitioning

SQL
CREATE TABLE match_events (
    event_id    UUID PRIMARY KEY,
    match_id    VARCHAR(50) NOT NULL,
    sport       VARCHAR(20) NOT NULL,
    event_type  VARCHAR(30) NOT NULL,
    minute      INTEGER,
    period      VARCHAR(20),
    event_data  JSONB NOT NULL,
    timestamp   TIMESTAMPTZ NOT NULL,
    provider    VARCHAR(30) NOT NULL
) PARTITION BY RANGE (timestamp);

CREATE TABLE match_events_2025_26
    PARTITION OF match_events
    FOR VALUES FROM ('2025-08-01') TO ('2026-07-01');

CREATE TABLE match_events_2024_25
    PARTITION OF match_events
    FOR VALUES FROM ('2024-08-01') TO ('2025-08-01');

CREATE INDEX idx_events_match
    ON match_events (match_id, timestamp);

CREATE INDEX idx_events_type
    ON match_events (event_type, sport, timestamp);

CREATE INDEX idx_events_player
    ON match_events USING GIN (
        event_data ->> 'player_id'
    );

Analytics Data Warehouse

For complex analytical queries (e.g., "which players have the highest xG per 90 minutes in the top 5 leagues this season"), we replicate event data to a columnar data warehouse using Apache Parquet files in S3. Amazon Athena or Snowflake provides SQL access over these files for analysts and data journalists.

C#
public class AnalyticsExporter : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            var matches = await GetCompletedMatchesAsync(
                DateTimeOffset.UtcNow.AddDays(-1));
            foreach (var match in matches)
            {
                var events = await GetMatchEventsAsync(match.MatchId);
                var parquetBytes = ConvertToParquet(events);
                var key = $"analytics/events/" +
                          $"{match.Sport}/" +
                          $"{match.Date:yyyy/MM/dd}/" +
                          $"{match.MatchId}.parquet";
                await _s3.PutObjectAsync(new PutObjectRequest
                {
                    BucketName = "sports-analytics-data",
                    Key = key,
                    InputStream = new MemoryStream(parquetBytes)
                });
            }
            await Task.Delay(TimeSpan.FromHours(1), ct);
        }
    }
}

12. Fixture Scheduling & Calendar

Fixtures are the backbone of the platform — every match, kickoff time, venue, and broadcast information must be accurately maintained for all sports and leagues worldwide. Fixture data comes from a combination of data providers and official league APIs, with automated detection of schedule changes (postponements, kickoff time changes, venue changes).

Fixture Data Model

C#
public class Fixture
{
    public string FixtureId { get; init; }
    public string ExternalId { get; init; }
    public SportType Sport { get; init; }
    public string LeagueId { get; init; }
    public string LeagueName { get; init; }
    public string Season { get; init; }
    public int Matchday { get; init; }
    public FixtureTeam Home { get; init; }
    public FixtureTeam Away { get; init; }
    public DateTimeOffset KickoffTime { get; init; }
    public string? Venue { get; init; }
    public string? City { get; init; }
    public string? Country { get; init; }
    public FixtureStatus Status { get; set; }
    public int? HomeScore { get; set; }
    public int? AwayScore { get; set; }
    public List<BroadcastInfo> Broadcasts { get; init; }
    public List<FixtureChange> ChangeHistory { get; } = new();
    public DateTimeOffset LastUpdated { get; set; }
}

public class FixtureTeam
{
    public string TeamId { get; init; }
    public string Name { get; init; }
    public string ShortName { get; init; }
    public string CrestUrl { get; init; }
}

public enum FixtureStatus
{
    Scheduled, InPlay, HalfTime, FullTime,
    ExtraTime, PenaltyShootout, Postponed,
    Cancelled, Abandoned, Delayed, Result
}

Fixture Change Detection

Leagues frequently change kickoff times — sometimes weeks before a match, sometimes hours before. The fixture sync service polls providers every 15 minutes and compares with stored fixtures. Any changes are detected, validated, and propagated to all dependent systems (notifications, cache invalidation, calendar sync).

C#
public class FixtureSyncService
{
    public async Task SyncFixturesAsync(
        string leagueId, SportType sport)
    {
        var remoteFixtures = await _provider
            .GetFixturesAsync(leagueId);
        var localFixtures = await _fixtureRepo
            .GetByLeagueAsync(leagueId);
        var localDict = localFixtures
            .ToDictionary(f => f.ExternalId);

        foreach (var remote in remoteFixtures)
        {
            if (localDict.TryGetValue(
                remote.ExternalId, out var local))
            {
                var changes = DetectChanges(local, remote);
                if (changes.Any())
                {
                    foreach (var change in changes)
                    {
                        local.ChangeHistory.Add(new FixtureChange
                        {
                            Field = change.Field,
                            OldValue = change.OldValue,
                            NewValue = change.NewValue,
                            ChangedAt = DateTimeOffset.UtcNow
                        });
                    }
                    ApplyChanges(local, remote);
                    await _fixtureRepo.UpdateAsync(local);
                    await _mediator.Publish(
                        new FixtureChanged(local, changes));
                }
            }
            else
            {
                var newFixture = MapToFixture(remote);
                await _fixtureRepo.InsertAsync(newFixture);
                await _mediator.Publish(
                    new FixtureCreated(newFixture));
            }
        }
    }
}

Calendar Integration

Users can export fixtures to their personal calendars (Google Calendar, Apple Calendar, Outlook). We generate iCalendar (.ics) files with automatic updates when kickoff times change.

13. League Standings & Tables

League tables are one of the most viewed pages on any sports platform. They must be calculated correctly according to league-specific rules (different leagues have different tiebreaker rules), update immediately after a match result, and handle complex scenarios like points deductions (e.g., Everton in the Premier League).

League Table Calculation

C#
public class LeagueTableCalculator
{
    private readonly ILeagueRepository _leagueRepo;

    public async Task<LeagueTable> CalculateAsync(
        string leagueId, string season)
    {
        var matches = await _leagueRepo
            .GetCompletedMatchesAsync(leagueId, season);
        var teams = await _leagueRepo
            .GetTeamsAsync(leagueId, season);
        var adjustments = await _leagueRepo
            .GetPointAdjustmentsAsync(leagueId, season);

        var standings = teams.Select(t => new TeamStanding
        {
            TeamId = t.TeamId,
            TeamName = t.Name,
            CrestUrl = t.CrestUrl
        }).ToDictionary(t => t.TeamId);

        foreach (var match in matches)
        {
            var home = standings[match.HomeTeamId];
            var away = standings[match.AwayTeamId];
            home.Played++;
            away.Played++;
            home.GoalsFor += match.HomeScore;
            home.GoalsAgainst += match.AwayScore;
            away.GoalsFor += match.AwayScore;
            away.GoalsAgainst += match.HomeScore;

            if (match.HomeScore > match.AwayScore)
            {
                home.Won++;
                home.Points += 3;
                away.Lost++;
            }
            else if (match.HomeScore < match.AwayScore)
            {
                away.Won++;
                away.Points += 3;
                home.Lost++;
            }
            else
            {
                home.Drawn++;
                away.Drawn++;
                home.Points++;
                away.Points++;
            }
            UpdateForm(home, match, isHome: true);
            UpdateForm(away, match, isHome: false);
        }

        foreach (var adj in adjustments)
        {
            if (standings.ContainsKey(adj.TeamId))
                standings[adj.TeamId].Points += adj.Points;
        }

        var sorted = standings.Values
            .OrderByDescending(s => s.Points)
            .ThenByDescending(s => s.GoalDifference)
            .ThenByDescending(s => s.GoalsFor)
            .ThenByDescending(s => s.HeadToHeadPoints)
            .ThenByDescending(s => s.HeadToHeadGoalDiff)
            .ToList();

        for (int i = 0; i < sorted.Count; i++)
            sorted[i].Position = i + 1;

        return new LeagueTable
        {
            LeagueId = leagueId,
            Season = season,
            Standings = sorted,
            LastUpdated = DateTimeOffset.UtcNow
        };
    }
}

Table Display Features

ColumnDescriptionTooltip
PosLeague positionPosition movement arrows
TeamTeam name + crestLink to team page
PMatches played-
WWins-
DDraws-
LLosses-
GFGoals for-
GAGoals against-
GDGoal differenceColor coded
PtsPointsBold for leaders
FormLast 5 results (W/D/L)Green/gray/red dots
Caching Strategy: League tables are expensive to compute (O(m * n) where m = matches and n = teams). We cache the computed table in Redis with a 30-second TTL during live match hours, extending to 5 minutes during off-hours. Cache invalidation is triggered by match completion events. For leagues with 20 teams and 380 matches, a full recalculation takes ~50ms.

14. Player Profiles & Season Stats

Player profiles aggregate career statistics, season-by-season breakdowns, match-by-match performance, and biographical data. They serve both the detailed stat pages and the fantasy sports feature (which needs current season stats for point calculations).

Player Data Model

C#
public class PlayerProfile
{
    public string PlayerId { get; init; }
    public string FullName { get; init; }
    public string ShortName { get; init; }
    public string Nationality { get; init; }
    public DateTime DateOfBirth { get; init; }
    public string Position { get; init; }
    public decimal? Height { get; init; }
    public decimal? Weight { get; init; }
    public string PreferredFoot { get; init; }
    public string PhotoUrl { get; init; }
    public List<ClubHistory> CareerHistory { get; init; }
    public PlayerSeasonStats CurrentSeason { get; set; }
    public List<PlayerSeasonStats> SeasonHistory { get; init; }
    public PlayerMarketValue? MarketValue { get; init; }
}

public class PlayerSeasonStats
{
    public string Season { get; init; }
    public string TeamId { get; init; }
    public string LeagueId { get; init; }
    public int Appearances { get; set; }
    public int Starts { get; set; }
    public int MinutesPlayed { get; set; }
    public int Goals { get; set; }
    public int Assists { get; set; }
    public decimal XG { get; set; }
    public decimal XA { get; set; }
    public int Shots { get; set; }
    public int ShotsOnTarget { get; set; }
    public int KeyPasses { get; set; }
    public int PassesCompleted { get; set; }
    public int PassesAttempted { get; set; }
    public decimal PassAccuracy { get; set; }
    public int TacklesWon { get; set; }
    public int Interceptions { get; set; }
    public int Blocks { get; set; }
    public int Clearances { get; set; }
    public int AerialDuelsWon { get; set; }
    public int AerialDuelsLost { get; set; }
    public int FoulsCommitted { get; set; }
    public int FoulsSuffered { get; set; }
    public int YellowCards { get; set; }
    public int RedCards { get; set; }
    public int CleanSheets { get; set; }
    public int Saves { get; set; }
    public int PenaltiesSaved { get; set; }
    public decimal SavePercentage { get; set; }
    public int GoalsConceded { get; set; }
}

public class PlayerStatsCalculator
{
    private readonly IPlayerRepository _repo;

    public async Task<PlayerSeasonStats> CalculateSeasonStatsAsync(
        string playerId, string season)
    {
        var appearances = await _repo
            .GetPlayerAppearancesAsync(playerId, season);
        var stats = new PlayerSeasonStats
        {
            Season = season,
            Appearances = appearances.Count,
            Starts = appearances.Count(a => a.IsStarter),
            MinutesPlayed = appearances.Sum(a => a.MinutesPlayed)
        };

        foreach (var appearance in appearances)
        {
            var matchEvents = await _repo
                .GetPlayerEventsAsync(playerId, appearance.MatchId);
            foreach (var evt in matchEvents)
            {
                switch (evt)
                {
                    case GoalEvent:
                        stats.Goals++;
                        break;
                    case AssistEvent:
                        stats.Assists++;
                        break;
                    case ShotEvent shot:
                        stats.Shots++;
                        if (shot.OnTarget) stats.ShotsOnTarget++;
                        stats.XG += shot.XGValue;
                        break;
                }
            }
        }

        stats.PassAccuracy = stats.PassesAttempted > 0
            ? Math.Round(
                (decimal)stats.PassesCompleted /
                stats.PassesAttempted * 100, 1)
            : 0;

        return stats;
    }
}

15. Fantasy Sports Integration

Fantasy sports is a major engagement driver — users who manage fantasy teams check scores far more frequently. The platform must calculate fantasy points in real-time as match events occur, update leaderboards instantly, and support transfers, captain picks, and league management.

Fantasy Points Engine

C#
public class FantasyPointsEngine
{
    private readonly IFantasyRepository _repo;
    private readonly IMatchEventBus _eventBus;

    private static readonly Dictionary<string, decimal>
        FootballPoints = new()
    {
        ["goal"] = 6m,
        ["assist"] = 3m,
        ["clean_sheet"] = 4m,
        ["goals_conceded"] = -1m,
        ["yellow_card"] = -1m,
        ["red_card"] = -3m,
        ["own_goal"] = -2m,
        ["penalty_saved"] = 5m,
        ["penalty_missed"] = -2m,
        ["save_per_3"] = 1m,
        ["bonus_top_performer"] = 1m,
    };

    public async Task<decimal> CalculatePointsAsync(
        string playerId, string matchId)
    {
        var events = await _repo
            .GetPlayerMatchEventsAsync(playerId, matchId);
        var profile = await _repo
            .GetPlayerProfileAsync(playerId);
        decimal points = 0;

        if (events.Any()) points += 2m;

        var minutes = events.MaxOrDefault(e => e.Minute);
        if (minutes >= 60) points += 1m;

        foreach (var evt in events)
        {
            if (FootballPoints.TryGetValue(
                evt.EventType.ToString().ToLower(),
                out var eventPoints))
            {
                points += eventPoints;
            }
            if (profile.Position == "DEF" && evt is GoalEvent)
                points += 1m;
            if (profile.Position == "GK" && evt is SaveEvent save && save.IsPenaltySave)
                points += FootballPoints["penalty_saved"];
        }

        var fantasyEntry = await _repo
            .GetFantasyEntryForPlayerAsync(playerId, matchId);
        if (fantasyEntry?.IsCaptain == true) points *= 2;

        return points;
    }

    public async Task ProcessMatchEventAsync(
        string matchId, MatchEvent evt)
    {
        var involvedPlayers = GetInvolvedPlayers(evt);
        foreach (var playerId in involvedPlayers)
        {
            var points = await CalculatePointsAsync(playerId, matchId);
            await _repo.UpsertFantasyPointsAsync(
                playerId, matchId, points);
            await UpdateLeagueStandingsAsync(playerId, points);
            await _eventBus.PublishAsync(new FantasyPointsUpdated
            {
                PlayerId = playerId,
                MatchId = matchId,
                Points = points
            });
        }
    }
}

Fantasy Points Table

ActionFWDMIDDEFGKP
Goal4566
Assist3333
Clean Sheet-144
Goals Conceded---1-1
Yellow Card-1-1-1-1
Red Card-3-3-3-3
Penalty Saved---5
Penalty Missed-2-2-2-2

16. Odds & Betting Data Feed

Betting odds are a critical feature that drives significant revenue through affiliate partnerships. The platform aggregates odds from multiple bookmakers, displays them alongside match data, and tracks line movements. This requires sub-second data freshness and strict compliance with gambling regulations.

Odds Data Model

C#
public class MatchOdds
{
    public string MatchId { get; init; }
    public DateTimeOffset LastUpdated { get; init; }
    public Dictionary<string, BookmakerOdds> Bookmakers { get; init; }
    public MarketOdds BestOdds { get; init; }
    public OddsMovementSummary Movement { get; init; }
}

public class BookmakerOdds
{
    public string BookmakerId { get; init; }
    public string BookmakerName { get; init; }
    public string LogoUrl { get; init; }
    public AffiliateLink AffiliateUrl { get; init; }
    public decimal HomeWin { get; set; }
    public decimal Draw { get; set; }
    public decimal AwayWin { get; set; }
    public Dictionary<decimal, OverUnderOdds> OverUnder { get; set; }
    public decimal BtsYes { get; set; }
    public decimal BtsNo { get; set; }
    public Dictionary<string, decimal> CorrectScore { get; set; }
    public Dictionary<decimal, HandicapOdds> Handicap { get; set; }
    public List<PlayerPropOdds> PlayerProps { get; set; }
    public DateTimeOffset SnapshotTime { get; init; }
}

public class OddsMovementTracker
{
    private readonly IDatabase _redis;

    public async Task TrackMovementAsync(
        string matchId, string bookmakerId, MarketOdds newOdds)
    {
        var key = $"odds_history:{matchId}:{bookmakerId}";
        var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
        var entries = new SortedSetEntry[]
        {
            new(JsonSerializer.Serialize(newOdds), timestamp)
        };
        await _redis.SortedSetAddAsync(key, entries);
        await _redis.SortedSetRemoveRangeByRankAsync(key, 0, -1001);

        var previous = await GetPreviousOddsAsync(matchId, bookmakerId);
        if (previous != null && DetectSignificantMovement(previous, newOdds))
        {
            await PublishOddsAlertAsync(matchId, new OddsMovement
            {
                Bookmaker = bookmakerId,
                Market = "home_win",
                OldOdds = previous.HomeWin,
                NewOdds = newOdds.HomeWin,
                Direction = newOdds.HomeWin > previous.HomeWin ? "drift" : "shorten"
            });
        }
    }
}
Regulatory Compliance: Betting features require strict compliance with local gambling regulations. In the UK, the platform must be licensed by the UK Gambling Commission. In Germany, odds display requires a 5-second delay. In some US states, affiliate links are prohibited. The odds display service must check the user's jurisdiction and apply appropriate restrictions. Age verification (18+/21+ depending on jurisdiction) is mandatory. Responsible gambling messaging must be displayed alongside all odds. We geo-fence features per jurisdiction and maintain a compliance rules engine that's updated when regulations change.

17. Highlight Clips & Video Integration

Highlight clips — goals, red cards, key saves — are the most shared content type on the platform. They must be available within seconds of the event, work across all devices, and be deliverable to millions of simultaneous viewers. We use a server-side rendering pipeline that transcodes clips on-the-fly and caches them on a global CDN.

Video Pipeline Architecture

flowchart LR FEED[Live Video Feed] --> INGEST[Video Ingestion] INGEST --> DETECT[Event Detection] DETECT --> CLIP[Clip Extraction] CLIP --> TRANSCODE[Transcoding] TRANSCODE --> PACK[Adaptive Streaming] PACK --> CDN[CloudFront CDN] CDN --> CLIENT[Client Players] DETECT --> THUMB[Thumbnail Generation] THUMB --> CDN DETECT --> META[Metadata Enrichment] META --> ES[Elasticsearch Index]

Clip Generation Service

C#
public class HighlightClipGenerator
{
    private readonly IVideoProcessor _ffmpeg;
    private readonly ICDNUploader _cdn;
    private readonly IThumbnailGenerator _thumbnails;

    public async Task<GeneratedClip> GenerateClipAsync(
        MatchEvent evt, string videoFeedUrl)
    {
        var window = evt switch
        {
            GoalEvent => new ClipWindow
                { PreSeconds = 15, PostSeconds = 10 },
            RedCard => new ClipWindow
                { PreSeconds = 10, PostSeconds = 5 },
            PenaltyEvent => new ClipWindow
                { PreSeconds = 8, PostSeconds = 8 },
            SaveEvent => new ClipWindow
                { PreSeconds = 5, PostSeconds = 5 },
            _ => new ClipWindow
                { PreSeconds = 5, PostSeconds = 5 }
        };

        var eventTime = evt.Timestamp;
        var startTime = eventTime.AddSeconds(-window.PreSeconds);
        var endTime = eventTime.AddSeconds(window.PostSeconds);

        var clipPath = await _ffmpeg.ExtractClipAsync(
            videoFeedUrl, startTime, endTime,
            new TranscodeOptions
            {
                Formats = new[]
                {
                    new VideoFormat { Width = 1920, Height = 1080, Bitrate = "5M" },
                    new VideoFormat { Width = 1280, Height = 720, Bitrate = "3M" },
                    new VideoFormat { Width = 854, Height = 480, Bitrate = "1.5M" },
                    new VideoFormat { Width = 640, Height = 360, Bitrate = "800k" },
                },
                AudioBitrate = "128k",
                Format = "mp4",
                Codec = "h264"
            });

        var thumbnail = await _thumbnails.GenerateAsync(
            clipPath, eventTime - startTime);

        var clipId = $"{evt.MatchId}_{evt.EventType}_{evt.Minute}";
        var clipUrl = await _cdn.UploadAsync(
            $"highlights/{clipId}/playlist.m3u8", clipPath);
        var thumbUrl = await _cdn.UploadAsync(
            $"highlights/{clipId}/thumbnail.jpg", thumbnail);

        return new GeneratedClip
        {
            ClipId = clipId,
            MatchId = evt.MatchId,
            EventType = evt.EventType,
            DurationSeconds = window.PreSeconds + window.PostSeconds,
            Url = clipUrl,
            ThumbnailUrl = thumbUrl,
            Resolutions = new[] { "1080p", "720p", "480p", "360p" }
        };
    }
}

18. Social Media Integration

Social integration drives organic growth and keeps users engaged beyond scores. The platform aggregates match-related social media posts (Twitter/X, Instagram, Reddit) into a unified feed, provides sharing functionality, and allows users to react to events in real-time.

Social Features Architecture

FeatureData SourceLatencyScale
Match Social FeedTwitter API, Instagram API, Reddit API5-15s10K posts/minute per match
User ReactionsIn-app reactions<1s100K reactions/minute per match
Share to SocialOAuth integrations<2s10K shares/minute
Match CommentaryIn-house editorial team10-30s100 comments/minute per match
Live PollsIn-app voting<1s50K votes/minute

Reaction System

C#
public class MatchReactionService
{
    private readonly IDatabase _redis;

    public async Task AddReactionAsync(
        string matchId, string userId,
        string eventType, ReactionType reaction)
    {
        var key = $"reactions:{matchId}:{eventType}";
        var hllKey = $"reactions_hll:{matchId}:{eventType}";
        await _redis.HyperLogLogAddAsync(hllKey, userId);
        await _redis.SortedSetIncrementAsync(
            key, reaction.ToString(), 1);
        var totalCount = await GetTotalReactionsAsync(matchId, eventType);
        await _redis.PublishAsync(
            Channel.Literal($"match:{matchId}:reactions"),
            JsonSerializer.Serialize(new
            {
                eventType,
                reaction,
                counts = totalCount,
                timestamp = DateTimeOffset.UtcNow
            }));
    }

    public async Task<Dictionary<string, long>>
        GetTotalReactionsAsync(string matchId, string eventType)
    {
        var key = $"reactions:{matchId}:{eventType}";
        var entries = await _redis.SortedSetRangeByRankWithScoresAsync(
            key, 0, -1, Order.Descending);
        return entries.ToDictionary(
            e => e.Element.ToString(),
            e => (long)e.Score);
    }
}

19. Multi-Sport Support

Supporting 50+ sports requires a plugin-based architecture where each sport is implemented as an isolated module. The core platform handles common concerns (authentication, caching, WebSocket delivery), while sport modules handle sport-specific logic (event types, statistics, scoring rules).

Sport Module Architecture

graph TB subgraph "Core Platform" AUTH[Authentication] CACHE[Cache Layer] WS_DELIVERY[WebSocket Delivery] NOTIF[Notifications] CDN_CORE[CDN] end subgraph "Sport Modules" subgraph "Football Module" FE[Football Events] FS[Football Stats] FXG[Football xG] end subgraph "Basketball Module" BE[Basketball Events] BS[Basketball Stats] BQ[Quarter Tracking] end subgraph "Cricket Module" CE[Cricket Events] CS[Cricket Stats] CO[Over Tracking] end subgraph "Tennis Module" TE[Tennis Events] TS[Tennis Stats] TG[Game/Set Tracking] end end subgraph "Sport Registry" REG[ISportModule Registry] end AUTH --> REG CACHE --> REG WS_DELIVERY --> REG FE --> REG BE --> REG CE --> REG TE --> REG

Sport Module Interface

C#
public interface ISportModule
{
    SportType Sport { get; }
    string Name { get; }
    Type EventType { get; }
    Type StatsType { get; }
    Type MatchStateType { get; }
    MatchEvent ProcessRawEvent(RawMatchEvent raw);
    object CalculateStats(MatchState state);
    object CalculateStandings(List<MatchResult> results);
    ValidationResult ValidateEvent(MatchEvent evt);
    bool IsMatchComplete(MatchState state);
    EventType MapProviderEventType(string providerType);
}

public class FootballModule : ISportModule
{
    public SportType Sport => SportType.Football;
    public string Name => "Football (Soccer)";
    public Type EventType => typeof(FootballEvent);
    public Type StatsType => typeof(FootballStats);
    public Type MatchStateType => typeof(FootballMatchState);

    public MatchEvent ProcessRawEvent(RawMatchEvent raw)
    {
        return raw.EventType switch
        {
            "goal" => new GoalEvent
            {
                EventId = raw.Id,
                MatchId = raw.MatchId,
                ScorerId = raw球员?.Id,
                TeamId = raw.Team?.Id,
                GoalType = MapGoalType(raw.Attributes),
                Minute = raw.Clock?.Elapsed
            },
            "yellow_card" => new CardEvent
            {
                EventId = raw.Id,
                MatchId = raw.MatchId,
                PlayerId = raw球员?.Id,
                TeamId = raw.Team?.Id,
                CardColor = CardColor.Yellow
            },
        };
    }

    public bool IsMatchComplete(MatchState state)
    {
        var football = (FootballMatchState)state;
        return football.Status == MatchStatus.FullTime ||
               football.Status == MatchStatus.PenaltyShootout;
    }
}

public class SportModuleRegistry
{
    private readonly Dictionary<SportType, ISportModule> _modules;

    public SportModuleRegistry(IEnumerable<ISportModule> modules)
    {
        _modules = modules.ToDictionary(m => m.Sport);
    }

    public ISportModule GetModule(SportType sport)
    {
        if (!_modules.TryGetValue(sport, out var module))
            throw new NotSupportedException($"Sport {sport} is not supported");
        return module;
    }
}

Supported Sports

SportPriorityEvents/MatchStats ComplexityProvider
Football (Soccer)P050-200High (xG, heat maps)Sportradar, Opta
Basketball (NBA)P0200-400High (play-by-play)Sportradar, NBA API
CricketP0300-600Very High (ball-by-ball)Sportradar, ESPNcricinfo
TennisP0100-300High (point-by-point)Sportradar, WTA/ATP
American Football (NFL)P1150-200High (play-by-play)NFL API, Sportradar
Baseball (MLB)P1250-350Very High (Sabermetrics)MLB API, Sportradar
RugbyP150-150MediumSportradar
Ice Hockey (NHL)P1150-250High (Corsi, Fenwick)NHL API, Sportradar
MMA/UFCP210-30LowUFC API
F1 RacingP250-100High (telemetry)F1 API

20. Offline-First Mobile App

Mobile users often have spotty connectivity — on the subway, in a stadium with poor signal, or on a rural train. The mobile app must show cached data immediately on launch, sync in the background, and gracefully handle offline scenarios while clearly indicating data freshness.

Offline-First Architecture

flowchart TB subgraph "Mobile App" UI[UI Layer] STORE[Local Store] SYNC[Sync Manager] WS_CLIENT[WebSocket Client] QUEUE[Outbox Queue] end subgraph "Backend" API[REST API] WS_SERVER[WebSocket Server] PUSH[Push Notifications] end UI --> STORE UI --> WS_CLIENT STORE --> SYNC SYNC -->|Online| API SYNC -->|Online| WS_SERVER SYNC -->|Offline| QUEUE QUEUE -->|When Online| API PUSH -->|Background| WS_CLIENT

Local Storage Schema (SQLite)

C#
public class LocalDatabase
{
    public async Task InitializeAsync()
    {
        await _db.ExecuteAsync(@"
            CREATE TABLE IF NOT EXISTS matches (
                match_id TEXT PRIMARY KEY,
                sport TEXT NOT NULL,
                league_id TEXT NOT NULL,
                home_team TEXT NOT NULL,
                away_team TEXT NOT NULL,
                home_score INTEGER DEFAULT 0,
                away_score INTEGER DEFAULT 0,
                status TEXT NOT NULL,
                kickoff_time TEXT NOT NULL,
                venue TEXT,
                last_updated TEXT NOT NULL,
                is_live INTEGER DEFAULT 0
            );
            CREATE TABLE IF NOT EXISTS match_events (
                event_id TEXT PRIMARY KEY,
                match_id TEXT NOT NULL,
                event_type TEXT NOT NULL,
                minute INTEGER,
                player_name TEXT,
                team_id TEXT,
                event_data TEXT NOT NULL,
                timestamp TEXT NOT NULL,
                FOREIGN KEY (match_id) REFERENCES matches(match_id)
            );
            CREATE TABLE IF NOT EXISTS fixtures (
                fixture_id TEXT PRIMARY KEY,
                league_id TEXT NOT NULL,
                matchday INTEGER,
                home_team TEXT NOT NULL,
                away_team TEXT NOT NULL,
                kickoff_time TEXT NOT NULL,
                venue TEXT,
                last_updated TEXT NOT NULL
            );
            CREATE TABLE IF NOT EXISTS standings (
                team_id TEXT PRIMARY KEY,
                league_id TEXT NOT NULL,
                position INTEGER,
                team_name TEXT NOT NULL,
                points INTEGER DEFAULT 0,
                played INTEGER DEFAULT 0,
                won INTEGER DEFAULT 0,
                drawn INTEGER DEFAULT 0,
                lost INTEGER DEFAULT 0,
                goals_for INTEGER DEFAULT 0,
                goals_against INTEGER DEFAULT 0,
                form TEXT,
                last_updated TEXT NOT NULL
            );
        ");
    }
}

public class OfflineSyncManager
{
    private readonly LocalDatabase _local;
    private readonly HttpClient _api;
    private readonly IConnectivity _connectivity;

    public async Task SyncOnConnectAsync()
    {
        if (!_connectivity.IsConnected) return;
        await FlushOutboxAsync();
        var liveMatches = await _local.GetLiveMatchesAsync();
        foreach (var match in liveMatches)
        {
            var events = await _api.GetFromJsonAsync<
                List<MatchEventDto>>(
                $"/api/matches/{match.MatchId}/events?since={match.LastUpdated:O}");
            if (events?.Any() == true)
            {
                await _local.UpsertMatchEventsAsync(
                    match.MatchId, events);
                await _local.UpdateMatchScoreAsync(
                    match.MatchId,
                    events.Last().HomeScore,
                    events.Last().AwayScore);
            }
        }
        if (await ShouldSyncFixturesAsync())
        {
            var fixtures = await _api
                .GetFromJsonAsync<List<FixtureDto>>(
                    "/api/fixtures/upcoming?limit=100");
            if (fixtures != null)
                await _local.SyncFixturesAsync(fixtures);
        }
        if (await ShouldSyncStandingsAsync())
        {
            var standings = await _api
                .GetFromJsonAsync<List<StandingDto>>(
                    "/api/standings/top-leagues");
            if (standings != null)
                await _local.SyncStandingsAsync(standings);
        }
    }
}

Offline Indicators

ScenarioUI IndicatorBehavior
Full connectivityLive dot (green)Real-time WebSocket updates
Intermittent connectivityYellow dot, "Updates may be delayed"Periodic polling, retry on failure
Fully offlineRed dot, "Offline - showing cached data"Local cache only, timestamp shown
Stale data (>5min)"Updated X minutes ago" badgeBackground sync when available

21. CDN & Static Asset Delivery

Static assets — team crests, player photos, league logos, app icons, highlight clip thumbnails — account for 60%+ of bandwidth. A global CDN reduces origin load by 95%+ and ensures sub-100ms load times worldwide.

CDN Strategy

Asset TypeTTLInvalidationFormatCache Key
Team crests7 daysOn logo changeSVG + PNG fallbackTeam ID + version
Player photos24 hoursOn photo updateWebP + JPEG fallbackPlayer ID + version
League logos30 daysOn logo changeSVG + PNG fallbackLeague ID + version
Highlight clips24 hoursNever (immutable)HLS (adaptive bitrate)Clip ID
JavaScript bundles1 yearDeploy-timeES modulesContent hash
CSS1 yearDeploy-timeMinifiedContent hash
Font files365 daysNeverWOFF2Font name + version
C#
public class CDNAssetService
{
    private readonly ICloudFront _cdn;
    private readonly IS3 _s3;

    public async Task<string> GetTeamCrestUrlAsync(
        string teamId, int size = 128)
    {
        var key = $"assets/crests/{teamId}/{size}w.webp";
        if (await _cdn.HeadObjectAsync(key) != null)
            return _cdn.GetPublicUrl(key);

        var original = await _s3.GetObjectAsync(
            "sports-assets",
            $"assets/crests/original/{teamId}.svg");

        var optimized = await ImageProcessor.ResizeAndConvertAsync(
            original.Body,
            new ResizeOptions
            {
                Width = size,
                Format = WebP,
                Quality = 85,
                FitMode = Contain
            });

        await _s3.PutObjectAsync("sports-assets", key, optimized,
            new PutObjectOptions
            {
                ContentType = "image/webp",
                CacheControl = "public, max-age=604800"
            });

        return _cdn.GetPublicUrl(key);
    }
}

22. Monitoring, Observability & Alerting

During a World Cup Final, a 30-second score outage affects 50 million users and generates thousands of social media complaints. The monitoring system must detect anomalies within seconds and alert the on-call team immediately. We use the three pillars of observability: metrics (Prometheus/Grafana), logs (ELK), and traces (Jaeger/Tempo).

Critical Metrics

MetricAlert ThresholdImpact
Ingestion latency (P99)> 5 secondsScores delayed for users
WebSocket fan-out latency (P99)> 3 secondsUsers see stale scores
WebSocket connection failures> 1% error rateUsers cannot connect
Kafka consumer lag> 10,000 messagesEvents backing up
Provider API response time> 10 secondsData delayed or missing
Notification delivery latency> 10 secondsStale push notifications
Redis memory usage> 80%Cache eviction risk
PostgreSQL replication lag> 5 secondsStale read data
API error rate (5xx)> 0.1%User-facing errors
API latency (P99)> 500msSlow page loads

Distributed Tracing

C#
public class EventProcessingTracer
{
    private readonly ITracer _tracer;

    public async Task<ProcessedEvent> TraceProcessingAsync(
        RawMatchEvent rawEvent)
    {
        using var span = _tracer.BuildSpan("process_match_event")
            .WithTag("match.id", rawEvent.MatchId)
            .WithTag("event.type", rawEvent.EventType.ToString())
            .WithTag("provider", rawEvent.Provider)
            .WithTag("sport", rawEvent.Sport.ToString())
            .StartActive();

        using (var validateSpan = _tracer
            .BuildSpan("validate").StartActive())
        {
            var result = _validator.Validate(rawEvent);
            validateSpan.Span.SetTag("valid", result.IsValid);
            if (!result.IsValid)
                return ProcessedEvent.Rejected(result.Errors);
        }

        using (var enrichSpan = _tracer
            .BuildSpan("enrich").StartActive())
        {
            await _enricher.EnrichAsync(rawEvent);
            enrichSpan.Span.SetTag(
                "enrichments_applied", rawEvent.Enrichments.Count);
        }

        using (var kafkaSpan = _tracer
            .BuildSpan("kafka_produce").StartActive())
        {
            await _producer.ProduceAsync("processed-events",
                new Message<string, MatchEvent>
                {
                    Key = rawEvent.MatchId,
                    Value = rawEvent
                });
            kafkaSpan.Span.SetTag("kafka.topic", "processed-events");
        }

        return ProcessedEvent.Accepted(rawEvent);
    }
}

Runbook for Common Incidents

Incident Response Playbook: 1) Provider outage - Switch to secondary provider within 30s (automated), notify team. 2) Kafka consumer lag spike - Auto-scale consumer group, investigate slow consumer. 3) WebSocket gateway overload - Scale out gateway fleet, shed lowest-priority subscriptions. 4) Redis memory critical - Evict cold match data, scale Redis cluster. 5) Database replication lag - Check for long-running queries, promote replica if needed.

23. Security & Compliance

Threat Model

ThreatAttack VectorImpactMitigation
Data scrapingAutomated API requestsData theft, competitive intelligenceRate limiting, bot detection, API keys
Score manipulationCompromised provider feedWrong scores shown to millionsMulti-provider validation, anomaly detection
Account takeoverCredential stuffingFantasy team theft, account abuseMFA, rate limiting, CAPTCHA
DDoS attackVolumetric attackPlatform unavailable during major eventAWS Shield, CloudFront rate limiting
API key leakClient-side exposureUnauthorized API accessShort-lived tokens, key rotation
GDPR violationImproper data handlingRegulatory finesData minimization, consent management

Authentication & Authorization

C#
public class SportsApiAuthHandler : AuthenticationHandler<JwtBearerOptions>
{
    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var token = Request.Headers.Authorization
            .FirstOrDefault()?.Split(" ").Last();
        if (string.IsNullOrEmpty(token))
            return AuthenticateResult.Fail("No token provided");

        var handler = new JwtSecurityTokenHandler();
        var key = await _signingKeyProvider.GetCurrentKeyAsync();
        try
        {
            var principal = handler.ValidateToken(token,
                new TokenValidationParameters
                {
                    ValidateIssuer = true,
                    ValidIssuer = "sports-platform",
                    ValidateAudience = true,
                    ValidAudience = "sports-api",
                    ValidateIssuerSigningKey = true,
                    IssuerSigningKey = key,
                    ValidateLifetime = true,
                    ClockSkew = TimeSpan.FromSeconds(30)
                }, out var validatedToken);

            var claims = principal.Claims.ToDictionary(
                c => c.Type, c => c.Value);
            var context = new AuthenticationContext(
                UserId: claims.GetValueOrDefault("sub"),
                Tier: claims.GetValueOrDefault("tier", "free"),
                Scopes: claims.GetValueOrDefault("scope", "")
                    .Split(' '));
            HttpContext.Items["AuthContext"] = context;
            return AuthenticateResult.Success(
                new AuthenticationTicket(principal, Scheme.Name));
        }
        catch (SecurityTokenException)
        {
            return AuthenticateResult.Fail("Invalid token");
        }
    }
}

public class RateLimitingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IDatabase _redis;

    private static readonly Dictionary<string, RateLimit>
        TierLimits = new()
    {
        ["free"] = new(100, TimeSpan.FromMinutes(1)),
        ["pro"] = new(1000, TimeSpan.FromMinutes(1)),
        ["enterprise"] = new(10000, TimeSpan.FromMinutes(1)),
    };

    public async Task InvokeAsync(HttpContext context)
    {
        var authCtx = context.Items["AuthContext"] as AuthContext;
        var tier = authCtx?.Tier ?? "free";
        var limit = TierLimits[tier];
        var key = $"ratelimit:{authCtx?.UserId ?? "anon"}";
        var count = await _redis.StringIncrementAsync(key);
        if (count == 1)
            await _redis.KeyExpireAsync(key, limit.Window);
        if (count > limit.MaxRequests)
        {
            context.Response.StatusCode = 429;
            context.Response.Headers.RetryAfter =
                ((int)limit.Window.TotalSeconds).ToString();
            await context.Response.WriteAsJsonAsync(new
            {
                error = "Rate limit exceeded",
                tier,
                limit = limit.MaxRequests,
                window = limit.Window.TotalSeconds
            });
            return;
        }
        await _next(context);
    }
}

GDPR & Data Privacy

  • Data minimization: We only collect data necessary for functionality. User email for auth, preferred teams for personalization, device token for notifications.
  • Right to deletion: Users can delete their account and all associated data within 30 days. A background job removes data from all stores (PostgreSQL, Redis, Elasticsearch, S3).
  • Data portability: Users can export their data (fantasy teams, watch history, preferences) as JSON.
  • Consent management: Cookie consent, notification permission, and data processing consent are tracked per-user with timestamps.
  • Data residency: EU users' data is stored in eu-west-1 (Ireland). Users in GDPR jurisdictions have stricter consent requirements.

24. API Design

The REST API follows a resource-oriented design with consistent patterns across all endpoints. All responses use a standard envelope format with pagination cursors, and the API versioning uses URL path segments (/v1/, /v2/).

API Endpoints

MethodEndpointDescriptionCache
GET/v1/matches/liveAll currently live matches5s
GET/v1/matches/{id}Match details and current score5s
GET/v1/matches/{id}/eventsMatch events timeline5s
GET/v1/matches/{id}/statsMatch statistics10s
GET/v1/matches/{id}/lineupsTeam lineups30s
GET/v1/fixturesUpcoming fixtures (filtered)60s
GET/v1/fixtures/{id}Fixture details60s
GET/v1/leagues/{id}/standingsLeague table30s
GET/v1/players/{id}Player profile5min
GET/v1/players/{id}/statsPlayer season stats5min
GET/v1/sportsList of supported sports1 hour
GET/v1/leaguesList of leagues1 hour
WS/ws/v1/streamWebSocket streaming endpointN/A

Response Envelope

JSON
{
    "status": "success",
    "data": {
        "matchId": "match_abc123",
        "sport": "football",
        "status": "in_play",
        "homeTeam": {
            "id": "team_ars",
            "name": "Arsenal",
            "score": 2,
            "crest": "https://cdn.example.com/crests/team_ars.svg"
        },
        "awayTeam": {
            "id": "team_mun",
            "name": "Manchester United",
            "score": 1,
            "crest": "https://cdn.example.com/crests/team_mun.svg"
        },
        "minute": 67,
        "period": "2ndHalf",
        "events": [
            {
                "eventId": "evt_001",
                "type": "goal",
                "minute": 12,
                "team": "home",
                "player": { "id": "p_saka", "name": "Bukayo Saka" },
                "assist": { "id": "p_ode", "name": "Martin Odegaard" },
                "description": "Right-footed shot from the centre of the box"
            }
        ],
        "statistics": {
            "possession": { "home": 58, "away": 42 },
            "shots": { "home": 15, "away": 8 },
            "shotsOnTarget": { "home": 7, "away": 3 },
            "corners": { "home": 8, "away": 4 },
            "fouls": { "home": 9, "away": 12 }
        }
    },
    "meta": {
        "timestamp": "2026-07-12T15:30:00Z",
        "cacheHit": false,
        "latencyMs": 45
    }
}

WebSocket Protocol

JSON
// Client to Server: Subscribe
{
    "type": "subscribe",
    "channel": "match:abc123:events"
}
// Client to Server: Subscribe to stats
{
    "type": "subscribe",
    "channel": "match:abc123:stats"
}
// Client to Server: Heartbeat
{
    "type": "ping"
}
// Server to Client: New goal event
{
    "type": "event",
    "channel": "match:abc123:events",
    "data": {
        "eventId": "evt_456",
        "type": "goal",
        "minute": 72,
        "scorer": { "id": "p_havertz", "name": "Kai Havertz" },
        "assist": { "id": "p_saka", "name": "Bukayo Saka" },
        "team": "home",
        "score": { "home": 3, "away": 1 }
    }
}
// Server to Client: Stats update
{
    "type": "stats_update",
    "channel": "match:abc123:stats",
    "data": {
        "possession": { "home": 61, "away": 39 },
        "shots": { "home": 18, "away": 9 },
        "lastUpdated": "2026-07-12T16:12:00Z"
    }
}
// Server to Client: Keepalive
{
    "type": "pong",
    "ts": "2026-07-12T16:12:30Z"
}

25. Cost Estimation

Costs vary dramatically between off-season (minimal live matches) and peak events (World Cup). We design for peak capacity but use auto-scaling to reduce costs during quiet periods.

Monthly Cost Breakdown (Peak Period - World Cup)

ServiceSpecificationMonthly Cost
Kafka (MSK)6 brokers, 500K msgs/sec,200
PostgreSQL (RDS)db.r6g.2xlarge, Multi-AZ, 2TB,800
Redis (ElastiCache)Cluster mode, 6 shards, r6g.xlarge,600
Elasticsearch6 nodes, r6g.xlarge.elasticsearch,200
WebSocket Gateways500 instances (c6g.2xlarge),000
REST API Servers50 instances (c6g.xlarge),000
Event Processing100 instances (c6g.xlarge),000
CloudFront CDN50TB transfer/month,250
S3 Storage50TB (assets, analytics),150
SQS (Notifications)500M messages/month
FCM/APNs (Push)2B notifications/month
Data Provider FeesSportradar + Opta + custom,000
Monitoring (Datadog)Full observability stack,000
DNS (Route53)High-volume queries
WAF & ShieldDDoS protection,000
Lambda/ServerlessNotification batching, image processing

Cost Summary

ScenarioMonthly CostAnnual Cost
Off-season (quiet)~,000-
Normal season~,000-
Peak (World Cup)~,000-
Blended Annual-~,000
Cost Optimization: Spot instances for event processing (60% savings), reserved instances for always-on services (40% savings), auto-scaling to zero for non-peak hours, S3 Intelligent-Tiering for analytics data, and CloudFront Origin Shield to reduce origin fetches by 80%. Total savings: ~,000/year compared to on-demand pricing.

26. Testing Strategy

Test Pyramid

graph TB E2E[End-to-End Tests
50 tests
~15 min] --> INT[Integration Tests
200 tests
~10 min] INT --> UNIT[Unit Tests
2000 tests
~3 min] style E2E fill:#f85149,color:#fff style INT fill:#d29922,color:#fff style UNIT fill:#3fb950,color:#fff

Load Testing Scenarios

ScenarioTargetSuccess Criteria
WebSocket connections50M concurrentAll connected within 5 minutes, <0.1% failure
WebSocket messages/second1M msgs/secP99 latency < 3 seconds end-to-end
REST API throughput100K requests/secP99 latency < 200ms
Ingestion throughput10K events/secAll events processed within 2 seconds
Notification delivery50M notifications in 30s95% delivered within 5 seconds
Database failoverPrimary downRead serving within 30s, write recovery within 2 min
Kafka broker failure2 of 6 brokers downAll events processed within 10 seconds

Integration Test Example

C#
[TestClass]
public class LiveMatchIntegrationTests
{
    private TestContainersFixture _containers;
    private HttpClient _api;
    private WebSocketClient _ws;

    [TestInitialize]
    public async Task Setup()
    {
        _containers = new TestContainersFixture(
            "docker-compose.test.yml");
        await _containers.StartAsync();
        _api = new HttpClient
        {
            BaseAddress = new Uri("http://localhost:5001")
        };
    }

    [TestMethod]
    public async Task Should_Update_Score_Via_WebSocket()
    {
        _ws = new WebSocketClient(
            "ws://localhost:5001/ws/v1/stream");
        await _ws.ConnectAsync();
        var matchId = "test_match_001";
        await _ws.SendAsync(new
        {
            type = "subscribe",
            channel = $"match:{matchId}:events"
        });
        var goalEvent = new
        {
            matchId,
            eventType = "goal",
            minute = 23,
            teamId = "home",
            playerId = "player_001",
            provider = "test"
        };
        var response = await _api.PostAsJsonAsync(
            "/api/v1/ingest/events", goalEvent);
        Assert.AreEqual(HttpStatusCode.Accepted,
            response.StatusCode);
        var message = await _ws.WaitForMessageAsync(
            TimeSpan.FromSeconds(5));
        Assert.IsNotNull(message);
        Assert.AreEqual("event", message.Type);
        Assert.AreEqual("goal",
            message.Data.GetProperty("type").GetString());
        await Task.Delay(500);
        var match = await _api.GetFromJsonAsync<MatchDto>(
            $"/api/v1/matches/{matchId}");
        Assert.AreEqual(1, match.HomeScore);
    }

    [TestMethod]
    public async Task Should_Batch_Multiple_Goals()
    {
        var matchId = "test_match_002";
        for (int i = 0; i < 3; i++)
        {
            await _api.PostAsJsonAsync(
                "/api/v1/ingest/events", new
                {
                    matchId,
                    eventType = "goal",
                    minute = 10 + i * 5,
                    teamId = "home",
                    playerId = $"player_{i:D3}",
                    provider = "test"
                });
            await Task.Delay(1000);
        }
        await Task.Delay(6000);
        var notifications = await GetNotificationsAsync(matchId);
        Assert.AreEqual(1, notifications.Count);
        Assert.IsTrue(notifications[0].Body.Contains("3 Goals"));
    }

    [TestCleanup]
    public async Task Cleanup()
    {
        _ws?.Dispose();
        await _containers.StopAsync();
    }
}
Testing Coverage Targets: Domain logic (event processing, statistics calculation, fantasy points) requires 90%+ unit test coverage. Integration tests (Kafka, Redis, PostgreSQL interactions) require 80%+ coverage. End-to-end tests cover the critical path: ingestion to processing to WebSocket delivery to notification push. Load tests run weekly in staging and before every major sporting event.

27. Interview Q&A Deep Dive

Q1: How do you handle 50 million concurrent WebSocket connections?

Answer: Each WebSocket gateway node handles ~50K connections (limited by file descriptors and memory). With 50M connections, we need 1,000 gateway nodes. However, the key optimization is connection sharing — most users subscribe to the same 10-20 popular matches. We use Redis Pub/Sub as the fan-out backbone: one message per match is published to Redis, and all gateway nodes subscribed to that match receive it and fan out to their local connections. This means we publish 1 message per event per match, not 50M messages. The gateway nodes are distributed across 3 regions (US, EU, APAC) using latency-based DNS routing. Each region has its own Redis cluster, with cross-region replication for failover. Auto-scaling groups track connection count per node and scale out when utilization exceeds 70%.

Q2: How do you ensure score accuracy when receiving data from multiple providers?

Answer: We use a priority-weighted voting system. Each provider is assigned a priority level (Sportradar=1, Opta=2, ESPN=3). When two providers report the same event within a 10-second window, we deduplicate using a composite key (match_id, event_type, minute, team_id). When providers disagree (e.g., Sportradar says 2-1 but Opta says 2-0), we take the higher-priority provider's score but flag the discrepancy for human review. We also run a periodic reconciliation job that compares scores across providers every 60 seconds for live matches. If a provider's data deviates from consensus, we down-weight their priority. The system has a "provider health dashboard" that tracks accuracy metrics per provider per sport per league.

Q3: How do you handle a sudden traffic spike during a World Cup Final?

Answer: The World Cup Final generates 50M+ concurrent connections. Pre-scaling is essential — we don't rely on auto-scaling to react in time. Before the match, we: 1) Pre-warm all WebSocket gateway instances to maximum capacity. 2) Pre-populate Redis caches with fixture data, team info, and player profiles. 3) Scale Kafka partitions to handle 10x normal throughput. 4) Activate CloudFront Shield Advanced and WAF rules. During the match, we monitor real-time metrics and proactively scale if connection growth exceeds predictions. For worst-case scenarios, we have a "graceful degradation" mode that reduces update frequency (every 5 seconds instead of real-time) to reduce backend load by 80% while keeping the platform responsive.

Q4: How do you design the event sourcing model for match state?

Answer: The current match state (score, cards, substitutions) is always derived by replaying events from kickoff. This provides: 1) Complete audit trail — any discrepancy can be traced to the exact event. 2) Time travel — we can reconstruct the state at any point in the match. 3) Easy debugging — if the score is wrong, we find the offending event and replay from there. The state is cached in Redis for fast reads and persisted to PostgreSQL for durability. When a new event arrives, we validate it against the current state (e.g., can't have a 4th substitution in normal time), apply it, update the cache, and publish to the fan-out layer. The event store is immutable — events are never deleted or modified, only appended.

Q5: How do you handle late-arriving data (e.g., VAR decisions)?

Answer: VAR decisions can reverse events minutes after they occur. Our system handles this through an "event correction" mechanism: 1) When a VAR decision reverses a goal, a new event of type GoalDisallowed is emitted, referencing the original GoalEvent. 2) The match state processor applies the correction: score decrements, the original goal event is marked as "reversed", and the timeline shows the reversal. 3) Clients receive a state_correction WebSocket message with the corrected state. 4) Push notifications for the original goal cannot be un-sent, so we send a follow-up correction notification: "VAR Decision: Goal by [Player] has been disallowed. Score updated to 1-0."

Q6: How do you handle sport-specific scoring rules within a unified platform?

Answer: We use the Strategy pattern with a sport module registry. Each sport implements ISportModule with sport-specific event processing, statistics calculation, and match completion rules. The core platform doesn't know or care that cricket has innings while tennis has sets. It just calls the registered sport module for each operation. This makes adding a new sport a matter of implementing one interface and registering it — no changes to core code. The sport module also defines which data providers map to which event types, so the ingestion layer is sport-agnostic.

Q7: How do you keep notification delivery under 2 seconds?

Answer: The notification pipeline runs in parallel with the WebSocket fan-out, not after it. When an event is processed, two messages are published simultaneously: one to the WebSocket fan-out topic and one to the notification topic. The notification service subscribes to the notification topic, batches by user+match for 5 seconds (configurable), and sends via FCM/APNs. The 2-second target is achievable because: 1) The notification pipeline is optimized for throughput, not correctness (best-effort delivery). 2) We use persistent connections to FCM/APNs (no TCP handshake per notification). 3) The batching window is 5 seconds, but the first notification in a batch is sent immediately without waiting.

Q8: How do you handle multiple time zones for fixture display?

Answer: All kickoff times are stored in UTC. The client converts to the user's local timezone based on their device settings or profile preference. The API returns both UTC and the user's local time. For fixture lists, we group by date in the user's timezone — a match kicking off at 11:45 PM UTC on December 31st might appear under January 1st for users in UTC+1. The calendar integration generates .ics files with VTIMEZONE components so the user's calendar app handles timezone conversion correctly.

Q9: How do you handle data provider outages without affecting users?

Answer: Defense in depth: 1) Multi-provider redundancy — at least 2 providers per sport. If Sportradar goes down, we seamlessly switch to Opta within 30 seconds (automated health checks). 2) Graceful degradation — if all providers for a sport are down, we show the last known score with a "Data may be delayed" banner. We never show stale data as current. 3) Provider health monitoring — we track response times, error rates, and data freshness for each provider. If a provider's error rate exceeds 5% in a 1-minute window, we automatically failover. 4) Cached fallback — recent fixture and standings data is cached in Redis with 5-minute TTL, so even during a complete provider outage, historical data remains available.

Q10: How do you design for global low-latency delivery?

Answer: Three-layer CDN strategy: 1) CloudFront for static assets (crests, photos, JS/CSS) with 200+ edge locations. 2) Regional WebSocket gateways (US, EU, APAC) with latency-based DNS routing. Users connect to the nearest region. 3) Redis clusters in each region for hot data, with cross-region async replication. The REST API uses CloudFront as a caching layer with dynamic origin shielding. For the WebSocket layer, the key insight is that users only care about latency for their subscribed matches, not all matches. So we partition the Redis Pub/Sub by region — a user in EU only receives updates for matches they're watching, routed through the EU gateway fleet.

Key Numbers to Remember

MetricValue
End-to-end latency target< 3 seconds
Peak concurrent users50M (World Cup Final)
Daily active users200M
WebSocket connections per node~50,000
Gateway nodes at peak500-1,000
Kafka partitions per topic128-256
Redis read latency< 1ms (P99)
Deduplication window10 seconds
Notification batch window5 seconds
Provider failover time< 30 seconds
Availability target99.99%
Annual infrastructure cost~,000

Pre-Interview Checklist

  • Understand event sourcing and why it's ideal for match state
  • Know the tradeoffs between Redis Pub/Sub and Kafka for fan-out
  • Design a multi-provider ingestion pipeline with deduplication
  • Explain WebSocket gateway architecture at 50M scale
  • Discuss sport-specific schema design with a plugin architecture
  • Know how to calculate league tables with tiebreakers and points deductions
  • Understand xG (Expected Goals) and advanced football analytics
  • Design a fantasy points engine that processes events in real-time
  • Discuss GDPR compliance for a global sports platform
  • Know how to handle VAR decisions and late-arriving data
  • Understand CDN caching strategies for sports assets
  • Design push notification batching to avoid notification spam

Live Sports Scoring & Stats Platform — Senior+ Guide | Ayodhyya