Introduction
In this chapter, I analyse the common elements of all TrackerEvents
and build the handle_tracker_event
module. This module exports some general-purpose functions that facilitate the processing of these events.
Exported Memebers
Constants
- INTERVALS_BASE
Functions
- calc_realtime_index
Events
In the previous module (<<Chapter 1 - Summarising Replays>>), I discuss how sc2reader
packs all information necessary to reproduce a match in a Replay
object. I also showed how to obtain some descriptive information about the game from this object and the Players
linked to it.
Here, I review the data contained in the Replay
's Event
objects. These objects are crucial, given how
:All of the gameplay and state information contained in the replay is packed into events. (Kim, 2015, p. 22) The main types of events registered by
sc2reader
are:
- Game Events: Human actions and certain triggered events.
- Message Events: Message and Pings to other players.
- Tracker Events: Game state information.
Additionally, users can access these objects through the Replay
objects' events
attribute.
For example, I can extract a list of all the events in a replay as follows:
rps_path = Path("./test_replays")
game_path = str(rps_path/"Jagannatha LE.SC2Replay")
single_replay = sc2reader.load_replay(game_path)
single_replay
# Extract all events
match_events = [event for event in single_replay.events]
# Separate the events by type
tracker_e = [event for event in single_replay.events
if isinstance(event, sc2reader.events.tracker.TrackerEvent)]
game_e = [event for event in single_replay.events
if isinstance(event, sc2reader.events.game.GameEvent)]
message_e = [event for event in single_replay.events
if isinstance(event, sc2reader.events.message.MessageEvent)]
print(f'The match contains {len(match_events)} events total')
print(f'The match contains {len(tracker_e)} events total')
print(f'The match contains {len(game_e)} events total')
print(f'The match contains {len(message_e)} events total')
In the following test, I check that the sum of the Tracker, Message and Game events effectively make up the whole of the events registered by the system.
ft.equals((len(tracker_e) + len(game_e) + len(message_e)), len(match_events))
Tracker Events
TrackerEvent
objects store information about the state of the game. Users can use them to track various aspects of a player's performance. Here, I review different types of TrackerEvents
, their attributes, and some of the issues I need to consider regarding each class.
TrackerEvents
documentation can be found at (Kim, 2015, p. 24-25).Furthermore, each type of object stores data that goes beyond what I explore here. To extract a complete list of the data stored in each type of event, you can call the __dict__
attribute on an object of each type.Replay
, one will typically found several kinds of TrackerEvents
. In the following code, I build a set
containing these types using a sample replay. This list shows all the TrackerEvent
's classes.
tracker_e_types = {type(event) for event in single_replay.events
if isinstance(event, sc2reader.events.tracker.TrackerEvent)}
tracker_e_types
setup_e = [event for event in single_replay.events
if isinstance(event, sc2reader.events.tracker.PlayerSetupEvent)
and event.pid == 1]
PlayerStatsEvents
To extract some initial information regarding the game's flow, one can consult the instances of sc2reader.events.tracker.PlayerStatsEvent
.
These events are generated for each player every ten seconds, regardless of the player's presence in the match. In other words, if one is analysing a game of more than two players and one leaves the match, the game still generates PlayerStatsEvents
for that player up until the end of the match.
PlayerStatsEvents
a match should have. For example, the following sample match lasted 590 seconds. Hence, there should be 59 PlayerStatEvent
s per player, plus two or three for start and end events. Similarly, taking into account the game's frames-per-second, one could estimate the duration of the match in frames.
Bellow, I show how one can get this information from a Replay
object. This information is critical for debugging and managing these events afterwards.
rpl_length = single_replay.game_length.seconds
rpl_fps = single_replay.game_fps #frames-per-second
rpl_frame_length = single_replay.game_length.seconds * single_replay.game_fps
print(f'{"Game length in seconds:":<30}{rpl_length:>6}')
print(f'{"Game frames per second:":<30}{rpl_fps:>6}')
print(f'{"Total frames in the match:":<30}{rpl_frame_length:>6}')
Likewise, in the following code, I extract the PlayerStatEvent
related to one player in the test match I am analysing.
p_one_state = [event for event in match_events
if isinstance(event, sc2reader.events.tracker.PlayerStatsEvent)
and event.pid == 1]
print(f'Number of PlayerStatsEvents for player 1: {len(p_one_state)}')
This information shows that there is a discrepancy in the number of PlayerStatsEvenst
there should be (around 60 for the sample match) and the actual number there is (85).
Similarly, looking at the seconds and frame count registered in the last event, it is clear that they do not match the duration of the game (590 seconds, 9440 frames).
print(f'Second recorded in the last event:', end=' ')
print(f'{p_one_state[len(p_one_state)-1].second:>6}')
print(f'Frame recorded in the last event:', end=' ')
print(f'{p_one_state[len(p_one_state)-1].frame:>7}')
Fortunately, these mismatches are consistent for all the players. For example, look at the number of events, the last second and frame registered for Player 2. This player has one event less because they were the match's winner.
p_two_state = [event for event in match_events
if isinstance(event, sc2reader.events.tracker.PlayerStatsEvent)
and event.pid == 2]
print(f'Number of events recorded for p2: {len(p_two_state)}')
print(f'Second recorded in the last event:' , end=' ')
print(f'{p_two_state[len(p_two_state)-1].second}')
print(f'Frame recorded in the last event:', end=' ')
print(f'{p_two_state[len(p_two_state)-1].frame}')
Events
. I propose a basic solution in the functions section of this module further below (see calc_realtime_index
function).UnitBornEvent
Event generated any time a unit enters the game through a production building. This type of event stores the following useful attributes:
second
(int): registered time of birth in seconds. Note that this timestamp does not match the match's duration. This inconsistency means that one must correct the time index of the event. (seecalc_realtime_index
function)unit_type_name
(str): type of the unit.location
(tuple(x, y)): tuple of ints that indicate the unit's x and y coordinates in the map.control_pid
(int): player id in the match.unit
: points to theunit
object, which stores helpful information such as the minerals, vespene, and supply cost of the unit type (see Kim, 2015, p. 30).
The following code illustrates some uses of these attributes:
# of a single player.
UnitBorn_e = [event for event in match_events
if isinstance(event, sc2reader.events.tracker.UnitBornEvent)
and event.control_pid == 1]
print(f'{"The recorded time of birth of a unit:":<40}', end=' ')
print(f'{UnitBorn_e[62].second:>10}')
print(f'{"The supply cost of the unit type:":<40}', end=' ')
print(f'{UnitBorn_e[62].unit.supply:>10}')
print(f'{"The unit type:":<40} {UnitBorn_e[62].unit_type_name:>10}')
print(f'{"The birth x, y location of the unit:":<40} ', end=' ')
print(f'{str(UnitBorn_e[62].location):>10}')
To group all the game's UnitBornEvents
, I can use the control_pid
attribute, which points to the Participant
that owns the unit.
control_pid
equal 0 are neutral units such as minerals, vespene geysers or obstacles.Additionally, having grouped all the UnitBornEvent
instances related to a particular player, I notice the following pattern:
- The first 15 units born (indexes 0-14) are
Beacons
sc2reader uses for tracking. I will ignore these objects. - Unit 15 is the starting base building (Nexus, CommandCenter, Hatchery).
- Units 16-27 are the first worker units of each player.
This pattern means I should only consider whatever units are born after index 27 to track the player's build order.
The following code illustrates this pattern.
UnitBorn_e = [event for event in match_events
if isinstance(event, sc2reader.events.tracker.UnitBornEvent)
and event.control_pid == 1]
born_order = {k:(k, event.unit_type_name, f'second: {event.second}')
for k, event in enumerate(UnitBorn_e) if k <=30}
pprint(born_order, compact=True)
For example, the following code produces a draft of the player's final unit tally.
counts = dict()
for event in UnitBorn_e:
counts.setdefault(event.unit_type_name, 0)
counts[event.unit_type_name] += 1
counts
UnitInitEvent
This type of event is a complement to the UnitBonrEvent
, UnitDoneEvent
and the UnitTypeChangeEvent
.
These events exists because not all units enter the game via production structures. Instead, buildings (other than the initial base), warped units, mutated units, and others are instantiated through the UnitInitEvent
with 0 life. Afterwards, they are built over time. During this building time, players may lose or cancel this unit or buildings under construction.
The information extracted from these type of events is similar to the information in the UnitBornedEvent
. Here, again, I will use the following attributes:
second
(int): registered time of birth in seconds. This attribute requires the same consideration as in the previous cases. (seecalc_realtime_index
function)unit_type_name
(str): type of the unit.location
(tuple(x, y)): tuple of ints that indicate the unit's x and y coordinates in the map.control_pid
(int): player id in the match.unit
: points to theunit
objectNote: in this case, there are no initial units that should be disregarded from the player’s build order.
UnitInit_e = [event for event in match_events
if isinstance(event, sc2reader.events.tracker.UnitInitEvent)
and event.control_pid == 1]
init_order = {k:(event.unit_type_name, f'second: {event.second}')
for k, event in enumerate(UnitInit_e)}
init_order
Also, given that these units can be buildings, troops or others, we can use this list to illustrate the use of the unit.is_army
, unit.is_building
, unit.is_worker
attributes.
print(f'Unit_name: {UnitInit_e[20].unit.name}', end=' ')
print(f'is building: {UnitInit_e[20].unit.is_building}')
print(f'Unit_name: {UnitInit_e[20].unit.name}', end=' ')
print(f'is army: {UnitInit_e[20].unit.is_army}')
print(f'Unit_name: {UnitInit_e[20].unit.name}', end=' ')
print(f'is worker: {UnitInit_e[20].unit.is_worker}')
print(f'Unit_name: {UnitInit_e[31].unit.name}', end=' ')
print(f'is building: {UnitInit_e[31].unit.is_building}')
print(f'Unit_name: {UnitInit_e[31].unit.name}', end=' ')
print(f'is army: {UnitInit_e[31].unit.is_army}')
print(f'Unit_name: {UnitInit_e[31].unit.name}', end=' ')
print(f'is worker: {UnitInit_e[31].unit.is_worker}')
UnitDoneEvent
This type of event is a complement to the UnitInitEvent
.
After a UnitInitEvent
and the unit's build time the unit comes into the game. At this point the unit triggers a UnitDoneEvent
.
This type of event has much less attributes associated to it than the UnitBonrEvent
and UnitInitEvent
. In this case, I will use the second
and unit
attributes.
If I want to use these events to construct the build order based on completed units and not just only in units that where initiated, I need to use some of the unit
object's attributes to complete the information. For example, to group the units per player I can use the unit.owner.pid
attribute.
UnitDone_e = [event for event in match_events
if isinstance(event, sc2reader.events.tracker.UnitDoneEvent)
and event.unit.owner.pid == 2]
The following code illustrates how an event's unit can offer some complementary attributes.
sample_unit = UnitDone_e[2].unit
print(f'Event\'s execution time: {UnitDone_e[2].second}')
print(f'Event\'s execution frame: {UnitDone_e[2].frame}')
print(sample_unit.name)
print(f'Init frame: {sample_unit.started_at}')
print(f'Game-enter frame: {sample_unit.finished_at}')
print(f'Owner: {str(sample_unit.owner)}')
UpgradeCompleteEvent
Other than constructing buildings and training troops, the third way players can spend their resources is by researching upgrades. Each time a player completes an upgrade it generates a UpgradeCompleteEvent
.
I will use the following features from this event type:
pid
(int): player id in the match.upgrade_type_name
(str): name of the upgrade.second (int)
: registered time of birth in seconds. This attribute requires the same consideration as in the previous cases. (seecalc_realtime_index
function)
Upgrades = [event for event in single_replay.events
if isinstance(event, sc2reader.events.tracker.UpgradeCompleteEvent)
and event.pid == 2]
upgrade_order = {k:(f'second: {event.second}', event.upgrade_type_name)
for k, event in enumerate(Upgrades)}
pprint(upgrade_order)
UnitTypeChangeEvent
During a game, some units and buildings appear (they change type) as the product of tech research, player ordered morphs, mergings and upgrades.
All these changes are registered by the UnitTypeChangeEvent
and each unit's type_history
attribute.
The following code lists two of player 1's GateWays, transformed into WarpGates via tech research.
prototype = [(event.second, event.unit.type_history, event.unit_id)
for event in single_replay.events
if isinstance(event, sc2reader.events.tracker.UnitTypeChangeEvent)
and event.unit.owner.pid == 1]
for unit in prototype:
for stage in unit[1].values():
print(f'Name: {stage.name} ID: {unit[2]}')
Meanwhile, in the following code, I show how one of Player 2's SiegeTanks changes back and forth between two states (i.e. SiegeTank and SiegeTankSieged). In contrast to the previous example, this change results from a player's direct command and can be reversed.
prototype_2 = [(event.second, event.unit.type_history, event.unit_id)
for event in single_replay.events
if isinstance(event, sc2reader.events.tracker.UnitTypeChangeEvent)
and event.unit.owner.pid == 2
and 'SiegeTank' in event.unit_type_name]
for unit in prototype_2[:1]:
for stage in unit[1].values():
print(f'Name: {stage.name} ID: {unit[2]}')
UnitDiedEvent
These events are generated when units are taken off the match for any reason.
Since UnitDiedEvents
can be triggered when a unit is morphed, merged or exhausted, it is important not to assume that every kill results from an opponent's actions.
Thus, in this case, I will use the following attributes:
second
(int): registered time of birth in seconds. This attribute requires the same consideration as in the previous cases. (seecalc_realtime_index
function)killer_pid
(int): player id for the player who destroys a unit. This attribute can be a significant component to build a player kill list.unit
(unit)
The following code lists the units killed by Player 1 in the sample replay.
UnitDied_e = [event for event in single_replay.events
if isinstance(event, sc2reader.events.tracker.UnitDiedEvent)
and event.killer_pid == 1
and event.unit.owner != None
and event.unit.owner.pid == 2]
kill_list = {k:(f'second: {event.second} '
+ f'unit: {event.unit.name:<16} '
+ f'owner: {event.unit.owner.pid} '
+ f'killer: {event.killer_pid}')
for k, event in enumerate(UnitDied_e)}
print('Player 1 kill list:')
pprint(kill_list)
UnitPositionEvent
This events are generated every 15 seconds and they record "the positions of the first 255 units that were damaged in the last interval" (Kim, 2015, p. 22).
This events can be usefull to infer skirmishes during a match. However, I am not going to consider them into my analysis.
Exportable constants
Additionally, this module exports a constant INTERVALS_BASE
which defines the number of seconds that should determine the time intervals used to analyse the replays.
In the following modules, I separate the analysis of many performance indicators derived from several types of Tracker Events into four game intervals, i.e. the whole, early, mid and late games. These intervals are meant to match the economy and initial strategy building portion of the game (i.e. the early game), the initial strategy execution and response state (i.e. the mid-game), and the counter strategic stage of the game (i.e. the late-game). The early match takes typically between 4 and 5 minutes, or 240 and 300 seconds. Afterwards, the mid-game takes about the same time. Suppose the game has not ended by minutes 8 to 10. In that case, players typically re-organise their strategy using updated, more advanced and expensive, units, i.e. late game.
Thus, in this case, I establish a base value in seconds that I use in later modules as the finish time for the early game. I also use it to calculate the end mark of the mid-game as double this base value. The late-game always covers the portion from the mid-game's end to the match's end.
I establish this constant here in case I need to change it. This way, any change will automatically be propagated through all modules.
Exportable Functions
Similarly, I export the calc_realtime_index
function. I use this function in later modules to recalculate the time when an event took place in seconds to match the game's recorded length.
This recalculation is necessary because Tracker Events seem to record the time they happened as the quotient of their recorded execution frame and the match's frames-per-second, which does not match its duration,
The following code illustrates the use of this function to correct the time index of various UnitBornEvent
. The function should work just as well with all TrakerEvents
.
first_e = UnitBorn_e[27]
second_e = UnitBorn_e[28]
last_e = UnitBorn_e[-1]
print(f'Unit: {first_e.unit.name:>8}',
f'Birth recorded time: {first_e.second:>5}',
f'Birth real time: ',
f'{calc_realtime_index(first_e.second, single_replay):>7.2f}')
print(f'Unit: {second_e.unit.name:>8}',
f'Birth recorded time: {second_e.second:>5}',
f'Birth real time: ',
f'{calc_realtime_index(second_e.second, single_replay):>7.2f}')
print(f'Unit: {last_e.unit.name:>8}',
f'Birth recorded time: {last_e.second:>5}',
f'Birth real time: ',
f'{calc_realtime_index(last_e.second, single_replay):>7.2f}')
print(f'Unit: {"FinalFrame":>8}',
f'Birth recorded time: {826:>5}',
f'Birth real time: {calc_realtime_index(826, single_replay):>7.2f}')
References
- Kim, G. (2015) 'sc2reader Documentation'. Available at: https://sc2reader.readthedocs.io/_/downloads/en/latest/pdf/.