Open the Steam Web API documentation and search for “Premier Rating.” You won’t find it. Valve’s public endpoints hand over lifetime kill counts, hours played, and achievement flags for Counter-Strike 2, but the number that actually defines your competitive standing in Premier mode, the color-banded CS Rating, never shows up in a JSON response. That gap is why so many players end up screenshotting their own profile after every match just to remember where they stood last week.
This tutorial builds a working CS2 rating tracker from scratch. It pulls what Valve’s API actually offers, logs your Premier Rating from a screenshot using optical character recognition, charts your progress over time, and cross-checks the result against third-party trackers so you know when a number looks off. By the end you’ll have a small Python project you can run after every session, with a CSV history and a chart that Valve simply doesn’t give you out of the box.
None of this requires reverse-engineering the game client or touching anything Valve would consider off-limits. Every API call in this guide hits a documented, public Steam Web API endpoint using your own key, and the one piece of data Valve doesn’t publish gets captured the same way a human would read it: by looking at a screenshot. That constraint actually shapes the whole design, and it’s worth understanding up front why a pure-API solution isn’t on the table before diving into twelve steps of setup.
Why Valve’s Steam API Won’t Hand You a Premier Rating Number
Counter-Strike 2 runs two parallel progression systems. Competitive mode still uses the old named skill groups (Silver through Global Elite), while Premier mode replaced that ladder with a numeric CS Rating. The distinction matters for anyone building a CS2 rank tracker, because the two systems live in different places internally and neither is fully exposed through Valve’s Steam Web API.
Current documentation and tracker-site write-ups confirm that the standard Steam Web API does not directly expose a player’s Premier CS Rating or their Competitive skill group. What it does expose is narrower: lifetime per-game statistics such as total kills, total wins, and hours played, served through the ISteamUserStats interface. That’s useful for building a historical baseline, but it will never return the number badge you see on your own profile after a Premier match.
Third-party tracker sites work around this by scraping public profile pages or relying on data feeds that update on their own schedule, not Valve’s. Sites like Leetify report current and peak Premier rating pulled from account data rather than a documented Valve endpoint, and update timing depends entirely on when Steam refreshes a player’s public stats. That’s the core reason this project leans on a hybrid approach: official API calls for what Valve actually publishes, plus a self-built capture pipeline for the one number Valve keeps to itself.
It’s also worth being honest about why Valve might keep this particular number out of the public API surface. A fully open, machine-readable Premier Rating endpoint would make it trivial to build automated boosting services, rank-based matchmaking manipulation tools, or bulk scrapers that hammer Steam’s infrastructure far harder than a personal tracker checking in once per session. Building your own lightweight, personal-use pipeline sidesteps that concern entirely: you’re reading your own screenshot, not scraping thousands of accounts, and you’re calling documented endpoints with your own rate-limited key rather than an undocumented one.
Understanding the CS2 Premier Rating Color Bands
Before writing any code, it helps to know what you’re actually tracking. Premier mode presents CS Rating as a number paired with a color band, running from gray at the bottom to gold at the top. The commonly reported thresholds, drawn from current Premier UI and tracker documentation, are laid out below.
| Color Band | CS Rating Range | Notes |
|---|---|---|
| Gray | 0 – 4,999 | Includes new accounts still in placement matches |
| Light Blue | 5,000 – 9,999 | Largest reported population band in most tracker samples |
| Blue | 10,000 – 14,999 | Community estimates place the average Premier score around 11,000, inside this band |
| Purple | 15,000 – 19,999 | Noticeable drop-off in population share versus Blue |
| Pink | 20,000 – 24,999 | Single-digit percentage of sampled players |
| Red | 25,000 – 29,999 | Under 2% of sampled players in most trackers |
| Gold | 30,000+ | A fraction of a percent of sampled players |
Treat the population figures as directional, not official. Valve has not published a global CS Rating distribution, so every percentage circulating online comes from a community tracker sampling its own user base. Two trackers checked in the same September-to-October 2026 window reported meaningfully different shares for the bottom band, one landing near 15-18% and another near 21.2%. Neither is wrong. They’re sampling different, possibly overlapping, populations at different moments. Build your own tracker precisely so you’re not stuck guessing which snapshot to trust.
One more caution before you start: as of this writing there is no confirmed Valve announcement of a “CS Rating 2.0” system. If you see that term on a forum or a YouTube thumbnail, treat it as speculation until Valve’s own Counter-Strike blog confirms it. The project below tracks the existing color-banded CS Rating, which is the system actually live in the client right now.
Prerequisites: Tools, Versions, and Accounts
Gather these before Step 1. Version numbers matter here because OCR behavior and API client behavior both shift across major releases.
- Python 3.11 or 3.12 (3.13 works but some OCR wheels lag behind on day one of a new release)
- A free Steam Web API key tied to your Steam account
- CS2 installed, with your Steam profile and game details set to public (at least temporarily, for the API calls to return data)
- pip packages:
requests2.32+,pandas2.2+,matplotlib3.9+,pytesseract0.3.13+,Pillow10.x,watchdog4.x,flask3.x (only needed for the optional dashboard in Step 12) - Tesseract OCR engine 5.x installed system-wide (not just the Python wrapper, the actual binary)
- A screenshot tool or Steam’s built-in F12 screenshot binding
Install the Python dependencies in one pass:
pip install requests==2.32.3 pandas==2.2.2 matplotlib==3.9.2 pytesseract==0.3.13 Pillow==10.4.0 watchdog==4.0.2 flask==3.0.3
On Windows, grab the Tesseract installer from the UB Mannheim build and note the install path (commonly C:\Program Files\Tesseract-OCR\tesseract.exe). On macOS, brew install tesseract. On Debian or Ubuntu, sudo apt install tesseract-ocr. Confirm it’s on your PATH with tesseract --version before moving on, since a missing binary is the single most common failure point in this whole project.
Step 1: Generate a Steam Web API Key
Log into Steam in a browser and visit the API key registration page. Enter any domain name (even localhost works for personal projects) and Valve issues a key immediately. Store it as an environment variable rather than hard-coding it into a script you might later share or commit.
export STEAM_API_KEY="your_32_character_key_here"
export STEAM_VANITY="your_custom_profile_name"
On Windows PowerShell, use $env:STEAM_API_KEY = "your_key" instead. Keys are rate-limited to roughly 100,000 calls per day per key, which is far more than a personal tracker running a handful of times a day will ever touch.
Step 2: Resolve Your SteamID64
Every Steam Web API call that targets a specific player wants a 64-bit SteamID, not your custom URL name. If your profile URL is steamcommunity.com/id/yourname, that yourname is a vanity string that needs resolving first.
import os
import requests
API_KEY = os.environ["STEAM_API_KEY"]
VANITY = os.environ["STEAM_VANITY"]
def resolve_steamid64(vanity_name):
url = "https://api.steampowered.com/ISteamUser/ResolveVanityURL/v1/"
params = {"key": API_KEY, "vanityurl": vanity_name}
resp = requests.get(url, params=params, timeout=10)
resp.raise_for_status()
data = resp.json()["response"]
if data.get("success") != 1:
raise ValueError(f"Could not resolve vanity URL: {data}")
return data["steamid"]
if __name__ == "__main__":
steamid64 = resolve_steamid64(VANITY)
print("SteamID64:", steamid64)
If your profile never had a custom URL set, skip this step and copy the 17-digit SteamID64 directly from your profile page URL instead.
Step 3: Pull Your Profile and CS2 Ownership Data
With a SteamID64 in hand, confirm the account is public and owns CS2 (Steam app ID 730, the same ID the game has used since its CS:GO days). This step doubles as an early sanity check: if your profile is private, every later call returns empty data instead of a clear error.
def get_player_summary(steamid64):
url = "https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/"
params = {"key": API_KEY, "steamids": steamid64}
resp = requests.get(url, params=params, timeout=10)
resp.raise_for_status()
players = resp.json()["response"]["players"]
if not players:
raise ValueError("No player found. Check the SteamID64.")
return players[0]
def owns_cs2(steamid64):
url = "https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/"
params = {"key": API_KEY, "steamid": steamid64, "include_appinfo": 0}
resp = requests.get(url, params=params, timeout=10)
resp.raise_for_status()
games = resp.json().get("response", {}).get("games", [])
return any(g["appid"] == 730 for g in games)
summary = get_player_summary(steamid64)
print("Persona name:", summary.get("personaname"))
print("Visibility state:", summary.get("communityvisibilitystate"))
print("Owns CS2:", owns_cs2(steamid64))
A communityvisibilitystate of 3 means public. Anything else means the later stats call will come back empty, not with a helpful error message, so it’s worth checking explicitly here rather than debugging it two steps downstream.
Step 4: Fetch Lifetime Stats With GetUserStatsForGame
This is the furthest the official API gets you toward anything rank-adjacent. GetUserStatsForGame returns lifetime counters, total kills, total deaths, total wins, total planted bombs defused, and similar cumulative figures, but no CS Rating field and no Premier-specific breakdown.
def get_lifetime_stats(steamid64):
url = "https://api.steampowered.com/ISteamUserStats/GetUserStatsForGame/v2/"
params = {"key": API_KEY, "steamid": steamid64, "appid": 730}
resp = requests.get(url, params=params, timeout=10)
if resp.status_code == 403:
raise PermissionError("Stats are private or profile not public.")
resp.raise_for_status()
stats_list = resp.json().get("playerstats", {}).get("stats", [])
return {s["name"]: s["value"] for s in stats_list}
lifetime = get_lifetime_stats(steamid64)
print("Total kills:", lifetime.get("total_kills"))
print("Total wins:", lifetime.get("total_wins"))
print("Total MVPs:", lifetime.get("total_mvps"))
Sample output on a typical account looks like this:
Persona name: shattered_player
Visibility state: 3
Owns CS2: True
Total kills: 48213
Total wins: 1876
Total MVPs: 612
Store this snapshot alongside your rating log. It won’t tell you your CS Rating, but pairing lifetime win totals with rating changes over months gives you a sense of whether your overall trajectory matches your rating trend, which is useful context the rating number alone doesn’t provide.
Step 5: Design a Rating Log Schema
Since Premier Rating has to be captured manually (via screenshot and OCR, covered next), define the log format up front so every later script writes to the same structure. A flat CSV keeps this simple and lets you open it directly in a spreadsheet if the charting script ever misbehaves.
import csv
from pathlib import Path
LOG_PATH = Path("ratings_log.csv")
FIELDS = ["timestamp", "rating", "color_band", "source", "note"]
def init_log():
if not LOG_PATH.exists():
with LOG_PATH.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS)
writer.writeheader()
def append_row(timestamp, rating, color_band, source="ocr", note=""):
with LOG_PATH.open("a", newline="") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS)
writer.writerow({
"timestamp": timestamp,
"rating": rating,
"color_band": color_band,
"source": source,
"note": note,
})
init_log()
The source column matters more than it looks. You’ll eventually have rows from OCR, rows you typed in manually, and possibly rows cross-checked against a third-party tracker. Keeping that distinction means a bad OCR read doesn’t silently get treated as gospel later.
Step 6: Capture Screenshots and Install Tesseract OCR
After a Premier match, CS2 shows your updated rating on the scoreboard and end-of-match screen. Press your screenshot bind (F12 by default through Steam) right when that number is visible. Steam saves these to a dedicated screenshots folder under your Steam userdata directory, which the next script will watch automatically.
Confirm Tesseract is reachable from Python before writing the parser:
import pytesseract
from PIL import Image
# Windows only: point pytesseract at the binary explicitly
# pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
print(pytesseract.get_tesseract_version())
If that prints a version number like 5.3.3, you’re ready for the next step. If it throws TesseractNotFoundError, the binary either isn’t installed or isn’t on PATH, which is the most frequent blocker for first-time setup.
Step 7: Write the OCR Parser for Your Rating Badge
Rating badges sit in a consistent screen position for a given resolution and UI scale, so cropping the screenshot down to just that badge before running OCR dramatically improves accuracy over trying to read the whole scoreboard at once.
import re
from PIL import Image
import pytesseract
# Crop box is (left, top, right, bottom) in pixels.
# Calibrate this once for your own resolution by opening a
# screenshot in an image editor and noting the badge coordinates.
RATING_CROP_BOX = (1680, 40, 1900, 90)
RATING_PATTERN = re.compile(r"(\d{1,2},?\d{3})")
def extract_rating(image_path):
img = Image.open(image_path)
cropped = img.crop(RATING_CROP_BOX)
# Upscale small crops; Tesseract reads larger text more reliably
cropped = cropped.resize((cropped.width * 3, cropped.height * 3))
text = pytesseract.image_to_string(cropped, config="--psm 7 digits")
match = RATING_PATTERN.search(text)
if not match:
return None
return int(match.group(1).replace(",", ""))
def rating_to_band(rating):
bands = [
(0, 4999, "Gray"),
(5000, 9999, "Light Blue"),
(10000, 14999, "Blue"),
(15000, 19999, "Purple"),
(20000, 24999, "Pink"),
(25000, 29999, "Red"),
(30000, float("inf"), "Gold"),
]
for low, high, name in bands:
if low <= rating <= high:
return name
return "Unknown"
The --psm 7 flag tells Tesseract to treat the crop as a single line of text, which works well for a tight rating-badge crop. Test this against three or four real screenshots before trusting it, since UI scale, resolution, and HUD opacity settings all shift the crop coordinates.
Calibrating the Crop Box for Your Own Resolution
The RATING_CROP_BOX constant from Step 7 is the single value most likely to need adjustment, since it was eyeballed against one specific resolution and UI scale setting. Rather than guessing, open a saved screenshot in any image editor that shows pixel coordinates on hover (Paint, Preview, GIMP, even a browser's dev tools on a locally opened file), hover over the top-left and bottom-right corners of the rating badge, and note the pixel values.
A short helper script speeds this up by cropping a candidate box and immediately saving it so you can check the result visually before wiring it into the main parser.
from PIL import Image
def preview_crop(image_path, box, out_path="crop_preview.png"):
img = Image.open(image_path)
cropped = img.crop(box)
cropped.save(out_path)
print(f"Saved preview crop to {out_path} (size: {cropped.size})")
# Try a candidate box, then open crop_preview.png to check it
preview_crop("screenshots/test.png", (1680, 40, 1900, 90))
Run it, open crop_preview.png, and adjust the four numbers until the saved image shows just the rating digits with a small margin and nothing else. A too-tight crop clips digits and breaks OCR, while a too-loose crop pulls in surrounding UI elements that confuse the text recognizer. The table below gives starting points for common resolutions, though your actual HUD scale setting in CS2's video options will shift these regardless of monitor resolution.
| Resolution | Aspect Ratio | Starting Crop Box (approx.) |
|---|---|---|
| 1920×1080 | 16:9 | (1680, 40, 1900, 90) |
| 2560×1440 | 16:9 | (2240, 55, 2530, 120) |
| 3440×1440 | 21:9 ultrawide | (3020, 55, 3410, 120) |
| 1280×1024 | 5:4 (stretched) | (1110, 38, 1260, 82) |
Treat these as rough starting boxes, not exact values. HUD scale, aspect ratio overrides, and whether you're capturing from the scoreboard versus the post-match summary screen all shift the badge position by enough pixels to matter for a tight crop.
Step 8: Log Parsed Ratings to CSV Automatically
Combine the parser from Step 7 with the logging schema from Step 5 into one callable function, then test it against a saved screenshot.
from datetime import datetime, timezone
def process_screenshot(image_path):
rating = extract_rating(image_path)
if rating is None:
print(f"Could not read a rating from {image_path}")
return
band = rating_to_band(rating)
timestamp = datetime.now(timezone.utc).isoformat()
append_row(timestamp, rating, band, source="ocr", note=str(image_path))
print(f"Logged {rating} ({band}) at {timestamp}")
# Manual test run
process_screenshot("screenshots/2026-10-03_premier_match.png")
A successful run prints something like:
Logged 14230 (Blue) at 2026-10-03T21:14:02.118374+00:00
Open ratings_log.csv afterward and confirm the row landed with the right columns before moving on to automation, since a schema mistake here compounds across every future entry. After a few logged matches, the raw file looks like this:
timestamp,rating,color_band,source,note
2026-09-28T19:02:11+00:00,13820,Blue,ocr,screenshots/2026-09-28_match1.png
2026-09-29T20:44:03+00:00,14050,Blue,ocr,screenshots/2026-09-29_match1.png
2026-09-30T18:15:47+00:00,13990,Blue,ocr,screenshots/2026-09-30_match1.png
2026-10-03T21:14:02+00:00,14230,Blue,ocr,screenshots/2026-10-03_premier_match.png
2026-10-03T21:30:55+00:00,14300,Blue,manual_crosscheck,Verified against third-party tracker
That's already enough rows to catch a useful pattern: a small dip on September 30th followed by recovery, with a manual cross-check two weeks in confirming the OCR pipeline is reading the badge correctly within a tight margin.
Validating Your Ratings Log Before You Trust It
Before building a chart on top of weeks of logged data, run a quick sanity pass over the CSV. OCR misreads tend to produce one of two failure signatures: a rating wildly outside the 0-30,000+ range (a digit got misread as a different digit) or a near-duplicate timestamp with an implausible jump (the folder watcher processed the same screenshot twice with a slightly different read each time).
import pandas as pd
def validate_log(csv_path="ratings_log.csv", max_jump=4000):
df = pd.read_csv(csv_path, parse_dates=["timestamp"]).sort_values("timestamp")
issues = []
out_of_range = df[(df["rating"] < 0) | (df["rating"] > 35000)]
for _, row in out_of_range.iterrows():
issues.append(f"Out-of-range rating {row['rating']} at {row['timestamp']}")
df["delta"] = df["rating"].diff().abs()
suspicious_jumps = df[df["delta"] > max_jump]
for _, row in suspicious_jumps.iterrows():
issues.append(f"Rating jumped by {row['delta']:.0f} at {row['timestamp']}, likely a misread")
dupes = df[df.duplicated(subset=["note"], keep=False) & (df["source"] == "ocr")]
if not dupes.empty:
issues.append(f"{len(dupes)} rows share the same source screenshot path")
if not issues:
print("Log looks clean:", len(df), "rows checked")
else:
for issue in issues:
print("CHECK:", issue)
return issues
validate_log()
A max_jump of 4,000 is a reasonable starting threshold since a single Premier match rarely moves your rating by more than a few hundred points, so a four-figure jump between consecutive rows almost always means a misread digit rather than a genuinely wild swing. Run this validation step before every chart regeneration, and fix flagged rows manually in the CSV rather than letting a bad OCR read quietly skew your trend line.
Step 9: Chart Your Rating Over Time With Matplotlib
Once you have more than a handful of rows, a simple line chart with the color-band thresholds overlaid makes the trend immediately legible, something no third-party tracker gives you tuned to your own log.
import pandas as pd
import matplotlib.pyplot as plt
def plot_rating_history(csv_path="ratings_log.csv", out_path="rating_chart.png"):
df = pd.read_csv(csv_path, parse_dates=["timestamp"])
df = df.sort_values("timestamp")
fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(df["timestamp"], df["rating"], marker="o", linewidth=1.5)
thresholds = [5000, 10000, 15000, 20000, 25000, 30000]
for t in thresholds:
ax.axhline(y=t, linestyle="--", linewidth=0.6, alpha=0.5)
ax.set_title("CS2 Premier Rating Over Time")
ax.set_xlabel("Date")
ax.set_ylabel("CS Rating")
fig.autofmt_xdate()
fig.tight_layout()
fig.savefig(out_path, dpi=150)
print(f"Saved chart to {out_path}")
plot_rating_history()
If the resulting PNG shows an empty plot, the most common cause is a timestamp parsing mismatch between how the log was written and how pandas expects to read it. Keep timestamps in ISO 8601 format (as the Step 8 code already does) and this stays a non-issue.
Step 10: Automate Capture With a Folder Watcher
Rather than running the parser by hand after every match, point a watchdog observer at your Steam screenshots folder so new files get processed automatically the moment they land on disk.
import time
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
SCREENSHOT_DIR = "/path/to/Steam/userdata//760/remote/730/screenshots"
class ScreenshotHandler(FileSystemEventHandler):
def on_created(self, event):
if event.is_directory:
return
if event.src_path.lower().endswith((".png", ".jpg")):
time.sleep(1) # let Steam finish writing the file
process_screenshot(event.src_path)
if __name__ == "__main__":
observer = Observer()
observer.schedule(ScreenshotHandler(), SCREENSHOT_DIR, recursive=False)
observer.start()
print(f"Watching {SCREENSHOT_DIR} for new screenshots...")
try:
while True:
time.sleep(5)
except KeyboardInterrupt:
observer.stop()
observer.join()
The one-second delay before processing matters: some filesystems fire the creation event before Steam finishes writing the image, and reading a half-written PNG produces a corrupt-image error instead of a clean OCR miss.
Step 11: Cross-Check Against Third-Party CS2 Trackers
Your OCR pipeline gives you ground truth for your own account, but it's worth periodically cross-referencing against a tracker site to catch silent OCR drift, a slowly shifting crop box, or a UI update that moved the badge a few pixels. Sites such as Leetify surface current and peak Premier rating pulled from account data, which makes a useful sanity check even though their update cadence depends on upstream data availability rather than a fixed schedule.
Add a manual verification row type to your log for these spot checks:
append_row(
datetime.now(timezone.utc).isoformat(),
rating=14300,
color_band=rating_to_band(14300),
source="manual_crosscheck",
note="Verified against third-party tracker",
)
If your OCR-logged value and the tracker's reported value disagree by more than a small margin, trust the number you captured directly from your own in-game screenshot. Trackers aggregate data from scraped profile pages and can lag behind your most recent match by hours or days.
Step 12: Build a Local Flask Dashboard (Advanced Bonus)
For a project you'll actually open day to day, a tiny local web page beats re-running a script and opening a PNG. This minimal Flask app regenerates the chart and serves it alongside your lifetime stats.
from flask import Flask, render_template_string, send_file
app = Flask(__name__)
TEMPLATE = """
<h1>CS2 Premier Rating Tracker</h1>
<p>Latest rating: {{ rating }} ({{ band }})</p>
<img src="/chart.png?v={{ cache_bust }}" width="800">
"""
@app.route("/")
def dashboard():
df = pd.read_csv("ratings_log.csv")
latest = df.sort_values("timestamp").iloc[-1]
plot_rating_history()
return render_template_string(
TEMPLATE,
rating=latest["rating"],
band=latest["color_band"],
cache_bust=int(time.time()),
)
@app.route("/chart.png")
def chart():
return send_file("rating_chart.png", mimetype="image/png")
if __name__ == "__main__":
app.run(debug=True, port=5050)
The cache_bust query parameter is a small but important detail: without it, browsers happily serve a stale cached chart image even after the underlying PNG has changed on disk.
The Steam Web API Endpoints Used in This Project
For reference, here's every official endpoint this tutorial touches, with what it returns and what it doesn't. The full endpoint catalog lives in Valve's Steamworks Web API overview, which is worth bookmarking if you extend this project beyond CS2, since the same authentication pattern and rate limits apply across every game that exposes stats through ISteamUserStats.
| Endpoint | Interface | Returns | Does Not Return |
|---|---|---|---|
| ResolveVanityURL | ISteamUser | SteamID64 from a custom URL name | Any rank or rating data |
| GetPlayerSummaries | ISteamUser | Persona name, avatar, profile visibility | CS Rating, skill group |
| GetOwnedGames | IPlayerService | Owned app IDs, playtime | In-game rank data |
| GetUserStatsForGame | ISteamUserStats | Lifetime kills, wins, MVPs, and similar counters | Premier CS Rating, Competitive skill group |
Common Pitfalls When Tracking Your Own CS2 Rank
- Confusing CS Rating with Competitive skill groups. They are separate systems with separate numbers, and a script built for one won't parse the other.
- Logging ratings during your first placement matches. Early-season or fresh-account ratings swing hard before enough matches calibrate the number, which makes early chart data noisy by design, not a bug in your script.
- Hammering the Steam Web API with no delay between calls. You're unlikely to hit the roughly 100,000-call daily ceiling with a personal tracker, but adding a short sleep between batch calls avoids transient throttling anyway.
- Hard-coding a crop box calibrated at one resolution, then running it on screenshots taken at a different resolution or aspect ratio after a monitor change.
- Treating a single third-party tracker's percentage breakdown as Valve's official distribution, when it's really one site's sample at one point in time.
- Forgetting that a season transition can shift what a given color band means in practice, even if the numeric thresholds themselves stay the same.
Troubleshooting Your CS2 Rating Tracker
Most failures in this pipeline fall into three buckets: an API credential or visibility problem (nothing comes back), an OCR problem (something comes back, but it's wrong), or a timing problem (the right data exists but the script looked for it before it was ready). Working through the list below roughly in order, from API issues down to chart and dashboard issues, mirrors the order data actually flows through the pipeline, so it's an efficient way to narrow down where a break happened.
- 403 Forbidden from the Steam Web API: your key is invalid or was revoked. Generate a fresh one from the API key page.
- GetPlayerSummaries returns a name but no other fields: the profile is set to private. Switch visibility to public in Steam privacy settings.
- GetOwnedGames returns an empty games list: game details can be private independently of the overall profile. Check that setting separately.
- OCR returns an empty string: the crop coordinates don't match your current resolution or UI scale. Re-calibrate by opening a fresh screenshot in an image viewer and noting the badge's pixel coordinates.
- OCR returns garbled characters instead of digits: confirm
eng.traineddatais present in your Tesseract installation and that you're running Tesseract 5.x, not an old 3.x build some package managers still ship. - 429 Too Many Requests: you're calling the API faster than its rate limit allows. Add a short sleep between consecutive calls.
- Duplicate rows in the CSV for the same screenshot: the folder watcher fired multiple filesystem events for one file write. Add a basic duplicate-path check before calling
append_row. - Matplotlib chart renders blank: timestamps weren't parsed as dates. Confirm you're passing
parse_dates=["timestamp"]topd.read_csv. - Flask dashboard shows an old chart after a new match: the browser cached
chart.png. Confirm the cache-busting query parameter is actually changing between requests. - GetUserStatsForGame returns completely empty stats: double-check the app ID. CS2 uses 730, inherited from CS:GO. A typo here silently returns nothing rather than an error.
Advanced Tips to Extend the Project
Once the core pipeline runs reliably, a few extensions make it genuinely useful day to day rather than a one-off experiment.
Push rating changes to a Discord webhook so you get a notification the moment a new row lands, instead of having to open the dashboard. A simple POST request with the new rating and band name is enough, and no extra library is required beyond requests, which you already have installed.
Export the CSV to Google Sheets with gspread if you want a shareable, always-current view without hosting the Flask dashboard anywhere persistent. This also makes it trivial to add manual annotations, like which teammates you queued with on a given session.
If you want performance context alongside the rating number, pull local demo files and analyze them with an open-source CS2 demo parser to compute HLTV-style per-match ratings. Pairing a performance-based rating with your logged CS Rating shows whether rating movement actually tracks in-match performance or whether queue variance is doing more of the work.
If you queue with the same small group of friends regularly, run one tracker instance per SteamID64 and merge the CSVs on a shared timestamp column. That turns the project from a single-player trend line into a lightweight team dashboard, useful for spotting whether the whole stack is trending up together or whether one account's rating is dragging while everyone else climbs, which is a much more useful signal before a tournament sign-up deadline than any single player's number in isolation.
Putting the Complete Project Together
Before trusting the pipeline during a real play session, run it once end to end against a single test screenshot. Take one screenshot of a Premier scoreboard right now, even mid-match, run the Step 7 crop preview against it, then run process_screenshot() directly and check that a sensible row lands in the CSV. This dry run catches crop-box and Tesseract installation problems in thirty seconds, instead of discovering them after a real Premier match when the screenshot you actually cared about gets logged as None.
The finished project is six small files. Keep them in one folder with the structure below, and the whole pipeline runs with two commands: one to start the watcher during a play session, and one to launch the dashboard afterward.
cs2-rating-tracker/
├── steam_api.py # Steps 1-4: API key, SteamID64, profile, lifetime stats
├── rating_log.py # Step 5: CSV schema and append_row()
├── ocr_parser.py # Steps 6-7: crop, OCR, band lookup
├── capture_pipeline.py # Step 8: ties parser to the log
├── chart.py # Step 9: matplotlib rendering
├── watcher.py # Step 10: folder automation
├── dashboard.py # Step 12: optional Flask app
└── ratings_log.csv # generated on first run
# Start watching for new screenshots during a play session
python watcher.py
# After the session, launch the dashboard to review progress
python dashboard.py
Everything here uses documented Steam Web API behavior plus standard, well-maintained Python packages, no private endpoints, no scraping Valve's own servers in ways that risk a rate-limit ban. The OCR layer is doing the one job Valve's API can't: reading a number that only ever gets rendered to your screen.
Frequently Asked Questions
Does Valve offer an official API for CS2 Premier Rating?
No. The Steam Web API exposes lifetime per-game statistics through GetUserStatsForGame, but it does not expose Premier CS Rating or Competitive skill group directly, based on current documentation and tracker-site reporting.
What's the difference between CS Rating and the old Competitive skill groups?
Competitive mode still uses the named ranks from Silver to Global Elite. Premier mode replaced that system with a numeric CS Rating presented in color bands from gray to gold. They track separate modes and aren't interchangeable.
Why do different trackers report different population percentages for the same rating band?
Because Valve hasn't published an official distribution. Every percentage online comes from a specific tracker's own sampled user base, collected at a specific point in time, so variance between sites of several percentage points in the same band is expected rather than a sign either source is wrong.
Is building an OCR-based rank tracker against Steam's terms of service?
This project only reads screenshots you took of your own match results and calls documented, authenticated Steam Web API endpoints with your own key. It doesn't automate gameplay, inject into the game client, or scrape other players' data without consent, which keeps it in line with typical personal-use API terms.
How often should I log my Premier Rating?
Once per play session is plenty for a meaningful trend line. Logging after every single match works too if the folder watcher is running, but the chart becomes noisy rather than more informative at that granularity.
What Steam app ID should I use for CS2 API calls?
Use 730. CS2 kept the same app ID that Counter-Strike: Global Offensive used, since it launched as an update to the existing game rather than a separate Steam listing.
Does CS Rating reset between seasons?
Premier seasons have historically involved rating adjustments at season boundaries. Keep the source and note columns in your log populated with season context so a sudden rating shift in your chart reads as an expected seasonal change rather than an OCR error.
Can I adapt this tracker for a teammate's rating instead of just my own?
The Steam Web API calls work for any public profile's SteamID64, so the lifetime-stats portion generalizes easily. The OCR portion only works on screenshots you can actually capture, which in practice limits it to your own client unless a teammate shares their screenshots with you directly.




