gem
v0.13.0

Full Match Data

gem.parse() returns a ParsedMatch object containing everything extracted from the replay. This guide walks through the fields.


Parsing a replay

import gem

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

gem.parse() runs the complete pipeline: entity parsing, combat log, game events, all extractors. A typical 45-minute replay takes 2–4 seconds.


Match-level fields

match.match_id             # int: Valve match ID
match.duration             # int: in-game match length in seconds (as OpenDota reports it)
match.duration_seconds     # float: raw recording span from the horn, incl. pauses and post-game
match.duration_minutes     # float: convenience property over duration_seconds
match.radiant_win          # bool | None
match.game_mode            # int: game mode enum
match.leagueid             # int: league ID (0 for non-league)

match.game_start_tick      # int | None: tick when creeps spawned
match.game_end_tick        # int: last tick observed by the parser (end of the recording)
match.post_game_tick       # int | None: tick the Ancient fell (the match actually ended)
match.game_clock           # GameClock | None: pause-aware tick <-> in-game time conversion

match.radiant_gold_adv     # list[int]: per-minute Radiant gold advantage
match.radiant_xp_adv       # list[int]: per-minute Radiant XP advantage
match.hero_visibility_events  # list[HeroVisibilityEvent]: change-only hero visibility
match.entity_visibility_events  # list[EntityVisibilityEvent]: networked Dota NPC visibility

In-game time vs replay ticks

Replay ticks (30 per second) are gem's canonical join key, but they keep running while a match is paused, and many replays keep recording for minutes after the Ancient falls. Use match.game_clock whenever you present or export a time the way the in-game clock shows it:

clock = match.game_clock
clock.pauses                          # [GamePause(start_tick=58418, end_tick=59072)]
clock.game_seconds_at(tick)           # whole in-game seconds, as OpenDota reports them
clock.game_time_at(tick)              # exact seconds, frozen during pauses
clock.format_tick(tick)               # "47:46"
clock.tick_at(600)                    # first replay tick at 10:00 on the in-game clock

match.post_game_tick marks the end of the match; match.game_end_tick is the end of the recording, which can be much later.

Hero visibility

Modern Source 2 replays carry a per-team bitset describing which entity slots are visible. gem records state changes for the ten canonical player heroes:

state = gem.hero_visibility_at(
    match,
    player_id=7,
    observing_team=2,  # Radiant
    tick=120_000,
)

if state is gem.VisibilityState.VISIBLE:
    print("Radiant could see the hero")

UNKNOWN is distinct from HIDDEN: it covers missing team data, time before the first sample, and the period after an entity identity is deleted or replaced. The timeline answers entity visibility only; it does not reconstruct fog geometry or attribute the revealing source.

Use gem.entity_visibility_at(...) with an entity index and serial for wards, creeps, buildings, summons, and other active networked Dota NPCs. These records are packet-boundary evidence only; they do not model arbitrary-point fog of war, source attribution, temporary viewers, terrain, or navigation.

Neutral item finds

Modern replays expose neutral item finds directly through DOTA_UM_FoundNeutralItem. gem.parse() stores those replay-observed events on match.neutral_item_finds:

for event in match.neutral_item_finds:
    print(event.tick, event.player_id, event.item_key, event.enhancement_key)

Each event keeps the raw numeric IDs (item_ability_id, enhancement_ability_id) and the resolved constants keys (item_key, enhancement_key). This is not inferred from neutral camp positions.

Team identity (league/tournament games)

For league games, team names, tags, and IDs are extracted from CDOTATeam entities in the replay. These are empty/zero for pub games.

match.radiant_team_id      # int: tournament team ID (e.g. 8261500 for Xtreme Gaming)
match.radiant_team_name    # str: team name (e.g. "Xtreme Gaming")
match.radiant_team_tag     # str: team tag (e.g. "XG")
match.dire_team_id         # int: tournament team ID
match.dire_team_name       # str: team name
match.dire_team_tag        # str: team tag

# Team IDs match the OpenDota /teams/{id} URL
# e.g. https://www.opendota.com/teams/8261500

Players

match.players is a list of 10 ParsedPlayer objects, one per player slot in Valve's ordering (slots 0–4 = Radiant, 5–9 = Dire).

for player in match.players:
    print(player.hero_name)     # "npc_dota_hero_axe"
    print(player.player_id)     # 0–9
    print(player.player_name)   # Steam persona name, e.g. "Ame"
    print(player.team)          # 2 = Radiant, 3 = Dire
    print(player.steam_id)      # 64-bit Steam ID, e.g. 76561198859019881
    print(player.account_id)    # 32-bit account ID, e.g. 898754153

# account_id matches the OpenDota/Dotabuff player URL
# e.g. https://www.opendota.com/players/898754153

Looking up a player by hero name

Instead of iterating match.players manually, use gem.find_player():

import gem

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

# All of these are equivalent:
axe = gem.find_player(match, "Axe")               # display name
axe = gem.find_player(match, "axe")               # case-insensitive
axe = gem.find_player(match, "npc_dota_hero_axe") # full NPC name
am  = gem.find_player(match, "Anti-Mage")         # hyphenated display name
am  = gem.find_player(match, "anti mage")         # spaced variant

if axe:
    print(f"Axe: {axe.kills}/{axe.deaths}/{axe.assists}")

Returns None if the hero was not in the match. For the reverse lookup (display name → NPC name) without a match object, use gem.constants.hero_npc_name():

from gem.constants import hero_npc_name

hero_npc_name("Anti-Mage")   # → "npc_dota_hero_antimage"
hero_npc_name("Shadow Fiend") # → "npc_dota_hero_nevermore"
hero_npc_name("unknown")      # → None

Per-player aggregate stats

player.kills           # int: kills attributed (summons credited to owner)
player.deaths          # int: deaths from any cause (hero, tower, creep, neutral)
player.assists         # int

player.stuns_dealt     # float: total stun seconds dealt to enemy heroes

# End-of-game scalars (simplest values; read these unless you need the curve)
player.net_worth       # int: net worth at game end
player.gold            # int: unspent gold at game end
player.gold_spent      # int: gold spent on items and consumables
player.last_hits       # int: last-hit count at game end
player.denies          # int: deny count at game end

# Permanent consumed-upgrade state from the embedded postgame summary.
# None means that the summary was unavailable; 0 and 1 are exact values.
player.aghanims_scepter  # int | None
player.aghanims_shard    # int | None
player.moonshard         # int | None

# Net worth / gold / XP are also available as per-minute time-series. The minute
# arrays can lag the exact game-end tick by up to ~59s, so prefer the scalars above
# for terminal values and the arrays for curves.
player.game_times_min                 # [0, 60, 120, ...]: authoritative game-time axis
player.net_worth_t_min[-1]          # int: net worth at the last minute sample
player.total_earned_gold_t_min[-1]  # int: cumulative gold at the last minute sample
player.lh_t_min[-1]                 # int: last-hit count at the last minute sample
player.dn_t_min[-1]                 # int: deny count at the last minute sample

# Total damage dealt / received — dicts keyed by target/attacker NPC name
player.damage            # dict[str, int]: damage dealt per target
player.damage_taken      # dict[str, int]: damage received per attacker
sum(player.damage.values())       # total damage dealt across all targets

player.damage_by_type        # dict[str, int]: damage dealt keyed by "physical"/"magical"/"pure"/"others"
player.damage_taken_by_type  # dict[str, int]: damage received keyed by damage type

The upgrade flags describe permanent consumed or granted state. They intentionally do not mean that the corresponding item was merely held in inventory.

Time-series logs

player.purchase_log    # list[CombatLogEntry]: item purchases in order (value_name = item)
player.runes_log       # list[CombatLogEntry]: rune pickups (rune_type = rune)
player.buyback_log     # list[CombatLogEntry]: raw BUYBACK entries
player.buybacks        # list[BuybackEvent]: one per buyback, with its gold cost

for bb in player.buybacks:
    # cost is exact when cost_exact, else estimated as 200 + net_worth // 13.
    # reliable_gold / unreliable_gold estimate how it was paid (or None).
    print(bb.tick, bb.cost, bb.cost_exact, bb.reliable_gold, bb.unreliable_gold)

player.lane_pos        # dict[str, dict[str, int]]: {cell x: {cell y: samples}} up to 10:00

player.lane                # int: 1=bot, 2=mid, 3=top, 4=Radiant jungle, 5=Dire jungle, 0=unknown
player.lane_role           # int: 1=safe, 2=mid, 3=off, 4=jungle, 0=unknown
player.is_roaming          # bool: most common lane holds under 45% of lane_pos samples
player.lane_last_hits      # int: last-hit count at the 10-minute mark
player.lane_denies         # int: deny count at the 10-minute mark
player.lane_total_gold     # int: cumulative total earned gold at the 10-minute mark
player.lane_total_xp       # int: cumulative total earned XP at the 10-minute mark
player.lane_efficiency_pct # int: floor(lane_total_gold / 4948 × 100); can exceed 100
player.lane_gold_adv       # int | None: gold vs lane opponents at 10 min (None for jungle)
player.lane_xp_adv         # int | None: XP vs lane opponents at 10 min (None for jungle)

Gold ledger

player.gold_ledger breaks a player's gold down into where it came from and where it went. It reads the running totals the game keeps per player: gold earned by source (hero kills, creeps, neutrals, passive income, Roshan, buildings, bounty runes, …), gold spent by category (items, consumables, support items, buybacks), and gold lost on death. final is read at the game-end tick; per_minute is parallel to player.game_times_min. It is None when the replay's team data has no complete ledger.

ledger = player.gold_ledger
if ledger is not None and ledger.final is not None:
    final = ledger.final
    print(final.hero_kill_gold, final.creep_kill_gold, final.income_gold)
    print(final.spent_on_items + final.spent_on_consumables)  # == player.gold_spent
    print(final.spent_on_buybacks, final.lost_to_death)

    # Gold from hero kills by minute
    for snap in ledger.per_minute:
        print(snap.game_time_s, snap.hero_kill_gold)

The earned sources sum to the player's total earned gold, except for a gold source a newer patch adds before the replay schema has a field for it. shared_gold is already counted in them, and spent_on_support is already part of items and consumables. In DataFrames, pass include="gold_ledger" for the player_gold_ledger and player_gold_ledger_minutes tables.

lane_pos counts one position sample per second up to game time 600 s, pre-horn included, in OpenDota map cells (world units / 128). lane, lane_role and is_roaming are computed from it as OpenDota computes them — see Lane Classification below.


Lane classification

lane_role comes from each hero's lane_pos heatmap, as OpenDota computes it: each map cell belongs to a lane, and the most common lane wins. Roaming is a separate flag, is_roaming, not a role. See the Laning guide for the details.

lane_roleLabelDescription
1Safe laneRadiant bottom / Dire top
2Mid laneDiagonal and central band
3Off laneRadiant top / Dire bottom
4JungleEither jungle
0UnknownNo position sample on the lane grid
LANE_NAMES = {1: "Safe", 2: "Mid", 3: "Off", 4: "Jungle", 0: "Unknown"}

for player in match.players:
    from gem.constants import hero_display
    print(
        f"{hero_display(player.hero_name):<20}"
        f"  lane: {LANE_NAMES[player.lane_role]:<8}"
        f"  LH@10: {player.lane_last_hits:>3}"
        f"  gold@10: {player.lane_total_gold:>6,}"
    )

The safe/off assignment is team-aware: Radiant safe lane is the bottom-right of the map, Dire safe lane is the top-left. See Laning Analysis for a full explanation of the lane grid, the lane efficiency formula, and gold/XP advantage computation.


Damage type breakdown

player.damage_by_type        # {"physical": int, "magical": int, "pure": int, "others": int}
player.damage_taken_by_type  # same keys

These are populated from the damage_type field on DAMAGE combat log entries. The "others" bucket covers damage to non-hero units (creeps, wards, zombies) where Valve does not populate the type field.

for player in match.players:
    d = player.damage_by_type
    total = sum(d.values())
    if total:
        phys_pct = 100 * d.get("physical", 0) / total
        mag_pct  = 100 * d.get("magical",  0) / total
        pure_pct = 100 * d.get("pure",     0) / total
        print(f"{hero_display(player.hero_name)}: "
              f"phys {phys_pct:.0f}%  mag {mag_pct:.0f}%  pure {pure_pct:.0f}%")

Draft

match.draft is a list of DraftEvent objects in chronological order:

for event in match.draft:
    team   = "Radiant" if event.team == 2 else "Dire"
    action = "picks" if event.is_pick else "bans"
    print(f"  {team} {action} {event.hero_name}")

DraftEvent fields: tick, slot_index, hero_id, hero_name, is_pick, team.


Wards

match.wards is a list of WardEvent objects:

for ward in match.wards:
    print(
        f"tick {ward.tick:,}: "
        f"{ward.placer} places {ward.ward_type} ward "
        f"at ({ward.x:.0f}, {ward.y:.0f})"
    )

WardEvent fields: tick, player_id, placer, ward_type ("observer" or "sentry"), team, x, y, expires_tick (or None), killed_tick (or None), killer (or None).


Objectives

Tower kills

match.towers   # list[TowerKill]: tower kill events in chronological order
# TowerKill fields: tick, team (losing team), killer (NPC name), tower_name

Roshan kills

match.roshans   # list[RoshanKill]: each Roshan kill event
# RoshanKill fields: tick, killer (NPC name), kill_number, drops

Barracks

match.barracks   # list[BarracksKill]: barracks destruction events
# BarracksKill fields: tick, team (losing team), killer (NPC name), barracks_name

Aegis events

match.aegis_events   # list[AegisEvent]
# AegisEvent fields: tick, player_id, event_type ("pickup"/"stolen"/"denied")

Chat

match.chat   # list[ChatEntry]
# ChatEntry fields: tick, player_slot, channel, text

Fights

match.fights   # list[Fight]

See Fight Detection for a full walkthrough.


Example: print a match summary

import gem
from gem.constants import hero_display

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

result = "Radiant" if match.radiant_win else "Dire"
print(f"Winner: {result}")
print(f"Duration: {match.duration_minutes:.1f} min")
print()

for player in match.players:
    hero = hero_display(player.hero_name)
    team = "R" if player.team == 2 else "D"
    nw = player.net_worth_t_min[-1] if player.net_worth_t_min else 0
    dmg = sum(player.damage.values())
    print(
        f"[{team}] {hero:<20}"
        f"  {player.kills}/{player.deaths}/{player.assists}"
        f"  {nw:>7,} NW"
        f"  {dmg:>8,} dmg"
    )