Using the Combat Log
The combat log records every meaningful game event at the entry level: damage, heals, kills, ability uses, item uses, gold changes, ward placements, buybacks. This guide shows how to subscribe to entries and derive common statistics.
For a conceptual explanation of parser stages and message routing, see How Proto Parsing Works.
Basic subscription
from gem.parser import ReplayParser
parser = ReplayParser("my_replay.dem")
def on_entry(entry):
print(entry.tick, entry.log_type, entry.attacker_name, "->", entry.target_name)
parser.on_combat_log_entry(on_entry)
parser.parse()Multiple handlers can be registered. All are called for every entry in arrival order.
Filtering by log type
The most useful filter — only process the entries you care about:
def on_entry(entry):
match entry.log_type:
case "DAMAGE":
...
case "DEATH":
...
case "MODIFIER_ADD":
...Or with a string comparison:
if entry.log_type == "DAMAGE" and entry.attacker_is_hero:
...Hero damage totals
from collections import defaultdict
from gem.parser import ReplayParser
damage_by_hero: dict[str, int] = defaultdict(int)
def on_entry(entry):
if entry.log_type == "DAMAGE" and entry.attacker_is_hero:
damage_by_hero[entry.attacker_name] += entry.value
parser = ReplayParser("my_replay.dem")
parser.on_combat_log_entry(on_entry)
parser.parse()
for hero, total in sorted(damage_by_hero.items(), key=lambda x: -x[1]):
print(f"{hero}: {total:,} damage")Hero kill log
kills = []
def on_entry(entry):
# Both must be heroes for a hero kill
if (entry.log_type == "DEATH"
and entry.attacker_is_hero
and entry.target_is_hero):
kills.append(entry)
parser.on_combat_log_entry(on_entry)
parser.parse()
for k in kills:
via = k.inflictor_name or "auto-attack"
t = k.tick / 30 # ticks to seconds
print(f"{t:6.0f}s {k.attacker_name} kills {k.target_name} [{via}]")Note: attacker_is_hero or target_is_hero would include tower kills and creep deaths. Use attacker_is_hero and target_is_hero for hero-vs-hero only.
Kill count with summon attribution
Summoned units (Warlock Golem, Undying Zombie, Pugna Nether Ward) show up as the attacker_name on DEATH events, not the player hero. gem's CombatAggregator handles this automatically when you use gem.parse(). For manual tracking:
# Map summoned unit name → owning hero name (built from entity stream)
summon_owner: dict[str, str] = {}
def on_entity(entity, op):
if not (op & EntityOp.CREATED):
return
name = entity.get_class_name()
if "Warlock_Golem" in name or "Zombie" in name:
# resolve owner via entity handle...
pass
kill_count: dict[str, int] = defaultdict(int)
def on_entry(entry):
if entry.log_type == "DEATH" and entry.target_is_hero:
attacker = summon_owner.get(entry.attacker_name, entry.attacker_name)
kill_count[attacker] += 1When using gem.parse(), kills credited to summons are attributed to the owning hero automatically in player.kills.
Damage type breakdown
DAMAGE entries include a damage_type field ("physical", "magical", "pure", or "" for unset). This lets you break down hero damage by school:
from collections import defaultdict
damage_by_type: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
def on_entry(entry):
if entry.log_type == "DAMAGE" and entry.attacker_is_hero and entry.damage_type:
damage_by_type[entry.attacker_name][entry.damage_type] += entry.value
parser.on_combat_log_entry(on_entry)
parser.parse()
for hero, by_type in sorted(damage_by_type.items()):
total = sum(by_type.values())
print(f"{hero}: {dict(by_type)} (total {total:,})")When using gem.parse(), the breakdown is pre-aggregated on each ParsedPlayer as damage_by_type and damage_taken_by_type (keys: "physical", "magical", "pure", "others").
INFO
damage_type is only populated for S2 combat log entries (modern replays). The "others" bucket in the pre-aggregated dicts covers damage against non-hero units where Valve does not set the type field.
Healing totals
healing: dict[str, int] = defaultdict(int)
def on_entry(entry):
if entry.log_type == "HEAL" and entry.attacker_is_hero:
healing[entry.attacker_name] += entry.valueAbility usage count
from collections import Counter
ability_uses: Counter[str] = Counter()
def on_entry(entry):
if entry.log_type == "ABILITY" and entry.attacker_is_hero:
ability_uses[entry.inflictor_name] += 1Ward placements
Ward placements appear as ITEM log entries:
_WARD_ITEMS = frozenset({
"item_ward_observer",
"item_ward_dispenser",
"item_ward_sentry",
})
ward_events = []
def on_entry(entry):
if entry.log_type == "ITEM" and entry.inflictor_name in _WARD_ITEMS:
ward_events.append(entry)The combat log records who placed the ward and when, but not where. For coordinates, use the entity stream (see WardsExtractor in Full Match Data or gem's WardsExtractor).
Buybacks
buybacks = []
def on_entry(entry):
if entry.log_type == "BUYBACK":
buybacks.append({
"tick": entry.tick,
"player_slot": entry.value, # value field holds the player slot
})Display names
gem.constants provides human-readable names backed by bundled dotaconstants data:
from gem.constants import hero_display, item_display, ability_display
hero_display("npc_dota_hero_axe") # → "Axe"
item_display("item_blink") # → "Blink Dagger"
ability_display("nevermore_shadowraze1") # → "Shadowraze"ability_display() handles Aghanim's Scepter and Shard abilities correctly. These abilities use internal names like arc_warden_scepter or ability_lamp_use that do not appear in the dotaconstants abilities table. gem falls back to stripping the hero prefix and title-casing the remainder:
ability_display("arc_warden_scepter") # → "Scepter"
ability_display("ability_lamp_use") # → "Lamp Use"
ability_display("zuus_shard") # → "Shard"Previously these returned the raw internal string. If you compare ability names across replays, use the raw entry.inflictor_name for stable matching rather than the display name.
Full working examples
examples/match_report.py— full dashboard including combat log, kills, and vision timelinesexamples/extraction_demo.py— developer guide for custom combat log handlers
See also
gem.group_ability_hits()— collapse multi-target DAMAGE entries into per-castAbilityCastrecords (Ravage, Black Hole, RP, etc.)gem.ability_level_at_tick()— look up what level an ability was when it was castgem.teamfight_at_tick()— find the teamfight window containing any combat log event