gem
v0.13.0

Reading Entity State

Entities are the game objects inside a replay: heroes, towers, creeps, the game rules object, runes, wards. Their state changes every tick. This guide shows how to subscribe to entity events and read field values.

For how entities are created, updated, and removed, and when your handler is called, see How Entities Are Decoded, Part 5: Entity Lifecycle. For where entity parsing fits into the overall pipeline, see How Proto Parsing Works.


Subscribing to entity events

Register a callback with parser.on_entity(handler) before calling parse(). The callback receives (entity, op) for every entity event in the replay.

from gem.parser import ReplayParser
from gem.state.entities import EntityOp

parser = ReplayParser("my_replay.dem")

def on_entity(entity, op):
    print(entity.get_class_name(), op)

parser.on_entity(on_entity)
parser.parse()

EntityOp flags

op is an EntityOp bitmask. Common patterns:

if op & EntityOp.CREATED:
    ...  # entity was just created
if op & EntityOp.UPDATED:
    ...  # one or more fields changed
if op & EntityOp.DELETED:
    ...  # entity was removed
if op & EntityOp.ENTERED:
    ...  # entity became active (accompanies CREATED or a re-activation)

A deleted entity arrives with LEFT | DELETED. Entities in the replays gem was checked against never leave without being deleted.

EntityOp.has(other) is equivalent to bool(op & other).


Reading field values

Every entity exposes typed getter methods. Field names come from the entity class schema (e.g. m_iHealth, m_flMana, m_iCurrentLevel).

hp    = entity.get_int32("m_iHealth")
mana  = entity.get_float32("m_flMana")
level = entity.get_int32("m_iCurrentLevel")
life  = entity.get_int32("m_lifeState")   # 0 = alive
team  = entity.get_int32("m_iTeamNum")    # 2 = Radiant, 3 = Dire

All typed getters return the value, or None if the field does not exist or the value is the wrong type. Guard with is not None rather than a truthiness check, since 0, 0.0, and False are all valid values:

hp = entity.get_int32("m_iHealth")
if hp is not None:
    ...  # hp is a valid int (possibly 0)

For quick untyped access:

val = entity.get("m_iHealth")  # returns the raw value, or None
if entity.exists("m_iHealth"):
    ...

To see everything an entity holds, entity.to_map() returns every stored value by the same names.


Filtering by class name

Most callbacks should filter by class name immediately — there are hundreds of entity classes and you usually only care about a few:

def on_entity(entity, op):
    name = entity.get_class_name()
    if "Hero" not in name:
        return
    # now work with hero entities only

Common class name patterns:

PatternMatches
name.startswith("CDOTA_Unit_Hero_")Hero units
name == "CDOTAPlayerController"One per connected player (casters too): owns the player's hero
name == "CDOTA_PlayerResource"Per-player kills, deaths, assists, level
name in ("CDOTA_DataRadiant", "CDOTA_DataDire")Per-player gold, XP, net worth, last hits
name == "CDOTAGamerulesProxy"Game rules: game state, draft, start time
name == "CDOTA_BaseNPC_Tower"Towers
name == "CDOTA_NPC_Observer_Ward"Placed observer wards
name == "CDOTA_NPC_Observer_Ward_TrueSight"Placed sentry wards

Hero position example

A unit's map position is split into a cell, one of a grid of 128-unit squares, and an offset inside that cell, from 0 to 256 (see Part 3: Field Decoders). gem's extractors combine them as cell × 128 + offset:

def world_coord(cell: int, offset: float) -> float:
    """Combine a cell and its offset into one coordinate, as gem does."""
    return cell * 128.0 + offset

def on_entity(entity, op):
    if not entity.get_class_name().startswith("CDOTA_Unit_Hero_"):
        return

    cell_x = entity.get_uint32("CBodyComponent.m_cellX")
    cell_y = entity.get_uint32("CBodyComponent.m_cellY")
    vec_x  = entity.get_float32("CBodyComponent.m_vecX")
    vec_y  = entity.get_float32("CBodyComponent.m_vecY")

    if None not in (cell_x, cell_y, vec_x, vec_y):
        x = world_coord(cell_x, vec_x)
        y = world_coord(cell_y, vec_y)
        print(f"{entity.get_class_name()} at ({x:.0f}, {y:.0f})")

Snapshot at a specific tick

To inspect all entities at a fixed point in time, stop parsing at that tick and query the entity manager afterwards:

from gem.parser import ReplayParser

parser = ReplayParser("my_replay.dem")
parser.stop_after_tick(6000)   # 200 s into the recording, not the game clock
parser.parse()

for entity in parser.entity_manager.all_active():
    if entity.get_class_name().startswith("CDOTA_Unit_Hero_"):
        hp = entity.get_int32("m_iHealth")
        print(f"{entity.get_class_name()}: {hp} HP")

Useful entity classes and fields

These names are checked against a full replay from game build 6808.

Hero entity (CDOTA_Unit_Hero_*)

FieldMeaning
m_iHealth, m_iMaxHealthCurrent and maximum HP
m_flMana, m_flMaxManaCurrent and maximum mana
m_iCurrentLevelHero level
m_iCurrentXPXP towards the next level (resets at each level-up)
m_lifeState0 while alive
m_iTeamNum2 = Radiant, 3 = Dire
CBodyComponent.m_cellX, m_cellYMap cell (coarse)
CBodyComponent.m_vecX, m_vecYOffset inside the cell (fine)
m_hOwnerEntityHandle to the owning CDOTAPlayerController

Player controller (CDOTAPlayerController)

FieldMeaning
m_nPlayerIDPlayer ID, stored doubled (10 is player 5)
m_hAssignedHeroHandle to the player's hero; the invalid handle 16,777,215 before a hero is assigned

Team data (CDOTA_DataRadiant, CDOTA_DataDire)

One row per player, m_vecDataTeam.0000 to .0004:

FieldMeaning
m_vecDataTeam.0000.m_iNetWorthNet worth
m_vecDataTeam.0000.m_iTotalEarnedGoldGold earned so far (only goes up)
m_vecDataTeam.0000.m_iReliableGold, …m_iUnreliableGoldSpendable gold, in two parts
m_vecDataTeam.0000.m_iLastHitCount, …m_iDenyCountLast hits and denies

Player resource (CDOTA_PlayerResource)

FieldMeaning
m_vecPlayerTeamData.0000.m_iKills, …m_iDeaths, …m_iAssistsK/D/A
m_vecPlayerTeamData.0000.m_iLevelHero level
m_vecPlayerTeamData.0000.m_hSelectedHeroHandle to the selected hero

Game rules (CDOTAGamerulesProxy)

FieldMeaning
m_pGameRules.m_nGameStateGame state enum
m_pGameRules.m_flGameStartTimeEngine time the game clock started (0 before)
m_pGameRules.m_nTotalPausedTicksTicks spent paused so far

For the in-game clock, use match.game_clock (or parser.game_time_s during a parse) rather than computing it from ticks: ticks keep running during pauses.


gem implementation

Source: src/gem/state/entities.py, src/gem/schema/field_state.py

EntityManager.all_active() returns all currently active entities. EntityManager.find_by_handle(handle) resolves an entity handle.