guide

Build CS2 fantasy projections from player stats

Jul 10, 20265 min read

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. Every player route takes the slug directly, so /players/s1mple/stats needs no ID lookup 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: pull a player's career stats by slug, run them through a scoring function, check the sample behind them, and you have a projection.

The projection pipeline: GET the player's career stats by slug, apply your scoring weights, gate on sample size, and produce a projected number.

Step 1: Pull career stats

Fetch the career averages from /players/{id}/stats, where {id} is the player's UUID or slug. You'll usually start from a human-friendly slug like s1mple, and it goes straight into the path. This is a single-resource call (not paginated), and it returns the all-time numbers:

The career-stats response gives you matches played plus averages: rating, ADR, KAST, and a K/D ratio, the inputs to a projection.

import os
import requests

BASE = "https://api.esportsodds.gg"
HEADERS = {"Authorization": f"Bearer {os.environ['ESPORTSODDS_API_KEY']}"}


def career(slug: str) -> dict:
    resp = requests.get(f"{BASE}/v1/cs2/players/{slug}/stats",
                        headers=HEADERS, timeout=10)
    if resp.status_code == 404:
        raise LookupError(f"no player with slug {slug!r}")
    resp.raise_for_status()
    return resp.json()["data"]

Teams work the same way: /teams/{slug} and its sub-routes take the slug too. If you store results, keep the player_id from the response as your key. It never changes, while a slug can when a player renames (the old slug still resolves).

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 2: 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,
    )

The null-versus-zero guard: read a field, and fall back to the league default only when it is None, so a genuine 0.0 flows through unchanged.

Step 3: 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"

The confidence gate: no matches means the projection is all defaults, few matches means treat it as provisional, many matches means the averages are stable.

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:

An example projected roster: five players ranked by projected fantasy points derived from their career averages.

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.

Pull by 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.

Start free with a sandbox key: a fixed sample of past matches, no card.