Skip to content

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 the two ingestion paths and edge cases, see Understanding: The Combat Log.


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] += 1

When 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").

Note

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.value

Ability 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] += 1

Ward 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 timelines
  • examples/extraction_demo.py — developer guide for custom combat log handlers