Map results
Per-map scores, the half-by-half split, and why overtime is null rather than zero.
GET /v1/{game}/matches/{id}/maps returns one row per map played, ordered by map_number.
A bo3 has up to 3 rows and a bo1 exactly 1 — but a series that ended early has fewer rows than
its format allows, so never assume a count from format.
Scores
score_a and score_b are final rounds won by team A and team B (the parent match's team_a_id /
team_b_id), including overtime.
map_name is the Valve identifier — de_ancient, de_mirage — never a display name, on every map
that was actually played. Two exceptions are worth coding around: a forfeited map carries the
literal string w.o. (110 rows corpus-wide, and one carries an empty string), and a handful of
technical rows carry whatever the source recorded instead of a map. Match on the de_ prefix
rather than assuming every value is one.
The half split
first_half_a/first_half_b and second_half_a/second_half_b break the map into halves, and are
null together when the split isn't available for that map. The final score is authoritative
regardless.
Starting side
first_half_side_a is the side team A started the map on — "ct" or "t", the same lowercase
spelling as team_side on round stats. Team B started on the other,
and the sides swap at the half, so with the half split you can attribute every round of regulation
to a side without fetching the rounds.
It is populated on about half of all maps and null on the rest. It is never inferred, so treat
null as "unknown" rather than defaulting it.
Overtime is null, not zero
overtime_a/overtime_b are null when the map didn't go to overtime — deliberately, so you
can distinguish "no overtime" from "overtime happened and this team won no rounds in it". Treating
null as 0 collapses that distinction.
maps = get(f"/v1/cs2/matches/{match_id}/maps")["data"]
for m in maps:
ot = "" if m["overtime_a"] is None else f" (OT {m['overtime_a']}-{m['overtime_b']})"
print(f"Map {m['map_number']}: {m['map_name']} {m['score_a']}-{m['score_b']}{ot}")Maps that were never played out
Not every row here is a map two teams played. A series can be settled by a forfeit, abandoned
mid-map, or awarded outright, and upstream still reports the result as a game. status says which
you are looking at:
Prop
Type
status is null on older rows, where the source never said. Treat null as played.
rounds_played is the round count reported for the map, and is null where it was never collected.
The score stays authoritative there.
Filter these out of any performance statistic
A 1–0 forfeit is not a map win on Dust2. Counting one distorts map win rates, ratings and head-to-head records alike, and it is a real population: hundreds of stored rows have a winning score below 13.
Our own numbers use two readings, and yours should match whichever you are reproducing. Series
scores count played and technical, because upstream credits a technical map toward the
result. Every performance statistic — team map records, ratings, per-map player stats — counts
played only.
maps = get(f"/v1/cs2/matches/{match_id}/maps")["data"]
played = [m for m in maps if (m["status"] or "played") == "played"]A walkover on the whole match, rather than one map, shows up as result_type: "walkover" on the
match itself — see Match lifecycle.
Reconciling with the series score
The match's score_a/score_b count maps won, not rounds. So a 2–1 series with map scores
13–8, 10–13, 13–11 gives score_a: 2, score_b: 1. Mixing the two is a common source of confusing
totals.
The map rows can also be fewer than the series score implies: a technical or walkover map counts
toward score_a/score_b, and on a match awarded outright (result_type: "walkover") there are no
map rows at all. Reconcile on the match's own score, not on a count of rows.
Which side started where
first_half_side_a — "ct" or "t" — is the side team A opened on; team B opened on the other.
It is null where we don't know, and never inferred. See Starting side above.
When each map began
started_at is the moment this map began, on the upstream's own clock — not the series start,
and not the previous map's end. A three-map series therefore has three different starts, which is
what you want for pacing a broadcast, aligning odds ticks to a map, or measuring how long a series
actually ran.
It is never derived. A map we have no start for stays null rather than borrowing a neighbouring
time, which would read as fact and be wrong by minutes to hours. It is also a real start, so on the
first map of a series it can legitimately precede the match's scheduled_at — matches begin when
both teams are ready.
Filter on the field, not on a date
The field is written forward, when a match's detail is fetched. Current matches carry it — 82–98%
of maps per day over the last week — while the archive mostly does not: 614 of 51,410 stored
maps (1.2%) have it today, and older rows fill in only if that match is re-fetched. Test
started_at != null; do not assume a cutoff date.