Round stats
Two rows per round, the signed economy tier, and what each counter measures.
GET /v1/{game}/matches/{id}/rounds returns two rows per round — one per team. Exactly one has
won: true.
Join to a map with (match_id, map_number); rounds are numbered from 1 within a map and overtime
keeps counting up, so round 25+ on a standard map is overtime rather than a restart.
economy_level is a signed tier, not a sentinel
economy_level can be negative, and a negative value is meaningful. It is a signed
classification of the buy — eco and force-buy rounds sit below zero. It is not a missing-data
sentinel, and filtering out negatives silently discards every eco round, which is usually the
exact population an economy analysis is about.
Read it alongside equipment_value, enemy_equipment_value and money_spent — the raw dollar
figures, with the opponent's value denormalised onto the same row so you can judge the buy matchup
without a join.
A zero money_spent is usually a save, not a gap
About a quarter of rounds have money_spent: 0, and in most of them that is the truth: a team that
saves buys nothing and carries the previous round's kit, so a zero spend beside a five-figure
equipment_value is a save. Roughly 208,000 such rounds follow a round the team lost, which is
exactly the shape of a save.
The field is genuinely absent on about 0.7% of matches, where every round of the match reads
money_spent: 0. utility_value is absent the same way on about 2% of matches. Check the match,
not the round: a scattered zero is a save, a match-wide zero is missing data.
Sides
team_side is lowercase ct or t. Sides swap at the half, so a team's side changes partway
through a map — never assume a team's side from the map alone.
pistol_round flags round 1 and the second-half opener.
Counters
All integers, never null:
| Field | Meaning |
|---|---|
kills / deaths / assists | Team totals for the round. deaths is 0–5. |
damage, headshots | Damage dealt; how many kills were headshots. Five opponents at 100 HP puts a normal round at or under 500, which holds for 98.6% of rows; 1.4% run higher and nine rows in the whole corpus are physically impossible (up to 49,048). Treat an extreme value as upstream noise rather than a signal. |
first_kills / first_deaths | Opening duel. Across a round's two rows these sum to 0 or 1. |
trade_kills / trade_deaths | Trade discipline — a kill that avenged a teammate who just died, and a death that was subsequently traded. |
clutches / clutch_attempts | Won, and reached. Divide for a conversion rate — guard the zero denominator. |
bomb_plants / bomb_defuses | 0 or 1. Plants are always 0 on the CT side, defuses always 0 on the T side. |
flash_assists, utility_value | Kills enabled by blinding; dollar value of grenades used. |
kast | A count of 0–5 players who contributed — not a percentage. |
Two further fields, end_reason and round_duration_ms, describe the round rather than the team
and can be null — see below.
How the round ended, and how long it took
Two fields describe the round itself rather than either team, so both rows of a round carry the
same value: end_reason and round_duration_ms.
end_reason is an open vocabulary, not a fixed set. These are the values in the data today:
end_reason | Meaning |
|---|---|
CTWin | CTs won by eliminating the Ts (or the clock ran out with no plant). |
TerroristsWin | Ts won by eliminating the CTs. |
TargetBombed | The bomb detonated. |
BombDefused | The bomb was defused. |
TargetSaved | The round timer expired with the bomb unplanted. |
Don't switch on end_reason exhaustively. It mirrors the game's own round-end names, and values
we haven't seen yet — a draw, or a surrender — will appear verbatim the first time they happen
rather than being mapped to something familiar. Handle an unknown value as valid.
round_duration_ms is wall-clock milliseconds between round boundaries, which is not the same
as the round's live length: it includes the freeze time before the round and whatever else happened
in that window — a timeout, a technical pause, or a scheduled break. The median round is 112 s.
About 13% exceed 155,000 ms, which is longer than a CS2 round can physically run (a 1:55 round plus
a 0:40 bomb timer), and 1% run past five minutes.
Those long rounds are not scattered at random: the round after a break carries the break. Round 13 — the half-time swap — is over 155 s on 19.1% of maps against a 13% average, and round 25, the first overtime round, on 19.3%; rounds 12 and 14, either side of half-time, are the calmest on the map at 6.2% and 5.8%. The five-minute tail is headed by round 1 and round 13 (map start and half-time) and is ordinary rather than exceptional — 756 of the 2,152 matches with duration data carry at least one, usually exactly one. So a long round is far more often a break than a slow round. Read the field as a pace signal — comparing pistol rounds to gun rounds, or one team's rounds to another's, ideally excluding rounds 1, 13 and 25 — never as an exact duration.
Both fields are null on rounds recorded before they were captured. They are populated for every
match ingested from 2026-09-19 and were backfilled across roughly the three months before that, so
filter on end_reason != null rather than assuming a cutoff date.
rounds = get(f"/v1/cs2/matches/{match_id}/rounds")["data"]
# One row per round: the two team rows agree on both fields.
timeline = {
(r["map_number"], r["round_number"]): (r["end_reason"], r["round_duration_ms"])
for r in rounds
}Rebuilding a scoreline
rounds = get(f"/v1/cs2/matches/{match_id}/rounds")["data"]
from collections import Counter
score = Counter(r["team_id"] for r in rounds if r["won"] and r["map_number"] == 1)That should reconcile with map 1's score_a/score_b from
map results. If it doesn't, you're probably summing across maps.