bOS v3.0 — Scoring Engine Technical Specification
Real-Time Fantasy Points Calculation System
Version: 3.0 | Last Updated: January 2026 | Status: Design Phase Audience: Engineering Team
Table of Contents
- Overview
- Architecture
- Event Processing Pipeline
- Scoring Rules Engine
- Sport-Specific Configurations
- Real-Time Leaderboard
- Props Resolution
- Trading Price Updates
- Settlement Engine
- Performance Requirements
1. Overview
1.1 Purpose
The Scoring Engine is the real-time brain of the bOS fantasy platform. It:
- Ingests live match events from data providers
- Calculates fantasy points for all athletes in real-time
- Updates leaderboards across all contests
- Resolves player props as outcomes are determined
- Adjusts trading market prices based on events
- Triggers settlements when matches complete
1.2 Design Principles
| Principle | Description |
|---|---|
| Event-Driven | All updates triggered by match events, not polling |
| Idempotent | Same event processed multiple times = same result |
| Auditable | Every point calculation traceable to source event |
| Configurable | Scoring rules defined in config, not code |
| Scalable | Handle 100+ concurrent matches, 1M+ entries |
1.3 System Context
2. Architecture
2.1 Component Overview
2.2 Technology Stack
| Component | Technology | Justification |
|---|---|---|
| Event Ingestion | Node.js + WebSocket | Real-time, async I/O |
| Message Queue | Apache Kafka | Ordered, durable, replayable |
3. Event Processing Pipeline
3.1 Event Types
Cricket T20 Events
| Event Type | Description | Fantasy Impact | Props Impact | Trading Impact |
|---|---|---|---|---|
ball.completed | Single delivery | Points for runs/wicket | Check thresholds | Price adjustment |
over.completed | Over finished | Maiden bonus | Economy check | Minor adjustment |
innings.completed | Innings ends | Batting bonuses | Resolve batting props | Major adjustment |
match.completed | Match ends | Final settlement | All props resolved | All markets settle |
player.milestone | 50, 100, hat-trick | Bonus points | Milestone props | Significant move |
player.out | Batsman dismissed | Duck penalty | Batting props lock | Price adjustment |
Event Schema
interface MatchEvent {
eventId: string; // Unique event identifier
matchId: string; // Match reference
eventType: EventType; // Type of event
timestamp: number; // Unix timestamp (ms)
sequence: number; // Ordered sequence number
// Event-specific payload
payload: BallEvent | OverEvent | InningsEvent | MatchEvent;
// Metadata
source: string; // Data provider
version: string; // Schema version
receivedAt: number; // When we received it
}
interface BallEvent {
innings: number;
over: number;
ball: number;
batter: {
playerId: string;
name: string;
runs: number;
ballsFaced: number;
fours: number;
sixes: number;
strikeRate: number;
};
bowler: {
playerId: string;
name: string;
runsConceded: number;
wickets: number;
overs: number;
economy: number;
maidens: number;
};
outcome: {
runs: number;
extras: number;
extraType?: 'wide' | 'noball' | 'bye' | 'legbye';
isWicket: boolean;
wicketType?: 'bowled' | 'caught' | 'lbw' | 'runout' | 'stumped';
fielder?: string;
};
matchState: {
score: number;
wickets: number;
overs: number;
runRate: number;
target?: number;
};
}
3.2 Event Validation
3.3 Deduplication Strategy
Events are deduplicated using a composite key:
Key = {matchId}:{eventType}:{innings}:{over}:{ball}:{sequence}
Redis SET with 24-hour TTL stores processed event keys.
4. Scoring Rules Engine
4.1 Rule Configuration Schema
# scoring_rules/cricket_t20.yaml
sport: cricket
format: t20
version: "2.0"
batting:
run:
points: 1
description: "Per run scored"
boundary_four:
points: 1
bonus: true
description: "Bonus for hitting a four"
boundary_six:
points: 2
bonus: true
description: "Bonus for hitting a six"
milestone_50:
points: 8
trigger:
stat: runs
threshold: 50
comparison: gte
description: "Half-century bonus"
milestone_100:
points: 16
trigger:
stat: runs
threshold: 100
comparison: gte
description: "Century bonus"
strike_rate_bonus:
conditions:
- min_balls: 10
ranges:
- min: 170
points: 6
- min: 150
max: 169.99
points: 4
- min: 130
max: 149.99
points: 2
strike_rate_penalty:
conditions:
- min_balls: 10
ranges:
- max: 50
points: -6
- min: 50.01
max: 60
points: -4
- min: 60.01
max: 70
points: -2
duck:
points: -2
trigger:
stat: runs
threshold: 0
comparison: eq
additional:
- stat: balls_faced
threshold: 1
comparison: gte
- stat: is_out
value: true
bowling:
wicket:
points: 25
description: "Per wicket taken"
exclusions:
- runout # Run outs don't count for bowler
lbw_bowled_bonus:
points: 8
trigger:
wicket_type:
- lbw
- bowled
description: "Bonus for LBW or bowled"
maiden:
points: 12
description: "Maiden over"
milestone_3_wickets:
points: 4
trigger:
stat: wickets
threshold: 3
comparison: gte
milestone_4_wickets:
points: 8
trigger:
stat: wickets
threshold: 4
comparison: gte
milestone_5_wickets:
points: 16
trigger:
stat: wickets
threshold: 5
comparison: gte
economy_bonus:
conditions:
- min_overs: 2
ranges:
- max: 5
points: 6
- min: 5.01
max: 6
points: 4
- min: 6.01
max: 7
points: 2
economy_penalty:
conditions:
- min_overs: 2
ranges:
- min: 12
points: -6
- min: 11
max: 11.99
points: -4
- min: 10
max: 10.99
points: -2
fielding:
catch:
points: 8
stumping:
points: 12
runout_direct:
points: 12
runout_indirect:
points: 6
multipliers:
captain:
factor: 2.0
description: "Captain gets 2x points"
vice_captain:
factor: 1.5
description: "Vice-captain gets 1.5x points"
4.2 Rules Engine Implementation
// Simplified Rust implementation
pub struct ScoringEngine {
rules: ScoringRules,
cache: RedisClient,
}
impl ScoringEngine {
pub async fn process_ball_event(&self, event: &BallEvent) -> Result<Vec<PointUpdate>> {
let mut updates = Vec::new();
// Calculate batter points
let batter_points = self.calculate_batter_points(event)?;
updates.push(PointUpdate {
player_id: event.batter.player_id.clone(),
points: batter_points,
breakdown: self.get_breakdown(&event.batter),
});
// Calculate bowler points
let bowler_points = self.calculate_bowler_points(event)?;
updates.push(PointUpdate {
player_id: event.bowler.player_id.clone(),
points: bowler_points,
breakdown: self.get_breakdown(&event.bowler),
});
// Calculate fielder points if applicable
if let Some(fielder) = &event.outcome.fielder {
let fielder_points = self.calculate_fielder_points(event)?;
updates.push(PointUpdate {
player_id: fielder.clone(),
points: fielder_points,
breakdown: vec![("fielding", fielder_points)],
});
}
Ok(updates)
}
fn calculate_batter_points(&self, event: &BallEvent) -> Result<f64> {
let mut points = 0.0;
let batter = &event.batter;
// Base runs
points += batter.runs as f64 * self.rules.batting.run.points;
// Boundary bonuses
points += batter.fours as f64 * self.rules.batting.boundary_four.points;
points += batter.sixes as f64 * self.rules.batting.boundary_six.points;
// Milestone bonuses (check if just crossed)
if self.just_crossed_milestone(batter, 50) {
points += self.rules.batting.milestone_50.points;
}
if self.just_crossed_milestone(batter, 100) {
points += self.rules.batting.milestone_100.points;
}
// Strike rate bonus/penalty (only at end of innings)
// Handled separately in innings_completed event
Ok(points)
}
}
5. Sport-Specific Configurations
5.1 Supported Sports
| Sport | Format | Status | Priority |
|---|---|---|---|
| Cricket | T20, ODI, Test | Active | P0 |
| Football | 90 min | Planned | P1 |
| Kabaddi | Pro Kabaddi | Planned | P2 |
| Basketball | NBA, WNBA | Future | P3 |
5.2 Cricket Formats Comparison
| Scoring Element | T20 | ODI | Test |
|---|---|---|---|
| Run | +1 | +1 | +1 |
| Boundary (4) | +1 bonus | +1 bonus | +1 bonus |
| Six | +2 bonus | +2 bonus | +2 bonus |
| Half-century | +8 | +4 | +4 |
| Century | +16 | +8 | +8 |
| Wicket | +25 | +25 | +25 |
| Maiden | +12 | +4 | +2 |
| SR Bonus (min balls) | 10 | 20 | N/A |
| Economy Bonus (min overs) | 2 | 5 | 10 |
5.3 Adding New Sports
To add a new sport:
- Create scoring rules YAML in
config/scoring_rules/{sport}_{format}.yaml - Implement event adapter for data provider
- Add event type mappings
- Create sport-specific validation rules
- Add to supported sports registry
6. Real-Time Leaderboard
6.1 Leaderboard Architecture
6.2 Redis Data Structures
# Player scores for a match
HSET match:{matchId}:players {playerId} {totalPoints}
# Entry scores for a contest
HSET contest:{contestId}:entries {entryId} {totalPoints}
# Leaderboard sorted set
ZADD contest:{contestId}:leaderboard {score} {entryId}
# Entry details (for display)
HSET entry:{entryId} userId {userId} teamName {name} captain {captainId} ...
# Get top 100
ZREVRANGE contest:{contestId}:leaderboard 0 99 WITHSCORES
# Get user's rank
ZREVRANK contest:{contestId}:leaderboard {entryId}
6.3 Update Flow
6.4 Leaderboard API Response
interface LeaderboardResponse {
contestId: string;
matchId: string;
totalEntries: number;
lastUpdated: number;
entries: LeaderboardEntry[];
userEntry?: {
rank: number;
entry: LeaderboardEntry;
prizeZone: boolean;
pointsToNextPrize: number;
};
}
interface LeaderboardEntry {
rank: number;
entryId: string;
userId: string;
username: string;
teamName: string;
points: number;
captain: {
playerId: string;
name: string;
points: number;
};
viceCaptain: {
playerId: string;
name: string;
points: number;
};
}
7. Props Resolution
7.1 Prop Types and Resolution
| Prop Type | Resolution Trigger | Example |
|---|---|---|
| Runs | Player out or innings end | Rohit 32.5+ runs |
| Wickets | Match end | Bumrah 1.5+ wickets |
| Boundaries | Player out or innings end | Kohli 3.5+ fours |
| Strike Rate | Player out (min balls) | Hardik 140+ SR |
| Economy | Match end (min overs) | Chahar under 8.0 |
| Team Total | Innings end | MI 175+ runs |
7.2 Resolution Engine
interface PropLine {
propId: string;
matchId: string;
playerId?: string;
teamId?: string;
statType: 'runs' | 'wickets' | 'boundaries' | 'strike_rate' | 'economy' | 'team_total';
line: number;
status: 'open' | 'locked' | 'resolved';
outcome?: 'over' | 'under' | 'push';
resolutionTrigger: 'player_out' | 'innings_end' | 'match_end';
resolvedAt?: number;
finalValue?: number;
}
class PropsResolver {
async onPlayerOut(event: PlayerOutEvent): Promise<void> {
// Find all props for this player that resolve on out
const props = await this.getPropsForPlayer(event.playerId, 'player_out');
for (const prop of props) {
const finalValue = await this.getFinalStat(event.playerId, prop.statType);
const outcome = this.determineOutcome(finalValue, prop.line);
await this.resolveProp(prop.propId, outcome, finalValue);
await this.settleSlips(prop.propId, outcome);
}
}
determineOutcome(value: number, line: number): 'over' | 'under' | 'push' {
if (value > line) return 'over';
if (value < line) return 'under';
return 'push'; // Exact match = refund
}
}
7.3 Slip Settlement
8. Trading Price Updates
8.1 Price Update Triggers
| Event | Price Impact | Example |
|---|---|---|
| Run scored | Minor adjustment | +0.5% for batting milestone market |
| Wicket | Moderate adjustment | -10% for batting milestone market |
| Milestone reached | Market settles | 50+ runs market settles YES |
| Match state change | Significant adjustment | Team needs 50 off 30 balls |
8.2 Probability Model
For player milestone markets (e.g., "Rohit 50+ runs"):
def calculate_probability(current_runs, balls_faced, balls_remaining, historical_sr):
"""
Calculate probability of reaching milestone based on current state.
"""
runs_needed = 50 - current_runs
if runs_needed <= 0:
return 1.0 # Already achieved
if balls_remaining <= 0:
return 0.0 # No balls left
# Expected runs based on historical strike rate
expected_runs = (balls_remaining * historical_sr) / 100
# Probability using normal distribution
# Adjust for match situation, pressure, etc.
std_dev = expected_runs * 0.4 # Volatility factor
probability = 1 - norm.cdf(runs_needed, expected_runs, std_dev)
return max(0.01, min(0.99, probability)) # Clamp to 1-99%
8.3 AMM Price Update
impl TradingEngine {
pub async fn on_ball_event(&self, event: &BallEvent) -> Result<()> {
// Find all markets affected by this event
let markets = self.get_affected_markets(event).await?;
for market in markets {
// Calculate new probability
let new_prob = self.calculate_probability(&market, event)?;
// Adjust AMM reserves to reflect new probability
self.adjust_reserves(&market, new_prob).await?;
// Publish price update
self.publish_price_update(&market).await?;
}
Ok(())
}
fn adjust_reserves(&self, market: &Market, target_prob: f64) -> Result<()> {
// Current reserves
let (yes_reserve, no_reserve) = market.reserves;
let total = yes_reserve + no_reserve;
// New reserves to achieve target probability
// Price_YES = no_reserve / total
// target_prob = new_no / total
// new_no = target_prob * total
let new_no = target_prob * total;
let new_yes = total - new_no;
market.reserves = (new_yes, new_no);
Ok(())
}
}
9. Settlement Engine
9.1 Settlement Triggers
| Mode | Trigger | Settlement Time |
|---|---|---|
| Fantasy | match.completed event | < 5 minutes |
| Props | Individual stat finalized | Real-time |
| Trading | Market close condition met | Immediate |
| Pools | Outcome confirmed | < 5 minutes |
9.2 Fantasy Settlement Flow
9.3 Prize Distribution Algorithm
interface PrizeStructure {
ranks: PrizeRank[];
totalPrizePool: number;
platformRake: number;
}
interface PrizeRank {
fromRank: number;
toRank: number;
prizePercentage?: number; // Percentage of pool
fixedPrize?: number; // Fixed amount
}
function calculatePrizes(
leaderboard: LeaderboardEntry[],
structure: PrizeStructure
): Map<string, number> {
const prizes = new Map<string, number>();
const distributablePool = structure.totalPrizePool * (1 - structure.platformRake);
for (const rank of structure.ranks) {
const entriesInRange = leaderboard.filter(
e => e.rank >= rank.fromRank && e.rank <= rank.toRank
);
if (entriesInRange.length === 0) continue;
let prizePerEntry: number;
if (rank.prizePercentage) {
const totalForRange = distributablePool * rank.prizePercentage;
prizePerEntry = totalForRange / entriesInRange.length;
} else if (rank.fixedPrize) {
prizePerEntry = rank.fixedPrize;
}
for (const entry of entriesInRange) {
prizes.set(entry.entryId, prizePerEntry);
}
}
return prizes;
}
9.4 Tie-Breaking Rules
| Priority | Rule | Description |
|---|---|---|
| 1 | Total Points | Higher points wins |
| 2 | Captain Points | Higher captain points wins |
| 3 | Vice-Captain Points | Higher VC points wins |
| 4 | Entry Time | Earlier entry wins |
| 5 | Split Prize | Equal split if still tied |
10. Performance Requirements
10.1 Latency Targets
| Operation | Target | P99 |
|---|---|---|
| Event ingestion | < 50ms | < 100ms |
| Points calculation | < 10ms | < 25ms |
| Leaderboard update | < 100ms | < 200ms |
| WebSocket push | < 50ms | < 100ms |
| End-to-end | < 500ms | < 1s |
10.2 Throughput Requirements
| Metric | Requirement |
|---|---|
| Events per second | 1,000+ |
| Concurrent matches | 100+ |
| Entries per match | 100,000+ |
| WebSocket connections | 500,000+ |
| Leaderboard updates/sec | 10,000+ |
10.3 Availability
| Metric | Target |
|---|---|
| Uptime | 99.9% |
| Recovery Time Objective (RTO) | < 5 minutes |
| Recovery Point Objective (RPO) | < 1 minute |
| Failover time | < 30 seconds |
10.4 Monitoring & Alerting
| Metric | Alert Threshold |
|---|---|
| Event processing lag | > 5 seconds |
| Leaderboard staleness | > 30 seconds |
| Error rate | > 0.1% |
| CPU usage | > 80% |
| Memory usage | > 85% |
| Redis latency | > 10ms |
Document maintained by bOS Engineering Team. Last updated January 2026.