gem
v0.13.0

Time-Series & DataFrames

gem samples entity state during parsing and assembles per-minute advantage curves, position logs, player snapshots, and event tables. This guide shows how to use those outputs from the high-level API and, when needed, from the lower-level extractor API.

Per-minute advantage curves

match.radiant_gold_adv and match.radiant_xp_adv are lists of integers, one entry per game minute. match.game_times_min is their parallel, authoritative game-relative axis in seconds (0, 60, 120, ...). Positive values favor Radiant; negative values favor Dire.

import gem

match = gem.parse("my_replay.dem")

print("Minute  Gold adv  XP adv")
for game_time_s, gold, xp in zip(
    match.game_times_min,
    match.radiant_gold_adv,
    match.radiant_xp_adv,
    strict=True,
):
    minute = game_time_s // 60
    gold_sign = "+" if gold >= 0 else ""
    xp_sign = "+" if xp >= 0 else ""
    print(f"{minute:>6}  {gold_sign}{gold:>8,}  {xp_sign}{xp:>7,}")

The advantage curves use total earned gold and XP, not current spendable gold or current level XP:

FieldEntityBehavior
m_iTotalEarnedGoldCDOTA_DataRadiant/DireMonotonic; use for gold advantage
m_iTotalEarnedXPCDOTA_DataRadiant/DireMonotonic; use for XP advantage
m_iGoldCDOTAPlayerControllerSpendable cash; drops when items are bought
m_iCurrentXPHero entityResets to 0 on level-up

DataFrame export

gem.parse_to_dataframe() returns a dict of flat pandas DataFrames:

import gem

frames = gem.parse_to_dataframe("my_replay.dem")

print(sorted(frames))
summary = frames["player_summary"]
combat = frames["combat_log"]

Every table starts with a match_id column (0 when the replay carries no match ID), so tables from different replays can be concatenated and joined. Core tables hold only primitive cells: list-valued fields are joined with ";", and per-player dict statistics are exported in long form. Nested records keep their full structure in the dataclass and JSON output.

Core tables (always returned):

KeyContents
matchSingle-row match metadata and final status bitmasks
player_summaryOne row per player: identity, K/D/A, final net worth/LH/DN, GPM/XPM, damage/healing totals, lane stats, damage-type split, largest hero hit, consumed Aghanim's/Moon Shard flags
player_timeseriesPer-player sampled state: tick, gold, total_earned_gold, total_earned_xp, net_worth, lh, dn, xp, and the hero's hp, max_hp, mana, max_mana
players_minutePer-player series resampled to one row per game minute, with game_time_s / minute join keys
player_breakdownsLong-form per-player dict stats: (player_id, stat, key, subkey, value)
positionsPer-player world (x, y) positions over time
radiant_advantageRadiant gold/XP advantage per minute, with game_time_s / minute join keys
combat_logRaw normalized combat log entries
wardsWard placement events with coordinates
objectivesTyped Gem objective rows such as towers, barracks, Roshan, tormentors, couriers
chatChat messages
draftPick and ban events
fightsGem fight windows, one row per fight with a fight_index
fight_playersPer-fight, per-player stats (deaths, damage, healing, gold/XP delta)
smoke_eventsSmoke activations; smoked is a ";"-joined hero list
smoke_membersFlat per-hero smoke application/removal timing and sampled positions
courier_snapshotsCourier state over time
neutral_item_findsNeutral item find events from DOTA_UM_FoundNeutralItem
hero_visibility_eventsAuthoritative per-team hero visibility transitions
entity_visibilityPer-team visibility transitions for tracked NPCs
vision_modifiersVision-granting modifier lifecycles; evidence_gaps is ";"-joined
vision_modifier_pairing_issuesModifier add/remove pairing diagnostics
player_kills_logPer-player kill log rows
player_purchase_logPer-player purchase log rows
player_runes_logPer-player rune pickup log rows
player_buyback_logPer-player buyback log rows

Optional groups (pass include=[...]):

GroupTables
"analysis"fight_positioning, roshan_conversions, roshan_conversion_fights, farming_routes, farming_route_segments, farming_route_points
"gold_ledger"player_gold_ledger (one row per player, at game end), player_gold_ledger_minutes (one row per player-minute)
"opendota"opendota_objectives, opendota_teamfights
frames = gem.parse_to_dataframe("my_replay.dem", include=["analysis"])
segments = frames["farming_route_segments"]

The analysis group runs the post-parse farming, Roshan and fight-positioning analyses, so it adds several seconds per replay. Leave it out when you only need the core tables.

Player tables

summary = frames["player_summary"]
series = frames["player_timeseries"]

print(summary[["player_id", "hero_name", "kills", "deaths", "assists", "net_worth"]])

# Join end-of-game scalars onto the sampled series when you need both.
joined = series.merge(summary[["match_id", "player_id", "lane_role"]], on=["match_id", "player_id"])

The hp, max_hp, mana and max_mana columns are the hero entity's own state at each sample (ParsedPlayer.hp_t, max_hp_t, mana_t, max_mana_t, parallel to times). HP is 0 while the hero is dead. A match loaded from JSON written before these fields existed has no hero state, and the columns are missing values, not 0. Samples are about one a second, so a burst of damage between two samples shows only as the drop between them.

p = match.players[0]
low = [tick for tick, hp, top in zip(p.times, p.hp_t, p.max_hp_t) if 0 < hp < 0.25 * top]

player_breakdowns holds the per-player dict statistics (damage, damage_targets, ability_uses, item_uses, purchase, gold_reasons, lane_pos, and so on) in long form. stat is the ParsedPlayer field name; subkey is set only for two-level fields such as damage_targets (inflictor -> target -> damage):

breakdowns = frames["player_breakdowns"]
axe_targets = breakdowns[(breakdowns["player_id"] == 0) & (breakdowns["stat"] == "damage_targets")]
by_target = axe_targets.groupby("subkey")["value"].sum()

Positions table

positions = frames["positions"]
axe_positions = positions[positions["hero_name"] == "npc_dota_hero_axe"]

print(axe_positions[["tick", "x", "y"]].head())

Positions are split into a dedicated table so movement-heavy analysis does not bloat the player-state tables.

Many replays

Use gem.parse_many_to_parquet() to write one Parquet directory per replay, then load a single table across all of them with gem.read_parquet_table():

gem.parse_many_to_parquet("replays/", "./out", workers=4)
combat = gem.read_parquet_table("./out", "combat_log")  # adds a `replay` column

Memory stays bounded by the worker count while parsing, and loading scales with the one table you read. gem.parse_many_to_dataframe() is deprecated: it keeps every parsed match and every table in memory until the batch finishes.

Plot gold advantage

import matplotlib.pyplot as plt
import gem

match = gem.parse("my_replay.dem")

minutes = [game_time_s / 60 for game_time_s in match.game_times_min]
gold_adv = match.radiant_gold_adv

fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(minutes, gold_adv)
ax.axhline(0, color="gray", linewidth=0.8)
ax.set_xlabel("Game minute")
ax.set_ylabel("Radiant gold advantage")
fig.tight_layout()
fig.savefig("gold_adv.png", dpi=150)

Low-level player sampling

When you need a custom sampling interval, attach PlayerExtractor directly to a ReplayParser:

from gem.extractors.players import PlayerExtractor
from gem.parser import ReplayParser

parser = ReplayParser("my_replay.dem")
players = PlayerExtractor(sample_interval=150)  # every 150 ticks, roughly 5 seconds
players.attach(parser)

parser.parse()

series = players.time_series(player_id=0)

print(series.ticks[:5])
print(series.gold_t[:5])
print(series.x_t[:5])
print(series.y_t[:5])

PlayerTimeSeries fields include player_id, ticks, gold_t, total_earned_gold_t, total_earned_xp_t, net_worth_t, lh_t, dn_t, xp_t, hp_t, max_hp_t, mana_t, max_mana_t, x_t, y_t, total_hero_damage_t, total_hero_healing_t, total_deaths_t, and total_stuns_t. The object returned by minute_time_series() also populates game_times_s with its exact game-relative minute boundaries; the dense time_series() output leaves that axis empty.

For most analysis code, prefer gem.parse() or gem.parse_to_dataframe() and use the lower-level extractor only when you need a different sampling interval or custom parser callbacks.