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_role | Label | Description |
|---|---|---|
| 1 | Safe lane | Radiant bottom / Dire top |
| 2 | Mid lane | Diagonal and central band |
| 3 | Off lane | Radiant top / Dire bottom |
| 4 | Jungle | Either jungle |
| 0 | Unknown | No 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"
)