Back

AnalyzeRL Boxcars

AnalyzeRL Boxcars preview

Python API for the AnalyzeRL Boxcars Rocket League replay parser.

Overview

AnalyzeRL Boxcars

Labelling and analyzing Rocket League replay data

AnalyzeRL Boxcars is a Rust package mounting the detection of events and player actions onto frame-wise Rocket League replay data parsed by the powerful boxcars library.

Requirements

  • Rocket League .replay files
  • The compiled Windows or Linux x86-64 binary from the repository's Releases page when using the CLI directly
  • Python 3.10 or newer when using the Python API
  • Rust 1.85 or newer only when building the binary from source

Parsing Pipeline

  • Collect replay objects from boxcars
  • Arrange frame-by-frame match dataframe
  • Mount EventModel and obtain interpretable frame-level events in a match

Installation

Download analyzerl_boxcars.exe from the assets attached to the latest repository release. Building locally is optional:

cargo build --release

Install the Python package when using the Python API:

python -m pip install analyzerl-boxcars

The Windows and Linux x86-64 wheels include the matching Rust binary, so Python API users do not need to download it separately.

The examples below use the downloaded release binary:

$analyzerl = "C:\path\to\analyzerl_boxcars.exe"
$replay = "C:\path\to\match.replay"

Python examples use the binary bundled with AnalyzeRLBoxcars:

from pathlib import Path

from analyzerl_boxcars import AnalyzeRLBoxcars

analyzer = AnalyzeRLBoxcars(
    n_workers=1,
)
replay = Path(r"C:\path\to\match.replay")

Usage

Parse Without Writing Files

CLI

The CLI parses silently when --export is omitted:

& $analyzerl $replay

Python

The Python API returns the frame-wise Polars dataframe directly:

frames = analyzer.parse_replay(replay, event_tagging=False)

Export Frame Data to CSV

CLI

& $analyzerl $replay --export "C:\path\to\match.csv"

Python

analyzer.parse_replay(
    replay,
    event_tagging=False,
    export="csv",
    export_dir=Path(r"C:\path\to\exports"),
)

Export Frame Data to Parquet

CLI

& $analyzerl $replay --export "C:\path\to\match.parquet"

Python

analyzer.parse_replay(
    replay,
    event_tagging=False,
    export="parquet",
    export_dir=Path(r"C:\path\to\exports"),
)

Stream or Return Frame Data

CLI

The CLI streams Parquet bytes to stdout and writes status text to stderr:

& $analyzerl $replay --event-tagging true --export stdout

Python

The Python API uses the same stdout transport internally and returns a Polars dataframe without writing a file:

frames = analyzer.parse_replay(replay, event_tagging=True)

Mount the Event Model

CLI

& $analyzerl $replay --event-tagging true --export "C:\path\to\match.parquet"

Python

labelled_frames = analyzer.parse_replay(replay, event_tagging=True)

Apply the Event Model to Frame Data

Frame data parsed with event_tagging=False retains the replay-native signals needed to mount the same event model later. Existing event columns are overwritten.

CLI

& $analyzerl "C:\path\to\frames.parquet" --apply-event-model --export "C:\path\to\labelled.parquet"

Python

from analyzerl_boxcars import EventModel, apply_event_model

labelled_frames = apply_event_model(frames)
labelled_frames = EventModel().apply(frames)

The Python API streams Parquet between Polars and the bundled Rust binary in memory; it does not create an intermediate file.

Generate Play-by-Play Data

PBP output contains one row per event instance. Replay files and existing CSV or Parquet frame tables are supported.

CLI

& $analyzerl $replay --pbp --export "C:\path\to\pbp.parquet"
& $analyzerl "C:\path\to\frames.parquet" --pbp --export stdout

Python

pbp = analyzer.parse_replay(replay, pbp=True)
pbp = analyzer.parse_replay(Path(r"C:\path\to\frames.parquet"), pbp=True)

Each PBP row includes event_type, event_id, event_start_frame, event_end_frame, and the inclusive frame count in event_duration. Stats calculation accepts PBP CSV and Parquet files directly.

Calculate Stats From a Replay

CLI

& $analyzerl $replay --calc-stats --export "C:\path\to\stats.csv"

Python

stats = analyzer.calculate_stats(replay)

Calculate Stats From Frame Data

Both CSV and Parquet frame exports are valid stats inputs.

CLI

& $analyzerl "C:\path\to\frames.csv" --calc-stats --export "C:\path\to\stats.csv"
& $analyzerl "C:\path\to\frames.parquet" --calc-stats --export "C:\path\to\stats.parquet"

Python

csv_stats = analyzer.calculate_stats(Path(r"C:\path\to\frames.csv"))
parquet_stats = analyzer.calculate_stats(Path(r"C:\path\to\frames.parquet"))

Export Stats

CLI

The CLI infers CSV or Parquet from the output extension:

& $analyzerl $replay --calc-stats --export "C:\path\to\stats.parquet"

Python

The Python API selects the format with export and writes through the Rust binary:

analyzer.calculate_stats(
    replay,
    export="parquet",
    export_dir=Path(r"C:\path\to\exports"),
)

The CLI supports .csv and .parquet export paths. The special stdout target always emits Parquet bytes. Python calls with export=False return a Polars dataframe; calls with export="csv" or export="parquet" write files and return None.

Output

The parser returns a frame-wise dataframe with replay, frame, ball, team, event, and player column groups. Player columns are kept together as the final column group so wide exports remain easier to scan.

When --pbp is supplied, the CLI returns event-wise rows and removes the frame-level *_in_progress columns. When --calc-stats is supplied, the CLI returns one row per replay_id and player_id instead of frame-level rows. Stats use all measurements available in the supplied frame or PBP dataframe.

When an export path is supplied, the CLI prints:

Parsed {replay_id}

Documentation

Full documentation coming soon.

License

AnalyzeRL Boxcars is closed-source, proprietary software. Copyright (c) 2026 Project Signal. All Rights Reserved. No permission is granted to use, copy, modify, or distribute the software except under a separate written agreement expressly authorized by Project Signal. See the proprietary license for complete terms.

Event Tagging

EventModel

This document describes the event model that is actually implemented by the package. It is not a proposed event specification. Every rule below is derived from src/events.rs, the replay-native values prepared in src/parse.rs, and the constants in src/props.rs.

Processing Order

The model builds all event columns in the following order:

self.label_game_states(&mut columns);
self.label_ball_touches(&mut columns);
self.label_player_states(&mut columns);
self.label_player_roles(&mut columns);
self.label_box_events(&mut columns);
Self::label_shot_event_links(&mut columns);
self.label_possession_bound_events(&mut columns);
self.label_tagging_events(&mut columns);
self.label_goal_lookahead(&mut columns);

This order matters. A later detector can use columns produced by an earlier detector. It cannot use columns which have not been labelled yet. label_possession_bound_events therefore labels air and ground dribbles before label_tagging_events specializes qualifying whiffs as fakes. label_goal_lookahead runs last because it consumes the stint_id column produced by label_game_states and the goal team ids written by label_box_events.

Shared Frame Rules

Active frame state

The parser does not hard-code an integer for live physics. It excludes the replay's frozen state, counts every other frame_state_id, and uses the most frequent remaining state as the live-state candidate. frame_is_active is stricter: it begins when the kickoff countdown releases to zero, ends after the score-update frame, and includes that goal frame itself when it is not in a countdown. This prevents a replay that reuses its live state during the 3-2-1 countdown, or applies a delayed score update there, from labelling countdown frames as active.

for frame in &prepared.frames {
    if frame.frame_state_id == prepared.frozen_state_id {
        continue;
    }
    *counts.entry(frame.frame_state_id.as_str()).or_default() += 1;
}

All event-progress columns begin as false on active frames and null on non-active frames. An event detector can subsequently write true outside active play if it calls mark_event without an active-frame guard. The current exceptions are documented under their respective events.

Ball contact

Frame-wise ball contact is geometric. Every player whose car center is within the ball radius plus the configured car reach is marked as touching. This permits multiple players to touch the ball on the same frame.

let contact_distance_sq = (BALL_RADIUS + CAR_BALL_TOUCH_REACH).powi(2);
if distance_sq <= contact_distance_sq {
    columns.player_touching_ball[slot][index] = true;
}

The current constants are BALL_RADIUS = 91.25 and CAR_BALL_TOUCH_REACH = 130.0, producing a center-to-center contact threshold of 221.25 Unreal units.

Possession uses a narrower interpretation of simultaneous contact. frame_touch_candidate considers the same geometric contact set, excludes demolished players, and selects only the closest player to the ball. Therefore, player_n_touching_ball can be true for multiple players while the possession state machine consumes one touch candidate.

Possession

A team normally needs two consecutive geometric touch frames to establish possession. The last team which possessed the ball needs one touch to regain it. One touch by the opposing team changes contested_possession to true but does not immediately transfer possession. Sixty frames without a touch returns possession to neutral.

let required_touches = if team == last_possession_team {
    POSSESSION_REGAIN_TOUCH_COUNT
} else {
    POSSESSION_START_TOUCH_COUNT
};

if possession_team == team {
    possession_player_slot = Some(player_slot);
    contested_possession = false;
} else if touch_streak_team == team && touch_streak_count >= required_touches {
    possession_team = team;
    possession_player_slot = Some(player_slot);
}

The possession constants are:

  • POSSESSION_START_TOUCH_COUNT = 2
  • POSSESSION_REGAIN_TOUCH_COUNT = 1
  • POSSESSION_IDLE_FRAMES = 60

Possession is always neutral outside the inferred active state. possession_id is {possession sequence}{team id}. Blue is team 0, orange is team 1, and neutral frames use 0.

Player action states

Jump, double-jump, dodge, boost, handbrake, throttle, and steer inputs originate in replay component or vehicle properties prepared by parse.rs. Component activity is persisted across Boxcars delta frames. Activation flags are sparse rising-edge signals; player_n_is_jumping, player_n_is_double_jumping, and player_n_is_dodging are active-span signals.

The action precedence is:

let is_dodging = dodge_active;
let is_double_jumping = !is_dodging && double_jump_active;
let is_single_jumping = !is_dodging && !is_double_jumping && jump_active;

This precedence prevents a dodge from also being labelled as a double jump or ordinary jump on the same frame.

Event identity and duration

Each event type owns a numeric type id and its complete frame-wise metadata group. An individual id is formatted as:

{event_team_id}{event_type_id:02}{event_instance:06}

The instance count is event-type-specific and team-agnostic. Team 9 is used when no event team can be inferred. For example, the first blue shot is 004000001; an event with type id 16, no explicit team, and no player fallback would be 916000001.

mark_event assigns identity at the same time it marks an event frame. There is no later pass which reconstructs event instances. Each event type owns a collection of active runs, allowing different player signatures to occur on the same frame without replacing one another. A run continues when its team matches, its player signature matches for signature-split events, the frame index is monotonic, and the gap does not exceed the event's tolerance.

let matching_run = runs.iter().position(|run| {
    run.active
        && run.team_id == event_team_id
        && (!event.split_by_signature || run.signature == signature)
        && index >= run.last_true_index
        && index.saturating_sub(run.last_true_index) <= event.gap_tolerance_frames + 1
});

Every event except air dribbles, ground dribbles, and respawns has zero configured gap tolerance. Dribbles tolerate 90 frames and respawns tolerate 120 frames. Kickoff and press are not split by player signature, but they remain split by event team. This makes a press a team-owned pressure period even when the first-man or targeted defender changes during the period.

Frame outputs use {event}_event_id, {event}_event_team_*, and {event}_event_player_n_*. Each call to mark_event writes only to that event's metadata group, so simultaneous event types cannot overwrite one another. Multiple instances of the same event type on one frame remain pipe-delimited inside that event's own id cell, with role pairs stored in matching ordinal player slots.

pbp.rs converts these frame groups into one row per unique event id. PBP rows use generic event_type, event_id, event_team_*, and event_player_n_* fields because each row contains exactly one event. event_start_frame, event_end_frame, and inclusive event_duration preserve the event span while the selected frame stores the event's boundary state.

Event Type IDs

| ID | Event | Progress column | |---:|---|---| | 1 | Kickoff | kickoff_in_progress | | 2 | Game Join | game_join_in_progress | | 3 | Game Leave | game_leave_in_progress | | 4 | Shot | shot_in_progress | | 5 | Save | save_in_progress | | 6 | Goal | goal_in_progress | | 7 | Assist | assist_in_progress | | 8 | Pass | pass_in_progress | | 9 | Missed Pass | missed_pass_in_progress | | 10 | Missed Shot | missed_shot_in_progress | | 11 | Air Dribble | air_dribble_in_progress | | 12 | Ground Dribble | ground_dribble_in_progress | | 13 | Flick | flick_in_progress | | 14 | Demo | demo_in_progress | | 15 | Bump | bump_in_progress | | 16 | Entry | entry_in_progress | | 17 | Exit | exit_in_progress | | 18 | Retrieval | retrieval_in_progress | | 19 | Boost Pickup | boost_pickup_in_progress | | 20 | Flip Reset | flip_reset_in_progress | | 21 | Press | press_in_progress | | 22 | Shadow | shadow_in_progress | | 23 | Whiff | whiff_in_progress | | 24 | Fake | fake_in_progress | | 25 | Challenge | challenge_in_progress | | 26 | Multi Commit | multi_commit_in_progress | | 27 | Respawn | respawn_in_progress | | 28 | Turnover | turnover_in_progress |

Events

1. Kickoff

  • Detection Algorithm: Detect a countdown-zero frame whose replicated state differs from the previous frame, then wait for the possession state machine to award possession.
  • Start Rule: frame_count_down_number == 0 and previous.frame_state_id != frame.frame_state_id.
  • End Rule: The first frame on which blue or orange receives possession.
  • Event Players: event_player_1 is the closest winning-team player to the ball anywhere in the kickoff span. event_player_2 is the closest losing-team player to the ball anywhere in the same span.
  • Event Team: The team which first receives possession.
if frame.frame_count_down_number == Some(0)
    && previous_frame.is_some_and(|previous| previous.frame_state_id != frame.frame_state_id)
{
    kickoff_start = Some(index);
}

if kickoff_start.is_some() && possession_team != POSSESSION_NEUTRAL_TEAM {
    for event_index in kickoff_start_index..=index {
        self.mark_event(columns, "kickoff", event_index,
            Some(possession_team), &[winner_slot, loser_slot]);
    }
}

The full interval is marked, including the possession-award frame. This implementation does not begin at countdown 3; countdown 3 is used separately for standard-boost reset events. A kickoff remains unlabelled if either team's closest player cannot be resolved. Stints are derived afterward: each transition from kickoff_in_progress != true to true increments stint_id.

2. Game Join

  • Detection Algorithm: Consume the frame's joined_slots, which parse.rs builds from changes to ProjectX.GRI_X:Reservations.
  • Start Rule: At least one player slot is added to or replaces a reservation on the frame.
  • End Rule: The same frame.
  • Event Players: Every joined player is assigned in joined_slots order.
  • Event Team: None is explicitly supplied. The event id can still infer a team from its first player.
if !frame.joined_slots.is_empty() {
    self.mark_event(columns, "game_join", index, None, &frame.joined_slots);
}

This is a reservation-change event, not a car-actor creation event. A newly observed reservation produces a join; replacing one reservation slot produces a leave for the old player and a join for the new player. Spectator filtering is inherited from the parser's player and reservation resolution.

3. Game Leave

  • Detection Algorithm: Consume the frame's left_slots, produced when a reservation number changes from one resolved player to another.
  • Start Rule: A previously occupied reservation slot is replaced by another player.
  • End Rule: The same frame.
  • Event Players: Every leaving player is assigned in left_slots order.
  • Event Team: None is explicitly supplied.
Some(previous_slot) if previous_slot != slot => {
    left_slots.push(previous_slot);
    joined_slots.push(slot);
}

Deleted car actors set player_present and player_on_field false, but they do not append to left_slots. Consequently, this event currently describes reservation replacement, not every physical disappearance or disconnect visible through actor deletion.

4. Shot

  • Detection Algorithm: Find a positive frame-wise change in player_shots. The shooting player's most recent geometric touch at or before that counter-change frame anchors the shot.
  • Start Rule: The shooting player's last touch at or before the shot-counter frame, falling back to the counter frame when no touch is available.
  • End Rule: The shot-counter frame. Only active frames in this inclusive range receive the shot event id.
  • Event Players: event_player_1 is the player whose shot counter increased.
  • Event Team: The shooter's team.
let start = self
    .last_player_touch_before_or_at(columns, slot, index)
    .unwrap_or(index);
self.mark_fresh_event_range(columns, "shot", start, index, Some(player.team), &[slot]);

After box-score events are labelled, each shot independently links to the nearest future same-team goal or opposing-team save within EVENT_LINK_WINDOW_FRAMES = 150. A tie favors the goal. The selected outcome id is copied onto every frame of that shot as exactly one of linked_goal_id or linked_save_id; these links do not extend the shot's event range.

5. Save

  • Detection Algorithm: Find a positive change in player_saves and anchor the interval at the geometrically closest touch frame.
  • Start Rule: The earlier of the save-counter frame and closest geometric touch frame.
  • End Rule: The later of those two frames.
  • Event Players: event_player_1 is the player whose save counter increased.
  • Event Team: The saver's team.
if current_saves > previous_saves {
    let anchor = self.closest_touch_frame(columns, index).unwrap_or(index);
    for event_index in anchor.min(index)..=anchor.max(index) {
        self.mark_event(columns, "save", event_index, Some(player.team), &[slot]);
    }
}

Like shots, the touch anchor is selected from all players' geometric contacts across the replay, not only the saver. Only active frames inside the resulting range are marked.

6. Goal

  • Detection Algorithm: Observe the exact frame where player_goals increases, then search within 150 frames in either direction for the closest same-team assist-counter increase.
  • Start Rule: The goal-counter change frame.
  • End Rule: The same frame.
  • Event Players: event_player_1 is the scorer. event_player_2 is the closest same-team assisting player, excluding the scorer, when available.
  • Event Team: The scorer's team.
if current_goals > previous_goals {
    self.mark_goal_stat_change_event(columns, index, slot);
}

self.mark_event(columns, "goal", index, Some(player.team), &players);

Goals deliberately do not use touch proximity. The replay's stat counter owns the event frame. When two candidate assist changes are equally close, the later one is preferred. This direct marker is not guarded by is_active_frame, so a goal can be true on a frame otherwise initialized as non-active.

Goal Lookahead Labels

The goal lookahead pass emits four derived frame columns rather than event progress flags: blue_next_goal, orange_next_goal, blue_goal_in_next_ten_seconds, and orange_goal_in_next_ten_seconds. They do not participate in event identity or stats counting.

  • Next-Goal Labels: Within each kickoff-to-kickoff stint_id, the first goal scored by either team labels that team's *_next_goal column true for every frame of the stint, including the goal frame itself. A stint which contains no goal leaves both *_next_goal columns false for all of its frames.
  • Ten-Second Lookahead: *_goal_in_next_ten_seconds is true on any frame within the 300 frames before a goal at the assumed 30 replay frames per second, where the window is inclusive of the goal frame. A goal therefore labels itself as a zero-second lookahead.
let lookahead_frames = (10.0 * REPLAY_FRAMES_PER_SECOND) as usize;

Goal frames are resolved from the goal event metadata's team ids, with team score deltas as a resilient fallback for frame-data inputs which contain team scores but lack per-player goal counters. Both signals are combined, so a goal frame is labelled even when one source is missing.

7. Assist

  • Detection Algorithm: Observe the exact frame where player_assists increases, then search within 150 frames in either direction for the closest same-team goal-counter increase.
  • Start Rule: The assist-counter change frame.
  • End Rule: The same frame.
  • Event Players: event_player_1 is the assisting player. event_player_2 is the closest same-team scorer, excluding the assister.
  • Event Team: The assisting player's team.
if current_assists > previous_assists {
    self.mark_assist_stat_change_event(columns, index, slot);
}

Assists are recorded at the counter update and never moved to a touch frame. The pairing search is temporal and team-constrained, but not stint-constrained. Like goals, assist markers may be written on a non-active frame.

8. Pass

  • Detection Algorithm: Track the latest geometric touch. When a different player from the same team next touches the ball, label the interval as a pass and optionally extend it to the nearest same-team assist-counter change within 150 frames.
  • Start Rule: The previous teammate's touch frame.
  • End Rule: The receiver's touch frame, extended through the linked assist frame when that frame is later.
  • Event Players: event_player_1 is the previous toucher/passer. event_player_2 is the next teammate toucher/receiver. A linked assister is appended as another role slot when distinct.
  • Event Team: The passer's team.
if previous_player.team == touch_player.team && previous_slot != touch_slot {
    let pass_link = self.closest_pass_link(index, previous_player.team);
    for event_index in previous_index..=end {
        self.mark_event(columns, "pass", event_index,
            Some(previous_player.team), &players);
    }
}

No maximum time or distance separates the two teammate touches. last_touch is cleared outside active play, which bounds passes to one active segment. Simultaneous geometric contacts are processed in player-slot order and update last_touch repeatedly; this can create a same-frame pass between teammates. The assist link searches around the receiver touch, not the pass start.

9. Missed Pass

  • Detection Algorithm: When the next observed touch belongs to the opposing team, reconsider the previous touch as a pass attempt. Find a same-team target in front of the ball, project progress using observed ball positions, and retain the target whose missed approach is smallest but still outside the completed-pass radius.
  • Start Rule: The previous player's touch frame when all pass-target filters succeed.
  • End Rule: The first frame, within 75 frames, where the ball has travelled at least the original ball-to-target distance along the original pass vector.
  • Event Players: event_player_1 is the passer. event_player_2 is the selected intended receiver.
  • Event Team: The passer's team.
if previous_player.team != touch_player.team {
    self.try_mark_missed_pass(columns, previous_index, previous_slot);
}

The pass attempt must satisfy all of the following:

  • Ball speed is at least 900.
  • The target is a teammate other than the passer.
  • Initial target distance is at most 1700.
  • Ball-velocity/target alignment has a normalized dot product of at least 0.35.
  • The observed closest ball-to-target distance is greater than 450 and at most 1800.
  • The ball passes the target's original along-vector distance within 75 frames.

The target position is frozen at the pass frame; receiver motion is not followed. A missed pass is only searched when an opponent touch follows the prior touch. An untouched pass which simply expires does not invoke this detector.

10. Missed Shot

  • Detection Algorithm: On a geometric touch, linearly project the ball to the opponent back-wall plane. Accept trajectories which travel toward goal, reach that plane within three seconds, enter an expanded goal area, and miss the actual goal opening.
  • Start Rule: The touch frame which creates the qualifying trajectory.
  • End Rule: The last consecutive active frame where ball Y velocity remains directed toward the opponent net.
  • Event Players: event_player_1 is the touching player.
  • Event Team: The touching player's team.
let time_to_goal = (goal_y - ball_state.pos.y) / ball_state.vel.y;
let x_at_goal = ball_state.pos.x + ball_state.vel.x * time_to_goal;
let z_at_goal = ball_state.pos.z + ball_state.vel.z * time_to_goal;

reaches_goal_area
    && (x_at_goal.abs() > GOAL_CENTER_TO_POST || z_at_goal > GOAL_HEIGHT)

The expanded projection permits 1900 units beyond either post and projected height up to the 2044-unit ceiling. The actual opening is bounded by GOAL_CENTER_TO_POST = 892.755 and GOAL_HEIGHT = 642.775. Gravity, bounces, spin, and future touches are not included. A per-player 90-frame cooldown suppresses another missed-shot start.

11. Air Dribble

  • Detection Algorithm: On each active frame, find the current possession player and test horizontal player-ball distance and car height.
  • Start Rule: The possession player's horizontal distance to the ball is at most 300, and the player's car center is at or above goal height 642.775.
  • End Rule: The last qualifying marked frame before possession, distance, height, or the dribble continuation condition fails.
  • Event Players: event_player_1 is the current possession player.
  • Event Team: The possession player's team.
let air_control = horizontal_distance <= POSSESSION_DISTANCE
    && carrier_state.entity.pos.z >= GOAL_HEIGHT;

if duration_ok && air_control {
    self.mark_event(columns, "air_dribble", index,
        Some(player.team), &[carrier_slot]);
}

Only the player's height is used here; ball height is not. The event descriptor allows the same player's event id to bridge up to 90 frames between qualifying labels. The duration_ok test compares the current frame to that player's most recent qualifying air-or-ground dribble frame and requires no more than three seconds. Event length counts marked frames, not bridged frames.

12. Ground Dribble

  • Detection Algorithm: On each active frame, find the current possession player and test the ball's horizontal and vertical placement relative to the car.
  • Start Rule: The car center is below goal height, horizontal player-ball distance is at most 180, and ball-minus-car Z separation is between 70 and 260.
  • End Rule: The last qualifying marked frame before possession or geometry fails.
  • Event Players: event_player_1 is the current possession player.
  • Event Team: The possession player's team.
let ground_control = carrier_state.entity.pos.z < GOAL_HEIGHT
    && horizontal_distance <= HOOD_DRIBBLE_HORIZONTAL_DISTANCE
    && (HOOD_DRIBBLE_MIN_VERTICAL_SEPARATION
        ..=HOOD_DRIBBLE_MAX_VERTICAL_SEPARATION)
        .contains(&vertical_separation);

The same 90-frame event-id gap tolerance and three-second duration_ok rule used for air dribbles apply. A qualifying ground-control frame also updates last_ground_dribble_frame, which supplies flick detection.

13. Flick

  • Detection Algorithm: Detect a sparse dodge activation by the current possession player after that player has recently held ground-dribble control and has now left the ground-control geometry.
  • Start Rule: ground_control == false, a ground-dribble frame occurred within one second, and player_n_dodged == true on the current frame.
  • End Rule: The final consecutive frame where player_n_is_dodging == true for the same player.
  • Event Players: event_player_1 is the flicking possession player.
  • Event Team: The flicking player's team.
if !ground_control
    && recent_ground_dribble
    && columns.player_dodged[carrier_slot][index].unwrap_or(false)
{
    let (_, end) = self.player_dodge_span(columns, carrier_slot, index);
    self.mark_event_range(columns, "flick", index, end,
        Some(player.team), &[carrier_slot]);
}

The detector uses the sparse dodge edge, not every active dodge frame, so one dodge cannot start a new flick on each frame. The player must still be the possession carrier on the activation frame. Ball release direction, impulse, and shot outcome are not tested.

14. Demo

  • Detection Algorithm: Consume attacker/victim slots resolved by parse.rs from ReplicatedDemolish, ReplicatedDemolishExtended, or ReplicatedDemolishGoalExplosion attributes.
  • Start Rule: A new victim is present, frame strength is not 0v0, and that victim is not already waiting for respawn.
  • End Rule: The same frame.
  • Event Players: event_player_1 is the attacker. event_player_2 is the victim.
  • Event Team: The attacker's team.
if let Some(victim_slot) = frame.demo_victim_slot {
    if columns.frame_strength_state[index] != "0v0"
        && respawn_targets[victim_slot].is_none()
    {
        self.mark_event(columns, "demo", index,
            Some(attacker.team), &[attacker_slot, victim_slot]);
        respawn_targets[victim_slot] = Some(index + 1);
    }
}

The parser suppresses repeated demolish objects while the victim is already demoed. If the victim resolves but the attacker does not, no demo flag is emitted, although the respawn target is still started. Demo detection runs without an active-frame guard.

15. Bump

  • Detection Algorithm: Compare every pair of present, non-demoed cars with frame states. Mark contact when center-to-center distance is at most 180 and at least one car has non-zero speed.
  • Start Rule: The pair enters the CAR_CONTACT_REACH sphere.
  • End Rule: The last consecutive frame on which that pair remains inside the sphere.
  • Event Players: Odd role slots contain primary bumpers; even role slots contain contacted players. The faster car by 3D speed at the beginning of contact is primary. Equal speeds favor the lower player slot encountered first. Those roles remain fixed until the pair disengages.
  • Event Team: The primary player's team.
let (primary_slot, secondary_slot) = active_bump_roles[left_slot][right_slot]
    .unwrap_or_else(|| {
        if player_speed_sq(left) >= player_speed_sq(right) {
            (left_slot, right_slot)
        } else {
            (right_slot, left_slot)
        }
    });
active_bump_roles[left_slot][right_slot] = Some((primary_slot, secondary_slot));

teammate_bump is true when both players share a team; otherwise opponent_bump is true. Bump identity uses the sorted pair as its signature, so the stable primary/secondary role does not create a second event if relative speeds change during contact. Multiple pairs append their own ids and event-player role blocks on the same frame. Bumps are evaluated outside the active-frame block, so contact can be marked during frozen or post-goal states.

16. Entry

  • Detection Algorithm: Classify ball Y as neutral when abs(y) < BACK_WALL_Y / 3, currently approximately 1706.67. Every active neutral-third frame is an entry frame.
  • Start Rule: The first active frame in a consecutive neutral-third sequence.
  • End Rule: The final neutral-third frame before the ball enters either end third or active play ends.
  • Event Players: The current parsed touch_player_slot, otherwise the latest parsed touch player observed during active play.
  • Event Team: The selected touch player's team when available.
match ball_zone {
    BallZone::Neutral => {
        self.mark_event(columns, "entry", index, team, players);
        columns.exit_in_progress[index] = Some(false);
    }
    BallZone::End => columns.entry_in_progress[index] = Some(false),
}

This is a field-zone state, not a single transition-frame event. The first end-third frame is already an exit frame; it is not also the final entry frame. The touch source here is frame.touch_player_slot, prepared from replay-native last-team-touch changes by assigning the nearest same-team car to the ball. It is separate from geometric player_n_touching_ball.

17. Exit

  • Detection Algorithm: Use the same ball-zone classifier as entry. Every active frame outside the neutral third is an exit frame.
  • Start Rule: The first active frame in either end third.
  • End Rule: The final end-third frame before the ball returns to neutral or active play ends.
  • Event Players: The current parsed touch player, falling back to the latest active parsed touch player.
  • Event Team: The selected touch player's team when available.
BallZone::End => {
    columns.entry_in_progress[index] = Some(false);
    self.mark_event(columns, "exit", index, team, players);
}

Both defensive and offensive end thirds use the same BallZone::End value. The event team is the latest touching player's team, not necessarily the team defending the occupied third. Entry and exit are null outside active play.

18. Retrieval

  • Detection Algorithm: Detect a transition from possession id 0 to any non-zero possession id.
  • Start Rule: The current active frame has possession and the immediately tracked previous possession id was neutral.
  • End Rule: The same frame.
  • Event Players: event_player_1 is the player currently credited with possession.
  • Event Team: The newly possessing team.
if self.is_active_frame(frame) && possession_id != 0 && previous_possession_id == 0 {
    self.mark_event(columns, "retrieval", index,
        Some(possession_team), &[player_slot]);
}

This does not detect every contested-to-controlled transition. During one opposing touch, the old team can remain the possession team while contested_possession becomes true, leaving possession_id non-zero. A later possession award therefore may not cross from 0 and will not be a retrieval under the current implementation.

19. Boost Pickup

  • Detection Algorithm: Detect meaningful raw boost increases and label the nearest field boost pad, or emit a standard-boost reset on the last frozen countdown-3 frame.
  • Start Rule: Active pickup: raw boost increases by more than 17.0. Reset: standard boost mutator, frozen state, countdown 3, and the next frame is not another matching reset frame.
  • End Rule: The same frame.
  • Event Players: The collecting player. Reset frames call the event once for every player with a frame state.
  • Event Team: The labelled player's team.
let pickup_type = Self::nearest_boost_pickup_type(current_state.entity.pos);

Pad pickups use the player's frame-to-frame raw boost increase. The threshold is below a small pad grant but above tiny replay corrections, so small pads collected while boosting are still retained. The nearest field pad labels the pickup as small or big; no continuation frames are marked. Reset events are written in frozen play and therefore intentionally turn a normally null non-active event cell true.

20. Flip Reset

  • Detection Algorithm: Build frame-wise dodge availability, detect availability changing from false to true after recent ball contact while airborne, and require a replay-native refresh signal or older underside-contact signal.
  • Start Rule: player_n_got_flip_reset == true and no reset was emitted for that player in the previous 30 frames.
  • End Rule: The same frame.
  • Event Players: event_player_1 is the player who reacquired the dodge.
  • Event Team: That player's team.
let got_flip_reset = recent_ball_contact
    && in_air
    && has_dodge
    && previous_has_dodge[slot] == Some(false)
    && (direct_dodge_refresh || older_reset_contact);

direct_dodge_refresh is true when any of these occurs:

  • player_dodge_reacquired from an increasing DodgesRefreshedCounter.
  • Dodge-component AirActivateCount changes from a value greater than zero to zero.
  • Double-jump-component AirActivateCount changes from a value greater than zero to zero.

The older signal requires geometric ball contact, airborne state, no dodge before contact, no active dodge or double jump, distance at most 230, and underside alignment of at least 0.75. Recent contact lasts eight frames. Dodge availability itself accounts for first-jump timing, consumed dodge/double jump, unlimited jumps, unlimited dodge time, and refresh state. The event detector consumes the engineered player_n_got_flip_reset; it does not independently repeat the contact-distance test.

21. Press

  • Detection Algorithm: For each team without possession, first require the ball to be in the opposing team's defensive half. Select the pressing team's closest on-field, non-demoed player to the ball. Select the defending possession carrier as the target; when that team has no possession carrier, select its closest on-field player who is moving toward the ball.
  • Start Rule: The selected presser is moving toward either the target or ball, and is within 1800 units of either one.
  • End Rule: The pressing team wins possession, the ball leaves the defending team's half, or either side can no longer provide a valid on-field presser/target state.
  • Event Players: event_player_1 is the pressing team's current first man. event_player_2 is the defending possession carrier or defending player approaching the ball.
  • Event Team: The pressing team.
let approaching_target = speed_toward_point(
    presser_state.entity.vel,
    presser_state.entity.pos,
    target_state.entity.pos,
).is_some_and(|speed| speed > 0.0);
let approaching_ball = speed_toward_point(
    presser_state.entity.vel,
    presser_state.entity.pos,
    ball_pos,
).is_some_and(|speed| speed > 0.0);

(approaching_target || approaching_ball)
    && (target_distance <= PRESS_CARRIER_DISTANCE
        || ball_distance <= PRESS_BALL_DISTANCE)

After a press starts, every valid frame in that pressure period is marked. The start-distance and approach predicates are not re-applied while that team's press remains active. A changing first man or target updates the event-player role values without creating a new event id because press runs are team-owned rather than signature-split. Blue presses while the ball is in orange's positive-Y half; orange presses while the ball is in blue's negative-Y half. Press has no cooldown.

22. Shadow

  • Detection Algorithm: For a possession carrier, identify an opponent between the carrier and that opponent's own net, laterally aligned, within the shadow distance band, moving away from the ball while the carrier or ball moves toward the attacking net.
  • Start Rule: The carrier satisfies ground- or air-dribble geometry; defender-carrier distance is 500..=1800; lateral X separation is at most 1400; positional and velocity predicates pass.
  • End Rule: The last consecutive frame on which all predicates pass.
  • Event Players: event_player_1 is the shadowing defender. event_player_2 is the possession carrier.
  • Event Team: The defender's team.
carrier_dribbling(ball_pos, carrier_state)
    && (500.0..=1800.0).contains(&defender_carrier_distance)
    && lateral_distance <= 1400.0
    && defender_between_carrier_and_own_net(...)
    && defender_driving_away_from_ball
    && carrier_moving_toward_opponent_net(...)

The attacking-motion threshold is 250 Y-units per second and uses whichever absolute Y velocity is larger between ball and carrier. A 90-frame recent-shadow value is computed, but the value is deliberately discarded with let _ = recent_shadow; it does not currently suppress or split shadow events. Contiguous frames with the same player pair therefore share one id.

23. Whiff

  • Detection Algorithm: Search sparse jump, double-jump, or dodge activation frames for a first-man attempt near the ball, reject any nearby contact, require the same player to remain touchless, and require another player to touch afterward.
  • Start Rule: Every whiff predicate succeeds on the action-activation frame.
  • End Rule: The end of the player's current dodge span. If no dodge span surrounds the trigger, start and end are the trigger frame.
  • Event Players: event_player_1 is the attempting player.
  • Event Team: The attempting player's team.
player_attempting_ball_play(..., WHIFF_BALL_DISTANCE)
    && action_trigger
    && first_man_for_frame(frame, slot, ball_pos)
    && !player_touches_ball_within(..., 90)
    && other_player_touches_ball_within(..., 120)
    && !recent_whiff

The complete filters are:

  • The player is present through a frame state and is not demolished.
  • The player is not geometrically touching the ball.
  • No player touches the ball in the one-frame window before or after the candidate.
  • The frame is not a kickoff frame.
  • The player is not under the ball within the 260-unit horizontal whiff radius.
  • 3D player-ball distance is at most 260.
  • A jump, double-jump, or dodge component activation occurs on this exact frame.
  • The player is closest to the ball among teammates.
  • The same player does not touch within the next 90 frames.
  • Another player does touch within the next 120 frames.
  • This player has not emitted another whiff/fake in the previous 120 frames.

player_attempting_ball_play can also recognize driving toward the ball at at least 800 units per second, but the outer action_trigger still requires a jump/double-jump/dodge activation. Therefore driving alone cannot currently create a whiff.

24. Fake

  • Detection Algorithm: Begin with the exact whiff candidate and dodge span described above, then inspect that same span for an opposing possession player. The possessing player must be in a labelled ground dribble, in a labelled air dribble, or within half a car length of the ball horizontally on at least one frame in the span.
  • Start Rule: A whiff candidate exists and an opposing possession player satisfies one of the three control predicates during its action span.
  • End Rule: The same dodge-span end used by whiffs.
  • Event Players: event_player_1 is the possessing player who causes the fake. event_player_2 is the opposing player who whiffs.
  • Event Team: The possessing player's team.
columns.player_has_possession[possessor_slot][index]
    && (columns.ground_dribble_in_progress[index].unwrap_or(false)
        || columns.air_dribble_in_progress[index].unwrap_or(false)
        || horizontal_distance <= FAKE_POSSESSION_DISTANCE)

FAKE_POSSESSION_DISTANCE = 59.0 Unreal units and is measured in the XY plane so vertical ball placement does not prevent the half-car-length fallback. Fake and whiff detection share the same candidate handler, exclusions, confirmation windows, cooldown, and action span. The first qualifying opposing possession player in that span owns the fake. A qualifying possession context routes the candidate to fake; otherwise it routes to whiff.

25. Challenge

  • Detection Algorithm: For the current possession carrier, test each opposing defender while possession is contested.
  • Start Rule: contested_possession == true, defender-ball distance is at most 425, and defender-carrier distance is at most 750.
  • End Rule: The final consecutive frame before contest status or either distance predicate fails, the carrier disappears, or possession no longer supplies a carrier.
  • Event Players: Odd role slots are challenging defenders; even role slots are the corresponding possession carriers.
  • Event Team: The defender's team.
columns.contested_possession[index].unwrap_or(false)
    && defender_ball_distance <= CHALLENGE_TOUCH_BALL_DISTANCE
    && defender_carrier_distance <= CHALLENGE_TOUCH_PLAYER_DISTANCE

A challenge does not explicitly require a jump, dodge, double jump, drive input, or touch. It is a contested-possession proximity state. Once a defender satisfies challenge rules, the detector continues before press evaluation for that defender/carrier pair. Multiple challenge pairs can be appended on one frame. stats.rs names odd-slot counts challenge_wins and even-slot counts challenge_losses, but the event detector does not inspect the eventual possession outcome; those names currently reflect defender/carrier roles rather than a separately proven win/loss result.

26. Multi Commit

  • Detection Algorithm: Compare every same-team player pair on active frames. Both players must independently satisfy player_attempting_ball_play within 425 units of the ball.
  • Start Rule: Two teammates simultaneously satisfy the attempt predicate.
  • End Rule: The last consecutive frame before either teammate stops satisfying it.
  • Event Players: Teammate pairs are stored in odd/even role slots.
  • Event Team: Their shared team.
if player_attempting_ball_play(left, ball, 425.0)
    && player_attempting_ball_play(right, ball, 425.0)
{
    self.mark_event(columns, "multi_commit", index,
        Some(left_player.team), &[left_slot, right_slot]);
}

An attempt means the player is not under the ball, is inside the distance limit, and is either actively jumping/double-jumping/dodging or driving toward the ball at at least 800 units per second. Teammates do not need to be within 750 units of one another; despite the challenge constant being available, the current multi-commit loop applies only the ball-distance attempt test.

27. Respawn

  • Detection Algorithm: Start tracking a victim one frame after a resolved demolition and keep marking while the parser's persistent player_demoed state remains true.
  • Start Rule: The frame after the demo object.
  • End Rule: The last frame before player_demoed becomes false.
  • Event Players: event_player_1 is the demolished/respawning player.
  • Event Team: That player's team.
respawn_targets[victim_slot] = Some(index + 1);

if frame.player_demoed[slot] {
    self.mark_event(columns, "respawn", index, Some(player.team), &[slot]);
} else {
    *respawn_start = None;
}

There is no standalone Boxcars respawn event consumed here. parse.rs clears demo state when actor state indicates that the car is on field, present, and driving, or when an unhidden car relationship reappears. The event descriptor tolerates gaps of up to 120 frames for the same player signature, although the loop normally marks every demoed frame. Respawn is evaluated outside the active-frame guard.

28. Turnover

  • Detection Algorithm: Retain geometric touch candidates from the previous 60 frames. When possession changes directly from one non-neutral team to the other, find the latest retained touch by the team which lost possession.
  • Start Rule: That latest old-team touch frame.
  • End Rule: The frame on which opposing possession is awarded.
  • Event Players: event_player_1 is the old-team player credited with the giving-away touch. event_player_2 is the new possession player.
  • Event Team: The team which lost possession.
if last_possession_team != POSSESSION_NEUTRAL_TEAM
    && current_possession_team != POSSESSION_NEUTRAL_TEAM
    && current_possession_team != last_possession_team
{
    self.mark_event_range(columns, "turnover", touch_index, index,
        Some(touch_team), &[touch_slot, possession_slot]);
}

TURNOVER_WINDOW_FRAMES = 60, corresponding to two seconds at the assumed 30 replay frames per second. The contest frames between the old-team touch and new possession are included. The search chooses the latest old-team touch in the window. A neutral interval breaks the direct non-neutral transition condition, so not every loss of possession becomes a turnover.

Event Counting Semantics

stats.rs counts frame events from each {event}_event_id, not every true progress frame. It splits concurrent ids, processes each id only on its final row, and assigns the count from that event's own role columns. PBP inputs are already event-wise and are counted directly from event_type, event_id, and the generic row-owned player roles.

let continues = next_event_ids
    .split('|')
    .any(|candidate| candidate == event_id);

The current role interpretations include kickoff winner/loser, passer/receiver, demo attacker/victim, press applier/receiver, and odd/even pair roles for bumps and challenges. Goals, assists, shots, and saves use replay stat counters directly in the stats model and are not duplicated through event counting.

Current Implementation Boundaries

  • Geometric ball contact uses sphere distance around car centers. It does not use oriented car hitboxes.
  • Possession consumes one closest contact candidate even when multiple player_n_touching_ball columns are true.
  • Shot and save touch anchors currently search all players and the full replay.
  • Goal and assist event frames are exact stat-counter frames and intentionally do not use touch anchoring.
  • Passes have no maximum teammate-touch interval.
  • Entry and exit are persistent zone states, not transition-only markers.
  • Fakes are a possession-dependent specialization of the same candidate path used for whiffs.
  • Presses are continuous team-owned spans; player-role slots can change inside one press instance as first-man and target assignments change.
  • Shadow calculates but does not apply its cooldown.
  • Bump, demo, respawn, game-join, game-leave, goal, assist, and standard boost-reset calls can write true values outside inferred active play.
  • Goal lookahead labels use the first goal of each stint and a ten-second inclusive window; stints without goals and frames outside a goal window retain their false defaults.
  • PBP contains boundary-frame state rather than a complete timeline. Time and trajectory statistics that require every frame remain frame-input calculations.