Bottom line: A CS2 fantasy projection is career averages fed through a scoring function you define. The API gives you the inputs (
avg_rating,avg_adr,avg_kast,kd_ratio) per player. The one step people miss is that the career-stats route needs a player UUID, not a slug, so you resolve the slug first. The scoring weights are yours to choose; this guide gives you a sensible starting point, the plumbing around it, and the two subtle bugs that silently corrupt a projection. Built on the EsportsOdds CS2 data API.
The pipeline
Four steps: turn a slug into an ID, pull that player's career stats, run them through a scoring function, and you have a projection.
Step 1: Resolve the slug to an ID
Detail routes on the API take UUIDs. You'll usually start from a human-friendly slug like s1mple, so resolve it against the players list, which accepts an exact ?slug=:
import os
import requests
BASE = "https://api.esportsodds.gg"
HEADERS = {"Authorization": f"Bearer {os.environ['ESPORTSODDS_API_KEY']}"}
def player_id(slug: str) -> str:
resp = requests.get(f"{BASE}/v1/cs2/players", params={"slug": slug},
headers=HEADERS, timeout=10)
resp.raise_for_status()
rows = resp.json()["data"]
if not rows:
raise LookupError(f"no player with slug {slug!r}")
return rows[0]["id"]
This slug-to-ID hop is the same one you use for teams and matches: any time a route wants an ID and you have a name, the list endpoint with ?slug= is the resolver.
Step 2: Pull career stats
Now fetch the career averages from /players/{id}/stats. This is a single-resource call (not paginated), and it returns the all-time numbers:
def career(slug: str) -> dict:
pid = player_id(slug)
resp = requests.get(f"{BASE}/v1/cs2/players/{pid}/stats",
headers=HEADERS, timeout=10)
resp.raise_for_status()
return resp.json()["data"]
The fields you'll use are avg_rating, avg_adr, avg_kast, kd_ratio, and matches (how many games back the averages). The averages are nullable: a player with no rated maps returns null for each, while the counting stats come back 0. Treat the two differently, which is exactly where the first bug lives.
Step 3: Score it, without eating a real zero
Here is where your fantasy format's rules live. But first, the guard. The obvious idiom for a nullable field is stats.get("avg_rating") or 1.0, and it is wrong. Python's boolean operators return an operand, and or treats 0.0 as false, so a player whose kd_ratio genuinely is 0.0 (a game with no kills) gets handed the league-average 1.0. A bad performance is silently promoted to an average one.
Test for None instead, so you fall back only when the value is truly absent:
def _num(stats: dict, key: str, default: float) -> float:
# Fall back ONLY on a missing value. A real 0.0 is a fact, not an absence,
# and must survive: `or default` would swallow it and inflate a poor player.
return x if (x := stats.get(key)) is not None else default
def project(stats: dict) -> float:
rating = _num(stats, "avg_rating", 1.0)
adr = _num(stats, "avg_adr", 70.0)
kast = _num(stats, "avg_kast", 0.70) # stored as a 0-1 fraction
kd = _num(stats, "kd_ratio", 1.0)
# Weights are illustrative: set these to match your scoring rules.
return round(
rating * 12.0 # overall impact
+ adr * 0.10 # damage output
+ kast * 8.0 # round-to-round consistency
+ (kd - 1.0) * 5.0, # duel win rate, centred on even
1,
)
Step 4: Gate on sample size before you trust it
The second bug is one of interpretation, not syntax. A career average over three matches will swing wildly; the same average over two hundred is stable. Ranking a three-match player against a two-hundred-match one as if the numbers were equally trustworthy is how a projection tool loses credibility. The matches field is your confidence signal, so use it:
def confidence(stats: dict, floor: int = 20) -> str:
n = stats.get("matches") or 0
if n == 0:
return "none" # no rated history; the averages are all defaults
return "high" if n >= floor else "low"
Now project a roster, skipping players with no history and flagging the thin ones:
ROSTER = ["s1mple", "zywoo", "sh1ro", "donk", "m0nesy"]
rows = []
for slug in ROSTER:
try:
stats = career(slug)
except LookupError as e:
print(f"skipping: {e}")
continue
conf = confidence(stats)
if conf == "none":
continue
rows.append((slug, project(stats), conf))
rows.sort(key=lambda r: r[1], reverse=True)
for slug, points, conf in rows:
flag = " (thin sample)" if conf == "low" else ""
print(f"{slug:10s} {points:6.1f}{flag}")
The output is a ranked slate you can drop into a draft tool, with the low-confidence rows called out rather than hidden:
A note on honesty
A projection built from career averages is a baseline, not a forecast of a specific match. It doesn't know about a roster change last week, a map that suits one team, or a stand-in. Treat it as a starting estimate and layer context on top; recent form is a good next input. Present it as "projected from career averages," which is what it is, and read how those inputs are computed in our methodology.
Resolve the slug, guard the nulls, weight by sample size, and rank. Every field you can feed into a score, and which are nullable, is listed in the API reference.
Full API reference: the CS2 data reference in the developer docs.