πŸš€ 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ΒΆ