π Getting startedΒΆ
This is a guided tour of Awpy, start to finish: open a demo, pull structured
datasets, look up players and server settings, reconstruct game state at any
moment, check lines of sight, and draw it all on the mapβs radar. Every output
below is real β it comes from a professional match played on de_ancient.
InstallΒΆ
Awpy ships as a pre-built wheel with a Rust parser inside β no compiler required. It supports Python 3.11+.
uv add awpy # or: pip install awpy
uv add 'awpy[plot]' # + matplotlib, for the plotting module
Get a demoΒΆ
Demos are .dem files recorded by the game server. Common sources:
Your own matches β CS2 downloads them under Watch β Your Matches, into
.../Counter-Strike Global Offensive/game/csgo/replays/.Pro matches β HLTV match pages ship GOTV demos for most tier-1 events.
Third-party platforms β FACEIT and other platforms offer demo downloads on the match room page.
Awpy parses CS2 (Source 2, PBDEMS2) demos. The older CS:GO format is not
supported.
Open a demoΒΆ
from awpy import Demo
demo = Demo("match.dem")
Construction memory-maps the file and verifies itβs a valid CS2 demo β it
raises FileNotFoundError or InvalidDemoError otherwise, so a try/except
around this line is all the validation you need.
The header is a plain dict with the map, server, and playback info:
demo.header["map_name"] # "de_ancient"
demo.header["playback_time"] # 2915.94 (seconds)
demo.header["playback_ticks"] # 186620 -> 64 ticks per second
The datasetsΒΆ
Everything analytical is a property on Demo returning a
Polars DataFrame, parsed on first access and cached:
demo.rounds # one row per round: ticks, winner, reason
demo.kills # every player_death, participants fully resolved
demo.damages # every player_hurt, health/armor before + after
demo.bomb # plants, defuses, drops, pickups
demo.shots # every weapon_fire, with shooter/weapon state
demo.grenades # grenade trajectories, tick by tick
demo.stats # per-player scoreboard: KAST, ADR, openings, trades
Polars makes the follow-up questions one-liners. How did rounds break down by side?
demo.rounds.group_by("winner_side").len()
# βββββββββββββββββββββ¬ββββββ
# β winner_side β len β
# βββββββββββββββββββββͺββββββ‘
# β counter-terrorist β 21 β
# β terrorist β 3 β
# βββββββββββββββββββββ΄ββββββ
Who led the server, and how clean was their aim?
demo.kills.group_by("attacker_name").len().sort("len", descending=True).head(3)
# βββββββββββββββββ¬ββββββ
# β attacker_name β len β
# βββββββββββββββββͺββββββ‘
# β blameF β 23 β
# β npl β 21 β
# β s1zzi β 20 β
# βββββββββββββββββ΄ββββββ
demo.kills["headshot"].mean() # 0.446 β 44.6% of kills were headshots
The scoreboard is precomputed β including KAST and ADR:
demo.stats.sort("adr", descending=True).select("name", "kills", "deaths", "kast", "adr").head(3)
# ββββββββββ¬ββββββββ¬βββββββββ¬ββββββββ¬βββββββββ
# β name β kills β deaths β kast β adr β
# ββββββββββͺββββββββͺβββββββββͺββββββββͺβββββββββ‘
# β npl β 19 β 19 β 70.83 β 98.13 β
# β blameF β 23 β 13 β 87.5 β 97.75 β
# β JDC β 19 β 16 β 75.0 β 90.5 β
# ββββββββββ΄ββββββββ΄βββββββββ΄ββββββββ΄βββββββββ
Every column of every dataset is documented in π Datasets.
Who played, what they said, how the server was set upΒΆ
demo.players
# βββββββββββββββββββββ¬βββββββββββ¬ββββββββββββ
# β steamid β name β side β
# βββββββββββββββββββββͺβββββββββββͺββββββββββββ‘
# β 0 β PWA CSTV β null β <- the GOTV bot
# β 76561198370176682 β gr1ks β terrorist β
# β 76561198118196092 β faveN β terrorist β
# β ... β ... β ... β
# βββββββββββββββββββββ΄βββββββββββ΄ββββββββββββ
demo.chat # tick, name, message, channel (may be empty β
# server-side recordings often strip chat)
demo.convars["mp_maxrounds"] # "24"
demo.convars["mp_freezetime"] # "20"
side in players is the last side a player was seen on β sides swap at
halftime, so treat it as βwhere they finishedβ, not a team identity.
Any game eventΒΆ
The headline datasets cover the common cases; demo.events covers everything
else. It behaves like a read-only mapping from event name to DataFrame:
demo.events.names # every event in this demo (40 kinds here)
demo.events.counts # {"player_death": 166, "weapon_fire": 3954, ...}
demo.events.flashbang_detonate # one event as a DataFrame (133 rows here)
demo.events["player_ping"] # same thing, by key
Event frames are long-form: a tick column plus one string column per event
key. Note that raw event keys like attacker are user ids (server slots),
not Steam ids β use demo.kills / demo.damages when you want participants
resolved, or join through demo.players.
Game state at any momentΒΆ
snapshots reconstructs every playerβs state β position, view angles, health,
armor β at a tick, or at every tick in a range:
freeze_end = demo.rounds.row(4, named=True)["freeze_end_tick"]
demo.snapshots(ticks=freeze_end + 640) # 10 seconds into round 5
# ββββββββββ¬ββββββββββββββββββββ¬βββββββββ¬βββββββββββ¬ββββββββββ¬βββββββββ¬ββββββββββ
# β name β side β health β x β y β z β yaw β
# ββββββββββͺββββββββββββββββββββͺβββββββββͺβββββββββββͺββββββββββͺβββββββββͺββββββββββ‘
# β JDC β counter-terrorist β 100 β -967.71 β -147.31 β 92.32 β -36.08 β
# β faveN β counter-terrorist β 100 β 1072.47 β 782.02 β 167.17 β -88.98 β
# β ... β ... β ... β ... β ... β ... β ... β
# ββββββββββ΄ββββββββββββββββββββ΄βββββββββ΄βββββββββββ΄ββββββββββ΄βββββββββ΄ββββββββββ
demo.snapshots(start_tick=freeze_end, end_tick=freeze_end + 640) # every tick of those 10 seconds
For arbitrary networked properties beyond what snapshots curates, ticks
returns one row per player per tick β keyed by steamid, natively typed, and
decoded in parallel:
demo.ticks() # default: X, Y, Z, health, armor, team_num
demo.ticks(["health", "m_iTeamNum"])
# columns: tick, steamid, health, m_iTeamNum
Names accept friendly aliases (X/Y/Z for computed position, health,
armor, team_num, name, money) or raw CS2 field names.
Maps: line-of-sight and radar assetsΒΆ
Positions are in world coordinates (Hammer units, Z-up). To reason about them
spatially, Awpy fetches per-map assets β collision meshes, radar images, and
coordinate transforms β from awpy-data,
downloading on first use and caching under ~/.awpy:
from awpy import VisibilityChecker
vc = VisibilityChecker(demo.header["map_name"]) # downloads the mesh if needed
kill = demo.kills.row(0, named=True)
vc.is_visible(
(kill["attacker_x"], kill["attacker_y"], kill["attacker_z"]),
(kill["victim_x"], kill["victim_y"], kill["victim_z"]),
) # True β a clean line of sight (this one was an AWP pick)
Pin a game version with version= (an integer ClientVersion like 2000873),
or manage the cache from the shell: awpy get, awpy versions, awpy clear.
See ποΈ Visibility & map data.
Draw itΒΆ
With the awpy[plot] extra, awpy.plot renders game states and heatmaps on
the mapβs radar image. Death locations for the whole match:
from awpy import plot
deaths = demo.kills.select(["victim_x", "victim_y", "victim_z"]).rows()
fig, ax = plot.heatmap(demo.header["map_name"], deaths, method="kde")
fig.savefig("deaths.png")
And because snapshots rows carry exactly what a marker needs, any moment of
the match is a frame:
snap = demo.snapshots(ticks=freeze_end + 640)
players = [
plot.Player(x=r["x"], y=r["y"], z=r["z"], yaw=r["yaw"], hp=r["health"],
armor=r["armor"], side=r["side"], label=r["name"])
for r in snap.iter_rows(named=True)
]
fig, ax = plot.frame(demo.header["map_name"], players)
fig.savefig("round5.png")
Multi-level maps, animated GIFs, and layered plots are covered in π Plotting.
The command lineΒΆ
Everything above has a no-code counterpart β installing the package installs
the awpy command:
awpy info match.dem # header + playback info
awpy stats match.dem # the scoreboard
awpy kills match.dem --limit 10 # first ten kills
awpy events match.dem --summary # event counts
awpy rounds match.dem --json # any table as JSON
awpy get # prefetch the latest map assets
See π» Command-line tool for the full command list.
Where nextΒΆ
π Datasets β every dataset, every column.
π Plotting β frames, heatmaps, multi-level maps, GIFs.
ποΈ Visibility & map data β line-of-sight, map data, and the asset cache.
π API reference β the full API reference.
β FAQ β common questions and sharp edges.