Estimate Vision
estimate_vision is an experimental post-parse helper that tries to answer a simple but important question:
At tick
t, did teamXlikely have vision of map point(x, y)?
This question matters for practical replay analysis:
- was an initiation blind or telegraphed?
- did a smoke break in enemy vision?
- was a gank visible before it happened?
- was a ward or hero actually giving information at that location?
The replay does not give gem a perfect, terrain-accurate fog-of-war oracle. So estimate_vision is intentionally implemented as a geometry-based approximation with explicit inputs and explicit limits.
IMPORTANT
estimate_vision is useful, but it is not true fog-of-war reconstruction.
It gives a defensible first-pass answer for many analytical workflows, not a terrain-perfect verdict.
What the function returns
API:
gem.estimate_vision(
match: ParsedMatch,
team: int,
tick: int,
x: float,
y: float,
) -> list[VisionSource]It returns a list of VisionSource objects sorted by ascending distance.
Each source is one thing that plausibly grants vision of the queried point:
- an allied hero
- an allied observer ward
- a vision-granting modifier reveal
If the list is empty, the point is treated as in fog for that team under the current approximation.
Why this is experimental
True Dota vision depends on more than Euclidean distance:
- cliffs and high-ground rules
- trees and terrain occlusion
- hero-specific or item-specific vision changes
- summons and creep vision
- special reveal mechanics
gem does not reconstruct all of that. Instead, estimate_vision deliberately uses the subset it can model reliably from parsed replay data.
That tradeoff is why the feature belongs in Experimental Features instead of being presented as a final truth layer.
Inputs used by the model
The current implementation in src/gem/analysis.py uses these inputs:
match.playersplayer.position_logmatch.wardsmatch.vision_modifiersmatch.game_start_tick- the queried
(team, tick, x, y)
These are all replay-derived structures. The approximation begins when gem interprets them geometrically.
High-level derivation
The function works in four steps.
1. Determine whether it is day or night
Dota vision changes with the day/night cycle, so the function first asks whether the queried tick is daytime.
Current constants in src/gem/analysis.py:
day hero vision = 1800
night hero vision = 800
ward vision = 1600
full cycle = 15 minutes
night starts = 7:30 into the cycle
tick rate = 30 ticks/secDerived tick constants:
DAY_NIGHT_CYCLE_TICKS = 15 * 60 * 30 = 27000
NIGHT_START_TICKS = 7 * 60 * 30 + 15 * 30 = 13950The function converts absolute replay tick into game-relative tick:
game_ticks = max(tick - game_start_tick, 0)
phase = game_ticks % 27000
daytime = phase < 13950Interpretation:
- before
7:30, it is day - after
7:30, it is night - after
15:00, the cycle repeats
This yields the hero vision radius for the rest of the check.
2. Check allied hero vision
For every player on the queried team:
- get the hero position at the queried tick with
position_at_tick(...) - compute Euclidean distance from the hero position to
(x, y) - compare that distance to the current day/night hero radius
If:
distance <= hero_radiusthen that hero is included as a VisionSource(kind="hero", ...).
Why this works reasonably well
For many tactical replay questions, the first-order question is simply:
Was an allied hero physically close enough that the target point was probably visible?
That is exactly what this check tries to answer.
What it ignores
This hero check does not model:
- cliffs
- trees
- special hero vision bonuses
- temporary vision reductions or increases
So it is best understood as a straight-line radius test around the hero's estimated position.
3. Check observer ward vision
For every observer ward on the queried team:
- ignore sentries
- ignore wards with missing coordinates
- require the ward to have already been placed by the queried tick
- require the ward to still be alive
- compute Euclidean distance from ward position to
(x, y) - compare that distance to the fixed observer radius
1600
If:
distance <= 1600then the ward is included as a VisionSource(kind="ward", ...).
The alive check is:
ward.tick <= query_tick
and query_tick <= (ward.killed_tick or ward.expires_tick)with the usual handling for missing kill / expire ticks.
Why wards are simpler than heroes
Observer wards do not have a day/night penalty in this model. They use a single constant vision radius.
That keeps the ward part of the approximation fairly straightforward.
4. Check vision-granting modifiers
This is the least obvious part of the function, and it is where the derivation matters most.
gem tracks certain reveal modifiers during parse and stores them in:
match.vision_modifiersEach event records:
- when the reveal started
- when it ended
- which modifier caused it
- which hero was revealed
- which team applied it
The current function then applies this rule:
- keep modifier events where
caster_team == team - require the modifier to be active at the queried tick
- find the revealed target hero
- get that hero's position at the queried tick
- treat that revealed hero position as a vision source for the query
Unlike hero and ward checks, modifier reveals do not use a radius gate in this approximation.
Instead, the function always adds a VisionSource(kind="modifier", vision_radius=0) once the modifier is active and the target hero position can be resolved.
The reported distance is:
distance(revealed_hero_position, query_point)Interpretation:
- if the query point is exactly the revealed hero's position, distance is
0 - if the query point is near that hero, distance is small
- if the query point is far from that hero, distance is large
The modifier source still appears because the revealed hero itself is considered directly seen.
Why vision_radius = 0 for modifiers
Because this is not a radius-based visibility check in the same sense as hero or ward vision.
The modifier is treated as a direct reveal mechanism. So vision_radius = 0 is really a signal that says:
this source was not accepted because of a radius threshold; it was accepted because the target was actively revealed
Tracked modifier families
The docs already list the current modifier set in the API reference, including examples like:
- Slardar Corrosive Haze
- Bounty Hunter Track
- Dust of Appearance
- Gem of True Sight
Those are the reveal-style effects the current approximation extends beyond pure geometry.
Data flow behind match.vision_modifiers
estimate_vision only works because the parser already extracts reveal-style modifier events during parse.
The high-level flow is:
- combat log normalization sees relevant modifier add/remove events
- gem records them as
VisionModifierEvent match_builderplaces them ontoParsedMatch.vision_modifiersestimate_visionreads them later during post-parse analysis
This means the function is not inventing modifier state from scratch at query time. It consumes a replay-derived event stream that was already captured during parsing.
For the event-stream derivation itself, see Vision Modifiers.
Exact decision model
The practical decision model is:
vision_sources = []
if allied hero is within day/night hero radius:
add hero source
if allied observer ward is alive and within 1600:
add ward source
if allied reveal modifier is active on an enemy hero:
add modifier source based on the revealed hero position
sort all accepted sources by ascending distance
return themThe function therefore answers:
Which modeled sources support the claim that this team could see this point?
not:
Can we reconstruct Valve's exact internal fog-of-war state?
Why distance sorting matters
The function sorts accepted sources by ascending distance before returning them.
That makes the first source a useful first explanation:
- nearest hero vision
- nearest ward
- nearest revealed-hero modifier source
This is especially useful in analyst tooling and reports where you want a compact answer like:
- "Radiant had vision via observer ward"
- "Dire had vision via Shadow Demon"
- "Dire had reveal via modifier_bounty_hunter_track"
What this approximation does well
It works reasonably well for questions like:
- "Was this blink initiation likely visible?"
- "Did this ward plausibly cover that ramp?"
- "Was the target hero explicitly revealed by Track or Corrosive Haze?"
- "Was there any obvious allied source that should have seen this point?"
These are practical analyst questions, and straight-line geometry captures a large fraction of them usefully.
What this approximation does poorly
It is weaker for questions where terrain and line-of-sight dominate:
- exact uphill/downhill vision disputes
- tree occlusion edge cases
- unusual hero-specific vision ranges
- summon-based scouting
- precise Dust/Gem aura geometry
This is where the feature should be treated as suggestive, not definitive.
Limitations by source type
Heroes
Limitations:
- uses nearest sampled hero position, not continuous movement
- ignores terrain and cliffs
- assumes a generic day/night radius
Wards
Limitations:
- uses simple circular coverage
- ignores terrain-specific ward vision interactions
- ignores all non-observer sight sources
Modifiers
Limitations:
- the query uses the revealed hero position, not a full reveal field
- Dust / Gem auras are approximated as direct reveals once tracked
- modifier coverage is only as good as the extracted modifier event stream
Relationship to position_at_tick
estimate_vision depends on position_at_tick(...) for hero positions and revealed-target positions.
That means its output inherits the assumptions of sampled movement:
- position logs are discrete samples, not full continuous trajectories
- the nearest sample is used for the queried tick
This is usually acceptable for high-level tactical questions, but it still matters for fine edge cases.
Why this is still useful
Even with the limitations, the function is valuable because the alternative is often much worse:
- manually eyeballing the replay
- guessing whether a ward covered an area
- ignoring reveal modifiers entirely
- treating every initiation as either obviously seen or obviously blind
estimate_vision gives a structured, repeatable approximation with explicit source objects and explicit caveats.
That is a good fit for automated replay analysis, agentic workflows, and report generation.
How to interpret the output responsibly
Use the function as:
- a first-pass visibility check
- an explanation generator for likely vision sources
- a screening tool for "visible vs likely fogged" situations
Do not use it as:
- final proof of true fog-of-war state
- exact terrain-aware scouting reconstruction
- a substitute for replay review in high-stakes edge cases
Example reasoning workflow
If a gank starts at (x, y) on tick t, a good workflow is:
- call
estimate_vision(match, team, t, x, y) - inspect whether the list is empty
- if not empty, inspect the nearest source
- check whether that source is a hero, ward, or modifier reveal
- confirm visually in replay if the situation is high stakes or terrain-sensitive
That makes the function useful without pretending it is perfect.
Code locations
Implementation and supporting structures:
src/gem/analysis.pysrc/gem/models.pysrc/gem/match_builder.pydocs/reference/analysis.md