📖 Examples¶
These examples show common Deadlock replay analysis tasks.
Resolving IDs to names¶
Boon DataFrames use integer IDs for heroes, teams, abilities, and modifiers. The IDs keep data compact and make operations fast. A change to a display name does not change its ID. Changes to internal names can change hashed IDs.
Use the mapping functions to resolve IDs. ability_names() and modifier_names()
return internal names. ability_display_names() returns localized ability labels.
ID lookups return dict[int, str]. ability_display_names() returns
dict[str, str], keyed by internal ability name:
from boon import hero_names, team_names, ability_names, modifier_names, game_mode_names
print(hero_names(version="6712"))
print(team_names())
print(ability_names(version="6712"))
print(modifier_names(version="6712"))
print(game_mode_names())
Use these with Polars replace_strict to add name columns, or with dict.get when iterating rows:
import polars as pl
from boon import Demo, hero_names
demo = Demo("match.dem")
heroes = hero_names()
# Add a hero name column to any DataFrame with a hero_id column
players = demo.players.with_columns(
pl.col("hero_id").replace_strict(heroes, default="Unknown").alias("hero")
)
# Or resolve when iterating
for row in demo.players.iter_rows(named=True):
print(heroes.get(row["hero_id"], "Unknown"))
Stats, states, and ammo¶
These examples use 108575009.dem at demo tick 187554. Replace the filename,
tick, and client version for another replay. List versions with boon versions.
Install the matching version with boon get VERSION. A query downloads a missing
version; it does not choose a version from the demo header.
Run this setup, then your selected example:
import polars as pl
from boon import AbilityStat, Demo, HeroStat, StatMode
version = "6712"
tick = 187554
demo = Demo("108575009.dem", preload=False)
roster = demo.players.select("steam_id", "player_name", "team_num")
steam_id = roster["steam_id"][0]
All hero stats for every player¶
report = demo.calculate_hero_stats(
ticks=tick, # A list, such as [187554, 187600], also works.
data_version=version,
stats=list(HeroStat),
mode=StatMode.CURRENT,
explain=True,
strict=False,
)
values = report.values.join(roster, on="steam_id", how="left", validate="m:1")
print(values.sort("tick", "player_name", "stat"))
print(values.filter(pl.col("status") != "calculated"))
print(report.contributions.filter(pl.col("stat") == "clip_size"))
print(report.metadata)
Without steam_ids, the query selects all players. Add steam_ids=[steam_id] to select one
account. heroes=[hero_id] selects the hero at each tick; an account can change
heroes. list(HeroStat) includes each stat once, despite the AMMO alias.
Use enum members or matching strings, such as
stats=["clip_size", "fire_rate", "bullet_resist"]. See the
complete hero stat table for all names and units.
fire_rate and weapon_damage return bonus percentages. clip_size returns
finite capacity. sprint_speed is the additional sprint component; add it to
move_speed for nominal full sprint speed.
strict=False keeps unresolved rows with null values. A partial value is a
known subtotal, not a verified final value. Always retain status and
diagnostic when you export a report. Do not sum contribution rows: they can
include intermediate inputs, such as spirit power.
Compare baseline and current values¶
query = dict(
ticks=tick,
data_version=version,
steam_ids=[steam_id],
stats=[HeroStat.CLIP_SIZE, HeroStat.FIRE_RATE],
strict=False,
)
current = demo.calculate_hero_stats(**query, mode="current").values
baseline = demo.calculate_hero_stats(**query, mode="baseline").values
keys = ["tick", "steam_id", "stat"]
comparison = current.join(
baseline.select(
*keys,
pl.col("value").alias("baseline_value"),
pl.col("status").alias("baseline_status"),
pl.col("diagnostic").alias("baseline_diagnostic"),
),
on=keys,
how="left",
validate="1:1",
)
print(comparison)
The mode table lists the two strings and enum members. The two modes use the selected tick. Each calculation applies its equation to the selected inputs. Current movement values do not include crouching, sprint acceleration, and bullet-hit slows.
Join recorded states to stat rows¶
states = demo.player_states(ticks=tick, data_version=version)
with_states = values.filter(pl.col("steam_id").is_not_null()).join(
states.filter(pl.col("steam_id").is_not_null()).select(
"tick", "steam_id", "states", "unknown_states"
),
on=["tick", "steam_id"],
how="left",
validate="m:1",
)
print(with_states.select("player_name", "stat", "value", "states"))
print(states.filter(pl.col("states").list.contains("IN_COMBAT")))
Run the hero-stat example first to create values. Use steam_id for roster
joins, and tick plus steam_id for sampled results. Do not combine null IDs;
never match them as one account. Use each row’s hero_id for its hero at that tick.
The roster’s hero ID describes the final hero.
State names come from the catalog, not a fixed enum. See
available state names to list them.
An empty known-state list does not prove that no states are set: inspect
unknown_states. A null mask means unavailable data.
Remaining rounds and unlimited ammo¶
ammo = demo.snapshots(ticks=tick, data_version=version)
print(ammo.select(
"tick", "steam_id", "hero_id", "ammo_fraction", "ammo", "max_ammo",
"unlimited_ammo", "ammo_status", "ammo_diagnostic",
))
Without data_version, snapshots include the recorded ammo_fraction but do
not calculate rounds or capacity. With it, ammo is ammo_fraction * max_ammo,
rounded to the nearest integer; halves round up. max_ammo stays finite when
unlimited_ammo is true. Missing or duplicate Steam IDs leave calculated ammo
fields null. Partial capacity also makes the round count partial.
Ability bonuses and imbues¶
imbues = demo.imbues(ticks=tick, steam_ids=[steam_id], data_version=version)
print(imbues.bindings) # Source item and selected ability.
print(imbues.effects) # Individual effects and targeting filters.
ability_report = demo.calculate_ability_stats(
ticks=tick,
data_version=version,
steam_ids=[steam_id],
stats=list(AbilityStat),
mode="current",
include_items=True,
explain=True,
strict=False,
)
print(ability_report.values)
print(ability_report.contributions.select(
"ability_id", "stat", "value", "source", "scope", "state", "included",
"diagnostic",
))
if not imbues.bindings.is_empty():
selected_id = imbues.bindings["ability_id"][0]
selected = demo.calculate_ability_stats(
ticks=tick,
data_version=version,
steam_ids=[steam_id],
abilities=[selected_id],
stats=["cooldown_reduction", "duration_bonus", "range_bonus"],
strict=False,
)
print(selected.values)
The default query selects signature abilities. include_items=True also selects
owned items. abilities accepts owned catalog IDs, not names or signature-slot
numbers. Read recorded IDs from imbues.bindings or demo.ability_ticks.
See the ability stat table for all strings,
enum members, and rules.
Ability results are percentages, not final seconds, meters, or running timers.
item_cooldown_reduction applies to items; cooldown_reduction applies to hero abilities.
not_applicable means that a rule does not apply, such as cooldown scaling on a
disabled item. Imbue effects are individual catalog values; they are not combined
bonuses. included=False contribution rows explain skipped effects.
Select a ruleset¶
from boon import rulesets
result = demo.calculate_hero_stats(
ticks=tick,
data_version=version,
stats=["clip_size", "fire_rate"],
rulesets={
"clip_size": rulesets.clip_size.v1,
"fire_rate": rulesets.fire_rate.v1,
},
strict=False,
)
print(result.metadata)
Without rulesets, the query uses the supported V1 rules. A mapping must contain one rule
for each requested stat. Rule objects have stat, id, version, and
documented_on. Rule versions identify equations; data_version selects game
data. Custom equations and other rule versions are not accepted.
Healing and barrier absorption in the summary¶
summary = demo.summary()
final = (
summary["snapshots"]
.filter(pl.col("steam_id").is_not_null())
.sort("snapshot_time_s")
.group_by("steam_id")
.last()
.join(roster, on="steam_id", how="left", validate="1:1")
)
print(final.select(
"player_name", "hero_id", "snapshot_time_s", "player_healing",
"barrier_absorption", "damage_absorbed",
))
Run the setup above first. player_healing is recorded healing.
barrier_absorption is damage stopped by barriers that the player provided.
damage_absorbed is damage stopped by barriers on that player. Some viewer
screens combine healing and provided barrier absorption. The summary reports each amount in its own column. Do not add cumulative counters from different reporting periods.
Use summary["healing"] for healing and regeneration by source and period,
summary["gold_sources"] for soul sources, and summary["damage"] for the
recorded matrix. See summary for fields and aggregation rules.
demo.healing and demo.barriers() are not available.
Match summary¶
Print the match duration, winner, and player roster.
from boon import Demo, hero_names, team_names
demo = Demo("match.dem")
heroes = hero_names()
teams = team_names()
print(f"Match {demo.match_id}")
print(f"Duration: {demo.total_clock_time}")
print(f"Winner: {teams.get(demo.winning_team_num, 'Unknown')}")
print()
players = demo.players
for row in players.iter_rows(named=True):
name = heroes.get(row["hero_id"], "Unknown")
team = teams.get(row["team_num"], "Unknown")
print(f" {name:<16} ({team})")
Kill feed¶
Build a kill feed with hero names and timestamps.
import polars as pl
from boon import Demo, hero_names
demo = Demo("match.dem")
heroes = hero_names()
kills = demo.kills.sort("tick")
for row in kills.iter_rows(named=True):
time = demo.tick_to_clock_time(row["tick"])
attacker = heroes.get(row["attacker_hero_id"], "Unknown")
victim = heroes.get(row["victim_hero_id"], "Unknown")
assisters = [heroes.get(a, "?") for a in row["assister_hero_ids"]]
assist_str = f" (assists: {', '.join(assisters)})" if assisters else ""
print(f"[{time}] {attacker} killed {victim}{assist_str}")
Net worth over time¶
Read net worth for each player at regular intervals.
import polars as pl
from boon import Demo
demo = Demo("match.dem")
sampled = demo.snapshots(seconds=60.0)
# One column per Steam account.
nw = (
sampled
.filter(pl.col("steam_id").is_not_null())
.select("tick", "steam_id", "gold_net_worth")
.pivot(on="steam_id", index="tick", values="gold_net_worth")
.sort("tick")
)
print(nw)
Damage breakdown¶
The damage dataset includes recorded hits on heroes, objectives, troopers, and neutrals. Filter attacker or victim IDs to select events. This example groups damage by hero and attacker class.
import polars as pl
from boon import Demo, hero_names
demo = Demo("match.dem")
heroes = hero_names()
damage = demo.damage
summary = (
damage
.group_by("attacker_hero_id", "attacker_class")
.agg(pl.col("damage").sum().alias("total_damage"))
.with_columns(
pl.col("attacker_hero_id")
.replace_strict(heroes, default="Unknown")
.alias("hero")
)
.sort("total_damage", descending=True)
)
print(summary)
Item build order¶
Show each player’s item purchase order with timestamps.
import polars as pl
from boon import Demo, hero_names, ability_names
demo = Demo("match.dem")
heroes = hero_names()
items = ability_names()
purchases = (
demo.item_purchases
.filter(pl.col("change") == "purchased")
.sort("tick")
)
for row in purchases.iter_rows(named=True):
time = demo.tick_to_clock_time(row["tick"])
hero = heroes.get(row["hero_id"], "Unknown")
item = items.get(row["ability_id"], "Unknown")
print(f"[{time}] {hero:<16} bought {item}")
Item upgrades¶
Use the replay’s catalog version to read component links.
Keep recorded purchases and sales; an item-path upgrade can still have change="purchased".
import polars as pl
from boon import Demo, ability_names
demo = Demo("108575009.dem", preload=False)
version = "6712"
names = ability_names(version=version)
purchases = demo.get_item_purchases(data_version=version)
upgrades = purchases.filter(pl.col("upgraded_from_ability_ids").list.len() > 0)
for row in upgrades.iter_rows(named=True):
item = names.get(row["ability_id"], str(row["ability_id"]))
components = [names.get(item_id, str(item_id)) for item_id in row["upgraded_from_ability_ids"]]
print(row["tick"], row["steam_id"], components, "->", item)
At tick 23567, Haze upgrades Extended Magazine to Titanic Magazine.
The purchase has upgraded_from_ability_ids=[1548066885].
Missing IDs, catalog links, or unique matches give [].
Match players with steam_id; use tick for the transaction time.
Current upgrades¶
Read the recorded upgrade vector at a selected tick. It can include hero abilities as well as items. Use the matching boon-data catalog to classify IDs.
from boon import Demo
demo = Demo("108575009.dem", preload=False)
players = demo.snapshots(ticks=[187554])
print(players.select("tick", "steam_id", "hero_id", "upgrade_ids", "boon_count"))
An empty list is a recorded empty vector. Null means missing or invalid data.
boon_count is null when the replay has no direct count field.
Enemy minimap icons¶
Read enemy hero icons as seen on each team’s minimap. Select an observing team to get its channel. Coordinates use the range 0–255, not world units.
import polars as pl
from boon import Demo
demo = Demo("108575009.dem", preload=False)
icons = demo.enemy_minimap_ticks(ticks=[187554])
team_icons = icons.filter(pl.col("observer_team") == 2)
print(team_icons.select(
"tick", "target_steam_id", "target_hero_id", "visible_on_map", "map_x", "map_y"
))
# Hidden icons have null current coordinates; inspect their last shown position.
hidden = team_icons.filter(~pl.col("visible_on_map"))
print(hidden.select("target_steam_id", "last_seen_tick", "last_seen_x", "last_seen_y"))
# Sample every 64 ticks. History still uses every recorded tick.
sampled = demo.enemy_minimap_ticks(every=64)
The result is a DataFrame of icons. To draw a map, convert its coordinates to the image coordinate system. See enemy_minimap_ticks(*, ticks=None, every=None) for all columns, missing values, and sampling options.
Objective timeline¶
Track when objectives are destroyed by filtering for health reaching zero.
import polars as pl
from boon import Demo, team_names
demo = Demo("match.dem")
teams = team_names()
destroyed = demo.objectives.filter(pl.col("health") == 0).sort("tick")
for row in destroyed.iter_rows(named=True):
time = demo.tick_to_clock_time(row["tick"])
team = teams.get(row["team_num"], "Unknown")
print(f"[{time}] {team} lost {row['objective_type']}")
Use (entity_id, entity_serial) to identify one objective lifetime. The same
index can later identify a new entity. Filter objective_type == "mid_boss"
to read boss health changes; use demo.mid_boss for lifecycle events.
Heatmap data¶
Extract player positions for a specific hero, suitable for plotting.
import polars as pl
from boon import Demo
demo = Demo("match.dem")
# Filter to a single hero's alive ticks
hero_id = 13 # Haze
alive = demo.player_ticks.filter(
(pl.col("hero_id") == hero_id) & (pl.col("is_alive") == True)
)
# x/y coordinates ready for matplotlib, seaborn, etc.
positions = alive.select("x", "y")
print(f"{positions.height} position samples for hero {hero_id}")
# positions["x"], positions["y"] pass straight to plt.scatter(...)
Active modifiers (buffs/debuffs)¶
Track the effective lifetime of abilities on players. An applied event starts
an effective lifetime. A removed event ends it because of an explicit state
change, an aura exit, slot reuse, or a finite-duration deadline. Boon does not
apply one death rule to all modifiers because some modifiers persist after
death.
import polars as pl
from boon import Demo, hero_names, ability_names
demo = Demo("match.dem")
heroes = hero_names()
abilities = ability_names()
# Load the opt-in dataset
demo.load("active_modifiers")
mods = demo.active_modifiers
# Filter to "applied" events and resolve names
applied = (
mods
.filter(pl.col("event") == "applied")
.with_columns([
pl.col("hero_id").replace_strict(heroes, default="Unknown").alias("hero"),
pl.col("ability_id").replace_strict(abilities, default="Unknown").alias("ability"),
])
)
# Top 10 most frequent abilities
top = (
applied
.group_by("hero", "ability")
.len()
.sort("len", descending=True)
.head(10)
)
print(top)
Street brawl scores¶
Street Brawl is game mode 4. Use street_brawl_ticks for state and street_brawl_rounds for scores.
Other modes raise NotStreetBrawlError when you access these datasets.
from boon import Demo
demo = Demo("street_brawl_match.dem")
rounds = demo.street_brawl_rounds
for row in rounds.iter_rows(named=True):
print(
f"Round {row['round']}: "
f"Amber {row['amber_score']} - Sapphire {row['sapphire_score']} "
f"(scored by team {row['scoring_team']})"
)