Overview
Fantasy sports apps turn spectators into participants: users draft real players, and those players’ real-world performance determines the user’s score. This creates a hard dependency on player-level data that goes far beyond what a live score app needs. You need passing yards, three-pointers made, saves, cards, and assists — not just the final score.
The architecture challenge is that fantasy points must be recalculated every time a player’s stats change, which happens continuously during live matches. A well-designed fantasy app ingests player stats, applies a scoring ruleset, updates contest standings, and pushes the updated points to every user in the contest — all within seconds.
Key difference from a live score app: A live score app cares about match-level data (the score). A fantasy app cares about player-level data (every stat event). That means 10-50x more data points per match and a real-time scoring engine on top of your ingestion pipeline.
Fantasy App Architecture
The fantasy architecture extends the live score architecture with three new components: a player stats ingester, a points calculation engine, and a contest manager that tracks which users own which players.
┌──────────────┐ ┌───────────────┐ ┌──────────────┐
│ Player Stats │──▶│ Scoring Engine │──▶│ Contest Mgr │
│ API (upstream)│ │ (rules engine) │ │ (rankings) │
└──────────────┘ └───────────────┘ └──────────────┘
│ │ │
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌──────────────┐
│ Player Cache│ │ Points Cache│ │ WebSocket │
│ (Redis) │ │ (Redis ZSET)│ │ Fan-out │
└────────────┘ └────────────┘ └──────────────┘
│
▼
┌──────────────────┐
│ PostgreSQL │
│ (players, rosters,│
│ contests, history)│
└──────────────────┘The scoring engine is the heart of the system. It listens for player stat updates, applies the contest’s scoring ruleset, writes the resulting points to a Redis sorted set (which keeps users ranked by score in real time), and publishes the update to the WebSocket layer.
Player Data Requirements
The data you need depends on the sport, but every fantasy app requires these player-level data streams:
| Data Stream | Why It Matters | Update Frequency |
|---|---|---|
| Live player stats | Core scoring input (points, yards, goals) | Every 10-30 seconds |
| Lineup confirmations | A benched player scores zero | Pre-match + in-game |
| Injury reports | Removes a player from scoring eligibility | Minutes to hours |
| Projections | Pre-draft baseline expectations | Daily |
| Season aggregates | Form indicators for drafting | Post-match |
Injury Reports and Status Tracking
Injury data is make-or-break for fantasy. If a star player is ruled out 30 minutes before kick-off and your app still has them in user lineups scoring zero points, users will churn. You need a real-time injury feed that triggers a lineup-status recalculation.
SportsDataIO and API-Sports both provide injury endpoints. Poll them aggressively in the hour before kick-off — that is when final inactive lists are published. Cache injury status with a 60-second TTL and push updates via WebSocket so users can swap players before the lock deadline.
Live Scoring and Real-Time Points
As live stats flow in, the scoring engine applies the contest’s ruleset to convert raw stats into fantasy points. The result is written to a Redis sorted set per contest, which gives you O(log N) rank updates and lets you stream live leaderboard positions to every user in the contest.
// lib/scoring-engine.js
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
// Scoring rulesets are configurable per contest type
const SCORING_RULES = {
football: {
goals: 4, // 4 points per goal
assists: 3, // 3 per assist
clean_sheet: 4, // 4 for defenders/GK if no goals conceded
yellow_card: -1, // -1 per yellow
red_card: -3, // -3 per red
minutes_played_bonus: (mins) => (mins >= 60 ? 2 : 0),
},
basketball: {
points: 1, // 1 fantasy point per real point
rebound: 1.2,
assist: 1.5,
steal: 3,
block: 3,
turnover: -1,
double_double: 5,
triple_double: 10,
},
};
export async function calculatePlayerScore(playerId, sport, stats, contestId) {
const rules = SCORING_RULES[sport] || SCORING_RULES.football;
let points = 0;
for (const [stat, value] of Object.entries(stats)) {
if (stat in rules && typeof rules[stat] === "number") {
points += value * rules[stat];
}
}
// Bonus for minutes played (football)
if (rules.minutes_played_bonus && stats.minutes_played) {
points += rules.minutes_played_bonus(stats.minutes_played);
}
// Double-double / triple-double bonus (basketball)
if (rules.double_double && sport === "basketball") {
const cats = ["points", "rebound", "assist", "steal", "block"];
const doubles = cats.filter((c) => stats[c] >= 10).length;
if (doubles >= 3) points += rules.triple_double;
else if (doubles >= 2) points += rules.double_double;
}
// Write to the contest's sorted set: member = playerId, score = points
await redis.zadd(`contest:${contestId}:scores`, points, String(playerId));
// Publish for WebSocket fan-out
await redis.publish("fantasy:points", JSON.stringify({
contestId,
playerId,
points: Math.round(points * 100) / 100,
ts: Date.now(),
}));
return points;
}Database Design
The database stores players, contests, rosters, and historical scoring. Live points live in Redis; only finalised results are persisted to PostgreSQL. For a deeper schema treatment, see our sports database guide.
-- schema.sql — fantasy sports core tables
CREATE TABLE players (
id SERIAL PRIMARY KEY,
api_ref VARCHAR(100) UNIQUE NOT NULL,
name VARCHAR(200) NOT NULL,
team_api_ref VARCHAR(100),
position VARCHAR(20),
sport VARCHAR(20) NOT NULL,
active BOOLEAN DEFAULT TRUE
);
CREATE TABLE contests (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
sport VARCHAR(20) NOT NULL,
entry_fee DECIMAL(10,2) DEFAULT 0,
prize_pool DECIMAL(10,2) DEFAULT 0,
lock_ts TIMESTAMPTZ NOT NULL, -- roster lock deadline
status VARCHAR(20) DEFAULT 'drafting', -- drafting|locked|live|final
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE rosters (
id SERIAL PRIMARY KEY,
contest_id INT REFERENCES contests(id),
user_id INT NOT NULL,
player_id INT REFERENCES players(id),
captain BOOLEAN DEFAULT FALSE,
picked_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(contest_id, user_id, player_id)
);
CREATE TABLE player_scores (
id SERIAL PRIMARY KEY,
player_id INT REFERENCES players(id),
contest_id INT REFERENCES contests(id),
final_points DECIMAL(8,2) NOT NULL,
stats_json JSONB,
finalized_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(player_id, contest_id)
);
CREATE INDEX idx_rosters_contest ON rosters(contest_id);
CREATE INDEX idx_rosters_user ON rosters(user_id, contest_id);
CREATE INDEX idx_player_scores_contest ON player_scores(contest_id);Code Examples
Full Scoring Pipeline (Next.js Route Handler)
This endpoint receives a batch of player stat updates from the ingester, runs them through the scoring engine, updates contest rankings, and returns the updated leaderboard. The WebSocket layer picks up the published events and pushes them to contest rooms.
// app/api/fantasy/score-batch/route.js
import { NextResponse } from "next/server";
import { calculatePlayerScore } from "@/lib/scoring-engine";
import { getContestRoster } from "@/lib/db";
export async function POST(request) {
const { contestId, sport, updates } = await request.json();
// updates: [{ playerId, stats: { goals: 1, assists: 0, ... } }, ...]
const roster = await getContestRoster(contestId);
const rosterPlayerIds = new Set(rooster.map((r) => r.player_id));
const results = [];
for (const { playerId, stats } of updates) {
// Only score players that are on someone's roster
if (!rosterPlayerIds.has(playerId)) continue;
const points = await calculatePlayerScore(
playerId, sport, stats, contestId
);
results.push({ playerId, points });
}
// Fetch the updated leaderboard from the Redis sorted set
// (done in the scoring engine via zadd)
return NextResponse.json({
contestId,
scored: results.length,
results,
});
}Best Practices
Make scoring rules configurable, not hard-coded
Store rulesets in the database or a config file, not in source code. Different contest types (50/50, head-to-head, tournament) use different rules. Configurable rules let you launch new contest types without redeploying the scoring engine.
Use Redis sorted sets for live leaderboards
A sorted set (ZSET) keeps members ranked by score automatically. ZADD updates a player’s score and ZREVRANGE returns the leaderboard in O(log N) — far faster than re-sorting in your application layer on every update.
Lock rosters before scoring starts
Enforce a hard roster lock at the match start time. After the lock, no roster changes are allowed. This prevents users from swapping in players mid-match and keeps scoring deterministic. Store the lock timestamp and validate every roster mutation against it.
Reconcile live points with final stats after the match
Live stat feeds can have corrections after a match ends (a stat officially credited to a different player). Run a reconciliation job that recalculates final points from the authoritative post-match stats and updates the database. Live Redis points are a preview, not the source of truth.
Backfill and cache projections before the draft
Pre-load player projections and season aggregates before draft windows open. Users need this data to make picks, and if your API is slow during a live draft, you lose them. Cache aggressively and refresh projections once daily.
Related Guides
How to Design a Sports Database
15 min readLive Score App: Technical Architecture
18 min readCaching Strategies for Sports API Data
12 min readFind the right player stats API
Compare providers by player stats depth, injury data, and projection quality to power your fantasy app.